@marktiderman/genesis-chat 0.1.0 → 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,332 @@
|
|
|
1
|
+
/** @feat:chat */
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { awayLine, awaySummary, namesLine, hoursSummary, isAway, isWithinWorkingHours, localClock, timeZoneLabel } from "../core/availability";
|
|
4
|
+
import {
|
|
5
|
+
createChatClient,
|
|
6
|
+
toAvailability,
|
|
7
|
+
toLinkPreview,
|
|
8
|
+
toMessage,
|
|
9
|
+
toScheduled,
|
|
10
|
+
toSearchHit,
|
|
11
|
+
toTopic,
|
|
12
|
+
type ChatSupabase,
|
|
13
|
+
} from "../core/client";
|
|
14
|
+
import { extractLinks, isHttpsUrl, isSchedulable, scheduleQuickPicks, snippetParts, topicKey } from "../core/extras";
|
|
15
|
+
import { createOutbox, type OutboxStore, type QueuedMessage } from "../core/outbox";
|
|
16
|
+
import type { ChatAvailability, WorkingHours } from "../core/types";
|
|
17
|
+
|
|
18
|
+
/** A supabase-js stand-in: records every rpc and table read, and answers from `answers`. */
|
|
19
|
+
function fakeSupabase(answers: Record<string, unknown> = {}) {
|
|
20
|
+
const calls: { kind: "rpc" | "from"; name: string; args?: unknown; filters: [string, ...unknown[]][] }[] = [];
|
|
21
|
+
const builder = (name: string) => {
|
|
22
|
+
const call = { kind: "from" as const, name, filters: [] as [string, ...unknown[]][] };
|
|
23
|
+
calls.push(call);
|
|
24
|
+
const b: Record<string, unknown> = {};
|
|
25
|
+
for (const m of ["select", "eq", "is", "in", "order", "limit", "neq", "gt", "lt"]) {
|
|
26
|
+
b[m] = (...args: unknown[]) => {
|
|
27
|
+
call.filters.push([m, ...args]);
|
|
28
|
+
return b;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
b.maybeSingle = async () => ({ data: answers[`${name}:single`] ?? null, error: null });
|
|
32
|
+
b.then = (resolve: (v: unknown) => void) => resolve({ data: answers[name] ?? [], error: null });
|
|
33
|
+
return b;
|
|
34
|
+
};
|
|
35
|
+
const supabase = {
|
|
36
|
+
schema: () => ({
|
|
37
|
+
rpc: async (fn: string, args: unknown) => {
|
|
38
|
+
calls.push({ kind: "rpc", name: fn, args, filters: [] });
|
|
39
|
+
const a = answers[fn];
|
|
40
|
+
return a instanceof Error ? { data: null, error: { message: a.message, code: "42501" } } : { data: a ?? null, error: null };
|
|
41
|
+
},
|
|
42
|
+
from: builder,
|
|
43
|
+
}),
|
|
44
|
+
} as unknown as ChatSupabase;
|
|
45
|
+
return { supabase, calls };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe("mappers for 0002", () => {
|
|
49
|
+
it("reads moderation on a message, and nothing else as moderation", () => {
|
|
50
|
+
const base = { id: "m", conversation_id: "c", seq: 1, client_id: "x", sender_role: "member", created_at: "2026-01-01T00:00:00Z" };
|
|
51
|
+
expect(toMessage({ ...base, moderation: "hidden" }).moderation).toBe("hidden");
|
|
52
|
+
expect(toMessage({ ...base, moderation: "flagged" }).moderation).toBe("flagged");
|
|
53
|
+
expect(toMessage({ ...base, moderation: "weird" }).moderation).toBeNull();
|
|
54
|
+
expect(toMessage(base).moderation).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("maps topics, scheduled messages (with their status), availability, hits and previews", () => {
|
|
58
|
+
expect(toTopic({ conversation_id: "c", topic: "w1", title: "Week 1", position: 2, closed_at: null, created_by: "u", created_at: "t" })).toEqual({
|
|
59
|
+
conversationId: "c", topic: "w1", title: "Week 1", position: 2, closedAt: null, createdBy: "u", createdAt: "t",
|
|
60
|
+
});
|
|
61
|
+
const row = { id: "s", conversation_id: "c", client_id: "k", body: "hi", attachments: [{ kind: "file", path: "c/u/a.pdf", duration_ms: 5 }], send_at: "2026-02-01T09:00:00Z", created_at: "t" };
|
|
62
|
+
const s = toScheduled(row);
|
|
63
|
+
expect(s).toMatchObject({ id: "s", status: "scheduled", sendAt: "2026-02-01T09:00:00Z", mentions: [], silent: false });
|
|
64
|
+
expect(s.attachments[0]).toEqual({ kind: "file", path: "c/u/a.pdf", durationMs: 5 });
|
|
65
|
+
expect(toScheduled({ ...row, error: "left" }).status).toBe("failed");
|
|
66
|
+
expect(toScheduled({ ...row, cancelled_at: "t" }).status).toBe("cancelled");
|
|
67
|
+
expect(toScheduled({ ...row, sent_message_id: "m" }).status).toBe("sent");
|
|
68
|
+
expect(toAvailability({ user_id: "u", away_message: "Back Monday", away_until: null, time_zone: "Europe/London", hours: { mon: [["09:00", "17:00"]] } })).toEqual({
|
|
69
|
+
userId: "u", awayMessage: "Back Monday", awayUntil: null, timeZone: "Europe/London", hours: { mon: [["09:00", "17:00"]] },
|
|
70
|
+
});
|
|
71
|
+
expect(toSearchHit({ message_id: "m", conversation_id: "c", seq: "4", sender_id: null, created_at: "t", snippet: "a «b»", in_transcript: true })).toEqual({
|
|
72
|
+
messageId: "m", conversationId: "c", seq: 4, senderId: null, createdAt: "t", snippet: "a «b»", inTranscript: true,
|
|
73
|
+
});
|
|
74
|
+
const p = toLinkPreview({ url: "https://x.test/a", status: "ok", title: "T", image_url: "http://x.test/i.png", fetched_at: "t" });
|
|
75
|
+
expect(p.imageUrl).toBeNull();
|
|
76
|
+
expect(toLinkPreview({ url: "https://x.test/a", image_url: "https://x.test/i.png", fetched_at: "t" }).imageUrl).toBe("https://x.test/i.png");
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("client methods for 0002", () => {
|
|
81
|
+
it("calls each function with its arguments", async () => {
|
|
82
|
+
const { supabase, calls } = fakeSupabase({ schedule_message: "sid", broadcast_message: ["m1", "m2"], search_messages: [], availability_for: [] });
|
|
83
|
+
const chat = createChatClient(supabase);
|
|
84
|
+
await chat.setTopic("c", "w1", { title: "Week 1", closed: true });
|
|
85
|
+
expect(await chat.schedule({ conversationId: "c", clientId: "k", body: "Later", sendAt: new Date("2030-01-01T09:00:00Z"), topic: "w1" })).toBe("sid");
|
|
86
|
+
await chat.cancelScheduled("sid");
|
|
87
|
+
await chat.setAvailability({ awayMessage: "Off", timeZone: "Europe/London" });
|
|
88
|
+
expect(await chat.broadcast(["a", "b"], "k", "Hello all")).toEqual(["m1", "m2"]);
|
|
89
|
+
await chat.search("plan", "c");
|
|
90
|
+
expect(await chat.search("p")).toEqual([]);
|
|
91
|
+
await chat.availabilityFor("c");
|
|
92
|
+
const rpc = Object.fromEntries(calls.filter((c) => c.kind === "rpc").map((c) => [c.name, c.args]));
|
|
93
|
+
expect(rpc.set_topic).toEqual({ p_conversation_id: "c", p_topic: "w1", p_title: "Week 1", p_position: null, p_closed: true });
|
|
94
|
+
expect(rpc.schedule_message).toMatchObject({ p_conversation_id: "c", p_client_id: "k", p_send_at: "2030-01-01T09:00:00.000Z", p_body: "Later", p_topic: "w1", p_attachments: [] });
|
|
95
|
+
expect(rpc.cancel_scheduled).toEqual({ p_id: "sid" });
|
|
96
|
+
expect(rpc.set_availability).toEqual({ p_away_message: "Off", p_away_until: null, p_time_zone: "Europe/London", p_hours: null });
|
|
97
|
+
expect(rpc.broadcast_message).toEqual({ p_conversation_ids: ["a", "b"], p_client_id: "k", p_body: "Hello all", p_attachments: [], p_silent: false });
|
|
98
|
+
expect(rpc.search_messages).toEqual({ p_query: "plan", p_conversation_id: "c", p_limit: 30 });
|
|
99
|
+
// A one-letter query never reaches the database.
|
|
100
|
+
expect(calls.filter((c) => c.name === "search_messages")).toHaveLength(1);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("reads topics in order, scheduled ones still waiting, and previews only through link_previews_for", async () => {
|
|
104
|
+
const { supabase, calls } = fakeSupabase({
|
|
105
|
+
topics: [{ conversation_id: "c", topic: "a", title: "A", position: 0, created_at: "t" }],
|
|
106
|
+
link_previews_for: [
|
|
107
|
+
{ url: "https://x.test", status: "ok", fetched_at: "t" },
|
|
108
|
+
{ url: "https://y.test", status: "failed", fetched_at: "t" },
|
|
109
|
+
],
|
|
110
|
+
});
|
|
111
|
+
const chat = createChatClient(supabase);
|
|
112
|
+
expect((await chat.topics("c"))[0]!.title).toBe("A");
|
|
113
|
+
await chat.scheduled("c");
|
|
114
|
+
expect(await chat.linkPreviewsFor([])).toEqual({});
|
|
115
|
+
const byMessage = await chat.linkPreviewsFor(["m1", "m2", "m1"]);
|
|
116
|
+
expect(Object.keys(byMessage).sort()).toEqual(["m1", "m2"]);
|
|
117
|
+
expect(byMessage.m1!.map((p) => p.url)).toEqual(["https://x.test"]);
|
|
118
|
+
const scheduled = calls.find((c) => c.name === "scheduled")!;
|
|
119
|
+
expect(scheduled.filters).toContainEqual(["is", "sent_message_id", null]);
|
|
120
|
+
expect(scheduled.filters).toContainEqual(["is", "cancelled_at", null]);
|
|
121
|
+
expect(scheduled.filters).toContainEqual(["eq", "conversation_id", "c"]);
|
|
122
|
+
// One call per message, never a read of the closed table.
|
|
123
|
+
expect(calls.filter((c) => c.name === "link_previews_for").map((c) => c.args)).toEqual([{ p_message_id: "m1" }, { p_message_id: "m2" }]);
|
|
124
|
+
expect(calls.some((c) => c.kind === "from" && c.name === "link_previews")).toBe(false);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("calls onJoin on every join, the first included, and onReconnect only on re-joins", async () => {
|
|
128
|
+
let status: ((s: string) => void) | undefined;
|
|
129
|
+
const channel = { on: () => channel, subscribe: (cb: (s: string) => void) => void (status = cb), send: vi.fn() };
|
|
130
|
+
const supabase = { channel: () => channel, removeChannel: vi.fn(), realtime: { setAuth: async () => undefined } } as unknown as ChatSupabase;
|
|
131
|
+
const onJoin = vi.fn();
|
|
132
|
+
const onReconnect = vi.fn();
|
|
133
|
+
createChatClient(supabase).subscribeConversation("c", "me", { onSignal: () => undefined, onJoin, onReconnect });
|
|
134
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
135
|
+
status!("SUBSCRIBED");
|
|
136
|
+
expect(onJoin).toHaveBeenCalledTimes(1);
|
|
137
|
+
expect(onReconnect).not.toHaveBeenCalled();
|
|
138
|
+
status!("SUBSCRIBED");
|
|
139
|
+
expect(onJoin).toHaveBeenCalledTimes(2);
|
|
140
|
+
expect(onReconnect).toHaveBeenCalledTimes(1);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("working hours", () => {
|
|
145
|
+
const weekdays: WorkingHours = { mon: [["09:00", "17:00"]], tue: [["09:00", "17:00"]], wed: [["09:00", "17:00"]], thu: [["09:00", "17:00"]], fri: [["09:00", "17:00"]] };
|
|
146
|
+
|
|
147
|
+
it("reads the clock in the person's time zone", () => {
|
|
148
|
+
// 2026-03-02 is a Monday. 14:30 UTC is 09:30 in New York (EST) and 23:30 in Tokyo.
|
|
149
|
+
const now = new Date("2026-03-02T14:30:00Z");
|
|
150
|
+
expect(localClock(now, "America/New_York")).toEqual({ day: "mon", minutes: 9 * 60 + 30 });
|
|
151
|
+
expect(isWithinWorkingHours(weekdays, "America/New_York", now)).toBe(true);
|
|
152
|
+
expect(isWithinWorkingHours(weekdays, "Asia/Tokyo", now)).toBe(false);
|
|
153
|
+
// 13:59 UTC is 08:59 in New York: not yet.
|
|
154
|
+
expect(isWithinWorkingHours(weekdays, "America/New_York", new Date("2026-03-02T13:59:00Z"))).toBe(false);
|
|
155
|
+
// The end is exclusive.
|
|
156
|
+
expect(isWithinWorkingHours(weekdays, "UTC", new Date("2026-03-02T17:00:00Z"))).toBe(false);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("follows daylight saving", () => {
|
|
160
|
+
// 2026-07-06 (Monday): New York is EDT (UTC-4), so 13:30 UTC is 09:30.
|
|
161
|
+
expect(isWithinWorkingHours(weekdays, "America/New_York", new Date("2026-07-06T13:30:00Z"))).toBe(true);
|
|
162
|
+
expect(isWithinWorkingHours(weekdays, "America/New_York", new Date("2026-03-02T13:30:00Z"))).toBe(false);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("runs an overnight range into the next day", () => {
|
|
166
|
+
const nights: WorkingHours = { fri: [["22:00", "02:00"]] };
|
|
167
|
+
expect(isWithinWorkingHours(nights, "UTC", new Date("2026-03-06T23:00:00Z"))).toBe(true); // Friday 23:00
|
|
168
|
+
expect(isWithinWorkingHours(nights, "UTC", new Date("2026-03-07T01:30:00Z"))).toBe(true); // Saturday 01:30
|
|
169
|
+
expect(isWithinWorkingHours(nights, "UTC", new Date("2026-03-07T02:00:00Z"))).toBe(false);
|
|
170
|
+
expect(isWithinWorkingHours(nights, "UTC", new Date("2026-03-06T21:59:00Z"))).toBe(false);
|
|
171
|
+
// Thursday night isn't covered by Friday's range.
|
|
172
|
+
expect(isWithinWorkingHours(nights, "UTC", new Date("2026-03-06T01:00:00Z"))).toBe(false);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("treats no hours as always, and ignores bad values", () => {
|
|
176
|
+
expect(isWithinWorkingHours(null, "UTC", new Date())).toBe(true);
|
|
177
|
+
expect(isWithinWorkingHours({}, "UTC", new Date())).toBe(true);
|
|
178
|
+
// Bad ranges are dropped; a day left with none is no hours at all (never a false "outside hours").
|
|
179
|
+
expect(isWithinWorkingHours({ mon: [["nine", "five"]] }, "UTC", new Date("2026-03-02T10:00:00Z"))).toBe(true);
|
|
180
|
+
expect(isWithinWorkingHours({ mon: [["nine", "five"], ["11:00", "12:00"]] }, "UTC", new Date("2026-03-02T10:00:00Z"))).toBe(false);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("summarises hours, grouping days alike", () => {
|
|
184
|
+
expect(hoursSummary(weekdays)).toBe("Mon–Fri, 9–5");
|
|
185
|
+
expect(hoursSummary({ ...weekdays, fri: [["09:00", "13:30"]] })).toBe("Mon–Thu, 9–5; Fri, 9–1:30");
|
|
186
|
+
expect(hoursSummary({ sat: [["10:00", "12:00"]], sun: [["10:00", "12:00"]] })).toBe("Sat, Sun, 10–12");
|
|
187
|
+
expect(hoursSummary(null)).toBe("");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("names a zone the way people say it", () => {
|
|
191
|
+
expect(timeZoneLabel("America/New_York")).toBe("Eastern");
|
|
192
|
+
expect(timeZoneLabel(null)).toBe("");
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe("awayLine", () => {
|
|
197
|
+
const a = (over: Partial<ChatAvailability>): ChatAvailability => ({ userId: "u", awayMessage: null, awayUntil: null, timeZone: "America/New_York", hours: null, ...over });
|
|
198
|
+
const hours: WorkingHours = { mon: [["09:00", "17:00"]], tue: [["09:00", "17:00"]], wed: [["09:00", "17:00"]], thu: [["09:00", "17:00"]], fri: [["09:00", "17:00"]] };
|
|
199
|
+
|
|
200
|
+
it("says when they reply, outside their hours, and nothing inside them", () => {
|
|
201
|
+
const evening = new Date("2026-03-02T23:00:00Z"); // 18:00 Monday in New York
|
|
202
|
+
expect(awayLine(a({ hours }), "Mark", { now: evening })).toBe("Mark replies Mon–Fri, 9–5 (Eastern)");
|
|
203
|
+
expect(awayLine(a({ hours }), "Mark", { now: new Date("2026-03-02T15:00:00Z") })).toBeNull();
|
|
204
|
+
expect(awayLine(a({ hours }), "Mark", { now: new Date("2026-03-02T15:00:00Z"), always: true })).toBe("Mark replies Mon–Fri, 9–5 (Eastern)");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("says until when they're away, with their message", () => {
|
|
208
|
+
const now = new Date("2026-03-04T12:00:00Z"); // Wednesday
|
|
209
|
+
const line = awayLine(a({ awayUntil: "2026-03-09T13:00:00Z", awayMessage: "Back Monday" }), "Mark", { now, locale: "en-US" });
|
|
210
|
+
expect(line).toBe("Away until Monday: Back Monday");
|
|
211
|
+
expect(awayLine(a({ awayMessage: "On retreat" }), "Mark", { now })).toBe("Mark is away: On retreat");
|
|
212
|
+
// An end date already past: not away (and no hours, so nothing to say).
|
|
213
|
+
expect(awayLine(a({ awayUntil: "2026-03-01T00:00:00Z", awayMessage: "Back soon" }), "Mark", { now })).toBeNull();
|
|
214
|
+
expect(isAway(a({ awayUntil: "2026-03-05T00:00:00Z" }), now)).toBe(true);
|
|
215
|
+
expect(isAway(null, now)).toBe(false);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe("awaySummary", () => {
|
|
220
|
+
const away = (name: string) => ({ name, availability: { userId: name, awayMessage: "Off", awayUntil: null, timeZone: null, hours: null } });
|
|
221
|
+
const off = (name: string) => ({ name, availability: { userId: name, awayMessage: null, awayUntil: null, timeZone: "UTC", hours: { mon: [["09:00", "09:01"]] as [string, string][] } } });
|
|
222
|
+
const here = (name: string) => ({ name, availability: null });
|
|
223
|
+
const now = new Date("2026-03-04T12:00:00Z");
|
|
224
|
+
|
|
225
|
+
it("names everyone who is away, two at most", () => {
|
|
226
|
+
expect(namesLine(["Sam", "Lee", "Ann", "Bo"])).toBe("Sam, Lee and 2 others");
|
|
227
|
+
expect(namesLine(["Sam", "Lee", "Ann"])).toBe("Sam, Lee and 1 other");
|
|
228
|
+
expect(awaySummary([here("Ann"), away("Sam")], { now })).toBe("Sam is away: Off");
|
|
229
|
+
expect(awaySummary([away("Sam"), away("Lee")], { now })).toBe("Sam and Lee are away");
|
|
230
|
+
expect(awaySummary([off("Sam"), off("Lee"), off("Ann")], { now })).toBe("Sam, Lee and 1 other are outside their hours");
|
|
231
|
+
expect(awaySummary([away("Sam"), off("Lee"), away("Ann"), off("Bo")], { now })).toBe("Sam, Lee and 2 others are away or outside their hours");
|
|
232
|
+
expect(awaySummary([here("Ann")], { now })).toBeNull();
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
describe("small helpers", () => {
|
|
237
|
+
it("finds links the way the database does", () => {
|
|
238
|
+
expect(extractLinks("see https://a.test/x and https://b.test, http://no.test, https://a.test/x")).toEqual(["https://a.test/x", "https://b.test,"]);
|
|
239
|
+
expect(extractLinks("https://1.test https://2.test https://3.test https://4.test")).toHaveLength(3);
|
|
240
|
+
// Credential-looking queries are never previewed (genesis_chat.message_links).
|
|
241
|
+
expect(extractLinks("reset at https://app.test/reset?token=abc and https://ok.test/a?page=2")).toEqual(["https://ok.test/a?page=2"]);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("accepts only https images", () => {
|
|
245
|
+
expect(isHttpsUrl("https://x.test/i.png")).toBe(true);
|
|
246
|
+
expect(isHttpsUrl("http://x.test/i.png")).toBe(false);
|
|
247
|
+
expect(isHttpsUrl("javascript:alert(1)")).toBe(false);
|
|
248
|
+
expect(isHttpsUrl("data:image/png;base64,AAAA")).toBe(false);
|
|
249
|
+
expect(isHttpsUrl(null)).toBe(false);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("splits a snippet at its marks", () => {
|
|
253
|
+
expect(snippetParts("the «plan» for «today»")).toEqual([
|
|
254
|
+
{ text: "the ", match: false },
|
|
255
|
+
{ text: "plan", match: true },
|
|
256
|
+
{ text: " for ", match: false },
|
|
257
|
+
{ text: "today", match: true },
|
|
258
|
+
]);
|
|
259
|
+
expect(snippetParts("open « only")).toEqual([{ text: "open « only", match: false }]);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("offers tomorrow and Monday at 9, once when they're the same day", () => {
|
|
263
|
+
const wed = new Date(2026, 2, 4, 15, 0);
|
|
264
|
+
const picks = scheduleQuickPicks(wed, "en-US");
|
|
265
|
+
expect(picks.map((p) => p.label)).toEqual(["Tomorrow, 9:00 AM", "Monday, 9:00 AM"]);
|
|
266
|
+
expect(picks[1]!.at).toEqual(new Date(2026, 2, 9, 9, 0));
|
|
267
|
+
const sun = new Date(2026, 2, 8, 15, 0);
|
|
268
|
+
expect(scheduleQuickPicks(sun, "en-US").map((p) => p.label)).toEqual(["Tomorrow, 9:00 AM"]);
|
|
269
|
+
expect(isSchedulable(new Date(Date.now() + 30_000))).toBe(false);
|
|
270
|
+
expect(isSchedulable(new Date(Date.now() + 3_600_000))).toBe(true);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("makes topic keys from titles", () => {
|
|
274
|
+
expect(topicKey("Week 1: Habits")).toBe("week-1-habits");
|
|
275
|
+
expect(topicKey("Café Créme")).toBe("cafe-creme");
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
describe("outbox fixes", () => {
|
|
280
|
+
const fakeClient = () => {
|
|
281
|
+
const sent: { clientId: string }[] = [];
|
|
282
|
+
return {
|
|
283
|
+
sent,
|
|
284
|
+
client: {
|
|
285
|
+
upload: vi.fn(),
|
|
286
|
+
send: vi.fn(async (input: { clientId: string; conversationId: string }) => {
|
|
287
|
+
sent.push(input);
|
|
288
|
+
return { id: `m-${input.clientId}`, conversationId: input.conversationId, clientId: input.clientId, seq: sent.length } as never;
|
|
289
|
+
}),
|
|
290
|
+
} as never,
|
|
291
|
+
};
|
|
292
|
+
};
|
|
293
|
+
const saved = (clientId: string): QueuedMessage => ({
|
|
294
|
+
clientId,
|
|
295
|
+
input: { conversationId: "c1", clientId, body: "from before" },
|
|
296
|
+
senderId: "me",
|
|
297
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
298
|
+
status: "failed",
|
|
299
|
+
files: [],
|
|
300
|
+
attempts: 1,
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("keeps a message sent while the saved queue is still loading", async () => {
|
|
304
|
+
const { client } = fakeClient();
|
|
305
|
+
let release!: (q: QueuedMessage[]) => void;
|
|
306
|
+
const store: OutboxStore = { load: () => new Promise((r) => (release = r)), save: async () => undefined };
|
|
307
|
+
let seen: QueuedMessage[] = [];
|
|
308
|
+
const outbox = createOutbox({ client, userId: "me", store, onChange: (q) => (seen = q) });
|
|
309
|
+
const first = outbox.send({ conversationId: "c2", body: "one" });
|
|
310
|
+
const second = outbox.send({ conversationId: "c2", body: "two" });
|
|
311
|
+
release([saved("old")]);
|
|
312
|
+
await Promise.all([first, second]);
|
|
313
|
+
// Nothing written during the load is lost; what was saved joins it.
|
|
314
|
+
const bodies = outbox.pending().map((p) => p.input.body);
|
|
315
|
+
expect(bodies).toContain("from before");
|
|
316
|
+
expect(seen.map((q) => q.clientId)).toContain("old");
|
|
317
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
318
|
+
expect((client as { send: ReturnType<typeof vi.fn> }).send.mock.calls.map((c) => (c[0] as { body: string }).body)).toEqual(["one", "two"]);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("clears the queue and the store", async () => {
|
|
322
|
+
const { client } = fakeClient();
|
|
323
|
+
const writes: QueuedMessage[][] = [];
|
|
324
|
+
const store: OutboxStore = { load: async () => [saved("a"), saved("b")], save: async (q) => void writes.push(q) };
|
|
325
|
+
const outbox = createOutbox({ client, userId: "me", store });
|
|
326
|
+
await outbox.load();
|
|
327
|
+
expect(outbox.pending()).toHaveLength(2);
|
|
328
|
+
await outbox.clear();
|
|
329
|
+
expect(outbox.pending()).toEqual([]);
|
|
330
|
+
expect(writes[writes.length - 1]).toEqual([]);
|
|
331
|
+
});
|
|
332
|
+
});
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment happy-dom
|
|
3
|
+
* @feat:chat
|
|
4
|
+
*/
|
|
5
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
6
|
+
import { act, cleanup, configure, renderHook, waitFor } from "@testing-library/react";
|
|
7
|
+
import type { ReactNode } from "react";
|
|
8
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
9
|
+
import type { OutboxStore, QueuedMessage } from "../core/outbox";
|
|
10
|
+
import {
|
|
11
|
+
ChatProvider,
|
|
12
|
+
useAvailability,
|
|
13
|
+
useBroadcast,
|
|
14
|
+
useChat,
|
|
15
|
+
useConversation,
|
|
16
|
+
useLinkPreviews,
|
|
17
|
+
useScheduled,
|
|
18
|
+
useSearch,
|
|
19
|
+
useTopics,
|
|
20
|
+
} from "../react";
|
|
21
|
+
import { createMemoryChatClient, type MemoryChatOptions } from "../web";
|
|
22
|
+
|
|
23
|
+
afterEach(cleanup);
|
|
24
|
+
configure({ asyncUtilTimeout: 4000 });
|
|
25
|
+
|
|
26
|
+
const ME = "00000000-0000-4000-8000-0000000000a1";
|
|
27
|
+
const SAM = "00000000-0000-4000-8000-0000000000b2";
|
|
28
|
+
const LEE = "00000000-0000-4000-8000-0000000000c3";
|
|
29
|
+
|
|
30
|
+
function setup(over: Partial<MemoryChatOptions> = {}, store?: OutboxStore) {
|
|
31
|
+
const client = createMemoryChatClient({
|
|
32
|
+
me: ME,
|
|
33
|
+
conversations: [
|
|
34
|
+
{
|
|
35
|
+
id: "c1",
|
|
36
|
+
members: [{ userId: ME, role: "owner" }, { userId: SAM }],
|
|
37
|
+
topics: [{ topic: "w1", title: "Week 1" }],
|
|
38
|
+
messages: [
|
|
39
|
+
{ id: "m1", senderId: SAM, body: "Our plan for today", createdAt: "2026-01-01T10:00:00Z" },
|
|
40
|
+
{ id: "m2", senderId: ME, body: "Sounds good https://example.test/post", createdAt: "2026-01-01T10:01:00Z" },
|
|
41
|
+
{ id: "m3", senderId: SAM, body: "", createdAt: "2026-01-01T10:02:00Z", attachments: [{ kind: "voice", transcript: "the plan is set" }] },
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
{ id: "c2", members: [{ userId: ME }, { userId: LEE }] },
|
|
45
|
+
],
|
|
46
|
+
availability: { [SAM]: { awayMessage: "Back Monday", awayUntil: "2099-01-01T00:00:00Z" } },
|
|
47
|
+
linkPreviews: [{ url: "https://example.test/post", title: "A post", imageUrl: "https://example.test/i.png" }],
|
|
48
|
+
...over,
|
|
49
|
+
});
|
|
50
|
+
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
51
|
+
const wrapper = ({ children }: { children: ReactNode }) => (
|
|
52
|
+
<QueryClientProvider client={queryClient}>
|
|
53
|
+
<ChatProvider client={client} userId={ME} outboxStore={store}>
|
|
54
|
+
{children}
|
|
55
|
+
</ChatProvider>
|
|
56
|
+
</QueryClientProvider>
|
|
57
|
+
);
|
|
58
|
+
return { client, queryClient, wrapper };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe("hooks for 0002", () => {
|
|
62
|
+
it("useTopics lists topics and adds one", async () => {
|
|
63
|
+
const { wrapper } = setup();
|
|
64
|
+
const { result } = renderHook(() => useTopics("c1"), { wrapper });
|
|
65
|
+
await waitFor(() => expect(result.current.topics.map((t) => t.title)).toEqual(["Week 1"]));
|
|
66
|
+
await act(() => result.current.setTopic("w2", { title: "Week 2" }));
|
|
67
|
+
await waitFor(() => expect(result.current.topics.map((t) => t.topic)).toEqual(["w1", "w2"]));
|
|
68
|
+
await act(() => result.current.setTopic("w1", { closed: true }));
|
|
69
|
+
await waitFor(() => expect(result.current.topics[0]!.closedAt).not.toBeNull());
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("useScheduled schedules and cancels", async () => {
|
|
73
|
+
const { wrapper } = setup();
|
|
74
|
+
const { result } = renderHook(() => useScheduled("c1"), { wrapper });
|
|
75
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
76
|
+
const at = new Date(Date.now() + 3_600_000);
|
|
77
|
+
await act(async () => {
|
|
78
|
+
await result.current.schedule({ conversationId: "c1", body: "Later", sendAt: at });
|
|
79
|
+
});
|
|
80
|
+
await waitFor(() => expect(result.current.items).toHaveLength(1));
|
|
81
|
+
expect(result.current.items[0]).toMatchObject({ body: "Later", status: "scheduled", sendAt: at.toISOString() });
|
|
82
|
+
await act(() => result.current.cancel(result.current.items[0]!.id));
|
|
83
|
+
await waitFor(() => expect(result.current.items).toHaveLength(0));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("useAvailability reads a conversation's and saves yours", async () => {
|
|
87
|
+
const { wrapper } = setup();
|
|
88
|
+
const { result } = renderHook(() => useAvailability("c1"), { wrapper });
|
|
89
|
+
await waitFor(() => expect(result.current.forUser(SAM)?.awayMessage).toBe("Back Monday"));
|
|
90
|
+
expect(result.current.mine).toBeNull();
|
|
91
|
+
await act(() => result.current.setMine({ awayMessage: "Off today", timeZone: "Europe/London" }));
|
|
92
|
+
await waitFor(() => expect(result.current.mine?.awayMessage).toBe("Off today"));
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("useBroadcast sends into each conversation", async () => {
|
|
96
|
+
const { wrapper, client } = setup();
|
|
97
|
+
const { result } = renderHook(() => useBroadcast(), { wrapper });
|
|
98
|
+
let ids: string[] = [];
|
|
99
|
+
await act(async () => {
|
|
100
|
+
ids = await result.current.send(["c1", "c2"], "Hello everyone");
|
|
101
|
+
});
|
|
102
|
+
expect(ids).toHaveLength(2);
|
|
103
|
+
const sends = client.calls.filter((c) => c.method === "send").map((c) => (c.args[0] as { conversationId: string }).conversationId);
|
|
104
|
+
expect(sends).toEqual(["c1", "c2"]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("useSearch waits for typing to stop, then finds text and transcripts", async () => {
|
|
108
|
+
const { wrapper, client } = setup();
|
|
109
|
+
const { result, rerender } = renderHook(({ q }) => useSearch(q, { debounceMs: 40 }), { wrapper, initialProps: { q: "p" } });
|
|
110
|
+
rerender({ q: "pl" });
|
|
111
|
+
rerender({ q: "plan" });
|
|
112
|
+
expect(result.current.isSearching).toBe(true);
|
|
113
|
+
await waitFor(() => expect(result.current.hits).toHaveLength(2));
|
|
114
|
+
// One search, for the final query.
|
|
115
|
+
expect(client.calls.filter((c) => c.method === "search").map((c) => c.args[0])).toEqual(["plan"]);
|
|
116
|
+
expect(result.current.hits.find((h) => h.inTranscript)?.snippet).toContain("«plan»");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("useLinkPreviews returns previews by message, only for messages you can read", async () => {
|
|
120
|
+
const { wrapper, client } = setup();
|
|
121
|
+
const { result } = renderHook(() => useLinkPreviews(["m2", "m1", "nope"]), { wrapper });
|
|
122
|
+
await waitFor(() => expect(result.current.m2?.[0]?.title).toBe("A post"));
|
|
123
|
+
expect(result.current.m1).toEqual([]);
|
|
124
|
+
expect(result.current.nope).toEqual([]);
|
|
125
|
+
// One read per message.
|
|
126
|
+
expect(client.calls.filter((c) => c.method === "linkPreviewsFor").map((c) => c.args[0]).sort()).toEqual([["m1"], ["m2"], ["nope"]]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("signals recording-video while a video records", async () => {
|
|
130
|
+
const { wrapper, client } = setup();
|
|
131
|
+
const { result } = renderHook(() => useConversation("c1"), { wrapper });
|
|
132
|
+
await waitFor(() => expect(result.current.canPost).toBe(true));
|
|
133
|
+
act(() => result.current.signalRecordingVideo());
|
|
134
|
+
expect(client.calls.filter((c) => c.method === "signal").map((c) => c.args[1])).toContain("recording-video");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("useConversation({ topic }) reads one topic's messages and posts into it", async () => {
|
|
138
|
+
const { wrapper, client } = setup();
|
|
139
|
+
client.receive("c1", SAM, "In week one", { topic: "w1" });
|
|
140
|
+
const { result } = renderHook(() => useConversation("c1", { topic: "w1" }), { wrapper });
|
|
141
|
+
await waitFor(() => expect(result.current.messages.map((m) => m.body)).toEqual(["In week one"]));
|
|
142
|
+
await act(async () => {
|
|
143
|
+
await result.current.send({ body: "Mine too" });
|
|
144
|
+
});
|
|
145
|
+
await waitFor(() => expect(result.current.messages.map((m) => m.body)).toEqual(["In week one", "Mine too"]));
|
|
146
|
+
expect(client.calls.filter((c) => c.method === "send").at(-1)!.args[0]).toMatchObject({ topic: "w1" });
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("review fixes in the hooks", () => {
|
|
151
|
+
it("shows messages that arrived while the thread was closed (catch-up on the first join)", async () => {
|
|
152
|
+
const { wrapper, client } = setup();
|
|
153
|
+
const first = renderHook(() => useConversation("c1"), { wrapper });
|
|
154
|
+
await waitFor(() => expect(first.result.current.messages).toHaveLength(3));
|
|
155
|
+
first.unmount();
|
|
156
|
+
client.receive("c1", SAM, "While you were away");
|
|
157
|
+
const again = renderHook(() => useConversation("c1"), { wrapper });
|
|
158
|
+
await waitFor(() => expect(again.result.current.messages.map((m) => m.body)).toContain("While you were away"));
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("keeps hasEarlier when a cached thread is opened again", async () => {
|
|
162
|
+
const { wrapper } = setup();
|
|
163
|
+
const first = renderHook(() => useConversation("c1", { pageSize: 2 }), { wrapper });
|
|
164
|
+
await waitFor(() => expect(first.result.current.hasEarlier).toBe(true));
|
|
165
|
+
first.unmount();
|
|
166
|
+
const again = renderHook(() => useConversation("c1", { pageSize: 2 }), { wrapper });
|
|
167
|
+
await waitFor(() => expect(again.result.current.messages).toHaveLength(2));
|
|
168
|
+
expect(again.result.current.hasEarlier).toBe(true);
|
|
169
|
+
await act(() => again.result.current.loadEarlier());
|
|
170
|
+
await waitFor(() => expect(again.result.current.messages).toHaveLength(3));
|
|
171
|
+
expect(again.result.current.hasEarlier).toBe(false);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("loadUntil reaches an older message", async () => {
|
|
175
|
+
const { wrapper } = setup();
|
|
176
|
+
const { result } = renderHook(() => useConversation("c1", { pageSize: 1 }), { wrapper });
|
|
177
|
+
await waitFor(() => expect(result.current.messages).toHaveLength(1));
|
|
178
|
+
let found = false;
|
|
179
|
+
await act(async () => {
|
|
180
|
+
found = await result.current.loadUntil(1);
|
|
181
|
+
});
|
|
182
|
+
expect(found).toBe(true);
|
|
183
|
+
await waitFor(() => expect(result.current.messages[0]!.id).toBe("m1"));
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("reports a failed access check as an error, with a retry", async () => {
|
|
187
|
+
const { wrapper, client } = setup();
|
|
188
|
+
const real = client.myRole;
|
|
189
|
+
client.myRole = vi.fn(async () => {
|
|
190
|
+
throw new Error("Failed to fetch");
|
|
191
|
+
});
|
|
192
|
+
const { result } = renderHook(() => useConversation("c1"), { wrapper });
|
|
193
|
+
await waitFor(() => expect(result.current.accessError).toBeTruthy());
|
|
194
|
+
client.myRole = real;
|
|
195
|
+
await act(async () => {
|
|
196
|
+
await result.current.refetchAccess();
|
|
197
|
+
});
|
|
198
|
+
await waitFor(() => expect(result.current.accessError).toBeNull());
|
|
199
|
+
expect(result.current.canPost).toBe(true);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const savedStore = (q: QueuedMessage[]) => {
|
|
203
|
+
let saved = q;
|
|
204
|
+
return { load: async () => saved, save: async (x: QueuedMessage[]) => void (saved = x), get: () => saved };
|
|
205
|
+
};
|
|
206
|
+
const failed = (clientId: string): QueuedMessage => ({
|
|
207
|
+
clientId,
|
|
208
|
+
input: { conversationId: "c1", clientId, body: "Left over" },
|
|
209
|
+
senderId: ME,
|
|
210
|
+
createdAt: new Date().toISOString(),
|
|
211
|
+
status: "failed",
|
|
212
|
+
files: [],
|
|
213
|
+
attempts: 1,
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("retries what a relaunch left unsent, once, after loading", async () => {
|
|
217
|
+
const store = savedStore([failed("left-1")]);
|
|
218
|
+
const { wrapper, client } = setup({}, store);
|
|
219
|
+
renderHook(() => useChat(), { wrapper });
|
|
220
|
+
await waitFor(() => expect(client.calls.some((c) => c.method === "send" && (c.args[0] as { clientId: string }).clientId === "left-1")).toBe(true));
|
|
221
|
+
await waitFor(() => expect(store.get()).toEqual([]));
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("retries when the browser comes back online, and exposes retryAll and clearOutbox", async () => {
|
|
225
|
+
const { wrapper, client } = setup();
|
|
226
|
+
const { result } = renderHook(() => ({ chat: useChat(), conv: useConversation("c1") }), { wrapper });
|
|
227
|
+
await waitFor(() => expect(result.current.conv.canPost).toBe(true));
|
|
228
|
+
client.failNextSends(1);
|
|
229
|
+
await act(async () => {
|
|
230
|
+
await result.current.chat.outbox.send({ conversationId: "c1", body: "Offline" });
|
|
231
|
+
});
|
|
232
|
+
await waitFor(() => expect(result.current.chat.queue[0]?.status).toBe("failed"));
|
|
233
|
+
await act(async () => {
|
|
234
|
+
window.dispatchEvent(new Event("online"));
|
|
235
|
+
});
|
|
236
|
+
await waitFor(() => expect(result.current.chat.queue).toHaveLength(0));
|
|
237
|
+
expect(typeof result.current.chat.retryAll).toBe("function");
|
|
238
|
+
|
|
239
|
+
client.failNextSends(1);
|
|
240
|
+
await act(async () => {
|
|
241
|
+
await result.current.chat.outbox.send({ conversationId: "c1", body: "Gone" });
|
|
242
|
+
});
|
|
243
|
+
await waitFor(() => expect(result.current.chat.queue[0]?.status).toBe("failed"));
|
|
244
|
+
await act(() => result.current.chat.clearOutbox());
|
|
245
|
+
expect(result.current.chat.queue).toEqual([]);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|