@marktiderman/genesis-chat 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +304 -13
- package/dist/{chunk-SAY24E6V.js → chunk-5D5A7I4Q.js} +166 -45
- package/dist/chunk-5D5A7I4Q.js.map +1 -0
- package/dist/{chunk-JQXZUEEL.js → chunk-7S6HYNWW.js} +382 -8
- package/dist/chunk-7S6HYNWW.js.map +1 -0
- package/dist/chunk-BJCE5O7V.js +70 -0
- package/dist/chunk-BJCE5O7V.js.map +1 -0
- package/dist/chunk-PZTOY2LY.js +576 -0
- package/dist/chunk-PZTOY2LY.js.map +1 -0
- package/dist/chunk-VEPZROQB.js +187 -0
- package/dist/chunk-VEPZROQB.js.map +1 -0
- package/dist/{client-BtdALFCx.d.ts → client-DMa2j7kz.d.ts} +152 -11
- package/dist/index.d.ts +78 -163
- package/dist/index.js +3 -2
- package/dist/native.d.ts +1910 -1
- package/dist/native.js +4900 -0
- package/dist/native.js.map +1 -1
- package/dist/outbox-B0y3phE0.d.ts +151 -0
- package/dist/push.d.ts +206 -0
- package/dist/push.js +3 -0
- package/dist/push.js.map +1 -0
- package/dist/react.d.ts +149 -7
- package/dist/react.js +3 -3
- package/dist/server.d.ts +510 -21
- package/dist/server.js +863 -21
- package/dist/server.js.map +1 -1
- package/dist/thread-DWXHKPdw.d.ts +57 -0
- package/dist/web.d.ts +313 -10
- package/dist/web.js +1731 -60
- package/dist/web.js.map +1 -1
- package/migrations/0002_genesis_chat_coaching.sql +934 -0
- package/migrations/tests/0002_genesis_chat_coaching.test.sql +265 -0
- package/package.json +74 -6
- package/src/__tests__/coaching-core.test.ts +332 -0
- package/src/__tests__/coaching-react.test.tsx +248 -0
- package/src/__tests__/coaching-web.test.tsx +449 -0
- package/src/__tests__/core.test.ts +91 -0
- package/src/__tests__/outbox.test.ts +122 -0
- package/src/__tests__/realtime.test.ts +70 -0
- package/src/__tests__/review-fixes-core.test.ts +193 -0
- package/src/__tests__/review-fixes-web.test.tsx +402 -0
- package/src/__tests__/server-effects.test.ts +158 -0
- package/src/__tests__/server-fakes.ts +90 -0
- package/src/__tests__/server-integrations.test.ts +247 -0
- package/src/__tests__/server-providers.test.ts +246 -0
- package/src/__tests__/server-runner.test.ts +277 -0
- package/src/__tests__/server-safety.test.ts +290 -0
- package/src/__tests__/web-markdown.test.tsx +88 -0
- package/src/__tests__/web.test.tsx +333 -0
- package/src/core/availability.ts +223 -0
- package/src/core/client.ts +757 -0
- package/src/core/extras.ts +106 -0
- package/src/core/media.ts +144 -0
- package/src/core/outbox.ts +429 -0
- package/src/core/thread.ts +171 -0
- package/src/core/types.ts +311 -0
- package/src/index.ts +13 -0
- package/src/native/__tests__/auto-retry.test.tsx +128 -0
- package/src/native/__tests__/composer-mic-lock.test.tsx +115 -0
- package/src/native/__tests__/composer-state.test.ts +188 -0
- package/src/native/__tests__/composer.test.tsx +133 -0
- package/src/native/__tests__/emoji.test.tsx +238 -0
- package/src/native/__tests__/inbox-screen.test.tsx +48 -0
- package/src/native/__tests__/message-bubble.test.tsx +127 -0
- package/src/native/__tests__/organise-logic.test.ts +164 -0
- package/src/native/__tests__/organise-screens.test.tsx +344 -0
- package/src/native/__tests__/photo-viewer.test.tsx +226 -0
- package/src/native/__tests__/push-payload.test.ts +66 -0
- package/src/native/__tests__/push.test.ts +209 -0
- package/src/native/__tests__/recorder-machine.test.ts +96 -0
- package/src/native/__tests__/rows-actions.test.ts +76 -0
- package/src/native/__tests__/thread-layout.test.ts +41 -0
- package/src/native/__tests__/thread-screen.test.tsx +116 -0
- package/src/native/__tests__/tus.test.ts +154 -0
- package/src/native/__tests__/use-voice-recorder.test.tsx +316 -0
- package/src/native/__tests__/video-media.test.tsx +421 -0
- package/src/native/__tests__/video-recorder-state.test.ts +87 -0
- package/src/native/__tests__/video-upload.test.ts +227 -0
- package/src/native/access-error.tsx +31 -0
- package/src/native/actions.ts +42 -0
- package/src/native/attachment-cards.tsx +116 -0
- package/src/native/auto-retry.ts +69 -0
- package/src/native/availability.tsx +376 -0
- package/src/native/broadcast-sheet.tsx +193 -0
- package/src/native/composer-state.ts +302 -0
- package/src/native/composer.tsx +453 -0
- package/src/native/context.tsx +70 -0
- package/src/native/copy.ts +296 -0
- package/src/native/emoji-data.ts +21 -0
- package/src/native/emoji-picker.tsx +229 -0
- package/src/native/emoji.ts +118 -0
- package/src/native/icon-button.tsx +40 -0
- package/src/native/inbox-screen.tsx +116 -0
- package/src/native/index.ts +184 -0
- package/src/native/link-preview.tsx +104 -0
- package/src/native/media-colors.ts +15 -0
- package/src/native/media.tsx +187 -0
- package/src/native/message-actions-sheet.tsx +121 -0
- package/src/native/message-bubble.tsx +374 -0
- package/src/native/moderation.tsx +42 -0
- package/src/native/modules.ts +275 -0
- package/src/native/organise-copy.ts +195 -0
- package/src/native/photo-viewer.tsx +531 -0
- package/src/native/push.ts +375 -0
- package/src/native/recorder-machine.ts +176 -0
- package/src/native/rows.ts +81 -0
- package/src/native/schedule-sheet.tsx +314 -0
- package/src/native/search-screen.tsx +168 -0
- package/src/native/stack-options.ts +36 -0
- package/src/native/start-sheet.tsx +87 -0
- package/src/native/thread-screen.tsx +620 -0
- package/src/native/topics.tsx +179 -0
- package/src/native/tus.ts +282 -0
- package/src/native/use-composer.ts +71 -0
- package/src/native/use-jump.ts +91 -0
- package/src/native/use-voice-recorder.ts +234 -0
- package/src/native/video-player.tsx +370 -0
- package/src/native/video-recorder-state.ts +140 -0
- package/src/native/video-recorder.tsx +271 -0
- package/src/native/video-upload-tray.tsx +77 -0
- package/src/native/video-upload.ts +432 -0
- package/src/native/voice-player.tsx +283 -0
- package/src/react/index.tsx +810 -0
- package/src/server/assistant.ts +235 -0
- package/src/server/crypto.ts +86 -0
- package/src/server/expo.ts +119 -0
- package/src/server/handler.ts +32 -0
- package/src/server/index.ts +57 -0
- package/src/server/moderation.ts +155 -0
- package/src/server/mux.ts +290 -0
- package/src/server/notifications.ts +130 -0
- package/src/server/runner.ts +468 -0
- package/src/server/transcribers.ts +111 -0
- package/src/server/types.ts +171 -0
- package/src/server/unfurl.ts +480 -0
- package/src/server/upload.ts +158 -0
- package/src/web/attachments.tsx +379 -0
- package/src/web/availability.tsx +299 -0
- package/src/web/broadcast.tsx +166 -0
- package/src/web/bubble.tsx +563 -0
- package/src/web/cards.tsx +136 -0
- package/src/web/composer.tsx +571 -0
- package/src/web/emoji-picker.tsx +84 -0
- package/src/web/error-boundary.tsx +33 -0
- package/src/web/format.ts +55 -0
- package/src/web/inbox.tsx +195 -0
- package/src/web/index.ts +75 -0
- package/src/web/markdown.tsx +245 -0
- package/src/web/memory-client.ts +706 -0
- package/src/web/panel.tsx +135 -0
- package/src/web/schedule.tsx +192 -0
- package/src/web/search.tsx +129 -0
- package/src/web/thread-context.ts +64 -0
- package/src/web/thread.tsx +802 -0
- package/src/web/topics.tsx +160 -0
- package/src/web/use-voice-recorder.ts +264 -0
- package/src/web/video.tsx +522 -0
- package/src/web/voice-note.tsx +197 -0
- package/dist/chunk-27UTR5FW.js +0 -322
- package/dist/chunk-27UTR5FW.js.map +0 -1
- package/dist/chunk-JQXZUEEL.js.map +0 -1
- package/dist/chunk-SAY24E6V.js.map +0 -1
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment happy-dom
|
|
3
|
+
* @feat:chat
|
|
4
|
+
* Fixes from the review of the phases 2–4 client, as people see them: a thread that one bad
|
|
5
|
+
* message can't crash, catch-up after a long time away, read places in topics, link previews,
|
|
6
|
+
* the emoji picker, broadcast and schedule retries, and the camera.
|
|
7
|
+
*/
|
|
8
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
9
|
+
import { act, cleanup, configure, fireEvent, render, renderHook, screen, waitFor } from "@testing-library/react";
|
|
10
|
+
import type { ReactNode } from "react";
|
|
11
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
12
|
+
import { ChatError } from "../core/client";
|
|
13
|
+
import type { ChatAttachment } from "../core/types";
|
|
14
|
+
import { CATCH_UP_MAX_PAGES, CATCH_UP_PAGE, ChatProvider, chatKeys, useConversation, useLinkPreviews, type ChatProviderProps } from "../react";
|
|
15
|
+
import {
|
|
16
|
+
ChatBroadcast,
|
|
17
|
+
ChatEmojiPicker,
|
|
18
|
+
ChatErrorBoundary,
|
|
19
|
+
ChatLinkPreview,
|
|
20
|
+
ChatThread,
|
|
21
|
+
createMemoryChatClient,
|
|
22
|
+
useVideoRecorder,
|
|
23
|
+
type MemoryChatClient,
|
|
24
|
+
type MemoryChatOptions,
|
|
25
|
+
type MemoryConversationSeed,
|
|
26
|
+
} from "../web";
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
cleanup();
|
|
30
|
+
vi.restoreAllMocks();
|
|
31
|
+
vi.unstubAllGlobals();
|
|
32
|
+
vi.useRealTimers();
|
|
33
|
+
});
|
|
34
|
+
configure({ asyncUtilTimeout: 5000 });
|
|
35
|
+
|
|
36
|
+
const ME = "00000000-0000-4000-8000-0000000000a1";
|
|
37
|
+
const SAM = "00000000-0000-4000-8000-0000000000b2";
|
|
38
|
+
const LEE = "00000000-0000-4000-8000-0000000000c3";
|
|
39
|
+
const names: Record<string, string> = { [ME]: "Me", [SAM]: "Sam", [LEE]: "Lee" };
|
|
40
|
+
const resolveUser = (id: string) => ({ name: names[id] ?? "Someone" });
|
|
41
|
+
const at = (min: number) => new Date(Date.now() - (600 - min) * 60_000).toISOString();
|
|
42
|
+
|
|
43
|
+
function providers(client: MemoryChatClient, extra: Partial<ChatProviderProps> = {}, queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })) {
|
|
44
|
+
const wrapper = ({ children }: { children: ReactNode }) => (
|
|
45
|
+
<QueryClientProvider client={queryClient}>
|
|
46
|
+
<ChatProvider client={client} userId={ME} {...extra}>
|
|
47
|
+
{children}
|
|
48
|
+
</ChatProvider>
|
|
49
|
+
</QueryClientProvider>
|
|
50
|
+
);
|
|
51
|
+
return { wrapper, queryClient };
|
|
52
|
+
}
|
|
53
|
+
const mount = (client: MemoryChatClient, ui: ReactNode, extra: Partial<ChatProviderProps> = {}) => render(<>{ui}</>, { wrapper: providers(client, extra).wrapper });
|
|
54
|
+
|
|
55
|
+
const dm = (over: Partial<MemoryConversationSeed> = {}): MemoryConversationSeed => ({
|
|
56
|
+
id: "c1",
|
|
57
|
+
members: [{ userId: ME }, { userId: SAM }],
|
|
58
|
+
messages: [{ id: "m1", senderId: SAM, body: "Hello", createdAt: at(1) }],
|
|
59
|
+
...over,
|
|
60
|
+
});
|
|
61
|
+
const make = (seed: MemoryConversationSeed, over: Partial<MemoryChatOptions> = {}) => createMemoryChatClient({ me: ME, conversations: [seed], ...over });
|
|
62
|
+
const openMenu = (el: HTMLElement) => fireEvent.keyDown(el, { key: "Enter" });
|
|
63
|
+
|
|
64
|
+
describe("one bad message can't take the thread down", () => {
|
|
65
|
+
it("draws a conversation whose other member stored malformed hours", async () => {
|
|
66
|
+
const client = make(dm(), { availability: { [SAM]: { timeZone: "UTC", hours: { mon: [[9, 17]], tue: 5 } as never } } });
|
|
67
|
+
mount(client, <ChatThread conversationId="c1" resolveUser={resolveUser} />);
|
|
68
|
+
await screen.findByText("Hello");
|
|
69
|
+
await waitFor(() => expect(client.calls.some((c) => c.method === "availabilityFor")).toBe(true));
|
|
70
|
+
expect(screen.getByText("Hello")).toBeTruthy();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("an attachment kind named after a prototype key (constructor) gets the generic card", async () => {
|
|
74
|
+
const client = make(dm({ messages: [{ id: "a", senderId: SAM, body: "see this", createdAt: at(1), attachments: [{ kind: "constructor", ref: {} }] }] }));
|
|
75
|
+
mount(client, <ChatThread conversationId="c1" resolveUser={resolveUser} />, { attachmentKinds: { form: { title: () => "A form" } } });
|
|
76
|
+
await screen.findByText("see this");
|
|
77
|
+
expect(screen.getByText("Constructor")).toBeTruthy();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("an app card whose functions throw or return junk falls back to the generic card", async () => {
|
|
81
|
+
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
82
|
+
const client = make(
|
|
83
|
+
dm({
|
|
84
|
+
messages: [
|
|
85
|
+
{ id: "a", senderId: SAM, body: "first", createdAt: at(1), attachments: [{ kind: "form", ref: {} }] },
|
|
86
|
+
{ id: "b", senderId: SAM, body: "second", createdAt: at(2), attachments: [{ kind: "lesson", ref: {} }] },
|
|
87
|
+
],
|
|
88
|
+
}),
|
|
89
|
+
);
|
|
90
|
+
mount(client, <ChatThread conversationId="c1" resolveUser={resolveUser} />, {
|
|
91
|
+
attachmentKinds: {
|
|
92
|
+
form: {
|
|
93
|
+
title: (a: ChatAttachment) => {
|
|
94
|
+
throw new Error(`bad ref ${String(a.kind)}`);
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
lesson: { title: () => 42 as unknown as string },
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
await screen.findByText("second");
|
|
101
|
+
expect(screen.getByText("Form")).toBeTruthy();
|
|
102
|
+
expect(screen.getByText("Lesson")).toBeTruthy();
|
|
103
|
+
expect(error).toHaveBeenCalled();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("an attachment slot that throws shows the generic card instead", async () => {
|
|
107
|
+
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
108
|
+
const client = make(dm({ messages: [{ id: "a", senderId: SAM, body: "with a form", createdAt: at(1), attachments: [{ kind: "form", name: "Check-in", ref: {} }] }] }));
|
|
109
|
+
const Boom = () => {
|
|
110
|
+
throw new Error("the app's renderer broke");
|
|
111
|
+
};
|
|
112
|
+
mount(client, <ChatThread conversationId="c1" resolveUser={resolveUser} slots={{ attachment: Boom }} />);
|
|
113
|
+
await screen.findByText("with a form");
|
|
114
|
+
expect(screen.getByText("Check-in")).toBeTruthy();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("ChatErrorBoundary shows its fallback and reports the error", () => {
|
|
118
|
+
const onError = vi.fn();
|
|
119
|
+
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
120
|
+
const Boom = () => {
|
|
121
|
+
throw new Error("row broke");
|
|
122
|
+
};
|
|
123
|
+
render(
|
|
124
|
+
<ChatErrorBoundary fallback={<span>could not draw</span>} onError={onError}>
|
|
125
|
+
<Boom />
|
|
126
|
+
</ChatErrorBoundary>,
|
|
127
|
+
);
|
|
128
|
+
expect(screen.getByText("could not draw")).toBeTruthy();
|
|
129
|
+
expect(onError).toHaveBeenCalled();
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("catch-up after a long time away", () => {
|
|
134
|
+
const seedMessages = (n: number) => Array.from({ length: n }, (_, i) => ({ id: `m${i + 1}`, senderId: SAM, body: `message ${i + 1}`, createdAt: new Date(Date.UTC(2026, 0, 1, 0, 0, i)).toISOString() }));
|
|
135
|
+
const key = [...chatKeys.conversation("c1"), "messages", null, null] as const;
|
|
136
|
+
|
|
137
|
+
it("pages until it has everything", async () => {
|
|
138
|
+
const client = make(dm({ messages: seedMessages(450) }));
|
|
139
|
+
const first = (await client.messages("c1", { beforeSeq: 2, limit: 1 })).messages;
|
|
140
|
+
const { wrapper, queryClient } = providers(client);
|
|
141
|
+
queryClient.setQueryData(key, { messages: first, hasEarlier: false });
|
|
142
|
+
const { result } = renderHook(() => useConversation("c1", { markRead: false }), { wrapper });
|
|
143
|
+
await waitFor(() => expect(result.current.messages).toHaveLength(450));
|
|
144
|
+
expect(client.calls.filter((c) => c.method === "messages" && (c.args[1] as { afterSeq?: number })?.afterSeq != null)).toHaveLength(3);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it(`after ${CATCH_UP_MAX_PAGES} full pages it shows the newest page, with the rest behind "load earlier"`, async () => {
|
|
148
|
+
const total = CATCH_UP_PAGE * CATCH_UP_MAX_PAGES + 300;
|
|
149
|
+
const client = make(dm({ messages: seedMessages(total) }));
|
|
150
|
+
const first = (await client.messages("c1", { beforeSeq: 2, limit: 1 })).messages;
|
|
151
|
+
const { wrapper, queryClient } = providers(client);
|
|
152
|
+
queryClient.setQueryData(key, { messages: first, hasEarlier: false });
|
|
153
|
+
const { result } = renderHook(() => useConversation("c1", { markRead: false }), { wrapper });
|
|
154
|
+
await waitFor(() => expect(result.current.messages.at(-1)?.body).toBe(`message ${total}`));
|
|
155
|
+
await waitFor(() => expect(result.current.messages).toHaveLength(50));
|
|
156
|
+
expect(result.current.hasEarlier).toBe(true);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("read places", () => {
|
|
161
|
+
const cohort = make({
|
|
162
|
+
id: "c1",
|
|
163
|
+
members: [{ userId: ME }, { userId: SAM }, { userId: LEE }],
|
|
164
|
+
topics: [{ topic: "w1", title: "Week 1" }],
|
|
165
|
+
messages: [
|
|
166
|
+
{ id: "t1", senderId: SAM, body: "in week 1", topic: "w1", createdAt: at(1) },
|
|
167
|
+
{ id: "g1", senderId: LEE, body: "general, unseen", createdAt: at(2) },
|
|
168
|
+
{ id: "t2", senderId: SAM, body: "more week 1", topic: "w1", createdAt: at(3) },
|
|
169
|
+
],
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("a topic view doesn't move your read place (it would pass unseen messages in other topics)", async () => {
|
|
173
|
+
const { wrapper } = providers(cohort);
|
|
174
|
+
const { result } = renderHook(() => useConversation("c1", { topic: "w1" }), { wrapper });
|
|
175
|
+
await waitFor(() => expect(result.current.messages).toHaveLength(2));
|
|
176
|
+
await act(() => new Promise((r) => setTimeout(r, 50)));
|
|
177
|
+
const mine = async () => (await cohort.members("c1")).find((m) => m.userId === ME)!.lastReadSeq;
|
|
178
|
+
expect(await mine()).toBe(0);
|
|
179
|
+
// The whole timeline does.
|
|
180
|
+
renderHook(() => useConversation("c1"), { wrapper });
|
|
181
|
+
await waitFor(async () => expect(await mine()).toBe(3));
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe("link previews", () => {
|
|
186
|
+
const preview = { url: "https://example.test/a", status: "ok" as const, title: "A post", description: null, imageUrl: "https://tracker.test/pixel.png", siteName: "Example", fetchedAt: "" };
|
|
187
|
+
|
|
188
|
+
it("show no image by default: the linked site never learns who read it", () => {
|
|
189
|
+
render(<ChatLinkPreview preview={preview} />);
|
|
190
|
+
expect(screen.getByTestId("genesis-chat-link-preview")).toBeTruthy();
|
|
191
|
+
expect(screen.queryByTestId("genesis-chat-link-preview-image")).toBeNull();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("load the image through the app's proxy when asked, and never without one", () => {
|
|
195
|
+
const { rerender } = render(<ChatLinkPreview preview={preview} images="proxy" proxyUrl={(u) => `/img-proxy?u=${encodeURIComponent(u)}`} />);
|
|
196
|
+
expect(screen.getByTestId("genesis-chat-link-preview-image").getAttribute("src")).toBe("/img-proxy?u=https%3A%2F%2Ftracker.test%2Fpixel.png");
|
|
197
|
+
rerender(<ChatLinkPreview preview={preview} images="proxy" />);
|
|
198
|
+
expect(screen.queryByTestId("genesis-chat-link-preview-image")).toBeNull();
|
|
199
|
+
rerender(<ChatLinkPreview preview={preview} images="direct" />);
|
|
200
|
+
expect(screen.getByTestId("genesis-chat-link-preview-image").getAttribute("src")).toBe(preview.imageUrl);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("ask once per message: a new message with a link doesn't refetch the others", async () => {
|
|
204
|
+
const client = make(dm({ messages: [{ id: "m1", senderId: SAM, body: "https://example.test/a", createdAt: at(1) }, { id: "m2", senderId: SAM, body: "https://example.test/b", createdAt: at(2) }] }), { linkPreviews: [preview] });
|
|
205
|
+
const { wrapper } = providers(client);
|
|
206
|
+
const { result, rerender } = renderHook(({ ids }) => useLinkPreviews(ids), { wrapper, initialProps: { ids: ["m1"] } });
|
|
207
|
+
await waitFor(() => expect(result.current.m1?.[0]?.title).toBe("A post"));
|
|
208
|
+
rerender({ ids: ["m1", "m2"] });
|
|
209
|
+
await waitFor(() => expect(result.current.m2).toBeDefined());
|
|
210
|
+
const asked = client.calls.filter((c) => c.method === "linkPreviewsFor").flatMap((c) => c.args[0] as string[]);
|
|
211
|
+
expect(asked.filter((id) => id === "m1")).toHaveLength(1);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe("the emoji picker", () => {
|
|
216
|
+
it("fetches nothing from a CDN: without emojibaseUrl it isn't offered", async () => {
|
|
217
|
+
const fetchMock = vi.fn();
|
|
218
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
219
|
+
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
220
|
+
render(<ChatEmojiPicker onSelect={() => undefined} />);
|
|
221
|
+
expect(screen.getByTestId("genesis-chat-emoji-picker-unset")).toBeTruthy();
|
|
222
|
+
const client = make(dm());
|
|
223
|
+
mount(client, <ChatThread conversationId="c1" resolveUser={resolveUser} />);
|
|
224
|
+
await screen.findByTestId("genesis-chat-input");
|
|
225
|
+
expect(screen.queryByTestId("genesis-chat-emoji-button")).toBeNull();
|
|
226
|
+
expect(screen.queryByTestId("genesis-chat-more-reactions")).toBeNull();
|
|
227
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("is offered once the app hosts the data", async () => {
|
|
231
|
+
const client = make(dm());
|
|
232
|
+
mount(client, <ChatThread conversationId="c1" resolveUser={resolveUser} emojibaseUrl="/emojibase" />);
|
|
233
|
+
expect(await screen.findByTestId("genesis-chat-emoji-button")).toBeTruthy();
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
describe("retrying a broadcast or a schedule", () => {
|
|
238
|
+
it("a broadcast sent again after a timeout keeps its clientId, and a double click sends once", async () => {
|
|
239
|
+
const client = createMemoryChatClient({ me: ME, conversations: [dm(), { id: "c2", members: [{ userId: ME }, { userId: LEE }] }] });
|
|
240
|
+
const real = client.broadcast.bind(client);
|
|
241
|
+
const ids: string[] = [];
|
|
242
|
+
let fail = true;
|
|
243
|
+
client.broadcast = vi.fn(async (convs: string[], clientId: string, body: string) => {
|
|
244
|
+
ids.push(clientId);
|
|
245
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
246
|
+
if (fail) {
|
|
247
|
+
fail = false;
|
|
248
|
+
throw new ChatError("timeout", undefined, "broadcast_message");
|
|
249
|
+
}
|
|
250
|
+
return real(convs, clientId, body);
|
|
251
|
+
});
|
|
252
|
+
mount(client, <ChatBroadcast resolveUser={resolveUser} />);
|
|
253
|
+
fireEvent.change(screen.getByLabelText("Message"), { target: { value: "Session moved" } });
|
|
254
|
+
await screen.findByText("Lee");
|
|
255
|
+
fireEvent.click(screen.getByRole("button", { name: "Select all" }));
|
|
256
|
+
const send = screen.getByTestId("genesis-chat-broadcast-send");
|
|
257
|
+
fireEvent.click(send);
|
|
258
|
+
fireEvent.click(send);
|
|
259
|
+
await waitFor(() => expect(ids).toHaveLength(1));
|
|
260
|
+
await act(() => new Promise((r) => setTimeout(r, 40)));
|
|
261
|
+
expect(ids).toHaveLength(1);
|
|
262
|
+
fireEvent.click(screen.getByTestId("genesis-chat-broadcast-send"));
|
|
263
|
+
await waitFor(() => expect(ids).toHaveLength(2));
|
|
264
|
+
expect(ids[1]).toBe(ids[0]);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("a schedule tried again after a failure keeps its clientId", async () => {
|
|
268
|
+
const client = make(dm());
|
|
269
|
+
const real = client.schedule.bind(client);
|
|
270
|
+
const ids: string[] = [];
|
|
271
|
+
let fail = true;
|
|
272
|
+
client.schedule = vi.fn(async (input) => {
|
|
273
|
+
ids.push(input.clientId);
|
|
274
|
+
if (fail) throw new ChatError("timeout", undefined, "schedule_message");
|
|
275
|
+
return real(input);
|
|
276
|
+
});
|
|
277
|
+
mount(client, <ChatThread conversationId="c1" resolveUser={resolveUser} />);
|
|
278
|
+
const box = (await screen.findByTestId("genesis-chat-input")) as HTMLTextAreaElement;
|
|
279
|
+
fireEvent.change(box, { target: { value: "Later" } });
|
|
280
|
+
openMenu(await screen.findByTestId("genesis-chat-send-menu"));
|
|
281
|
+
fireEvent.click((await screen.findAllByTestId("genesis-chat-schedule-pick"))[0]!);
|
|
282
|
+
await waitFor(() => expect(ids.length).toBeGreaterThan(0));
|
|
283
|
+
await waitFor(() => expect((screen.getByTestId("genesis-chat-send-menu") as HTMLButtonElement).disabled).toBe(false));
|
|
284
|
+
expect(box.value).toBe("Later");
|
|
285
|
+
fail = false;
|
|
286
|
+
const before = ids.length;
|
|
287
|
+
openMenu(screen.getByTestId("genesis-chat-send-menu"));
|
|
288
|
+
fireEvent.click((await screen.findAllByTestId("genesis-chat-schedule-pick"))[0]!);
|
|
289
|
+
await waitFor(() => expect(box.value).toBe(""));
|
|
290
|
+
expect(ids.length).toBeGreaterThan(before);
|
|
291
|
+
expect(new Set(ids).size).toBe(1);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("the custom time defaults to an hour from when the dialog opens", async () => {
|
|
295
|
+
vi.useFakeTimers({ toFake: ["Date"] });
|
|
296
|
+
const t0 = new Date(2026, 9, 26, 8, 0, 0);
|
|
297
|
+
vi.setSystemTime(t0);
|
|
298
|
+
const client = make(dm());
|
|
299
|
+
mount(client, <ChatThread conversationId="c1" resolveUser={resolveUser} />);
|
|
300
|
+
fireEvent.change(await screen.findByTestId("genesis-chat-input"), { target: { value: "Later" } });
|
|
301
|
+
// The menu (and its dialog) are drawn now, three hours before the dialog opens.
|
|
302
|
+
const menu = await screen.findByTestId("genesis-chat-send-menu");
|
|
303
|
+
vi.setSystemTime(new Date(2026, 9, 26, 11, 0, 0));
|
|
304
|
+
openMenu(menu);
|
|
305
|
+
fireEvent.click(await screen.findByTestId("genesis-chat-schedule-custom"));
|
|
306
|
+
const input = (await screen.findByTestId("genesis-chat-schedule-input")) as HTMLInputElement;
|
|
307
|
+
await waitFor(() => expect(input.value).toBe("2026-10-26T12:00"));
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
describe("the camera", () => {
|
|
312
|
+
const realMedia = Object.getOwnPropertyDescriptor(navigator, "mediaDevices");
|
|
313
|
+
afterEach(() => {
|
|
314
|
+
if (realMedia) Object.defineProperty(navigator, "mediaDevices", realMedia);
|
|
315
|
+
else delete (navigator as { mediaDevices?: unknown }).mediaDevices;
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
class FakeRecorder {
|
|
319
|
+
static isTypeSupported = (t: string) => t === "video/webm";
|
|
320
|
+
state: "inactive" | "recording" = "inactive";
|
|
321
|
+
mimeType = "video/webm";
|
|
322
|
+
ondataavailable: ((e: { data: Blob }) => void) | null = null;
|
|
323
|
+
onstop: (() => void) | null = null;
|
|
324
|
+
start() {
|
|
325
|
+
this.state = "recording";
|
|
326
|
+
}
|
|
327
|
+
stop() {
|
|
328
|
+
this.state = "inactive";
|
|
329
|
+
this.ondataavailable?.({ data: new Blob(["frames"], { type: "video/webm" }) });
|
|
330
|
+
this.onstop?.();
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
async function recordTake() {
|
|
334
|
+
vi.stubGlobal("MediaRecorder", FakeRecorder);
|
|
335
|
+
Object.defineProperty(navigator, "mediaDevices", { configurable: true, value: { getUserMedia: async () => ({ getTracks: () => [{ stop: () => undefined }] }) } });
|
|
336
|
+
fireEvent.click(await screen.findByTestId("genesis-chat-video-button"));
|
|
337
|
+
const record = await screen.findByTestId("genesis-chat-video-record");
|
|
338
|
+
await waitFor(() => expect((record as HTMLButtonElement).disabled).toBe(false));
|
|
339
|
+
await act(async () => {
|
|
340
|
+
fireEvent.click(record);
|
|
341
|
+
});
|
|
342
|
+
fireEvent.click(await screen.findByTestId("genesis-chat-video-stop"));
|
|
343
|
+
await screen.findByTestId("genesis-chat-video-retake");
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
it("cancels a hosted video upload, sending nothing", async () => {
|
|
347
|
+
let signal: AbortSignal | undefined;
|
|
348
|
+
vi.stubGlobal("fetch", vi.fn((_url: string, init: RequestInit) => new Promise((_r, reject) => {
|
|
349
|
+
signal = init.signal ?? undefined;
|
|
350
|
+
signal?.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")));
|
|
351
|
+
})));
|
|
352
|
+
const client = make(dm());
|
|
353
|
+
mount(client, <ChatThread conversationId="c1" resolveUser={resolveUser} />, { videoUpload: { create: async () => ({ url: "https://up.test/1", providerId: "u1", provider: "mux" }) } });
|
|
354
|
+
await recordTake();
|
|
355
|
+
await act(async () => {
|
|
356
|
+
fireEvent.click(screen.getByTestId("genesis-chat-video-send"));
|
|
357
|
+
});
|
|
358
|
+
fireEvent.click(await screen.findByTestId("genesis-chat-video-cancel-upload"));
|
|
359
|
+
await waitFor(() => expect(signal?.aborted).toBe(true));
|
|
360
|
+
await screen.findByTestId("genesis-chat-video-send");
|
|
361
|
+
expect(client.calls.some((c) => c.method === "send")).toBe(false);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it("keeps the take's preview while it uploads, and frees it once the message lands", async () => {
|
|
365
|
+
const revoke = vi.fn();
|
|
366
|
+
vi.stubGlobal("URL", Object.assign(class extends URL {}, { createObjectURL: () => "blob:take", revokeObjectURL: revoke, canParse: URL.canParse }));
|
|
367
|
+
const client = make(dm());
|
|
368
|
+
let land!: () => void;
|
|
369
|
+
const upload = client.upload.bind(client);
|
|
370
|
+
client.upload = vi.fn(async (...args: Parameters<typeof upload>) => {
|
|
371
|
+
await new Promise<void>((r) => (land = r));
|
|
372
|
+
return upload(...args);
|
|
373
|
+
});
|
|
374
|
+
mount(client, <ChatThread conversationId="c1" resolveUser={resolveUser} />);
|
|
375
|
+
await recordTake();
|
|
376
|
+
await act(async () => {
|
|
377
|
+
fireEvent.click(screen.getByTestId("genesis-chat-video-send"));
|
|
378
|
+
});
|
|
379
|
+
await waitFor(() => expect(screen.queryByTestId("genesis-chat-video-recorder")).toBeNull());
|
|
380
|
+
expect(revoke).not.toHaveBeenCalledWith("blob:take");
|
|
381
|
+
await waitFor(() => expect(client.upload).toHaveBeenCalled());
|
|
382
|
+
land();
|
|
383
|
+
await waitFor(() => expect(client.calls.some((c) => c.method === "send")).toBe(true));
|
|
384
|
+
await waitFor(() => expect(revoke).toHaveBeenCalledWith("blob:take"));
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it("turns off a camera that answers after the recorder closed", async () => {
|
|
388
|
+
vi.stubGlobal("MediaRecorder", class { static isTypeSupported = () => true; });
|
|
389
|
+
let answer!: (s: unknown) => void;
|
|
390
|
+
const stop = vi.fn();
|
|
391
|
+
Object.defineProperty(navigator, "mediaDevices", { configurable: true, value: { getUserMedia: () => new Promise((r) => (answer = r)) } });
|
|
392
|
+
const { result, unmount } = renderHook(() => useVideoRecorder({ maxMs: 60_000 }));
|
|
393
|
+
let opened!: Promise<boolean>;
|
|
394
|
+
act(() => {
|
|
395
|
+
opened = result.current.open();
|
|
396
|
+
});
|
|
397
|
+
unmount();
|
|
398
|
+
answer({ getTracks: () => [{ stop }, { stop }] });
|
|
399
|
+
expect(await opened).toBe(false);
|
|
400
|
+
expect(stop).toHaveBeenCalledTimes(2);
|
|
401
|
+
});
|
|
402
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/** @feat:chat The runner's phase 2–4 work: moderation and unfurl effects, scheduled posting, retention. */
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { createChatRunner, type ChatEffect, type LinkUnfurler, type Moderator } from "../server";
|
|
4
|
+
import { fakeSupabase } from "./server-fakes";
|
|
5
|
+
|
|
6
|
+
const effect = (id: number, kind: string, payload: Record<string, unknown>, attempts = 1): ChatEffect => ({ id, kind, key: `${kind}:${id}`, payload, attempts });
|
|
7
|
+
const deliver = async () => {};
|
|
8
|
+
|
|
9
|
+
describe("createChatRunner: moderate", () => {
|
|
10
|
+
const message = { id: "m1", body: "you are useless", deleted_at: null };
|
|
11
|
+
const photo = { id: "a1", message_id: "m1", kind: "image", provider: "storage", path: "c/u/p.jpg", mime: "image/jpeg" };
|
|
12
|
+
const voice = { id: "a2", message_id: "m1", kind: "voice", provider: "storage", path: "c/u/v.m4a", mime: "audio/mp4" };
|
|
13
|
+
|
|
14
|
+
it("claims moderate only with a moderator, sends text and signed photo URLs, and stores the verdict", async () => {
|
|
15
|
+
const moderate = vi.fn(async () => ({ result: "hidden" as const, reason: "Automatic moderation: harassment 0.95" }));
|
|
16
|
+
const moderator: Moderator = { name: "fake", moderate };
|
|
17
|
+
const sb = fakeSupabase({
|
|
18
|
+
rpc: { claim_effects: () => [effect(1, "moderate", { message_id: "m1" })] },
|
|
19
|
+
tables: { messages: [message], attachments: [photo, voice] },
|
|
20
|
+
});
|
|
21
|
+
expect(await createChatRunner({ supabase: sb.client, deliver, moderator }).run()).toMatchObject({ claimed: 1, done: 1, failed: 0 });
|
|
22
|
+
expect(sb.rpcCalls[0]!.args.p_kinds).toEqual(["push", "transcribe", "storage.remove", "video.remove", "moderate"]);
|
|
23
|
+
expect(sb.signed).toEqual(["c/u/p.jpg@600"]);
|
|
24
|
+
expect(moderate).toHaveBeenCalledWith({ text: "you are useless", imageUrls: ["https://files.test/c/u/p.jpg?token=x"] });
|
|
25
|
+
expect(sb.rpcCalls).toContainEqual({
|
|
26
|
+
fn: "moderate_message",
|
|
27
|
+
args: { p_message_id: "m1", p_result: "hidden", p_reason: "Automatic moderation: harassment 0.95" },
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("sends every photo, in position order, so none past the first few escapes the verdict", async () => {
|
|
32
|
+
const moderate = vi.fn(async () => ({ result: "clean" as const }));
|
|
33
|
+
const photos = [5, 0, 3, 1, 4, 2].map((position) => ({ ...photo, id: `p${position}`, position, path: `c/u/${position}.jpg` }));
|
|
34
|
+
const sb = fakeSupabase({
|
|
35
|
+
rpc: { claim_effects: () => [effect(1, "moderate", { message_id: "m1" })] },
|
|
36
|
+
tables: { messages: [message], attachments: photos },
|
|
37
|
+
});
|
|
38
|
+
await createChatRunner({ supabase: sb.client, deliver, moderator: { name: "fake", moderate } }).run();
|
|
39
|
+
expect(moderate).toHaveBeenCalledWith({ text: "you are useless", imageUrls: [0, 1, 2, 3, 4, 5].map((n) => `https://files.test/c/u/${n}.jpg?token=x`) });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("does not claim moderate without a moderator", async () => {
|
|
43
|
+
const sb = fakeSupabase({ rpc: { claim_effects: () => [] } });
|
|
44
|
+
await createChatRunner({ supabase: sb.client, deliver }).run();
|
|
45
|
+
expect(sb.rpcCalls[0]!.args.p_kinds).not.toContain("moderate");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("calls a deleted message clean (releasing its held push) without judging it, and retries a provider failure", async () => {
|
|
49
|
+
const moderate = vi.fn(async () => ({ result: "clean" as const }));
|
|
50
|
+
const gone = fakeSupabase({
|
|
51
|
+
rpc: { claim_effects: () => [effect(1, "moderate", { message_id: "m1" })] },
|
|
52
|
+
tables: { messages: [{ ...message, deleted_at: "2026-10-25T00:00:00Z" }] },
|
|
53
|
+
});
|
|
54
|
+
expect(await createChatRunner({ supabase: gone.client, deliver, moderator: { name: "fake", moderate } }).run()).toMatchObject({ done: 1 });
|
|
55
|
+
expect(moderate).not.toHaveBeenCalled();
|
|
56
|
+
expect(gone.rpcCalls).toContainEqual({ fn: "moderate_message", args: { p_message_id: "m1", p_result: "clean", p_reason: null } });
|
|
57
|
+
|
|
58
|
+
const down = fakeSupabase({ rpc: { claim_effects: () => [effect(1, "moderate", { message_id: "m1" })] }, tables: { messages: [message] } });
|
|
59
|
+
const failing: Moderator = { name: "fake", moderate: () => Promise.reject(new Error("OpenAI moderation answered HTTP 503")) };
|
|
60
|
+
expect(await createChatRunner({ supabase: down.client, deliver, moderator: failing }).run()).toMatchObject({ failed: 1 });
|
|
61
|
+
expect(down.rpcCalls).toContainEqual({ fn: "complete_effect", args: { p_id: 1, p_error: "OpenAI moderation answered HTTP 503" } });
|
|
62
|
+
expect(down.rpcCalls.some((c) => c.fn === "moderate_message")).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("never fails open silently: on the last attempt it files flagged, moderation unavailable", async () => {
|
|
66
|
+
const sb = fakeSupabase({ rpc: { claim_effects: () => [effect(1, "moderate", { message_id: "m1" }, 8)] }, tables: { messages: [message] } });
|
|
67
|
+
const failing: Moderator = { name: "fake", moderate: () => Promise.reject(new Error("OpenAI moderation answered HTTP 503")) };
|
|
68
|
+
expect(await createChatRunner({ supabase: sb.client, deliver, moderator: failing }).run()).toMatchObject({ failed: 1 });
|
|
69
|
+
expect(sb.rpcCalls).toContainEqual({
|
|
70
|
+
fn: "moderate_message",
|
|
71
|
+
args: { p_message_id: "m1", p_result: "flagged", p_reason: "moderation unavailable" },
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("createChatRunner: unfurl", () => {
|
|
77
|
+
it("saves an ok preview with its fields", async () => {
|
|
78
|
+
const unfurler: LinkUnfurler = {
|
|
79
|
+
unfurl: vi.fn(async (url: string) => ({ url, status: "ok" as const, title: "Plan", description: "Week 3", imageUrl: "https://x.test/i.png", siteName: "X" })),
|
|
80
|
+
};
|
|
81
|
+
const sb = fakeSupabase({ rpc: { claim_effects: () => [effect(1, "unfurl", { url: "https://x.test/plan" })] } });
|
|
82
|
+
expect(await createChatRunner({ supabase: sb.client, deliver, unfurler }).run()).toMatchObject({ done: 1 });
|
|
83
|
+
expect(sb.rpcCalls[0]!.args.p_kinds).toContain("unfurl");
|
|
84
|
+
expect(sb.rpcCalls).toContainEqual({
|
|
85
|
+
fn: "save_link_preview",
|
|
86
|
+
args: { p_url: "https://x.test/plan", p_status: "ok", p_title: "Plan", p_description: "Week 3", p_image_url: "https://x.test/i.png", p_site_name: "X" },
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("saves blocked and failed statuses without fields", async () => {
|
|
91
|
+
const unfurler: LinkUnfurler = { unfurl: async (url) => ({ url, status: "blocked", title: "should not be kept" }) };
|
|
92
|
+
const sb = fakeSupabase({ rpc: { claim_effects: () => [effect(1, "unfurl", { url: "https://10.0.0.1/" })] } });
|
|
93
|
+
await createChatRunner({ supabase: sb.client, deliver, unfurler }).run();
|
|
94
|
+
expect(sb.rpcCalls).toContainEqual({
|
|
95
|
+
fn: "save_link_preview",
|
|
96
|
+
args: { p_url: "https://10.0.0.1/", p_status: "blocked", p_title: null, p_description: null, p_image_url: null, p_site_name: null },
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("createChatRunner: scheduled messages", () => {
|
|
102
|
+
it("posts each due message and counts posted and refused", async () => {
|
|
103
|
+
const sb = fakeSupabase({
|
|
104
|
+
rpc: {
|
|
105
|
+
claim_effects: () => [],
|
|
106
|
+
due_scheduled: () => ["s1", "s2", "s3"],
|
|
107
|
+
post_scheduled: (a) => (a.p_id === "s2" ? null : `msg-${String(a.p_id)}`),
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
const report = await createChatRunner({ supabase: sb.client, deliver }).run();
|
|
111
|
+
expect(report.scheduled).toEqual({ due: 3, posted: 2, skipped: 1, failed: 0 });
|
|
112
|
+
expect(sb.rpcCalls).toContainEqual({ fn: "due_scheduled", args: { p_limit: 50 } });
|
|
113
|
+
expect(sb.rpcCalls.filter((c) => c.fn === "post_scheduled").map((c) => c.args.p_id)).toEqual(["s1", "s2", "s3"]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("reports a failing due_scheduled without failing the run, and can be turned off", async () => {
|
|
117
|
+
const sb = fakeSupabase({
|
|
118
|
+
rpc: {
|
|
119
|
+
claim_effects: () => [],
|
|
120
|
+
due_scheduled: () => {
|
|
121
|
+
throw new Error("42501");
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
const report = await createChatRunner({ supabase: sb.client, deliver }).run();
|
|
126
|
+
expect(report).toMatchObject({ claimed: 0, scheduled: { due: 0, posted: 0, skipped: 0, failed: 0 }, errors: ["due_scheduled: 42501"] });
|
|
127
|
+
|
|
128
|
+
const off = fakeSupabase({ rpc: { claim_effects: () => [] } });
|
|
129
|
+
const quiet = await createChatRunner({ supabase: off.client, deliver, scheduled: false }).run();
|
|
130
|
+
expect(quiet.scheduled).toBeUndefined();
|
|
131
|
+
expect(off.rpcCalls.some((c) => c.fn === "due_scheduled")).toBe(false);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("createChatRunner: retention", () => {
|
|
136
|
+
it("purges only when asked, in batches until a short one", async () => {
|
|
137
|
+
const answers = [1000, 1000, 12];
|
|
138
|
+
const sb = fakeSupabase({ rpc: { claim_effects: () => [], purge_expired: () => answers.shift() ?? 0 } });
|
|
139
|
+
expect((await createChatRunner({ supabase: sb.client, deliver }).run()).purged).toBeUndefined();
|
|
140
|
+
expect(sb.rpcCalls.some((c) => c.fn === "purge_expired")).toBe(false);
|
|
141
|
+
|
|
142
|
+
const report = await createChatRunner({ supabase: sb.client, deliver, retention: true }).run();
|
|
143
|
+
expect(report.purged).toBe(2012);
|
|
144
|
+
expect(sb.rpcCalls.filter((c) => c.fn === "purge_expired").map((c) => c.args)).toEqual([{ p_limit: 1000 }, { p_limit: 1000 }, { p_limit: 1000 }]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("reports a purge failure", async () => {
|
|
148
|
+
const sb = fakeSupabase({
|
|
149
|
+
rpc: {
|
|
150
|
+
claim_effects: () => [],
|
|
151
|
+
purge_expired: () => {
|
|
152
|
+
throw new Error("57014");
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
expect(await createChatRunner({ supabase: sb.client, deliver, retention: true, scheduled: false }).run()).toMatchObject({ purged: 0, errors: ["purge_expired: 57014"] });
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/** @feat:chat Fakes for the server tests: a supabase-js shaped object and a fetch. */
|
|
2
|
+
import type { ChatSupabase } from "../core/client";
|
|
3
|
+
|
|
4
|
+
export type Row = Record<string, unknown>;
|
|
5
|
+
type RpcHandler = (args: Record<string, unknown>) => unknown;
|
|
6
|
+
type Err = { message?: string; code?: string; statusCode?: string } | null;
|
|
7
|
+
|
|
8
|
+
export interface FakeSupabase {
|
|
9
|
+
client: ChatSupabase;
|
|
10
|
+
rpcCalls: { fn: string; args: Record<string, unknown> }[];
|
|
11
|
+
removed: { bucket: string; paths: string[] }[];
|
|
12
|
+
signed: string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** rpc results by function name; tables as arrays of rows, filtered by eq(). */
|
|
16
|
+
export function fakeSupabase(opts: {
|
|
17
|
+
rpc?: Record<string, RpcHandler>;
|
|
18
|
+
tables?: Record<string, Row[]>;
|
|
19
|
+
removeError?: Err;
|
|
20
|
+
signedUrlError?: Err;
|
|
21
|
+
}): FakeSupabase {
|
|
22
|
+
const rpcCalls: FakeSupabase["rpcCalls"] = [];
|
|
23
|
+
const removed: FakeSupabase["removed"] = [];
|
|
24
|
+
const signed: string[] = [];
|
|
25
|
+
const builder = (table: string) => {
|
|
26
|
+
const filters: [string, unknown][] = [];
|
|
27
|
+
let orderBy: string | null = null;
|
|
28
|
+
const sorted = (list: Row[]) => (orderBy ? [...list].sort((x, y) => Number(x[orderBy!]) - Number(y[orderBy!])) : list);
|
|
29
|
+
const rows = () => sorted((opts.tables?.[table] ?? []).filter((r) => filters.every(([k, v]) => r[k] === v)));
|
|
30
|
+
const b = {
|
|
31
|
+
select: () => b,
|
|
32
|
+
eq: (k: string, v: unknown) => (filters.push([k, v]), b),
|
|
33
|
+
order: (k: string) => ((orderBy = k), b),
|
|
34
|
+
limit: () => b,
|
|
35
|
+
maybeSingle: async () => ({ data: rows()[0] ?? null, error: null }),
|
|
36
|
+
// Awaiting the builder itself lists every matching row.
|
|
37
|
+
then: (resolve: (v: { data: Row[]; error: null }) => unknown, reject?: (e: unknown) => unknown) =>
|
|
38
|
+
Promise.resolve({ data: rows(), error: null }).then(resolve, reject),
|
|
39
|
+
};
|
|
40
|
+
return b;
|
|
41
|
+
};
|
|
42
|
+
const client = {
|
|
43
|
+
schema: () => ({
|
|
44
|
+
rpc: async (fn: string, args: Record<string, unknown>) => {
|
|
45
|
+
rpcCalls.push({ fn, args });
|
|
46
|
+
const h = opts.rpc?.[fn];
|
|
47
|
+
try {
|
|
48
|
+
return { data: h ? h(args) : null, error: null };
|
|
49
|
+
} catch (e) {
|
|
50
|
+
return { data: null, error: { code: (e as Error).message } };
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
from: builder,
|
|
54
|
+
}),
|
|
55
|
+
storage: {
|
|
56
|
+
from: (bucket: string) => ({
|
|
57
|
+
remove: async (paths: string[]) => {
|
|
58
|
+
removed.push({ bucket, paths });
|
|
59
|
+
return { data: opts.removeError ? null : paths.map((name) => ({ name })), error: opts.removeError ?? null };
|
|
60
|
+
},
|
|
61
|
+
createSignedUrl: async (path: string, seconds: number) => {
|
|
62
|
+
signed.push(`${path}@${seconds}`);
|
|
63
|
+
return opts.signedUrlError
|
|
64
|
+
? { data: null, error: opts.signedUrlError }
|
|
65
|
+
: { data: { signedUrl: `https://files.test/${path}?token=x` }, error: null };
|
|
66
|
+
},
|
|
67
|
+
}),
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
return { client: client as unknown as ChatSupabase, rpcCalls, removed, signed };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface FakeFetch {
|
|
74
|
+
fetch: typeof fetch;
|
|
75
|
+
calls: { url: string; init: RequestInit | undefined }[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A fetch answering with `respond(url, init)`. */
|
|
79
|
+
export function fakeFetch(respond: (url: string, init: RequestInit | undefined) => Response | Promise<Response>): FakeFetch {
|
|
80
|
+
const calls: FakeFetch["calls"] = [];
|
|
81
|
+
const f = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
82
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
83
|
+
calls.push({ url, init });
|
|
84
|
+
return respond(url, init);
|
|
85
|
+
}) as typeof fetch;
|
|
86
|
+
return { fetch: f, calls };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const jsonResponse = (body: unknown, status = 200) =>
|
|
90
|
+
new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
|