feat(chat): sync multi-role backend APIs
This commit is contained in:
@@ -3,9 +3,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Result } from "@/utils/result";
|
||||
|
||||
const getHistoryMock = vi.hoisted(() => vi.fn());
|
||||
const getPreviewsMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/data/repositories/chat_repository_loader", () => ({
|
||||
loadChatRepository: async () => ({ getHistory: getHistoryMock }),
|
||||
loadChatRepository: async () => ({
|
||||
getHistory: getHistoryMock,
|
||||
getPreviews: getPreviewsMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
import {
|
||||
@@ -17,6 +21,7 @@ import { createSplashLatestMessageCache } from "../splash_latest_message_cache";
|
||||
describe("fetchSplashLatestMessagePreview", () => {
|
||||
beforeEach(() => {
|
||||
getHistoryMock.mockReset();
|
||||
getPreviewsMock.mockReset();
|
||||
});
|
||||
|
||||
it("uses the last message returned by history", async () => {
|
||||
@@ -29,10 +34,10 @@ describe("fetchSplashLatestMessagePreview", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await fetchSplashLatestMessagePreview();
|
||||
const result = await fetchSplashLatestMessagePreview("elio");
|
||||
|
||||
expect(getHistoryMock).toHaveBeenCalledWith(
|
||||
"character_elio",
|
||||
"elio",
|
||||
1,
|
||||
0,
|
||||
);
|
||||
@@ -50,13 +55,44 @@ describe("fetchSplashLatestMessagePreview", () => {
|
||||
it("returns null when history is empty", async () => {
|
||||
getHistoryMock.mockResolvedValue(Result.ok({ messages: [] }));
|
||||
|
||||
const result = await fetchSplashLatestMessagePreview();
|
||||
const result = await fetchSplashLatestMessagePreview("elio");
|
||||
|
||||
expect(Result.isOk(result) && result.data).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadSplashLatestMessagePreview", () => {
|
||||
it("loads all character previews once and caches each conversation", async () => {
|
||||
const cache = createSplashLatestMessageCache();
|
||||
getPreviewsMock.mockResolvedValue(
|
||||
Result.ok({
|
||||
items: [
|
||||
{
|
||||
characterId: "elio",
|
||||
message: { type: "text", content: "Hello Elio" },
|
||||
},
|
||||
{
|
||||
characterId: "maya-tan",
|
||||
message: { type: "text", content: "Hello Maya" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await loadSplashLatestMessagePreview({
|
||||
cache,
|
||||
characterId: "maya-tan",
|
||||
resolveIdentity: vi.fn().mockResolvedValue(Result.ok("user:maya")),
|
||||
resolvePreviewIdentity: async (characterId) =>
|
||||
Result.ok(`user:${characterId}`),
|
||||
});
|
||||
|
||||
expect(Result.isOk(result) && result.data).toBe("Hello Maya");
|
||||
expect(getPreviewsMock).toHaveBeenCalledOnce();
|
||||
expect(cache.get("user:elio")?.message).toBe("Hello Elio");
|
||||
expect(cache.get("user:maya-tan")?.message).toBe("Hello Maya");
|
||||
});
|
||||
|
||||
it("reuses a successful result for the same identity", async () => {
|
||||
const cache = createSplashLatestMessageCache();
|
||||
const fetchPreview = vi.fn().mockResolvedValue(Result.ok("Hello again"));
|
||||
@@ -64,11 +100,13 @@ describe("loadSplashLatestMessagePreview", () => {
|
||||
|
||||
const first = await loadSplashLatestMessagePreview({
|
||||
cache,
|
||||
characterId: "elio",
|
||||
fetchPreview,
|
||||
resolveIdentity,
|
||||
});
|
||||
const second = await loadSplashLatestMessagePreview({
|
||||
cache,
|
||||
characterId: "elio",
|
||||
fetchPreview,
|
||||
resolveIdentity,
|
||||
});
|
||||
@@ -85,6 +123,7 @@ describe("loadSplashLatestMessagePreview", () => {
|
||||
|
||||
const result = await loadSplashLatestMessagePreview({
|
||||
cache,
|
||||
characterId: "elio",
|
||||
fetchPreview,
|
||||
resolveIdentity: vi
|
||||
.fn()
|
||||
@@ -105,11 +144,13 @@ describe("loadSplashLatestMessagePreview", () => {
|
||||
|
||||
await loadSplashLatestMessagePreview({
|
||||
cache,
|
||||
characterId: "elio",
|
||||
fetchPreview,
|
||||
resolveIdentity,
|
||||
});
|
||||
const retry = await loadSplashLatestMessagePreview({
|
||||
cache,
|
||||
characterId: "elio",
|
||||
fetchPreview,
|
||||
resolveIdentity,
|
||||
});
|
||||
@@ -127,11 +168,13 @@ describe("loadSplashLatestMessagePreview", () => {
|
||||
|
||||
await loadSplashLatestMessagePreview({
|
||||
cache,
|
||||
characterId: "elio",
|
||||
fetchPreview,
|
||||
resolveIdentity,
|
||||
});
|
||||
await loadSplashLatestMessagePreview({
|
||||
cache,
|
||||
characterId: "elio",
|
||||
fetchPreview,
|
||||
resolveIdentity,
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { ChatMediaKind } from "@/data/schemas/chat";
|
||||
|
||||
const CHARACTER_NAMESPACE = "::character:";
|
||||
const LEGACY_OWNER_KEY_PATTERN = /^(?:user|device):.+$/u;
|
||||
|
||||
export function buildChatConversationKey(
|
||||
ownerKey: string,
|
||||
@@ -20,10 +19,3 @@ export function buildChatMediaCacheKey(input: {
|
||||
}): string {
|
||||
return `${input.ownerKey}:${input.messageId}:${input.kind}`;
|
||||
}
|
||||
|
||||
export function isLegacyChatCacheOwnerKey(ownerKey: string): boolean {
|
||||
return (
|
||||
!ownerKey.includes(CHARACTER_NAMESPACE) &&
|
||||
LEGACY_OWNER_KEY_PATTERN.test(ownerKey)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
resolveChatConversationKey,
|
||||
type ChatCacheIdentityResolver,
|
||||
} from "@/data/repositories/chat_cache_identity";
|
||||
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
|
||||
import { Result, type Result as ResultT } from "@/utils/result";
|
||||
|
||||
import type { SplashLatestMessageCache } from "./splash_latest_message_cache";
|
||||
@@ -11,13 +10,26 @@ import { getLatestSplashMessagePreview } from "./splash_latest_message_preview";
|
||||
|
||||
export interface LoadSplashLatestMessagePreviewInput {
|
||||
cache: SplashLatestMessageCache;
|
||||
characterId?: string;
|
||||
characterId: string;
|
||||
fetchPreview?: (characterId: string) => Promise<ResultT<string | null>>;
|
||||
fetchPreviews?: () => Promise<
|
||||
ResultT<
|
||||
readonly {
|
||||
characterId: string;
|
||||
message: import("@/data/schemas/chat").ChatMessage | null;
|
||||
}[]
|
||||
>
|
||||
>;
|
||||
resolveIdentity?: ChatCacheIdentityResolver;
|
||||
resolvePreviewIdentity?: ChatConversationIdentityResolver;
|
||||
}
|
||||
|
||||
type ChatConversationIdentityResolver = (
|
||||
characterId: string,
|
||||
) => Promise<ResultT<string>>;
|
||||
|
||||
export async function resolveSplashLatestMessageCacheIdentity(
|
||||
characterId = DEFAULT_CHARACTER_ID,
|
||||
characterId: string,
|
||||
): Promise<string | null> {
|
||||
const result = await resolveChatConversationKey(characterId);
|
||||
return Result.isOk(result) ? result.data : null;
|
||||
@@ -25,9 +37,11 @@ export async function resolveSplashLatestMessageCacheIdentity(
|
||||
|
||||
export async function loadSplashLatestMessagePreview({
|
||||
cache,
|
||||
characterId = DEFAULT_CHARACTER_ID,
|
||||
characterId,
|
||||
fetchPreview = fetchSplashLatestMessagePreview,
|
||||
fetchPreviews = fetchSplashLatestMessagePreviews,
|
||||
resolveIdentity = () => resolveChatConversationKey(characterId),
|
||||
resolvePreviewIdentity = resolveChatConversationKey,
|
||||
}: LoadSplashLatestMessagePreviewInput): Promise<ResultT<string | null>> {
|
||||
const identityResult = await resolveIdentity();
|
||||
const identity = Result.isOk(identityResult) ? identityResult.data : null;
|
||||
@@ -37,15 +51,67 @@ export async function loadSplashLatestMessagePreview({
|
||||
if (cached) return Result.ok(cached.message);
|
||||
}
|
||||
|
||||
const result = await fetchPreview(characterId);
|
||||
const result =
|
||||
fetchPreview !== fetchSplashLatestMessagePreview
|
||||
? await fetchPreview(characterId)
|
||||
: await loadBatchPreview({
|
||||
cache,
|
||||
characterId,
|
||||
fetchPreviews,
|
||||
resolvePreviewIdentity,
|
||||
});
|
||||
if (identity && Result.isOk(result)) {
|
||||
cache.set(identity, result.data);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function loadBatchPreview(input: {
|
||||
cache: SplashLatestMessageCache;
|
||||
characterId: string;
|
||||
fetchPreviews: NonNullable<LoadSplashLatestMessagePreviewInput["fetchPreviews"]>;
|
||||
resolvePreviewIdentity: ChatConversationIdentityResolver;
|
||||
}): Promise<ResultT<string | null>> {
|
||||
const previews = await input.fetchPreviews();
|
||||
if (Result.isErr(previews)) {
|
||||
return fetchSplashLatestMessagePreview(input.characterId);
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
previews.data.map(async (item) => {
|
||||
const identity = await input.resolvePreviewIdentity(item.characterId);
|
||||
if (Result.isErr(identity)) return;
|
||||
input.cache.set(
|
||||
identity.data,
|
||||
item.message ? getLatestSplashMessagePreview([item.message]) : null,
|
||||
);
|
||||
}),
|
||||
);
|
||||
const selected = previews.data.find(
|
||||
(item) => item.characterId === input.characterId,
|
||||
);
|
||||
return Result.ok(
|
||||
selected?.message
|
||||
? getLatestSplashMessagePreview([selected.message])
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchSplashLatestMessagePreviews(): Promise<
|
||||
ResultT<
|
||||
readonly {
|
||||
characterId: string;
|
||||
message: import("@/data/schemas/chat").ChatMessage | null;
|
||||
}[]
|
||||
>
|
||||
> {
|
||||
const repository = await loadChatRepository();
|
||||
const result = await repository.getPreviews();
|
||||
return Result.isErr(result) ? Result.err(result.error) : Result.ok(result.data.items);
|
||||
}
|
||||
|
||||
export async function fetchSplashLatestMessagePreview(
|
||||
characterId = DEFAULT_CHARACTER_ID,
|
||||
characterId: string,
|
||||
): Promise<ResultT<string | null>> {
|
||||
const repository = await loadChatRepository();
|
||||
const result = await repository.getHistory(characterId, 1, 0);
|
||||
|
||||
@@ -39,7 +39,7 @@ describe("subscription exit helpers", () => {
|
||||
it("uses explicit chat image return urls first", async () => {
|
||||
consumePendingChatImageReturnMock.mockResolvedValue({
|
||||
reason: "image_paywall",
|
||||
characterId: "character_elio",
|
||||
characterId: "elio",
|
||||
messageId: "msg_1",
|
||||
returnUrl: "/chat?image=msg_1",
|
||||
createdAt: 1,
|
||||
@@ -74,14 +74,14 @@ describe("subscription exit helpers", () => {
|
||||
it("returns directly to private room without consuming chat state", async () => {
|
||||
consumePendingChatImageReturnMock.mockResolvedValue({
|
||||
reason: "image_paywall",
|
||||
characterId: "character_elio",
|
||||
characterId: "elio",
|
||||
messageId: "msg_1",
|
||||
returnUrl: "/chat?image=msg_1",
|
||||
createdAt: 1,
|
||||
});
|
||||
peekPendingChatUnlockMock.mockResolvedValue({
|
||||
reason: "single_message_unlock",
|
||||
characterId: "character_elio",
|
||||
characterId: "elio",
|
||||
displayMessageId: "msg_1",
|
||||
messageId: "msg_1",
|
||||
kind: "image",
|
||||
@@ -104,7 +104,7 @@ describe("subscription exit helpers", () => {
|
||||
it("keeps private room ahead of stale chat state after payment", async () => {
|
||||
peekPendingChatUnlockMock.mockResolvedValue({
|
||||
reason: "single_message_unlock",
|
||||
characterId: "character_elio",
|
||||
characterId: "elio",
|
||||
displayMessageId: "msg_1",
|
||||
messageId: "msg_1",
|
||||
kind: "image",
|
||||
@@ -123,7 +123,7 @@ describe("subscription exit helpers", () => {
|
||||
it("keeps pending chat unlocks ahead of chat fallback after payment", async () => {
|
||||
peekPendingChatUnlockMock.mockResolvedValue({
|
||||
reason: "single_message_unlock",
|
||||
characterId: "character_elio",
|
||||
characterId: "elio",
|
||||
displayMessageId: "msg_1",
|
||||
messageId: "msg_1",
|
||||
kind: "image",
|
||||
@@ -141,7 +141,7 @@ describe("subscription exit helpers", () => {
|
||||
it("peeks chat unlock return urls without clearing them", async () => {
|
||||
peekPendingChatUnlockMock.mockResolvedValue({
|
||||
reason: "single_message_unlock",
|
||||
characterId: "character_elio",
|
||||
characterId: "elio",
|
||||
displayMessageId: "msg_1",
|
||||
messageId: "msg_1",
|
||||
kind: "image",
|
||||
@@ -160,7 +160,7 @@ describe("subscription exit helpers", () => {
|
||||
consumePendingChatImageReturnMock.mockResolvedValue(null);
|
||||
peekPendingChatUnlockMock.mockResolvedValue({
|
||||
reason: "single_message_unlock",
|
||||
characterId: "character_elio",
|
||||
characterId: "elio",
|
||||
displayMessageId: "msg_1",
|
||||
messageId: "msg_1",
|
||||
kind: "image",
|
||||
|
||||
@@ -67,3 +67,11 @@ export async function consumePendingChatPromotion(
|
||||
export async function clearPendingChatPromotion(): Promise<void> {
|
||||
await NavigationStorage.clearPendingChatPromotion();
|
||||
}
|
||||
|
||||
export async function clearPendingChatNavigation(): Promise<void> {
|
||||
await Promise.all([
|
||||
NavigationStorage.clearPendingChatUnlock(),
|
||||
NavigationStorage.clearPendingChatPromotion(),
|
||||
NavigationStorage.clearPendingChatImageReturn(),
|
||||
]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user