feat(chat): sync multi-role backend APIs

This commit is contained in:
2026-07-20 11:29:54 +08:00
parent 16b5c16e76
commit b6fdc912ae
84 changed files with 1488 additions and 439 deletions
@@ -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,
});
-8
View File
@@ -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)
);
}
+72 -6
View File
@@ -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(),
]);
}