@marktiderman/genesis-chat 0.1.1 → 0.2.1
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 +379 -13
- package/dist/chunk-A4YJX7KT.js +544 -0
- package/dist/chunk-A4YJX7KT.js.map +1 -0
- package/dist/chunk-AZS3HNLM.js +30 -0
- package/dist/chunk-AZS3HNLM.js.map +1 -0
- package/dist/chunk-BJCE5O7V.js +70 -0
- package/dist/chunk-BJCE5O7V.js.map +1 -0
- package/dist/chunk-GBJ3YI4E.js +966 -0
- package/dist/chunk-GBJ3YI4E.js.map +1 -0
- package/dist/chunk-JU2FAPBI.js +609 -0
- package/dist/chunk-JU2FAPBI.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-DUIG1hum.d.ts} +187 -11
- package/dist/index.d.ts +78 -163
- package/dist/index.js +3 -2
- package/dist/native.d.ts +1939 -1
- package/dist/native.js +5023 -0
- package/dist/native.js.map +1 -1
- package/dist/outbox-B9NAWQ58.d.ts +219 -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 +176 -7
- package/dist/react.js +3 -3
- package/dist/server.d.ts +551 -21
- package/dist/server.js +976 -34
- package/dist/server.js.map +1 -1
- package/dist/thread-oPra7pkW.d.ts +57 -0
- package/dist/web.d.ts +335 -12
- package/dist/web.js +1784 -64
- package/dist/web.js.map +1 -1
- package/migrations/0002_genesis_chat_coaching.sql +934 -0
- package/migrations/0003_genesis_chat_search_threads.sql +85 -0
- package/migrations/tests/0002_genesis_chat_coaching.test.sql +269 -0
- package/migrations/tests/0003_genesis_chat_search_threads.test.sql +50 -0
- package/package.json +78 -6
- package/src/__tests__/coaching-core.test.ts +334 -0
- package/src/__tests__/coaching-react.test.tsx +319 -0
- package/src/__tests__/coaching-web.test.tsx +521 -0
- package/src/__tests__/core.test.ts +91 -0
- package/src/__tests__/fake-xhr.ts +54 -0
- package/src/__tests__/outbox-hosted.test.ts +269 -0
- package/src/__tests__/outbox.test.ts +122 -0
- package/src/__tests__/realtime.test.ts +70 -0
- package/src/__tests__/review-279.test.ts +41 -0
- package/src/__tests__/review-fixes-core.test.ts +193 -0
- package/src/__tests__/review-fixes-web.test.tsx +405 -0
- package/src/__tests__/server-effects.test.ts +158 -0
- package/src/__tests__/server-fake-video.test.ts +81 -0
- package/src/__tests__/server-fakes.ts +96 -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 +303 -0
- package/src/__tests__/web-markdown.test.tsx +88 -0
- package/src/__tests__/web.test.tsx +349 -0
- package/src/core/availability.ts +223 -0
- package/src/core/client.ts +783 -0
- package/src/core/extras.ts +106 -0
- package/src/core/media.ts +144 -0
- package/src/core/outbox.ts +635 -0
- package/src/core/thread.ts +171 -0
- package/src/core/transfer.ts +71 -0
- package/src/core/types.ts +328 -0
- package/src/index.ts +14 -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 +254 -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 +424 -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 +498 -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 +132 -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 +122 -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 +126 -0
- package/src/native/message-bubble.tsx +366 -0
- package/src/native/moderation.tsx +42 -0
- package/src/native/modules.ts +277 -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 +328 -0
- package/src/native/search-screen.tsx +189 -0
- package/src/native/stack-options.ts +36 -0
- package/src/native/start-sheet.tsx +87 -0
- package/src/native/thread-screen.tsx +647 -0
- package/src/native/topics.tsx +234 -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 +410 -0
- package/src/native/video-recorder-state.ts +140 -0
- package/src/native/video-recorder.tsx +274 -0
- package/src/native/video-upload-tray.tsx +77 -0
- package/src/native/video-upload.ts +432 -0
- package/src/native/voice-player.tsx +301 -0
- package/src/react/index.tsx +894 -0
- package/src/react/previews.ts +51 -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/fake-video.ts +171 -0
- package/src/server/handler.ts +32 -0
- package/src/server/index.ts +58 -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 +585 -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 +196 -0
- package/src/web/index.ts +75 -0
- package/src/web/markdown.tsx +245 -0
- package/src/web/memory-client.ts +714 -0
- package/src/web/panel.tsx +135 -0
- package/src/web/schedule.tsx +204 -0
- package/src/web/search.tsx +129 -0
- package/src/web/thread-context.ts +64 -0
- package/src/web/thread.tsx +830 -0
- package/src/web/topics.tsx +160 -0
- package/src/web/use-voice-recorder.ts +264 -0
- package/src/web/video.tsx +516 -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 +0 -525
- package/dist/chunk-JQXZUEEL.js.map +0 -1
- package/dist/chunk-SAY24E6V.js +0 -309
- package/dist/chunk-SAY24E6V.js.map +0 -1
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/** @feat:chat */
|
|
2
|
+
import { createHmac, createPublicKey, generateKeyPairSync, verify } from "node:crypto";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import {
|
|
5
|
+
createExpoDeliver,
|
|
6
|
+
deadTokens,
|
|
7
|
+
deepgramTranscriber,
|
|
8
|
+
chatUploadPassthrough,
|
|
9
|
+
muxPlaybackUrl,
|
|
10
|
+
muxVideo,
|
|
11
|
+
openAiTranscriber,
|
|
12
|
+
verifyMuxSignature,
|
|
13
|
+
type ChatNotification,
|
|
14
|
+
} from "../server";
|
|
15
|
+
import { fakeFetch, fakeSupabase, jsonResponse } from "./server-fakes";
|
|
16
|
+
|
|
17
|
+
const SECRET = "mux-webhook-secret";
|
|
18
|
+
const NOW = 1_761_400_000_000;
|
|
19
|
+
const sign = (body: string, t: number, secret = SECRET) => `t=${t},v1=${createHmac("sha256", secret).update(`${t}.${body}`).digest("hex")}`;
|
|
20
|
+
|
|
21
|
+
describe("verifyMuxSignature", () => {
|
|
22
|
+
const body = JSON.stringify({ type: "video.asset.ready", data: { id: "asset1" } });
|
|
23
|
+
const t = Math.floor(NOW / 1000);
|
|
24
|
+
|
|
25
|
+
it("accepts a valid signature", async () => {
|
|
26
|
+
expect(await verifyMuxSignature(sign(body, t), body, SECRET, { now: NOW })).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
it("refuses a tampered body or a wrong secret", async () => {
|
|
29
|
+
expect(await verifyMuxSignature(sign(body, t), body.replace("asset1", "asset2"), SECRET, { now: NOW })).toBe(false);
|
|
30
|
+
expect(await verifyMuxSignature(sign(body, t, "other"), body, SECRET, { now: NOW })).toBe(false);
|
|
31
|
+
expect(await verifyMuxSignature(null, body, SECRET, { now: NOW })).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
it("refuses a stale timestamp (over 5 minutes)", async () => {
|
|
34
|
+
expect(await verifyMuxSignature(sign(body, t - 301), body, SECRET, { now: NOW })).toBe(false);
|
|
35
|
+
expect(await verifyMuxSignature(sign(body, t - 299), body, SECRET, { now: NOW })).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("muxVideo", () => {
|
|
40
|
+
const mux = (f = fakeFetch(() => jsonResponse({}))) =>
|
|
41
|
+
muxVideo({ tokenId: "id", tokenSecret: "secret", webhookSecret: SECRET, fetch: f.fetch, now: () => NOW });
|
|
42
|
+
const webhook = (event: unknown, header?: string) => {
|
|
43
|
+
const body = JSON.stringify(event);
|
|
44
|
+
return new Request("https://app.test/api/mux", { method: "POST", body, headers: { "mux-signature": header ?? sign(body, Math.floor(NOW / 1000)) } });
|
|
45
|
+
};
|
|
46
|
+
const CONV = "2d8f2e4a-6b3c-4e5b-9c73-8b2b4d2f6c33";
|
|
47
|
+
const OTHER_CONV = "4fab4a6c-8d5e-4a7d-9e95-ad4d6f4b8e55";
|
|
48
|
+
const SENDER = "5abc5b7d-9e6f-4b8e-8fa6-be5e7a5c9f66";
|
|
49
|
+
const pendingVideo = {
|
|
50
|
+
id: "0b6f0c2e-4f1a-4c39-9a51-6f0f2b0d4a11",
|
|
51
|
+
conversation_id: CONV,
|
|
52
|
+
message_id: "m1",
|
|
53
|
+
provider: "mux",
|
|
54
|
+
provider_id: "upload1",
|
|
55
|
+
playback_id: null,
|
|
56
|
+
transcript_status: "pending",
|
|
57
|
+
};
|
|
58
|
+
const bound = chatUploadPassthrough({ conversationId: CONV });
|
|
59
|
+
|
|
60
|
+
it("creates a direct upload with captions and the passthrough", async () => {
|
|
61
|
+
const f = fakeFetch(() => jsonResponse({ data: { id: "upload1", url: "https://storage.mux.test/u" } }, 201));
|
|
62
|
+
expect(await mux(f).createUpload({ corsOrigin: "https://app.test", passthrough: "att1" })).toEqual({ uploadId: "upload1", url: "https://storage.mux.test/u" });
|
|
63
|
+
expect(f.calls[0]!.url).toBe("https://api.mux.com/video/v1/uploads");
|
|
64
|
+
expect((f.calls[0]!.init!.headers as Record<string, string>).authorization).toBe(`Basic ${btoa("id:secret")}`);
|
|
65
|
+
expect(JSON.parse(String(f.calls[0]!.init!.body))).toEqual({
|
|
66
|
+
cors_origin: "https://app.test",
|
|
67
|
+
new_asset_settings: {
|
|
68
|
+
playback_policies: ["public"],
|
|
69
|
+
video_quality: "basic",
|
|
70
|
+
passthrough: "att1",
|
|
71
|
+
inputs: [{ generated_subtitles: [{ language_code: "en", name: "English (auto)" }] }],
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("removes an asset, and counts 404 as removed", async () => {
|
|
77
|
+
const f = fakeFetch(() => new Response(null, { status: 404 }));
|
|
78
|
+
await expect(mux(f).remove("asset1")).resolves.toBeUndefined();
|
|
79
|
+
expect(f.calls[0]).toMatchObject({ url: "https://api.mux.com/video/v1/assets/asset1", init: { method: "DELETE" } });
|
|
80
|
+
await expect(mux(fakeFetch(() => new Response(null, { status: 500 }))).remove("a")).rejects.toThrow("HTTP 500");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("refuses a webhook with a bad signature", async () => {
|
|
84
|
+
const sb = fakeSupabase({ tables: { attachments: [pendingVideo] } });
|
|
85
|
+
const res = await mux().handleWebhook(webhook({ type: "video.asset.ready", data: {} }, "t=1,v1=00"), { supabase: sb.client });
|
|
86
|
+
expect(res.status).toBe(401);
|
|
87
|
+
expect(sb.rpcCalls).toEqual([]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("marks the attachment ready on video.asset.ready, found by upload id", async () => {
|
|
91
|
+
const sb = fakeSupabase({ tables: { attachments: [pendingVideo] } });
|
|
92
|
+
const res = await mux().handleWebhook(
|
|
93
|
+
webhook({
|
|
94
|
+
type: "video.asset.ready",
|
|
95
|
+
data: { id: "asset1", upload_id: "upload1", passthrough: bound, duration: 12.345, playback_ids: [{ id: "play1", policy: "public" }] },
|
|
96
|
+
}),
|
|
97
|
+
{ supabase: sb.client },
|
|
98
|
+
);
|
|
99
|
+
expect(res.status).toBe(200);
|
|
100
|
+
expect(sb.rpcCalls).toEqual([
|
|
101
|
+
{
|
|
102
|
+
fn: "update_attachment",
|
|
103
|
+
args: { p_attachment_id: pendingVideo.id, p_patch: { status: "ready", provider_id: "asset1", playback_id: "play1", duration_ms: 12345 } },
|
|
104
|
+
},
|
|
105
|
+
]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("marks it failed on video.asset.errored", async () => {
|
|
109
|
+
const sb = fakeSupabase({ tables: { attachments: [pendingVideo] } });
|
|
110
|
+
await mux().handleWebhook(webhook({ type: "video.asset.errored", data: { id: "asset1", upload_id: "upload1", passthrough: bound } }), {
|
|
111
|
+
supabase: sb.client,
|
|
112
|
+
});
|
|
113
|
+
expect(sb.rpcCalls[0]!.args).toEqual({ p_attachment_id: pendingVideo.id, p_patch: { status: "failed", provider_id: "asset1" } });
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("never finds an attachment by passthrough: a passthrough naming another attachment swaps nothing", async () => {
|
|
117
|
+
// The attacker's own upload (upload2) carries the victim attachment's id as passthrough.
|
|
118
|
+
const sb = fakeSupabase({ tables: { attachments: [pendingVideo] } });
|
|
119
|
+
const ready = (data: Record<string, unknown>) =>
|
|
120
|
+
mux().handleWebhook(webhook({ type: "video.asset.ready", data: { id: "asset-evil", playback_ids: [{ id: "evil", policy: "public" }], ...data } }), {
|
|
121
|
+
supabase: sb.client,
|
|
122
|
+
});
|
|
123
|
+
expect((await ready({ upload_id: "upload2", passthrough: pendingVideo.id })).status).toBe(404);
|
|
124
|
+
expect(sb.rpcCalls).toEqual([]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("applies an event only when its passthrough names the attachment's conversation and sender", async () => {
|
|
128
|
+
const sb = fakeSupabase({ tables: { attachments: [pendingVideo], messages: [{ id: "m1", sender_id: SENDER }] } });
|
|
129
|
+
const ready = (passthrough: string | undefined) =>
|
|
130
|
+
mux().handleWebhook(webhook({ type: "video.asset.ready", data: { id: "asset1", upload_id: "upload1", passthrough } }), { supabase: sb.client });
|
|
131
|
+
// An upload minted for another conversation, someone else's, or not by the upload handler at all.
|
|
132
|
+
expect((await ready(chatUploadPassthrough({ conversationId: OTHER_CONV }))).status).toBe(409);
|
|
133
|
+
expect((await ready(chatUploadPassthrough({ conversationId: CONV, userId: "6bcd6c8e-af70-4c9f-9ab7-cf6f8b6daa77" }))).status).toBe(409);
|
|
134
|
+
expect((await ready(pendingVideo.id)).status).toBe(409);
|
|
135
|
+
expect((await ready(undefined)).status).toBe(409);
|
|
136
|
+
expect(sb.rpcCalls).toEqual([]);
|
|
137
|
+
expect((await ready(chatUploadPassthrough({ conversationId: CONV, userId: SENDER }))).status).toBe(200);
|
|
138
|
+
expect(sb.rpcCalls).toHaveLength(1);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("answers 404 for an unknown attachment so Mux retries", async () => {
|
|
142
|
+
const sb = fakeSupabase({ tables: { attachments: [] } });
|
|
143
|
+
const res = await mux().handleWebhook(webhook({ type: "video.asset.ready", data: { id: "x", upload_id: "nope" } }), { supabase: sb.client });
|
|
144
|
+
expect(res.status).toBe(404);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("keeps generated captions as the transcript", async () => {
|
|
148
|
+
const ready = { ...pendingVideo, provider_id: "asset1", playback_id: "play1" };
|
|
149
|
+
const sb = fakeSupabase({ tables: { attachments: [ready] } });
|
|
150
|
+
const f = fakeFetch(() => new Response("Hello from the gym.\n"));
|
|
151
|
+
const res = await mux(f).handleWebhook(
|
|
152
|
+
webhook({ type: "video.asset.track.ready", data: { id: "track1", asset_id: "asset1", type: "text", text_source: "generated_vod" } }),
|
|
153
|
+
{ supabase: sb.client },
|
|
154
|
+
);
|
|
155
|
+
expect(res.status).toBe(200);
|
|
156
|
+
expect(f.calls[0]!.url).toBe("https://stream.mux.com/play1/text/track1.txt");
|
|
157
|
+
expect(sb.rpcCalls[0]!.args).toEqual({ p_attachment_id: ready.id, p_patch: { transcript: "Hello from the gym.", transcript_status: "ready" } });
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("signs playback URLs with RS256 (Mux's base64 PKCS#1 key)", async () => {
|
|
161
|
+
const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
|
|
162
|
+
const pem = privateKey.export({ type: "pkcs1", format: "pem" }).toString();
|
|
163
|
+
const url = await muxPlaybackUrl("play1", { signingKeyId: "kid1", privateKey: btoa(pem) }, { now: NOW, expiresInSeconds: 60 });
|
|
164
|
+
const token = new URL(url).searchParams.get("token")!;
|
|
165
|
+
expect(url.startsWith("https://stream.mux.com/play1.m3u8?token=")).toBe(true);
|
|
166
|
+
const [h, c, s] = token.split(".");
|
|
167
|
+
const decode = (x: string) => JSON.parse(Buffer.from(x, "base64url").toString());
|
|
168
|
+
expect(decode(h!)).toEqual({ alg: "RS256", typ: "JWT", kid: "kid1" });
|
|
169
|
+
expect(decode(c!)).toEqual({ sub: "play1", aud: "v", exp: Math.floor(NOW / 1000) + 60 });
|
|
170
|
+
const ok = verify("RSA-SHA256", Buffer.from(`${h}.${c}`), createPublicKey(publicKey.export({ type: "spki", format: "pem" })), Buffer.from(s!, "base64url"));
|
|
171
|
+
expect(ok).toBe(true);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe("transcribers", () => {
|
|
176
|
+
const input = { url: "https://files.test/v.m4a?token=x", mime: "audio/mp4", durationMs: 4000 };
|
|
177
|
+
|
|
178
|
+
it("Deepgram: posts the URL and reads the first alternative", async () => {
|
|
179
|
+
const f = fakeFetch(() => jsonResponse({ results: { channels: [{ detected_language: "en", alternatives: [{ transcript: " Hi there. " }] }] } }));
|
|
180
|
+
expect(await deepgramTranscriber({ apiKey: "dg", fetch: f.fetch }).transcribe(input)).toEqual({ text: "Hi there.", language: "en" });
|
|
181
|
+
expect(f.calls[0]!.url).toBe("https://api.deepgram.com/v1/listen?model=nova-3&smart_format=true&detect_language=true");
|
|
182
|
+
expect((f.calls[0]!.init!.headers as Record<string, string>).authorization).toBe("Token dg");
|
|
183
|
+
expect(JSON.parse(String(f.calls[0]!.init!.body))).toEqual({ url: input.url });
|
|
184
|
+
await expect(deepgramTranscriber({ apiKey: "dg", fetch: fakeFetch(() => new Response("no", { status: 402 })).fetch }).transcribe(input)).rejects.toThrow("HTTP 402");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("OpenAI: downloads the file and posts it as multipart", async () => {
|
|
188
|
+
const f = fakeFetch((url) => (url.startsWith("https://files.test") ? new Response(new Uint8Array([1, 2, 3])) : jsonResponse({ text: "Hi." })));
|
|
189
|
+
expect(await openAiTranscriber({ apiKey: "sk", fetch: f.fetch }).transcribe(input)).toEqual({ text: "Hi.", language: undefined });
|
|
190
|
+
const form = f.calls[1]!.init!.body as FormData;
|
|
191
|
+
expect(f.calls[1]!.url).toBe("https://api.openai.com/v1/audio/transcriptions");
|
|
192
|
+
expect(form.get("model")).toBe("gpt-4o-mini-transcribe");
|
|
193
|
+
expect((form.get("file") as File).name).toBe("audio.m4a");
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe("createExpoDeliver", () => {
|
|
198
|
+
const notification: ChatNotification = {
|
|
199
|
+
category: "messages",
|
|
200
|
+
messageId: "m1",
|
|
201
|
+
conversationId: "c1",
|
|
202
|
+
recipients: [
|
|
203
|
+
{ userId: "bo", badge: 2, mentioned: false },
|
|
204
|
+
{ userId: "cy", badge: 5, mentioned: true },
|
|
205
|
+
],
|
|
206
|
+
title: "Ana",
|
|
207
|
+
body: "see you",
|
|
208
|
+
senderId: "ana",
|
|
209
|
+
collapseKey: "chat:c1",
|
|
210
|
+
threadKey: "c1",
|
|
211
|
+
idempotencyKey: "push:m1",
|
|
212
|
+
data: { url: "/messages/c1", conversationId: "c1", messageId: "m1", seq: 7 },
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
it("sends in chunks of 100 with badges, thread and collapse ids, and returns the tickets", async () => {
|
|
216
|
+
const devices = Array.from({ length: 250 }, (_, i) => ({ userId: i % 2 ? "cy" : "bo", token: `ExponentPushToken[${i}]` }));
|
|
217
|
+
const f = fakeFetch((_url, init) => {
|
|
218
|
+
const batch = JSON.parse(String(init!.body)) as { to: string }[];
|
|
219
|
+
return jsonResponse({ data: batch.map((m) => (m.to === "ExponentPushToken[7]" ? { status: "error", details: { error: "DeviceNotRegistered" } } : { status: "ok", id: `t-${m.to}` })) });
|
|
220
|
+
});
|
|
221
|
+
const deliver = createExpoDeliver({ tokensFor: async () => devices, fetch: f.fetch, accessToken: "expo", collapse: true });
|
|
222
|
+
const tickets = await deliver(notification);
|
|
223
|
+
expect(f.calls.map((c) => (JSON.parse(String(c.init!.body)) as unknown[]).length)).toEqual([100, 100, 50]);
|
|
224
|
+
const first = (JSON.parse(String(f.calls[0]!.init!.body)) as Record<string, unknown>[])[1];
|
|
225
|
+
expect(first).toEqual({
|
|
226
|
+
to: "ExponentPushToken[1]",
|
|
227
|
+
title: "Ana",
|
|
228
|
+
body: "see you",
|
|
229
|
+
data: notification.data,
|
|
230
|
+
sound: "default",
|
|
231
|
+
badge: 5,
|
|
232
|
+
threadId: "c1",
|
|
233
|
+
collapseId: "chat:c1",
|
|
234
|
+
channelId: "messages",
|
|
235
|
+
categoryId: "messages",
|
|
236
|
+
});
|
|
237
|
+
expect((f.calls[0]!.init!.headers as Record<string, string>).authorization).toBe("Bearer expo");
|
|
238
|
+
expect(tickets).toHaveLength(250);
|
|
239
|
+
expect(deadTokens(tickets)).toEqual(["ExponentPushToken[7]"]);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("throws when nothing was sent, so the push is retried", async () => {
|
|
243
|
+
const deliver = createExpoDeliver({ tokensFor: async () => [{ userId: "bo", token: "t" }], fetch: fakeFetch(() => new Response(null, { status: 503 })).fetch });
|
|
244
|
+
await expect(deliver(notification)).rejects.toThrow("HTTP 503");
|
|
245
|
+
});
|
|
246
|
+
});
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/** @feat:chat */
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { createChatRunner, chatRunnerHandler, type ChatEffect, type ChatNotification, type Transcriber } from "../server";
|
|
4
|
+
import { fakeSupabase } from "./server-fakes";
|
|
5
|
+
|
|
6
|
+
const effect = (id: number, kind: string, payload: Record<string, unknown>, attempts = 1): ChatEffect => ({
|
|
7
|
+
id,
|
|
8
|
+
kind,
|
|
9
|
+
key: `${kind}:${id}`,
|
|
10
|
+
payload,
|
|
11
|
+
attempts,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const target = (userId: string, over: Record<string, unknown> = {}) => ({
|
|
15
|
+
user_id: userId,
|
|
16
|
+
conversation_id: "c1",
|
|
17
|
+
title: null,
|
|
18
|
+
sender_id: "ana",
|
|
19
|
+
preview: "see you at 6",
|
|
20
|
+
mentioned: false,
|
|
21
|
+
unread_total: 3,
|
|
22
|
+
...over,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const names: Record<string, string> = { ana: "Ana" };
|
|
26
|
+
|
|
27
|
+
describe("createChatRunner: push", () => {
|
|
28
|
+
it("delivers one notification per message with every recipient, titled by the sender in a direct conversation", async () => {
|
|
29
|
+
const sb = fakeSupabase({
|
|
30
|
+
rpc: {
|
|
31
|
+
claim_effects: () => [effect(1, "push", { message_id: "m1", conversation_id: "c1" })],
|
|
32
|
+
push_targets: () => [target("bo"), target("cy", { mentioned: true, unread_total: 7 })],
|
|
33
|
+
},
|
|
34
|
+
tables: { conversations: [{ id: "c1", kind: "direct" }] },
|
|
35
|
+
});
|
|
36
|
+
const deliver = vi.fn(async (_n: ChatNotification) => {});
|
|
37
|
+
const runner = createChatRunner({ supabase: sb.client, deliver, resolveName: (id) => names[id] });
|
|
38
|
+
expect(await runner.run()).toMatchObject({ claimed: 1, done: 1, failed: 0 });
|
|
39
|
+
expect(deliver).toHaveBeenCalledTimes(1);
|
|
40
|
+
expect(deliver.mock.calls[0]![0]).toEqual({
|
|
41
|
+
category: "messages",
|
|
42
|
+
messageId: "m1",
|
|
43
|
+
conversationId: "c1",
|
|
44
|
+
recipients: [
|
|
45
|
+
{ userId: "bo", badge: 3, mentioned: false },
|
|
46
|
+
{ userId: "cy", badge: 7, mentioned: true },
|
|
47
|
+
],
|
|
48
|
+
title: "Ana",
|
|
49
|
+
body: "see you at 6",
|
|
50
|
+
senderId: "ana",
|
|
51
|
+
threadKey: "c1",
|
|
52
|
+
idempotencyKey: "push:m1",
|
|
53
|
+
data: { url: "/messages/c1", conversationId: "c1", messageId: "m1", seq: null },
|
|
54
|
+
mutableContent: false,
|
|
55
|
+
categoryId: "messages",
|
|
56
|
+
});
|
|
57
|
+
// Only the kinds it handles are claimed; the effect is settled as done.
|
|
58
|
+
expect(sb.rpcCalls[0]).toEqual({ fn: "claim_effects", args: { p_kinds: ["push", "transcribe", "storage.remove", "video.remove"], p_limit: 50 } });
|
|
59
|
+
expect(sb.rpcCalls.at(-1)).toEqual({ fn: "complete_effect", args: { p_id: 1, p_error: null } });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("titles a group by its title and prefixes the preview with the sender", async () => {
|
|
63
|
+
const sb = fakeSupabase({
|
|
64
|
+
rpc: { claim_effects: () => [effect(1, "push", { message_id: "m1" })], push_targets: () => [target("bo", { title: "Cohort 4" })] },
|
|
65
|
+
tables: { conversations: [{ id: "c1", kind: "group" }] },
|
|
66
|
+
});
|
|
67
|
+
const deliver = vi.fn(async (_n: ChatNotification) => {});
|
|
68
|
+
await createChatRunner({ supabase: sb.client, deliver, resolveName: (id) => names[id] }).run();
|
|
69
|
+
expect(deliver.mock.calls[0]![0]).toMatchObject({ title: "Cohort 4", body: "Ana: see you at 6" });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("sets a collapse key only when the app asks, and always carries the message id and seq", async () => {
|
|
73
|
+
const make = () =>
|
|
74
|
+
fakeSupabase({
|
|
75
|
+
rpc: { claim_effects: () => [effect(1, "push", { message_id: "m1" })], push_targets: () => [target("bo")] },
|
|
76
|
+
tables: { conversations: [{ id: "c1", kind: "group" }], messages: [{ id: "m1", seq: 42 }] },
|
|
77
|
+
});
|
|
78
|
+
const plain = vi.fn(async (_n: ChatNotification) => {});
|
|
79
|
+
await createChatRunner({ supabase: make().client, deliver: plain }).run();
|
|
80
|
+
const n = plain.mock.calls[0]![0];
|
|
81
|
+
expect("collapseKey" in n).toBe(false);
|
|
82
|
+
expect(n.threadKey).toBe("c1");
|
|
83
|
+
expect(n.data).toMatchObject({ messageId: "m1", seq: 42, conversationId: "c1" });
|
|
84
|
+
|
|
85
|
+
const collapsing = vi.fn(async (_n: ChatNotification) => {});
|
|
86
|
+
await createChatRunner({ supabase: make().client, deliver: collapsing, collapse: true }).run();
|
|
87
|
+
expect(collapsing.mock.calls[0]![0].collapseKey).toBe("chat:c1");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("asks for a push badge without the kinds the app leaves out of its own", async () => {
|
|
91
|
+
const sb = fakeSupabase({
|
|
92
|
+
rpc: { claim_effects: () => [effect(1, "push", { message_id: "m1" })], push_targets: () => [target("bo", { title: "Cohort 4" })] },
|
|
93
|
+
tables: { conversations: [{ id: "c1", kind: "group" }] },
|
|
94
|
+
});
|
|
95
|
+
await createChatRunner({ supabase: sb.client, deliver: vi.fn(async () => {}), badgeExcludeKinds: ["support"] }).run();
|
|
96
|
+
expect(sb.rpcCalls.find((c) => c.fn === "push_targets")?.args).toEqual({ p_message_id: "m1", p_badge_exclude_kinds: ["support"] });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("builds an iOS rich push: the sender's name and photo, the group, and the message ids", async () => {
|
|
100
|
+
const conv = "0a1b2c3d-0000-4000-8000-000000000001";
|
|
101
|
+
const sb = fakeSupabase({
|
|
102
|
+
rpc: {
|
|
103
|
+
claim_effects: () => [effect(1, "push", { message_id: "m1" })],
|
|
104
|
+
push_targets: () => [{ ...target("bo", { title: "Cohort 4" }), conversation_id: conv }],
|
|
105
|
+
},
|
|
106
|
+
tables: { conversations: [{ id: conv, kind: "group" }] },
|
|
107
|
+
});
|
|
108
|
+
const deliver = vi.fn(async (_n: ChatNotification) => {});
|
|
109
|
+
await createChatRunner({
|
|
110
|
+
supabase: sb.client,
|
|
111
|
+
deliver,
|
|
112
|
+
resolveName: (id) => names[id],
|
|
113
|
+
resolveAvatar: () => "https://cdn.example.test/ana.jpg",
|
|
114
|
+
}).run();
|
|
115
|
+
const n = deliver.mock.calls[0]![0];
|
|
116
|
+
expect(n.mutableContent).toBe(true);
|
|
117
|
+
expect(n.categoryId).toBe("messages");
|
|
118
|
+
expect(n.data).toMatchObject({ conversationId: conv, messageId: "m1", senderId: "ana", senderName: "Ana", senderAvatar: "https://cdn.example.test/ana.jpg", groupName: "Cohort 4" });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("opens the app's own route when a notification is tapped", async () => {
|
|
122
|
+
const sb = fakeSupabase({
|
|
123
|
+
rpc: { claim_effects: () => [effect(1, "push", { message_id: "m1" })], push_targets: () => [target("bo", { title: "Cohort 4" })] },
|
|
124
|
+
tables: { conversations: [{ id: "c1", kind: "group" }] },
|
|
125
|
+
});
|
|
126
|
+
const deliver = vi.fn(async (_n: ChatNotification) => {});
|
|
127
|
+
await createChatRunner({ supabase: sb.client, deliver, linkFor: (c, m) => `/inbox?c=${c}&m=${m}` }).run();
|
|
128
|
+
expect(deliver.mock.calls[0]![0].data.url).toBe("/inbox?c=c1&m=m1");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("settles a message with no targets as done without delivering", async () => {
|
|
132
|
+
const sb = fakeSupabase({ rpc: { claim_effects: () => [effect(1, "push", { message_id: "m1" })], push_targets: () => [] } });
|
|
133
|
+
const deliver = vi.fn(async () => {});
|
|
134
|
+
expect(await createChatRunner({ supabase: sb.client, deliver }).run()).toMatchObject({ claimed: 1, done: 1, failed: 0 });
|
|
135
|
+
expect(deliver).not.toHaveBeenCalled();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("settles a failed delivery with its error, and keeps working the others", async () => {
|
|
139
|
+
const sb = fakeSupabase({
|
|
140
|
+
rpc: {
|
|
141
|
+
claim_effects: () => [effect(1, "push", { message_id: "m1" }), effect(2, "push", { message_id: "m2" }), effect(3, "storage.remove", { bucket: "genesis-chat", path: "c/u/a.jpg" })],
|
|
142
|
+
push_targets: (a) => [target("bo", { conversation_id: String(a.p_message_id) })],
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
const deliver = vi.fn(async (n: ChatNotification) => {
|
|
146
|
+
if (n.messageId === "m1") throw new Error("push service down");
|
|
147
|
+
});
|
|
148
|
+
expect(await createChatRunner({ supabase: sb.client, deliver }).run()).toMatchObject({ claimed: 3, done: 2, failed: 1 });
|
|
149
|
+
const settled = sb.rpcCalls.filter((c) => c.fn === "complete_effect").map((c) => c.args);
|
|
150
|
+
expect(settled).toContainEqual({ p_id: 1, p_error: "push service down" });
|
|
151
|
+
expect(settled).toContainEqual({ p_id: 2, p_error: null });
|
|
152
|
+
expect(settled).toContainEqual({ p_id: 3, p_error: null });
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("caps effects of one kind in flight", async () => {
|
|
156
|
+
let inFlight = 0;
|
|
157
|
+
let most = 0;
|
|
158
|
+
const sb = fakeSupabase({
|
|
159
|
+
rpc: {
|
|
160
|
+
claim_effects: () => Array.from({ length: 10 }, (_, i) => effect(i, "push", { message_id: `m${i}` })),
|
|
161
|
+
push_targets: () => [target("bo")],
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
const deliver = async () => {
|
|
165
|
+
most = Math.max(most, ++inFlight);
|
|
166
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
167
|
+
inFlight--;
|
|
168
|
+
};
|
|
169
|
+
const report = await createChatRunner({ supabase: sb.client, deliver, batch: { concurrency: 3, limit: 10 } }).run();
|
|
170
|
+
expect(report.done).toBe(10);
|
|
171
|
+
expect(most).toBe(3);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("claims only the kinds it was given", async () => {
|
|
175
|
+
const sb = fakeSupabase({ rpc: { claim_effects: () => [] } });
|
|
176
|
+
await createChatRunner({ supabase: sb.client, deliver: async () => {}, kinds: ["transcribe"] }).run();
|
|
177
|
+
expect(sb.rpcCalls[0]!.args.p_kinds).toEqual(["transcribe"]);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe("createChatRunner: storage.remove and video.remove", () => {
|
|
182
|
+
it("removes the file, and counts not found as done", async () => {
|
|
183
|
+
const ok = fakeSupabase({ rpc: { claim_effects: () => [effect(1, "storage.remove", { bucket: "genesis-chat", path: "c/u/a.jpg" })] } });
|
|
184
|
+
expect(await createChatRunner({ supabase: ok.client, deliver: async () => {} }).run()).toMatchObject({ done: 1 });
|
|
185
|
+
expect(ok.removed).toEqual([{ bucket: "genesis-chat", paths: ["c/u/a.jpg"] }]);
|
|
186
|
+
|
|
187
|
+
const gone = fakeSupabase({
|
|
188
|
+
rpc: { claim_effects: () => [effect(1, "storage.remove", { path: "c/u/b.jpg" })] },
|
|
189
|
+
removeError: { message: "Object not found", statusCode: "404" },
|
|
190
|
+
});
|
|
191
|
+
expect(await createChatRunner({ supabase: gone.client, deliver: async () => {} }).run()).toMatchObject({ done: 1, failed: 0 });
|
|
192
|
+
|
|
193
|
+
const down = fakeSupabase({
|
|
194
|
+
rpc: { claim_effects: () => [effect(1, "storage.remove", { path: "c/u/b.jpg" })] },
|
|
195
|
+
removeError: { message: "Internal", statusCode: "500" },
|
|
196
|
+
});
|
|
197
|
+
expect(await createChatRunner({ supabase: down.client, deliver: async () => {} }).run()).toMatchObject({ done: 0, failed: 1 });
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("removes a hosted video through the adapter, and is done without one", async () => {
|
|
201
|
+
const remove = vi.fn(async () => {});
|
|
202
|
+
const sb = fakeSupabase({ rpc: { claim_effects: () => [effect(1, "video.remove", { provider: "mux", provider_id: "asset1" })] } });
|
|
203
|
+
await createChatRunner({ supabase: sb.client, deliver: async () => {}, video: { name: "mux", remove } }).run();
|
|
204
|
+
expect(remove).toHaveBeenCalledWith("asset1");
|
|
205
|
+
const none = fakeSupabase({ rpc: { claim_effects: () => [effect(1, "video.remove", { provider: "mux", provider_id: "asset1" })] } });
|
|
206
|
+
expect(await createChatRunner({ supabase: none.client, deliver: async () => {} }).run()).toMatchObject({ done: 1 });
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe("createChatRunner: transcribe", () => {
|
|
211
|
+
const voice = { id: "a1", kind: "voice", provider: "storage", path: "c/u/v.m4a", mime: "audio/mp4", duration_ms: 4200, transcript_status: "pending" };
|
|
212
|
+
|
|
213
|
+
it("signs a 10 minute URL, transcribes it and stores the transcript", async () => {
|
|
214
|
+
const sb = fakeSupabase({ rpc: { claim_effects: () => [effect(1, "transcribe", { attachment_id: "a1" })] }, tables: { attachments: [voice] } });
|
|
215
|
+
const transcribe = vi.fn(async () => ({ text: "Running late, sorry" }));
|
|
216
|
+
const transcriber: Transcriber = { name: "fake", transcribe };
|
|
217
|
+
expect(await createChatRunner({ supabase: sb.client, deliver: async () => {}, transcriber }).run()).toMatchObject({ done: 1 });
|
|
218
|
+
expect(sb.signed).toEqual(["c/u/v.m4a@600"]);
|
|
219
|
+
expect(transcribe).toHaveBeenCalledWith({ url: "https://files.test/c/u/v.m4a?token=x", mime: "audio/mp4", durationMs: 4200, language: undefined });
|
|
220
|
+
expect(sb.rpcCalls).toContainEqual({
|
|
221
|
+
fn: "update_attachment",
|
|
222
|
+
args: { p_attachment_id: "a1", p_patch: { transcript: "Running late, sorry", transcript_status: "ready" } },
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("turns transcripts off without a transcriber", async () => {
|
|
227
|
+
const sb = fakeSupabase({ rpc: { claim_effects: () => [effect(1, "transcribe", { attachment_id: "a1" })] }, tables: { attachments: [voice] } });
|
|
228
|
+
expect(await createChatRunner({ supabase: sb.client, deliver: async () => {} }).run()).toMatchObject({ done: 1 });
|
|
229
|
+
expect(sb.rpcCalls).toContainEqual({ fn: "update_attachment", args: { p_attachment_id: "a1", p_patch: { transcript_status: "off" } } });
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("retries a provider failure, and marks it failed on the last attempt", async () => {
|
|
233
|
+
const transcriber: Transcriber = { name: "fake", transcribe: async () => Promise.reject(new Error("Deepgram answered HTTP 503")) };
|
|
234
|
+
const early = fakeSupabase({ rpc: { claim_effects: () => [effect(1, "transcribe", { attachment_id: "a1" }, 3)] }, tables: { attachments: [voice] } });
|
|
235
|
+
expect(await createChatRunner({ supabase: early.client, deliver: async () => {}, transcriber }).run()).toMatchObject({ failed: 1 });
|
|
236
|
+
expect(early.rpcCalls.filter((c) => c.fn === "update_attachment")).toEqual([]);
|
|
237
|
+
expect(early.rpcCalls).toContainEqual({ fn: "complete_effect", args: { p_id: 1, p_error: "Deepgram answered HTTP 503" } });
|
|
238
|
+
|
|
239
|
+
const last = fakeSupabase({ rpc: { claim_effects: () => [effect(1, "transcribe", { attachment_id: "a1" }, 8)] }, tables: { attachments: [voice] } });
|
|
240
|
+
expect(await createChatRunner({ supabase: last.client, deliver: async () => {}, transcriber }).run()).toMatchObject({ failed: 1 });
|
|
241
|
+
expect(last.rpcCalls).toContainEqual({ fn: "update_attachment", args: { p_attachment_id: "a1", p_patch: { transcript_status: "failed" } } });
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("leaves a hosted video's transcript to its provider, and a deleted attachment alone", async () => {
|
|
245
|
+
const sb = fakeSupabase({
|
|
246
|
+
rpc: { claim_effects: () => [effect(1, "transcribe", { attachment_id: "v1" }), effect(2, "transcribe", { attachment_id: "gone" })] },
|
|
247
|
+
tables: { attachments: [{ ...voice, id: "v1", kind: "video", provider: "mux", path: null }] },
|
|
248
|
+
});
|
|
249
|
+
const transcribe = vi.fn();
|
|
250
|
+
expect(await createChatRunner({ supabase: sb.client, deliver: async () => {}, transcriber: { name: "fake", transcribe } }).run()).toMatchObject({ done: 2 });
|
|
251
|
+
expect(transcribe).not.toHaveBeenCalled();
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
describe("chatRunnerHandler", () => {
|
|
256
|
+
const secret = "s".repeat(40);
|
|
257
|
+
const runner = { run: vi.fn(async () => ({ claimed: 2, done: 2, failed: 0 })) };
|
|
258
|
+
const handler = chatRunnerHandler({ runner, secret });
|
|
259
|
+
|
|
260
|
+
it("rejects a missing or wrong secret without running", async () => {
|
|
261
|
+
expect((await handler(new Request("https://app.test/api/chat-runner", { method: "POST" }))).status).toBe(401);
|
|
262
|
+
const wrong = await handler(new Request("https://app.test/api/chat-runner", { method: "POST", headers: { authorization: `Bearer ${"t".repeat(40)}` } }));
|
|
263
|
+
expect(wrong.status).toBe(401);
|
|
264
|
+
expect(runner.run).not.toHaveBeenCalled();
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("runs with the right secret and answers the counts", async () => {
|
|
268
|
+
const res = await handler(new Request("https://app.test/api/chat-runner", { method: "POST", headers: { authorization: `Bearer ${secret}` } }));
|
|
269
|
+
expect(res.status).toBe(200);
|
|
270
|
+
expect(await res.json()).toEqual({ claimed: 2, done: 2, failed: 0 });
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("refuses everyone when the secret is too short", async () => {
|
|
274
|
+
const weak = chatRunnerHandler({ runner, secret: "short" });
|
|
275
|
+
expect((await weak(new Request("https://app.test/x", { method: "POST", headers: { authorization: "Bearer short" } }))).status).toBe(500);
|
|
276
|
+
});
|
|
277
|
+
});
|