import { adminSecretsStore, env, SELF } from 'cloudflare:test' import { beforeAll, beforeEach, describe, expect, it } from 'vitest' import '../../chat.app' import { NotificationType } from '../../../../notify/src/notification-types' import { ChatModerationState, getMessage, getThreadMessages, insertMessage, SCHEMA_DDL, } from '../../message-db' import { addThreadMember, ChatThreadType, createThread, joinedChatContents, findThreadWithMembers, getThreadForPlayer, getThreadsForPlayer, isThreadMember, leftChatContents, markThreadRead, postMessage, removeThreadMember, setThreadFavorited, startedChatContents, SYSTEM_SENDER_ID, THREAD_SCHEMA_DDL, } from '../../thread-db' import type { Env } from '../../context' import type { ChatMessage } from '../../message-db' declare module 'cloudflare:test' { interface ProvidedEnv extends Env {} } const ORIGIN = 'https://example.com' // Mint a token the way the `auth` worker does, signing with the shared test key seeded // into the JWT_SECRET store. const TEST_SECRET = 'test-signing-key' function b64url(input: ArrayBuffer | string): string { const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input) let binary = '' for (const byte of bytes) binary += String.fromCharCode(byte) return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') } async function bearer(sub: number, version?: string): Promise> { const now = Math.floor(Date.now() / 1000) const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url( JSON.stringify({ sub: String(sub), exp: now + 3600, 'rn.ver': version }) )}` const key = await crypto.subtle.importKey( 'raw', new TextEncoder().encode(TEST_SECRET), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'] ) const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(signingInput)) return { Authorization: `Bearer ${signingInput}.${b64url(sig)}` } } beforeAll(async () => { // Seed the shared JWT signing key into the local Secrets Store so .get() resolves. await adminSecretsStore(env.JWT_SECRET).create(TEST_SECRET) for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run() for (const stmt of THREAD_SCHEMA_DDL) await env.DB.prepare(stmt).run() }) describe('chat endpoints', () => { it('2026 party reads return a list, including when the caller has no party', async () => { const empty = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(42601, '20260323.14'), }) expect(empty.status).toBe(200) expect(await empty.json()).toEqual([]) const threadId = await createThread(env.DB, [42601], null, undefined, ChatThreadType.Party) const present = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(42601, '20260323.14'), }) expect(await present.json()).toMatchObject([{ ChatThreadId: threadId }]) }) it('GET / reports service status', async () => { const res = await SELF.fetch(`${ORIGIN}/`) expect(res.status).toBe(200) expect(await res.json()).toEqual({ service: 'chat', status: 'ok' }) }) it('GET /thread 401s without a token', async () => { const res = await SELF.fetch(`${ORIGIN}/thread`) expect(res.status).toBe(401) }) it('GET /thread serves the caller their own threads', async () => { const player = 881001 const thread = await createThread(env.DB, [player, 881002]) const latest = await postMessage(env.DB, { chatThreadId: thread, senderPlayerId: 881002, timeSent: '2022-02-21T18:08:56.0362822', contents: '{"Type":0,"Version":1,"Data":"hi"}', }) const res = await SELF.fetch(`${ORIGIN}/thread?MessageCount=16&Mode=0`, { headers: await bearer(player), }) expect(res.status).toBe(200) expect(await res.json()).toEqual([ { latestMessage: latest, chatThreadId: thread, playerIds: [player, 881002], lastReadMessageId: 0, chatThreadName: '', chatThreadType: 0, snoozedUntil: null, isFavorited: false, }, ]) }) it('GET /thread/:id/message serves the thread newest first', async () => { const player = 881003 const thread = await createThread(env.DB, [player, 881004]) const older = await postMessage(env.DB, { chatThreadId: thread, senderPlayerId: 881004, timeSent: '2022-02-19T22:13:56.7224503', contents: '{"Type":0,"Version":1,"Data":"on discord?"}', }) const newer = await postMessage(env.DB, { chatThreadId: thread, senderPlayerId: 881004, timeSent: '2022-02-21T18:08:56.0362822', contents: '{"Type":0,"Version":1,"Data":"hi"}', }) const res = await SELF.fetch(`${ORIGIN}/thread/${thread}/message?MessageCount=16&Mode=0`, { headers: await bearer(player), }) expect(res.status).toBe(200) expect(await res.json()).toEqual([newer, older]) }) it('GET /thread/:id/message honours MessageCount', async () => { const player = 881005 const thread = await createThread(env.DB, [player]) for (const data of ['one', 'two', 'three']) { await postMessage(env.DB, { chatThreadId: thread, senderPlayerId: player, contents: JSON.stringify({ Type: 0, Version: 1, Data: data }), }) } const res = await SELF.fetch(`${ORIGIN}/thread/${thread}/message?MessageCount=2`, { headers: await bearer(player), }) expect(await res.json()).toHaveLength(2) }) // A thread's existence is itself private, so a non-member gets the same 404 as for a // thread that never existed — not a 403 that confirms it's there. it('GET /thread/:id/message hides threads the caller is not in', async () => { const thread = await createThread(env.DB, [881006, 881007]) const res = await SELF.fetch(`${ORIGIN}/thread/${thread}/message`, { headers: await bearer(881008), }) expect(res.status).toBe(404) const missing = await SELF.fetch(`${ORIGIN}/thread/999999/message`, { headers: await bearer(881008), }) expect(missing.status).toBe(404) }) }) describe('message storage', () => { // A real message as the client sends it, kept verbatim (including the JSON-in-a-string // `contents` envelope) so the round-trip is tested against the actual payload shape. const CONTENTS = '{"Type":0,"Version":1,"Data":"This is jordanparki7 from your Oculus friends. We\'re friends in Rec Room now!"}' it('round-trips a message, assigning an id', async () => { const stored = await insertMessage(env.DB, { chatThreadId: 116181128, senderPlayerId: 10441985, timeSent: '2022-05-22T12:47:03.6536656', contents: CONTENTS, }) expect(stored.chatMessageId).toBeGreaterThan(0) expect(stored).toEqual({ chatMessageId: stored.chatMessageId, chatThreadId: 116181128, senderPlayerId: 10441985, timeSent: '2022-05-22T12:47:03.6536656', contents: CONTENTS, moderationState: ChatModerationState.None, }) expect(await getMessage(env.DB, stored.chatMessageId)).toEqual(stored) }) it('defaults timeSent to now', async () => { const stored = await insertMessage(env.DB, { chatThreadId: 999, senderPlayerId: 42, contents: CONTENTS, }) expect(Date.parse(stored.timeSent)).toBeGreaterThan(Date.now() - 60_000) }) it('lists a thread newest first and pages backwards', async () => { const thread = 116181129 const first = await insertMessage(env.DB, { chatThreadId: thread, senderPlayerId: 1, contents: CONTENTS, }) const second = await insertMessage(env.DB, { chatThreadId: thread, senderPlayerId: 2, contents: CONTENTS, }) const page = await getThreadMessages(env.DB, thread) expect(page.map((m) => m.chatMessageId)).toEqual([second.chatMessageId, first.chatMessageId]) const older = await getThreadMessages(env.DB, thread, { before: second.chatMessageId }) expect(older.map((m) => m.chatMessageId)).toEqual([first.chatMessageId]) // Messages from other threads never leak into a thread's listing. expect(await getThreadMessages(env.DB, 404)).toEqual([]) }) }) describe('thread storage', () => { // The viewing player from the captured thread-list response. const VIEWER = 10441985 function contents(data: string): string { return JSON.stringify({ Type: 0, Version: 1, Data: data }) } it('renders the thread list in the shape the client expects', async () => { const dm = await createThread(env.DB, [9489959, VIEWER]) const latest = await postMessage(env.DB, { chatThreadId: dm, senderPlayerId: VIEWER, timeSent: '2022-05-22T12:47:03.6536656', contents: contents( "This is jordanparki7 from your Oculus friends. We're friends in Rec Room now!" ), }) await markThreadRead(env.DB, dm, VIEWER, latest.chatMessageId) const [thread] = await getThreadsForPlayer(env.DB, VIEWER) expect(thread).toEqual({ latestMessage: latest, chatThreadId: dm, playerIds: [9489959, VIEWER], lastReadMessageId: latest.chatMessageId, chatThreadName: '', chatThreadType: 0, snoozedUntil: null, isFavorited: false, }) }) it('keeps a named group thread with all its members', async () => { const members = [VIEWER, 10452682, 12534039, 12535328, 12631702] const group = await createThread(env.DB, members, 'Group Chat =]') await postMessage(env.DB, { chatThreadId: group, senderPlayerId: VIEWER, contents: contents('sussy baka'), }) const thread = await getThreadForPlayer(env.DB, group, VIEWER) expect(thread?.chatThreadName).toBe('Group Chat =]') expect(thread?.playerIds).toEqual(members.sort((a, b) => a - b)) }) // System notices ("Player <@U…> started a chat") and player messages both carry // markup the server must not touch — the mention token, and HTML entities the // client escaped itself. Stored and served back byte-for-byte. it('stores message contents verbatim, markup and all', async () => { const thread = await createThread(env.DB, [VIEWER, 29565301]) const notice = await postMessage(env.DB, { chatThreadId: thread, senderPlayerId: 29565301, contents: contents('Player <@U29565301> started a chat'), }) const escaped = await postMessage(env.DB, { chatThreadId: thread, senderPlayerId: 29563053, contents: contents('Ly2 bae <<<333'), }) expect(await getMessage(env.DB, notice.chatMessageId)).toEqual(notice) expect((await getMessage(env.DB, escaped.chatMessageId))?.contents).toBe(escaped.contents) }) it('orders threads newest first and honours the page size', async () => { const viewer = 777001 const older = await createThread(env.DB, [viewer, 1]) const newer = await createThread(env.DB, [viewer, 2]) await postMessage(env.DB, { chatThreadId: older, senderPlayerId: viewer, contents: contents('first'), }) await postMessage(env.DB, { chatThreadId: newer, senderPlayerId: viewer, contents: contents('second'), }) const threads = await getThreadsForPlayer(env.DB, viewer) expect(threads.map((t) => t.chatThreadId)).toEqual([newer, older]) expect(await getThreadsForPlayer(env.DB, viewer, { limit: 1 })).toHaveLength(1) }) it('gates reads on membership', async () => { const thread = await createThread(env.DB, [777002, 777003]) expect(await isThreadMember(env.DB, thread, 777002)).toBe(true) expect(await isThreadMember(env.DB, thread, 777004)).toBe(false) // A non-member sees neither the thread nor its place in their own list. expect(await getThreadForPlayer(env.DB, thread, 777004)).toBeNull() expect(await getThreadsForPlayer(env.DB, 777004)).toEqual([]) }) it('keeps read state, favorites, and snoozes per viewer', async () => { const a = 777005 const b = 777006 const thread = await createThread(env.DB, [a, b]) const first = await postMessage(env.DB, { chatThreadId: thread, senderPlayerId: a, contents: contents('one'), }) const second = await postMessage(env.DB, { chatThreadId: thread, senderPlayerId: b, contents: contents('two'), }) await markThreadRead(env.DB, thread, a, second.chatMessageId) await setThreadFavorited(env.DB, thread, a, true) await markThreadRead(env.DB, thread, b, first.chatMessageId) const forA = await getThreadForPlayer(env.DB, thread, a) const forB = await getThreadForPlayer(env.DB, thread, b) expect(forA?.lastReadMessageId).toBe(second.chatMessageId) expect(forA?.isFavorited).toBe(true) expect(forB?.lastReadMessageId).toBe(first.chatMessageId) expect(forB?.isFavorited).toBe(false) // A late ack from a second client can't walk the thread back to unread. await markThreadRead(env.DB, thread, a, first.chatMessageId) expect((await getThreadForPlayer(env.DB, thread, a))?.lastReadMessageId).toBe( second.chatMessageId ) }) it('leaves an empty thread with no latest message', async () => { const thread = await createThread(env.DB, [777007]) expect(await getThreadForPlayer(env.DB, thread, 777007)).toMatchObject({ latestMessage: null, lastReadMessageId: 0, playerIds: [777007], }) }) it('drops a removed member from the roster but keeps the thread', async () => { const thread = await createThread(env.DB, [777008, 777009]) await postMessage(env.DB, { chatThreadId: thread, senderPlayerId: 777008, contents: contents('mellon'), }) await removeThreadMember(env.DB, thread, 777009) expect(await getThreadForPlayer(env.DB, thread, 777009)).toBeNull() expect((await getThreadForPlayer(env.DB, thread, 777008))?.playerIds).toEqual([777008]) }) }) // The party thread is a stub until its real shape is observed off a live client; these // pin down only what the stub promises — auth, and an object body rather than a 404. describe('GET /settings/partyinvite', () => { it('answers the invite-link lifetime as a bare single-key object', async () => { const res = await SELF.fetch(`${ORIGIN}/settings/partyinvite`, { headers: await bearer(885001), }) expect(res.status).toBe(200) // One key, no `{ success, error, value }` envelope around it. expect(await res.json()).toEqual({ InviteLinkLifetimeInMinutes: 60 }) }) it('401s without a token', async () => { const res = await SELF.fetch(`${ORIGIN}/settings/partyinvite`) expect(res.status).toBe(401) }) }) // The GET serves the caller's CURRENT party — the thread named by their `LatestPartyChat` // player setting — as the BARE thread, where the POST wraps the same projection. describe('GET /thread/party', () => { async function settings(playerId: number): Promise> { return ( (await env.RECFLARE_PLAYER_SETTINGS.get>( `player:${playerId}`, 'json' )) ?? {} ) } it('answers the party named by LatestPartyChat, bare — no ChatResult wrapper', async () => { const caller = 883201 const created = await SELF.fetch(`${ORIGIN}/thread/party`, { method: 'POST', headers: await bearer(caller), }) const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } } const res = await SELF.fetch(`${ORIGIN}/thread/party?maxCount=1&mode=0`, { headers: await bearer(caller), }) expect(res.status).toBe(200) // The whole body is the thread: no `ChatThread`/`ChatResult` keys around it. expect(await res.json()).toEqual({ ChatThreadId: ChatThread.ChatThreadId, ChatThreadType: ChatThreadType.Party, LastReadMessageId: 0, Messages: [], LatestMessage: null, PlayerIds: [caller], ChatThreadName: null, SnoozedUntil: null, IsFavorited: false, ClubId: null, }) }) it('POST records the thread id in the caller’s LatestPartyChat setting', async () => { const caller = 883202 await env.RECFLARE_PLAYER_SETTINGS.put( `player:${caller}`, JSON.stringify({ OobeState: 'Complete' }) ) const created = await SELF.fetch(`${ORIGIN}/thread/party`, { method: 'POST', headers: await bearer(caller), }) const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } } // Merged, not overwritten: the bag holds every other setting the player has. expect(await settings(caller)).toEqual({ OobeState: 'Complete', LatestPartyChat: String(ChatThread.ChatThreadId), }) }) it('follows the setting to the newest party after a second one is opened', async () => { const caller = 883203 await SELF.fetch(`${ORIGIN}/thread/party`, { method: 'POST', headers: await bearer(caller) }) const second = await SELF.fetch(`${ORIGIN}/thread/party`, { method: 'POST', headers: await bearer(caller), }) const { ChatThread } = (await second.json()) as { ChatThread: { ChatThreadId: number } } const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) }) const body = (await res.json()) as { ChatThreadId: number } expect(body.ChatThreadId).toBe(ChatThread.ChatThreadId) }) it('carries the party’s members and messages once it has them', async () => { const caller = 883204 const guest = 883205 const created = await SELF.fetch(`${ORIGIN}/thread/party`, { method: 'POST', headers: await bearer(caller), }) const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } } await addThreadMember(env.DB, ChatThread.ChatThreadId, guest) const posted = await postMessage(env.DB, { chatThreadId: ChatThread.ChatThreadId, senderPlayerId: caller, contents: '{"Type":0,"Version":1,"Data":"regroup at the bridge"}', }) const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) }) const body = (await res.json()) as { PlayerIds: number[] Messages: Array<{ ChatMessageId: number; Contents: string }> LatestMessage: { ChatMessageId: number } | null } expect(body.PlayerIds).toEqual([caller, guest]) // PascalCase messages here too — the camelCase spelling is the other projections'. expect(body.Messages).toHaveLength(1) expect(body.Messages[0]?.ChatMessageId).toBe(posted.chatMessageId) expect(body.LatestMessage?.ChatMessageId).toBe(posted.chatMessageId) }) it('answers {} for a player who has never opened one', async () => { const res = await SELF.fetch(`${ORIGIN}/thread/party?maxCount=1&mode=0`, { headers: await bearer(883001), }) expect(res.status).toBe(200) expect(await res.json()).toEqual({}) }) it('JOINS a caller who holds the key but isn’t on the thread yet', async () => { // How a player enters someone else's party: the client points their // LatestPartyChat at it, and they have no membership row until this read. const host = 883210 const guest = 883211 const created = await SELF.fetch(`${ORIGIN}/thread/party`, { method: 'POST', headers: await bearer(host), }) const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } } await env.RECFLARE_PLAYER_SETTINGS.put( `player:${guest}`, JSON.stringify({ LatestPartyChat: String(ChatThread.ChatThreadId) }) ) expect(await isThreadMember(env.DB, ChatThread.ChatThreadId, guest)).toBe(false) const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(guest) }) const body = (await res.json()) as { ChatThreadId: number; PlayerIds: number[] } // They're on the thread now, and the body they get back says so. expect(await isThreadMember(env.DB, ChatThread.ChatThreadId, guest)).toBe(true) expect(body.ChatThreadId).toBe(ChatThread.ChatThreadId) expect(body.PlayerIds).toEqual([host, guest]) // The host sees them too — one thread, one roster. expect((await getThreadForPlayer(env.DB, ChatThread.ChatThreadId, host))?.playerIds).toEqual([ host, guest, ]) }) it('re-joining is a no-op — the roster doesn’t grow on every read', async () => { const caller = 883212 await SELF.fetch(`${ORIGIN}/thread/party`, { method: 'POST', headers: await bearer(caller) }) for (let i = 0; i < 3; i++) { await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) }) } const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) }) expect(((await res.json()) as { PlayerIds: number[] }).PlayerIds).toEqual([caller]) }) it('re-joins a caller who left, while their key still names the party', async () => { // Leaving doesn't clear the key, so the next read walks them back in. Ending a // party is what drops the key (`match`'s POST /player/logout). const caller = 883206 const created = await SELF.fetch(`${ORIGIN}/thread/party`, { method: 'POST', headers: await bearer(caller), }) const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } } await removeThreadMember(env.DB, ChatThread.ChatThreadId, caller) const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) }) expect(((await res.json()) as { ChatThreadId: number }).ChatThreadId).toBe( ChatThread.ChatThreadId ) expect(await isThreadMember(env.DB, ChatThread.ChatThreadId, caller)).toBe(true) }) it('serves the party from D1 alone, with no LatestPartyChat key at all', async () => { // The fast path: a player already on a party is answered from the membership join, // so the settings KV is never read. Proven by deleting the key the POST wrote. const caller = 883230 const created = await SELF.fetch(`${ORIGIN}/thread/party`, { method: 'POST', headers: await bearer(caller), }) const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } } await env.RECFLARE_PLAYER_SETTINGS.delete(`player:${caller}`) const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) }) expect(((await res.json()) as { ChatThreadId: number }).ChatThreadId).toBe( ChatThread.ChatThreadId ) }) it('serves the NEWEST party when the caller is a member of several', async () => { // Membership outlives a party — nothing removes the row when one ends — so the // party you are in is the most recent one you are on. const caller = 883231 await SELF.fetch(`${ORIGIN}/thread/party`, { method: 'POST', headers: await bearer(caller) }) const second = await SELF.fetch(`${ORIGIN}/thread/party`, { method: 'POST', headers: await bearer(caller), }) const { ChatThread } = (await second.json()) as { ChatThread: { ChatThreadId: number } } const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) }) expect(((await res.json()) as { ChatThreadId: number }).ChatThreadId).toBe( ChatThread.ChatThreadId ) }) it('prefers the party the caller is ON over one their key merely names', async () => { // The consequence of checking D1 first: while a membership row survives, a key // pointing somewhere else is not consulted. Switching parties means leaving the old // one (DELETE /thread/{id}/leave), not just repointing the key. const caller = 883232 const host = 883233 const mine = await SELF.fetch(`${ORIGIN}/thread/party`, { method: 'POST', headers: await bearer(caller), }) const other = await SELF.fetch(`${ORIGIN}/thread/party`, { method: 'POST', headers: await bearer(host), }) const own = (await mine.json()) as { ChatThread: { ChatThreadId: number } } const theirs = (await other.json()) as { ChatThread: { ChatThreadId: number } } await env.RECFLARE_PLAYER_SETTINGS.put( `player:${caller}`, JSON.stringify({ LatestPartyChat: String(theirs.ChatThread.ChatThreadId) }) ) const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) }) expect(((await res.json()) as { ChatThreadId: number }).ChatThreadId).toBe( own.ChatThread.ChatThreadId ) expect(await isThreadMember(env.DB, theirs.ChatThread.ChatThreadId, caller)).toBe(false) }) /** Age a party by rewriting its `created_at` — what the join window is measured from. */ async function openedMinutesAgo(chatThreadId: number, minutes: number): Promise { await env.DB.prepare('UPDATE message_thread SET created_at = ?2 WHERE chat_thread_id = ?1') .bind(chatThreadId, new Date(Date.now() - minutes * 60_000).toISOString()) .run() } it('refuses to join a party older than the 60-minute invite lifetime', async () => { const host = 883220 const latecomer = 883221 const created = await SELF.fetch(`${ORIGIN}/thread/party`, { method: 'POST', headers: await bearer(host), }) const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } } await openedMinutesAgo(ChatThread.ChatThreadId, 61) await env.RECFLARE_PLAYER_SETTINGS.put( `player:${latecomer}`, JSON.stringify({ LatestPartyChat: String(ChatThread.ChatThreadId) }) ) const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(latecomer) }) expect(await res.json()).toEqual({}) // Refused, not quietly joined: no membership row was written. expect(await isThreadMember(env.DB, ChatThread.ChatThreadId, latecomer)).toBe(false) }) it('still joins a party inside the window', async () => { const host = 883222 const guest = 883223 const created = await SELF.fetch(`${ORIGIN}/thread/party`, { method: 'POST', headers: await bearer(host), }) const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } } await openedMinutesAgo(ChatThread.ChatThreadId, 59) await env.RECFLARE_PLAYER_SETTINGS.put( `player:${guest}`, JSON.stringify({ LatestPartyChat: String(ChatThread.ChatThreadId) }) ) const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(guest) }) expect(((await res.json()) as { PlayerIds: number[] }).PlayerIds).toEqual([host, guest]) }) it('keeps serving an aged party to the players already on it', async () => { // The window gates JOINING only — a party doesn't go dark on its own members. const host = 883224 const created = await SELF.fetch(`${ORIGIN}/thread/party`, { method: 'POST', headers: await bearer(host), }) const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } } await openedMinutesAgo(ChatThread.ChatThreadId, 240) const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(host) }) expect(((await res.json()) as { ChatThreadId: number }).ChatThreadId).toBe( ChatThread.ChatThreadId ) }) it('refuses to join a party whose created_at won’t parse', async () => { // Fails closed: no timestamp, no join. const host = 883225 const stranger = 883226 const created = await SELF.fetch(`${ORIGIN}/thread/party`, { method: 'POST', headers: await bearer(host), }) const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } } await env.DB.prepare('UPDATE message_thread SET created_at = ?2 WHERE chat_thread_id = ?1') .bind(ChatThread.ChatThreadId, 'not-a-timestamp') .run() await env.RECFLARE_PLAYER_SETTINGS.put( `player:${stranger}`, JSON.stringify({ LatestPartyChat: String(ChatThread.ChatThreadId) }) ) const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(stranger) }) expect(await res.json()).toEqual({}) expect(await isThreadMember(env.DB, ChatThread.ChatThreadId, stranger)).toBe(false) }) it('answers {} when the setting names a thread that isn’t a party, joining nobody', async () => { // The type check runs BEFORE the join, so a key pointed at someone else's DM can't // put the caller in it. const caller = 883207 const dm = await createThread(env.DB, [883208, 883213]) await env.RECFLARE_PLAYER_SETTINGS.put( `player:${caller}`, JSON.stringify({ LatestPartyChat: String(dm) }) ) const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) }) expect(await res.json()).toEqual({}) expect(await isThreadMember(env.DB, dm, caller)).toBe(false) }) it('answers {} when the setting names a thread that doesn’t exist', async () => { const caller = 883214 await env.RECFLARE_PLAYER_SETTINGS.put( `player:${caller}`, JSON.stringify({ LatestPartyChat: '99999' }) ) const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) }) expect(await res.json()).toEqual({}) expect(await isThreadMember(env.DB, 99999, caller)).toBe(false) }) it('answers {} for an unparseable stored id', async () => { const caller = 883209 await env.RECFLARE_PLAYER_SETTINGS.put( `player:${caller}`, JSON.stringify({ LatestPartyChat: 'not-an-id' }) ) const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) }) expect(await res.json()).toEqual({}) }) it('401s without a token', async () => { const res = await SELF.fetch(`${ORIGIN}/thread/party?maxCount=1&mode=0`) expect(res.status).toBe(401) }) }) // The POST on the same path is not a stub: it opens a real thread, of type Party, with // only the caller on it — and answers the client's own PascalCase CreatePartyChat shape, // which is neither of the two camelCase thread projections. describe('POST /thread/party', () => { it('answers the PascalCase { ChatThread, ChatResult } wrapper for an empty party', async () => { const caller = 883101 const res = await SELF.fetch(`${ORIGIN}/thread/party`, { method: 'POST', headers: await bearer(caller), }) expect(res.status).toBe(200) const body = (await res.json()) as { ChatThread: { ChatThreadId: number }; ChatResult: number } // Every wire key, exactly — a party opens empty, unnamed and with no club, and the // client reads none of the camelCase spellings the other thread routes serve. expect(body).toEqual({ ChatThread: { ChatThreadId: body.ChatThread.ChatThreadId, ChatThreadType: ChatThreadType.Party, LastReadMessageId: 0, Messages: [], LatestMessage: null, PlayerIds: [caller], ChatThreadName: null, SnoozedUntil: null, IsFavorited: false, ClubId: null, }, ChatResult: 0, }) // And it's a real thread: it reads back through the normal thread routes. const stored = await getThreadForPlayer(env.DB, body.ChatThread.ChatThreadId, caller) expect(stored?.chatThreadType).toBe(ChatThreadType.Party) expect(stored?.playerIds).toEqual([caller]) }) it('posts no “started a chat” notice into the party', async () => { const caller = 883105 const res = await SELF.fetch(`${ORIGIN}/thread/party`, { method: 'POST', headers: await bearer(caller), }) const { ChatThread } = (await res.json()) as { ChatThread: { ChatThreadId: number } } expect(await getThreadMessages(env.DB, ChatThread.ChatThreadId)).toEqual([]) }) it('opens a NEW party every call rather than resolving to the last one', async () => { const caller = 883102 const first = await SELF.fetch(`${ORIGIN}/thread/party`, { method: 'POST', headers: await bearer(caller), }) const second = await SELF.fetch(`${ORIGIN}/thread/party`, { method: 'POST', headers: await bearer(caller), }) const a = (await first.json()) as { ChatThread: { ChatThreadId: number } } const b = (await second.json()) as { ChatThread: { ChatThreadId: number } } expect(b.ChatThread.ChatThreadId).not.toBe(a.ChatThread.ChatThreadId) }) it('keeps party threads out of the DM fetch-or-create', async () => { // A party the caller invited someone onto has the same roster as their DM would. const caller = 883103 const other = 883104 const res = await SELF.fetch(`${ORIGIN}/thread/party`, { method: 'POST', headers: await bearer(caller), }) const { ChatThread } = (await res.json()) as { ChatThread: { ChatThreadId: number } } await addThreadMember(env.DB, ChatThread.ChatThreadId, other) const dm = await SELF.fetch(`${ORIGIN}/thread/withmembers`, { method: 'POST', headers: { ...(await bearer(caller)), 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ ids: String(other) }), }) const opened = (await dm.json()) as { chatThreadId: number; chatThreadType: number } expect(opened.chatThreadId).not.toBe(ChatThread.ChatThreadId) expect(opened.chatThreadType).toBe(ChatThreadType.Player) }) it('401s without a token', async () => { const res = await SELF.fetch(`${ORIGIN}/thread/party`, { method: 'POST' }) expect(res.status).toBe(401) }) }) describe('GET /thread/chatPrivacySetting', () => { it('reports Friends for both settings by default, keyed to the caller', async () => { const res = await SELF.fetch(`${ORIGIN}/thread/chatPrivacySetting`, { headers: await bearer(886001), }) expect(res.status).toBe(200) // camelCase, and the enum by NUMBER (0 = Friends) — this build has no by-name enum // formatter, so a string would decode as nothing. expect(await res.json()).toEqual({ playerId: 886001, directMessagePrivacySetting: 0, groupChatPrivacySetting: 0, }) }) it('reads the stored settings out of the player settings map', async () => { await env.RECFLARE_PLAYER_SETTINGS.put( 'player:886003', JSON.stringify({ directMessagePrivacySetting: 'Favorites', groupChatPrivacySetting: 'NoOne', }) ) const res = await SELF.fetch(`${ORIGIN}/thread/chatPrivacySetting`, { headers: await bearer(886003), }) expect(await res.json()).toEqual({ playerId: 886003, directMessagePrivacySetting: 1, groupChatPrivacySetting: 2, }) }) it('falls back to Friends for a stored value it can’t parse', async () => { await env.RECFLARE_PLAYER_SETTINGS.put( 'player:886004', JSON.stringify({ directMessagePrivacySetting: 'Nobody at all' }) ) const res = await SELF.fetch(`${ORIGIN}/thread/chatPrivacySetting`, { headers: await bearer(886004), }) expect( ((await res.json()) as { directMessagePrivacySetting: number }).directMessagePrivacySetting ).toBe(0) }) it('reads playerId off the token, not a query param', async () => { const res = await SELF.fetch(`${ORIGIN}/thread/chatPrivacySetting?playerId=999999`, { headers: await bearer(886002), }) expect(((await res.json()) as { playerId: number }).playerId).toBe(886002) }) it('401s without a token', async () => { const res = await SELF.fetch(`${ORIGIN}/thread/chatPrivacySetting`) expect(res.status).toBe(401) }) }) describe('PUT /thread/chatPrivacySetting', () => { const path = `${ORIGIN}/thread/chatPrivacySetting` /** The client's PUT: one form field, the enum by NAME. */ async function put(playerId: number, body: Record) { return SELF.fetch(path, { method: 'PUT', headers: await bearer(playerId), body: new URLSearchParams(body), }) } const settings = async (playerId: number) => env.RECFLARE_PLAYER_SETTINGS.get>(`player:${playerId}`, 'json') it('stores the DM setting and answers the resulting settings', async () => { const res = await put(887001, { directMessagePrivacySetting: 'Favorites' }) expect(res.status).toBe(200) // The same body the GET serves — the enum by NUMBER, not the name that was posted. expect(await res.json()).toEqual({ playerId: 887001, directMessagePrivacySetting: 1, groupChatPrivacySetting: 0, }) // Stored by NAME in the player settings map the `playersettings` worker owns. expect((await settings(887001))?.directMessagePrivacySetting).toBe('Favorites') }) it('stores the group chat setting on its own', async () => { const res = await put(887002, { groupChatPrivacySetting: 'NoOne' }) expect(await res.json()).toEqual({ playerId: 887002, directMessagePrivacySetting: 0, groupChatPrivacySetting: 2, }) }) it('leaves the other setting alone — the client sends one field per call', async () => { await put(887003, { directMessagePrivacySetting: 'NoOne' }) const res = await put(887003, { groupChatPrivacySetting: 'Favorites' }) expect(await res.json()).toEqual({ playerId: 887003, directMessagePrivacySetting: 2, groupChatPrivacySetting: 1, }) }) it('merges, leaving the player’s other settings untouched', async () => { await env.RECFLARE_PLAYER_SETTINGS.put( 'player:887004', JSON.stringify({ 'Recroom.OOBE': '77' }) ) await put(887004, { directMessagePrivacySetting: 'Favorites' }) expect(await settings(887004)).toEqual({ 'Recroom.OOBE': '77', directMessagePrivacySetting: 'Favorites', }) }) // Re-posting the stored value writes nothing to KV — writes are the cost, and the client // posts these freely. The raw value is seeded with whitespace JSON.stringify never // produces; surviving the PUT untouched means no write happened. it('does not write KV when the value is already stored', async () => { const padded = '{ "Recroom.OOBE": "77", "directMessagePrivacySetting": "Favorites" }' await env.RECFLARE_PLAYER_SETTINGS.put('player:887010', padded) const res = await put(887010, { directMessagePrivacySetting: 'Favorites' }) expect(await res.json()).toMatchObject({ directMessagePrivacySetting: 1 }) expect(await env.RECFLARE_PLAYER_SETTINGS.get('player:887010', 'text')).toBe(padded) }) it('accepts the enum by ordinal too, and is case-insensitive about the name', async () => { expect(await (await put(887005, { directMessagePrivacySetting: '2' })).json()).toMatchObject({ directMessagePrivacySetting: 2, }) expect( await (await put(887005, { directMessagePrivacySetting: 'favorites' })).json() ).toMatchObject({ directMessagePrivacySetting: 1 }) }) it('accepts the fields in the query string as well as the body', async () => { const res = await SELF.fetch(`${path}?groupChatPrivacySetting=NoOne`, { method: 'PUT', headers: await bearer(887006), }) expect(await res.json()).toMatchObject({ groupChatPrivacySetting: 2 }) }) it('ignores an unreadable value rather than 400ing or writing a default', async () => { await put(887007, { directMessagePrivacySetting: 'Favorites' }) const res = await put(887007, { directMessagePrivacySetting: 'Whoever' }) expect(res.status).toBe(200) // Unchanged — a value that won't parse is not a write of `Friends`. expect(await res.json()).toMatchObject({ directMessagePrivacySetting: 1 }) }) it('401s without a token', async () => { const res = await SELF.fetch(path, { method: 'PUT', body: new URLSearchParams({ directMessagePrivacySetting: 'NoOne' }), }) expect(res.status).toBe(401) }) }) describe('GET /thread/checkCanSendDirectMessageWithPrivacySetting', () => { const path = `${ORIGIN}/thread/checkCanSendDirectMessageWithPrivacySetting` it('always allows the DM, as a bare ChatResult integer', async () => { const res = await SELF.fetch(`${path}?receivingPlayerId=205`, { headers: await bearer(884001), }) expect(res.status).toBe(200) expect(res.headers.get('content-type')).toContain('application/json') // The whole body is the ChatResult — 0 is Success. NOT a boolean: the client // instantiates its response wrapper with the enum, so `true` would decode as nothing. expect(await res.text()).toBe('0') }) it('answers the same for any player, and with no player named', async () => { // `receivingPlayerId` is ignored — nothing here stores a privacy setting to refuse on. for (const query of ['?receivingPlayerId=205', '?receivingPlayerId=999999', '']) { const res = await SELF.fetch(`${path}${query}`, { headers: await bearer(884002) }) expect(await res.text()).toBe('0') } }) it('401s without a token', async () => { const res = await SELF.fetch(`${path}?receivingPlayerId=205`) expect(res.status).toBe(401) }) }) describe('POST /thread/withmembers', () => { async function withMembers(caller: number, body: string) { return SELF.fetch(`${ORIGIN}/thread/withmembers`, { method: 'POST', headers: { ...(await bearer(caller)), 'Content-Type': 'application/x-www-form-urlencoded', }, body, }) } it('opens a thread with the named players plus the caller', async () => { const caller = 882001 const res = await withMembers(caller, 'ids=2&ids=155&messageCount=50') expect(res.status).toBe(200) const thread = (await res.json()) as { chatThreadId: number playerIds: number[] messages: unknown[] } // A page of messages, not the list's single latestMessage. A brand-new thread // isn't empty: it opens with the system "started a chat" notice. expect(thread.messages).toEqual([ expect.objectContaining({ senderPlayerId: SYSTEM_SENDER_ID, contents: startedChatContents(caller), }), ]) expect(thread).toMatchObject({ playerIds: [2, 155, caller], lastReadMessageId: 0, chatThreadName: '', snoozedUntil: null, isFavorited: false, }) expect(thread).not.toHaveProperty('latestMessage') // The thread is real: it shows up in the caller's list, and its members can read it. expect((await getThreadsForPlayer(env.DB, caller)).map((t) => t.chatThreadId)).toContain( thread.chatThreadId ) expect(await isThreadMember(env.DB, thread.chatThreadId, 155)).toBe(true) }) // Two nulls GetChatBetweenPlayers can't survive: lastReadMessageId deserializes into // a non-nullable int ("expected 'Number Token', actual 'null'"), and chatThreadName // is dereferenced unchecked (NullReferenceException). Unread is 0, unnamed is ''. it('never serializes lastReadMessageId or chatThreadName as null', async () => { const res = await withMembers(882008, 'ids=153') const body = await res.text() expect(body).not.toContain('"lastReadMessageId":null') expect(body).not.toContain('"chatThreadName":null') const thread = JSON.parse(body) as { lastReadMessageId: number; chatThreadName: string } expect(thread.lastReadMessageId).toBe(0) expect(thread.chatThreadName).toBe('') }) it('collapses duplicate ids and the caller naming themselves', async () => { const caller = 882002 const res = await withMembers(caller, `ids=${caller}&ids=882003&ids=882003`) expect(((await res.json()) as { playerIds: number[] }).playerIds).toEqual([caller, 882003]) }) // Fetch-or-create: reopening a chat with the same people must land back in the // conversation that already has the history, not a fresh empty one. it('returns the existing thread rather than opening a second', async () => { const caller = 882004 const first = (await (await withMembers(caller, 'ids=882005')).json()) as { chatThreadId: number } await postMessage(env.DB, { chatThreadId: first.chatThreadId, senderPlayerId: 882005, contents: '{"Type":0,"Version":1,"Data":"hi"}', }) const second = (await (await withMembers(caller, 'ids=882005')).json()) as { chatThreadId: number messages: unknown[] } expect(second.chatThreadId).toBe(first.chatThreadId) // The opening notice plus the real message — reopening adds neither a thread nor // a second notice. expect(second.messages).toHaveLength(2) }) // Membership is matched as a whole set, so a DM isn't mistaken for a group that // happens to contain the same two people. it('does not confuse a subset or superset for the same thread', async () => { const caller = 882009 const pair = (await (await withMembers(caller, 'ids=882010')).json()) as { chatThreadId: number } const trio = (await (await withMembers(caller, 'ids=882010&ids=882011')).json()) as { chatThreadId: number } expect(trio.chatThreadId).not.toBe(pair.chatThreadId) }) it('honours messageCount when paging the thread', async () => { const caller = 882012 const opened = (await (await withMembers(caller, 'ids=882013')).json()) as { chatThreadId: number } for (const data of ['one', 'two', 'three']) { await postMessage(env.DB, { chatThreadId: opened.chatThreadId, senderPlayerId: caller, contents: JSON.stringify({ Type: 0, Version: 1, Data: data }), }) } const paged = (await (await withMembers(caller, 'ids=882013&messageCount=2')).json()) as { messages: unknown[] } expect(paged.messages).toHaveLength(2) }) it('rejects a thread with nobody else in it', async () => { const caller = 882006 expect((await withMembers(caller, '')).status).toBe(400) expect((await withMembers(caller, `ids=${caller}`)).status).toBe(400) expect((await withMembers(caller, 'ids=notanumber')).status).toBe(400) }) it('rejects an oversized roster', async () => { const ids = Array.from({ length: 60 }, (_, i) => `ids=${883000 + i}`).join('&') expect((await withMembers(882007, ids)).status).toBe(400) }) it('401s without a token', async () => { const res = await SELF.fetch(`${ORIGIN}/thread/withmembers`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'ids=2', }) expect(res.status).toBe(401) }) }) describe('POST /thread', () => { async function createViaPost(caller: number, body: string) { return SELF.fetch(`${ORIGIN}/thread`, { method: 'POST', headers: { ...(await bearer(caller)), 'Content-Type': 'application/x-www-form-urlencoded', }, body, }) } // The call the client actually makes after /thread/withmembers: members, blank // contents. A blank field must not post an empty message, and reports // invalid-arguments rather than success. it('opens a thread with no message when messageContents is blank', async () => { const caller = 884001 const res = await createViaPost(caller, 'ids=155&ids=2&messageContents=') expect(res.status).toBe(200) const body = (await res.json()) as { chatThread: { chatThreadId: number; latestMessage: { senderPlayerId: number } | null } chatResult: number } expect(body.chatResult).toBe(1) expect(body.chatThread).toMatchObject({ playerIds: [2, 155, caller] }) // Nothing of the caller's was posted — but the thread still isn't empty: it opens // with the system notice, which is what the client needs to render it at all. const messages = await getThreadMessages(env.DB, body.chatThread.chatThreadId) expect(messages).toEqual([ expect.objectContaining({ senderPlayerId: SYSTEM_SENDER_ID, contents: startedChatContents(caller), }), ]) expect(body.chatThread.latestMessage?.senderPlayerId).toBe(SYSTEM_SENDER_ID) }) it('posts the first message when messageContents is given', async () => { const caller = 884002 const contents = '{"Type":0,"Version":1,"Data":"hi"}' const res = await createViaPost( caller, `ids=884003&messageContents=${encodeURIComponent(contents)}` ) const body = (await res.json()) as { chatThread: { chatThreadId: number latestMessage: { contents: string; senderPlayerId: number } | null } chatResult: number } expect(body.chatResult).toBe(0) // Stored verbatim, attributed to the caller, and already the thread's latest. expect(body.chatThread.latestMessage).toMatchObject({ contents, senderPlayerId: caller }) // The opening notice, then the caller's message. expect(await getThreadMessages(env.DB, body.chatThread.chatThreadId)).toHaveLength(2) }) // The first message goes through the same profanity filter every later one does. it('masks profanity in the first message', async () => { const caller = 884010 const contents = '{"Type":0,"Version":1,"Data":"fuck this"}' const res = await createViaPost( caller, `ids=884011&messageContents=${encodeURIComponent(contents)}` ) const body = (await res.json()) as { chatThread: { chatThreadId: number; latestMessage: { contents: string } | null } chatResult: number } expect(body.chatResult).toBe(0) expect(body.chatThread.latestMessage?.contents).toBe( '{"Type":0,"Version":1,"Data":"**** this"}' ) }) // Sending to people you already have a thread with appends to it, rather than // stranding the message in a second conversation. it('appends to the existing thread with the same members', async () => { const caller = 884005 const first = (await ( await createViaPost(caller, 'ids=884006&messageContents=%7B%22Data%22%3A%22one%22%7D') ).json()) as { chatThread: { chatThreadId: number } } const second = (await ( await createViaPost(caller, 'ids=884006&messageContents=%7B%22Data%22%3A%22two%22%7D') ).json()) as { chatThread: { chatThreadId: number } } expect(second.chatThread.chatThreadId).toBe(first.chatThread.chatThreadId) // The opening notice, then both messages. expect(await getThreadMessages(env.DB, first.chatThread.chatThreadId)).toHaveLength(3) }) it('rejects a thread with nobody else in it', async () => { expect((await createViaPost(884004, 'messageContents=')).status).toBe(400) }) it('401s without a token', async () => { const res = await SELF.fetch(`${ORIGIN}/thread`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'ids=2&messageContents=', }) expect(res.status).toBe(401) }) }) describe('GET /thread/:id', () => { async function openThread(caller: number, chatThreadId: number, query = '?messageCount=50') { return SELF.fetch(`${ORIGIN}/thread/${chatThreadId}${query}`, { headers: await bearer(caller), }) } it('opens a thread with its recent messages, newest first', async () => { const caller = 885001 const chatThreadId = await createThread(env.DB, [caller, 885002]) const older = await postMessage(env.DB, { chatThreadId, senderPlayerId: 885002, timeSent: '2022-02-19T22:13:56.7224503', contents: '{"Type":0,"Version":1,"Data":"on discord?"}', }) const newer = await postMessage(env.DB, { chatThreadId, senderPlayerId: 885002, timeSent: '2022-02-21T18:08:56.0362822', contents: '{"Type":0,"Version":1,"Data":"hi"}', }) const res = await openThread(caller, chatThreadId) expect(res.status).toBe(200) // An object, not a bare array: the client parses this one as a thread and rejects // an array outright ("expected '{', actual '['"). const body = await res.text() expect(body.startsWith('{')).toBe(true) const thread = JSON.parse(body) as { messages: unknown[] } expect(thread).toMatchObject({ chatThreadId, playerIds: [caller, 885002], lastReadMessageId: 0, chatThreadName: '', snoozedUntil: null, isFavorited: false, }) expect(thread.messages).toEqual([newer, older]) expect(thread).not.toHaveProperty('latestMessage') }) // The sibling route serves the same messages as a bare array — the two shapes are // deliberately different, and the client depends on which is which. it('carries the same messages /thread/:id/message serves as an array', async () => { const caller = 885010 const chatThreadId = await createThread(env.DB, [caller, 885011]) await postMessage(env.DB, { chatThreadId, senderPlayerId: 885011, contents: '{"Type":0,"Version":1,"Data":"hi"}', }) const thread = (await (await openThread(caller, chatThreadId)).json()) as { messages: unknown[] } const messages = await ( await SELF.fetch(`${ORIGIN}/thread/${chatThreadId}/message?MessageCount=50`, { headers: await bearer(caller), }) ).json() expect(Array.isArray(messages)).toBe(true) expect(thread.messages).toEqual(messages) }) it('honours messageCount', async () => { const caller = 885003 const chatThreadId = await createThread(env.DB, [caller, 885004]) for (const data of ['one', 'two', 'three']) { await postMessage(env.DB, { chatThreadId, senderPlayerId: caller, contents: JSON.stringify({ Type: 0, Version: 1, Data: data }), }) } const res = await openThread(caller, chatThreadId, '?messageCount=2') expect(((await res.json()) as { messages: unknown[] }).messages).toHaveLength(2) }) // A thread opened moments ago has nothing in it and still has to open — an empty // messages array, not a 404. it('opens an empty thread with an empty messages array', async () => { const caller = 885005 const chatThreadId = await createThread(env.DB, [caller, 885006]) const res = await openThread(caller, chatThreadId) expect(res.status).toBe(200) const { messages } = (await res.json()) as { messages: unknown[] } // Built directly by createThread with no starter, so genuinely empty. expect(messages).toEqual([]) }) it('hides threads the caller is not in', async () => { const chatThreadId = await createThread(env.DB, [885007, 885008]) expect((await openThread(885009, chatThreadId)).status).toBe(404) expect((await openThread(885009, 999999)).status).toBe(404) }) it('401s without a token', async () => { const res = await SELF.fetch(`${ORIGIN}/thread/1?messageCount=50`) expect(res.status).toBe(401) }) }) describe('ChatMessageReceived push', () => { /** The stubbed hub (see vitest.config.ts) records what it was sent. */ interface SentNotification { playerId: number notificationType: NotificationType data: Record } const hub = env.RECFLARE_NOTIFICATIONS_HUB as unknown as { getByName(name: string): { takeSent(): Promise } } async function send(caller: number, body: string) { return SELF.fetch(`${ORIGIN}/thread`, { method: 'POST', headers: { ...(await bearer(caller)), 'Content-Type': 'application/x-www-form-urlencoded', }, body, }) } beforeEach(async () => { await hub.getByName('global').takeSent() }) // Every member is notified, the sender included: the client doesn't fold the HTTP // response into its thread cache, so without a self-push its own message doesn't // show until the next refetch. it('notifies every member of the thread, sender included', async () => { const caller = 886001 const contents = '{"Type":0,"Version":1,"Data":"hi"}' const res = await send( caller, `ids=886002&ids=886003&messageContents=${encodeURIComponent(contents)}` ) const { chatThread } = (await res.json()) as { chatThread: { chatThreadId: number; latestMessage: { chatMessageId: number } } } const sent = await hub.getByName('global').takeSent() expect(sent.every((n) => n.notificationType === NotificationType.ChatMessageReceived)).toBe( true ) // TWO waves over one channel, because that channel is all the client has: the // thread's opening notice, which is what makes the new conversation appear at all, // and then the message itself. Each goes to all three members. const notice = sent.filter((n) => (n.data as { senderPlayerId: number }).senderPlayerId === -5) const message = sent.filter( (n) => (n.data as { senderPlayerId: number }).senderPlayerId === caller ) for (const wave of [notice, message]) { expect(wave.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([caller, 886002, 886003]) } expect(notice[0]!.data).toEqual({ chatMessageId: expect.any(Number), chatThreadId: chatThread.chatThreadId, senderPlayerId: SYSTEM_SENDER_ID, timeSent: expect.any(String), contents: startedChatContents(caller), moderationState: 0, }) expect(message[0]!.data).toEqual({ chatMessageId: chatThread.latestMessage.chatMessageId, chatThreadId: chatThread.chatThreadId, senderPlayerId: caller, timeSent: expect.any(String), contents, moderationState: 0, }) }) it('pushes the opening notice even when no message is sent', async () => { // The thread is real whether or not anything was said in it, and a thread nobody // was told about is one nobody sees — the client has no "thread opened" channel. const caller = 886004 await send(caller, 'ids=886005&messageContents=') const sent = await hub.getByName('global').takeSent() expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([caller, 886005]) expect((sent[0]!.data as { senderPlayerId: number }).senderPlayerId).toBe(SYSTEM_SENDER_ID) expect((sent[0]!.data as { contents: string }).contents).toBe(startedChatContents(caller)) }) it('pushes nothing when the thread already existed and nothing was said', async () => { // Second call on the same pair: no new thread, no message — nothing to announce. await send(886006, 'ids=886007&messageContents=') await hub.getByName('global').takeSent() await send(886006, 'ids=886007&messageContents=') expect(await hub.getByName('global').takeSent()).toEqual([]) }) async function withMembers(caller: number, body: string): Promise { return SELF.fetch(`${ORIGIN}/thread/withmembers`, { method: 'POST', headers: { ...(await bearer(caller)), 'Content-Type': 'application/x-www-form-urlencoded', }, body, }) } it('POST /thread/withmembers announces the thread it opens', async () => { // The reported bug: this opened the conversation silently, so the other player saw // nothing until the first message arrived. const caller = 886010 const other = 886011 const res = await withMembers(caller, `ids=${other}`) const { chatThreadId } = (await res.json()) as { chatThreadId: number } const sent = await hub.getByName('global').takeSent() expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([caller, other]) expect(sent.every((n) => n.notificationType === NotificationType.ChatMessageReceived)).toBe( true ) expect(sent[0]!.data).toEqual({ chatMessageId: expect.any(Number), chatThreadId, senderPlayerId: SYSTEM_SENDER_ID, timeSent: expect.any(String), contents: startedChatContents(caller), moderationState: 0, }) }) it('POST /thread/withmembers pushes nothing when it resolves to an existing thread', async () => { // Fetch-or-create: the second call opens nothing, so there is nothing to announce // — re-announcing would ping both players every time the screen is opened. const caller = 886012 await withMembers(caller, 'ids=886013') await hub.getByName('global').takeSent() await withMembers(caller, 'ids=886013') expect(await hub.getByName('global').takeSent()).toEqual([]) }) it('adding a member announces the join to the WHOLE thread', async () => { const caller = 886014 const existing = 886015 const added = 886016 const res = await withMembers(caller, `ids=${existing}`) const { chatThreadId } = (await res.json()) as { chatThreadId: number } await hub.getByName('global').takeSent() await SELF.fetch(`${ORIGIN}/thread/${chatThreadId}/member/${added}`, { method: 'POST', headers: await bearer(caller), }) // Everyone: the two who were already there because the roster changed under them, // and the new member because this is what puts the thread on their screen. const sent = await hub.getByName('global').takeSent() expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([caller, existing, added]) expect(sent.every((n) => n.notificationType === NotificationType.ChatMessageReceived)).toBe( true ) expect(sent[0]!.data).toMatchObject({ chatThreadId, senderPlayerId: SYSTEM_SENDER_ID, contents: joinedChatContents(added), }) // The notice is a real message on the thread, not just a frame — the mirror of the // "left" one, and it is the thread's newest. const [newest] = await getThreadMessages(env.DB, chatThreadId, { limit: 1 }) expect(newest?.contents).toBe(joinedChatContents(added)) }) it('joining a party announces it to the party', async () => { const host = 886017 const guest = 886018 const created = await SELF.fetch(`${ORIGIN}/thread/party`, { method: 'POST', headers: await bearer(host), }) const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } } await env.RECFLARE_PLAYER_SETTINGS.put( `player:${guest}`, JSON.stringify({ LatestPartyChat: String(ChatThread.ChatThreadId) }) ) await hub.getByName('global').takeSent() await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(guest) }) // The host hears about it too — that is the point of announcing a join. const sent = await hub.getByName('global').takeSent() expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([host, guest]) expect(sent[0]!.data).toMatchObject({ chatThreadId: ChatThread.ChatThreadId, senderPlayerId: SYSTEM_SENDER_ID, contents: joinedChatContents(guest), }) }) it('announces a party join once, not on every subsequent read', async () => { // The join happens once; reading your own party afterwards is not a roster change. const host = 886019 const guest = 886020 const created = await SELF.fetch(`${ORIGIN}/thread/party`, { method: 'POST', headers: await bearer(host), }) const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } } await env.RECFLARE_PLAYER_SETTINGS.put( `player:${guest}`, JSON.stringify({ LatestPartyChat: String(ChatThread.ChatThreadId) }) ) await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(guest) }) await hub.getByName('global').takeSent() await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(guest) }) expect(await hub.getByName('global').takeSent()).toEqual([]) }) }) // A membership row whose thread row is gone must never be resolved to: it would hand // back an id nothing can render ("thread N vanished after creation"), and because the // oldest match wins it would keep winning on every later call. describe('orphaned membership rows', () => { it('ignores members of a thread whose message_thread row is gone', async () => { const caller = 887001 const other = 887002 const orphaned = await createThread(env.DB, [caller, other]) await env.DB.prepare('DELETE FROM message_thread WHERE chat_thread_id = ?1') .bind(orphaned) .run() expect(await findThreadWithMembers(env.DB, [caller, other])).toBeNull() // Opening the chat recovers: a usable thread comes back, and it isn't the orphan. const res = await SELF.fetch(`${ORIGIN}/thread/withmembers`, { method: 'POST', headers: { ...(await bearer(caller)), 'Content-Type': 'application/x-www-form-urlencoded', }, body: `ids=${other}&messageCount=50`, }) expect(res.status).toBe(200) const thread = (await res.json()) as { chatThreadId: number; playerIds: number[] } expect(thread.chatThreadId).not.toBe(orphaned) expect(thread.playerIds).toEqual([caller, other]) // And it stays stable — the orphan never wins a later lookup. expect(await findThreadWithMembers(env.DB, [caller, other])).toBe(thread.chatThreadId) }) }) describe('marking a thread read', () => { async function read(caller: number, path: string, method = 'POST') { return SELF.fetch(`${ORIGIN}${path}`, { method, headers: await bearer(caller) }) } it('moves the pointer to a specific message, on both verbs', async () => { const caller = 888001 const chatThreadId = await createThread(env.DB, [caller, 888002]) const first = await postMessage(env.DB, { chatThreadId, senderPlayerId: 888002, contents: '{"Type":0,"Version":1,"Data":"one"}', }) const second = await postMessage(env.DB, { chatThreadId, senderPlayerId: 888002, contents: '{"Type":0,"Version":1,"Data":"two"}', }) const res = await read(caller, `/thread/${chatThreadId}/message/${first.chatMessageId}/read`) expect(res.status).toBe(200) // The bare ChatResult integer, not an envelope. expect(await res.json()).toBe(0) expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.lastReadMessageId).toBe( first.chatMessageId ) await read(caller, `/thread/${chatThreadId}/message/${second.chatMessageId}/read`, 'PUT') expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.lastReadMessageId).toBe( second.chatMessageId ) }) it('marks the whole thread read without a message id', async () => { const caller = 888003 const chatThreadId = await createThread(env.DB, [caller, 888004]) const latest = await postMessage(env.DB, { chatThreadId, senderPlayerId: 888004, contents: '{"Type":0,"Version":1,"Data":"hi"}', }) expect((await read(caller, `/thread/${chatThreadId}/read`)).status).toBe(200) expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.lastReadMessageId).toBe( latest.chatMessageId ) }) // The client acks whatever id it was shown — including the synthetic message's // 9007199254740976. Clamping keeps that from stranding the thread as read forever. it('clamps an id beyond the thread to the real latest message', async () => { const caller = 888005 const chatThreadId = await createThread(env.DB, [caller, 888006]) const real = await postMessage(env.DB, { chatThreadId, senderPlayerId: 888006, contents: '{"Type":0,"Version":1,"Data":"hi"}', }) await read(caller, `/thread/${chatThreadId}/message/9007199254740976/read`) expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.lastReadMessageId).toBe( real.chatMessageId ) // A later real message is still unread, rather than swallowed by the bogus ack. const next = await postMessage(env.DB, { chatThreadId, senderPlayerId: 888006, contents: '{"Type":0,"Version":1,"Data":"later"}', }) expect( (await getThreadForPlayer(env.DB, chatThreadId, caller))?.lastReadMessageId ).toBeLessThan(next.chatMessageId) }) it('never moves the pointer backwards', async () => { const caller = 888007 const chatThreadId = await createThread(env.DB, [caller, 888008]) const first = await postMessage(env.DB, { chatThreadId, senderPlayerId: 888008, contents: '{"Type":0,"Version":1,"Data":"one"}', }) const second = await postMessage(env.DB, { chatThreadId, senderPlayerId: 888008, contents: '{"Type":0,"Version":1,"Data":"two"}', }) await read(caller, `/thread/${chatThreadId}/message/${second.chatMessageId}/read`) await read(caller, `/thread/${chatThreadId}/message/${first.chatMessageId}/read`) expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.lastReadMessageId).toBe( second.chatMessageId ) }) it('is gated on membership and auth', async () => { const chatThreadId = await createThread(env.DB, [888009, 888010]) expect((await read(888011, `/thread/${chatThreadId}/read`)).status).toBe(404) const anon = await SELF.fetch(`${ORIGIN}/thread/${chatThreadId}/read`, { method: 'POST' }) expect(anon.status).toBe(401) }) }) describe('POST /thread/:id', () => { // The exact body the client sends: a Version 2 envelope whose Data carries a `<=>` // prefix. Nothing in the worker parses it, so it must survive byte-for-byte. const CONTENTS = '{"Type":0,"Version":2,"Data":"<=>hey"}' async function send(caller: number, path: string, contents = CONTENTS) { return SELF.fetch(`${ORIGIN}${path}`, { method: 'POST', headers: { ...(await bearer(caller)), 'Content-Type': 'application/x-www-form-urlencoded', }, body: `messageContents=${encodeURIComponent(contents)}`, }) } it('appends to an existing thread and answers the send wrapper', async () => { const caller = 889001 const chatThreadId = await createThread(env.DB, [caller, 889002], null, caller) const res = await send(caller, `/thread/${chatThreadId}`) expect(res.status).toBe(200) const body = (await res.json()) as { chatResult: number chatThread: { chatThreadId: number playerIds: number[] lastReadMessageId: number chatThreadType: number messages: ChatMessage[] } } expect(body.chatResult).toBe(0) // The whole thread comes back, newest message first, with the opening notice // beneath it — the shape the client re-renders the conversation from. expect(body.chatThread).toMatchObject({ chatThreadId, playerIds: [caller, 889002] }) expect(body.chatThread.messages).toHaveLength(2) expect(body.chatThread.messages[0]).toMatchObject({ chatThreadId, senderPlayerId: caller, contents: CONTENTS, moderationState: 0, }) expect(body.chatThread.messages[1]).toMatchObject({ senderPlayerId: SYSTEM_SENDER_ID }) // Sending marks the thread read for the sender, so it doesn't come back unread. expect(body.chatThread.lastReadMessageId).toBe(body.chatThread.messages[0]!.chatMessageId) // And it's stored, not just echoed. expect(await getThreadMessages(env.DB, chatThreadId)).toEqual(body.chatThread.messages) }) it('answers the PascalCase ChatMessage the client’s send handler dereferences', async () => { const caller = 889020 const chatThreadId = await createThread(env.DB, [caller, 889021], null, caller) const res = await send(caller, `/thread/${chatThreadId}`) const body = (await res.json()) as { ChatMessage: Record | null ChatResult: number chatResult: number chatThread: { messages: ChatMessage[] } } // The handler reads `ChatMessage` the moment `ChatResult` is 0 — a success answered // without one is a null-reference exception inside the client, not a failed parse. expect(body.ChatResult).toBe(0) const posted = body.chatThread.messages[0]! expect(body.ChatMessage).toEqual({ ChatMessageId: posted.chatMessageId, ChatThreadId: chatThreadId, SenderPlayerId: caller, TimeSent: posted.timeSent, // The envelope verbatim. The client parses this into `MessageJson` in a // post-deserialize hook that only LOGS on failure and then dereferences the null, // so `Contents` has to stay a `{ Type, Version, Data }` string with a non-null Data. Contents: CONTENTS, ModerationState: 0, }) // The two result keys are one value serialized twice, so a decoder reading either // spelling lands on the same answer. expect(body.chatResult).toBe(body.ChatResult) }) it('accepts the /thread/:id/message spelling too', async () => { const caller = 889003 const chatThreadId = await createThread(env.DB, [caller, 889004], null, caller) const res = await send(caller, `/thread/${chatThreadId}/message`) expect(((await res.json()) as { chatResult: number }).chatResult).toBe(0) expect(await getThreadMessages(env.DB, chatThreadId)).toHaveLength(2) }) it('pushes ChatMessageReceived to every member', async () => { const hub = env.RECFLARE_NOTIFICATIONS_HUB as unknown as { getByName(name: string): { takeSent(): Promise> } } const caller = 889005 const chatThreadId = await createThread(env.DB, [caller, 889006], null, caller) await hub.getByName('global').takeSent() await send(caller, `/thread/${chatThreadId}`) const sent = await hub.getByName('global').takeSent() expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([caller, 889006]) expect(sent.every((n) => n.notificationType === NotificationType.ChatMessageReceived)).toBe( true ) }) it('reports invalid arguments for blank contents without storing anything', async () => { const caller = 889007 const chatThreadId = await createThread(env.DB, [caller, 889008], null, caller) const res = await send(caller, `/thread/${chatThreadId}`, ' ') expect(res.status).toBe(200) const body = (await res.json()) as { ChatMessage: unknown ChatResult: number chatResult: number chatThread: { messages: unknown[] } } expect(body.chatResult).toBe(1) expect(body.ChatResult).toBe(1) // Nothing was posted, so there is no message to carry — and a null is safe here // precisely because the client only dereferences `ChatMessage` on result 0. expect(body.ChatMessage).toBeNull() // The thread still comes back — only the opening notice is in it. expect(body.chatThread.messages).toHaveLength(1) expect(await getThreadMessages(env.DB, chatThreadId)).toHaveLength(1) }) // The same filter api's POST /api/sanitize/v1 runs — the client isn't obliged to have // called it, so a message posted straight here must not reach the thread unfiltered. it('masks profanity in the envelope’s Data and leaves the rest of it alone', async () => { const caller = 889012 const chatThreadId = await createThread(env.DB, [caller, 889013], null, caller) const res = await send( caller, `/thread/${chatThreadId}`, '{"Type":0,"Version":2,"Data":"<=>what the fuck man","Blocks":[]}' ) const body = (await res.json()) as { chatResult: number chatThread: { messages: ChatMessage[] } } expect(body.chatResult).toBe(0) // One `*` per character, so the word keeps its length; Type/Version/Blocks and the // Version 2 `<=>` marker come through untouched. expect(body.chatThread.messages[0]!.contents).toBe( '{"Type":0,"Version":2,"Data":"<=>what the **** man","Blocks":[]}' ) // Masked in the row too, not just in the response. expect((await getThreadMessages(env.DB, chatThreadId))[0]!.contents).toBe( body.chatThread.messages[0]!.contents ) }) // Nothing to object to must come back as the very bytes that were sent — the envelope // is only rebuilt when something was actually masked. it('stores a clean envelope byte-for-byte', async () => { const caller = 889014 const chatThreadId = await createThread(env.DB, [caller, 889015], null, caller) const contents = '{"Type":0,"Version":2,"Data":"Grape Escape","Blocks":[{"Id":"x"}]}' await send(caller, `/thread/${chatThreadId}`, contents) expect((await getThreadMessages(env.DB, chatThreadId))[0]!.contents).toBe(contents) }) // Contents that aren't an envelope are plain text with nothing in them to preserve. it('censors contents that aren’t a JSON envelope whole', async () => { const caller = 889016 const chatThreadId = await createThread(env.DB, [caller, 889017], null, caller) await send(caller, `/thread/${chatThreadId}`, 'fuck off') expect((await getThreadMessages(env.DB, chatThreadId))[0]!.contents).toBe('**** off') }) it('is gated on membership and auth', async () => { const chatThreadId = await createThread(env.DB, [889009, 889010], null, 889009) expect((await send(889011, `/thread/${chatThreadId}`)).status).toBe(404) const anon = await SELF.fetch(`${ORIGIN}/thread/${chatThreadId}`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'messageContents=hi', }) expect(anon.status).toBe(401) }) }) describe('POST /thread/:id/member/:playerId', () => { async function addMember(caller: number, chatThreadId: number, playerId: number) { return SELF.fetch(`${ORIGIN}/thread/${chatThreadId}/member/${playerId}`, { method: 'POST', headers: await bearer(caller), }) } it('adds a player to a thread the caller is in', async () => { const caller = 890001 const chatThreadId = await createThread(env.DB, [caller, 890002], null, caller) const res = await addMember(caller, chatThreadId, 890003) expect(res.status).toBe(200) expect(await res.json()).toBe(0) expect(await isThreadMember(env.DB, chatThreadId, 890003)).toBe(true) // The new member sees the thread, its history included. const thread = await getThreadForPlayer(env.DB, chatThreadId, 890003) expect(thread?.playerIds).toEqual([caller, 890002, 890003]) }) it('reports the player is already on the thread', async () => { const caller = 890004 const chatThreadId = await createThread(env.DB, [caller, 890005], null, caller) expect(await (await addMember(caller, chatThreadId, 890005)).json()).toBe(4) }) // A non-member gets the same answer as for a thread that doesn't exist, so the // endpoint can't be used to probe for threads. it('refuses a caller who is not on the thread', async () => { const chatThreadId = await createThread(env.DB, [890006, 890007], null, 890006) expect(await (await addMember(890008, chatThreadId, 890009)).json()).toBe(3) expect(await isThreadMember(env.DB, chatThreadId, 890009)).toBe(false) expect(await (await addMember(890008, 999999, 890009)).json()).toBe(3) }) it('401s without a token', async () => { const res = await SELF.fetch(`${ORIGIN}/thread/1/member/2`, { method: 'POST' }) expect(res.status).toBe(401) }) }) describe('renaming and leaving a thread', () => { async function post(caller: number, path: string, body?: string, method = 'POST') { return SELF.fetch(`${ORIGIN}${path}`, { method, headers: { ...(await bearer(caller)), ...(body === undefined ? {} : { 'Content-Type': 'application/x-www-form-urlencoded' }), }, body, }) } it('renames a thread for everyone on it', async () => { const caller = 891001 const chatThreadId = await createThread(env.DB, [caller, 891002], null, caller) const res = await post(caller, `/thread/${chatThreadId}/rename`, 'name=my%20chat') expect(res.status).toBe(200) expect(await res.json()).toBe(0) // Both members see the new name. expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.chatThreadName).toBe('my chat') expect((await getThreadForPlayer(env.DB, chatThreadId, 891002))?.chatThreadName).toBe('my chat') }) it('clears the name back to unnamed, never null', async () => { const caller = 891003 const chatThreadId = await createThread(env.DB, [caller, 891004], 'Group Chat =]', caller) await post(caller, `/thread/${chatThreadId}/rename`, 'name=') expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.chatThreadName).toBe('') }) it('truncates an overlong name rather than rejecting it', async () => { const caller = 891005 const chatThreadId = await createThread(env.DB, [caller, 891006], null, caller) await post(caller, `/thread/${chatThreadId}/rename`, `name=${'x'.repeat(200)}`) expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.chatThreadName).toHaveLength( 128 ) }) it('refuses to rename a thread the caller is not on', async () => { const chatThreadId = await createThread(env.DB, [891007, 891008], null, 891007) expect(await (await post(891009, `/thread/${chatThreadId}/rename`, 'name=nope')).json()).toBe(3) expect((await getThreadForPlayer(env.DB, chatThreadId, 891007))?.chatThreadName).toBe('') }) it('leaves a thread, posting the notice and keeping the history', async () => { const caller = 891010 const stayer = 891011 const chatThreadId = await createThread(env.DB, [caller, stayer], null, caller) await postMessage(env.DB, { chatThreadId, senderPlayerId: caller, contents: '{"Type":0,"Version":1,"Data":"bye"}', }) const res = await post(caller, `/thread/${chatThreadId}/leave`) expect(res.status).toBe(200) expect(await res.json()).toBe(0) // Gone for the leaver, intact for everyone else. expect(await isThreadMember(env.DB, chatThreadId, caller)).toBe(false) expect(await getThreadForPlayer(env.DB, chatThreadId, caller)).toBeNull() const remaining = await getThreadForPlayer(env.DB, chatThreadId, stayer) expect(remaining?.playerIds).toEqual([stayer]) expect(remaining?.latestMessage).toMatchObject({ senderPlayerId: SYSTEM_SENDER_ID, contents: leftChatContents(caller), }) // Opening notice, the message, and the leave notice. expect(await getThreadMessages(env.DB, chatThreadId)).toHaveLength(3) }) it('accepts DELETE for leave as well as POST', async () => { const caller = 891012 const chatThreadId = await createThread(env.DB, [caller, 891013], null, caller) expect( await (await post(caller, `/thread/${chatThreadId}/leave`, undefined, 'DELETE')).json() ).toBe(0) expect(await isThreadMember(env.DB, chatThreadId, caller)).toBe(false) }) it('reports membership-not-found when leaving a thread you are not on', async () => { const chatThreadId = await createThread(env.DB, [891014, 891015], null, 891014) expect(await (await post(891016, `/thread/${chatThreadId}/leave`)).json()).toBe(3) // Nothing was posted to a thread the caller has no business touching. expect(await getThreadMessages(env.DB, chatThreadId)).toHaveLength(1) }) it('401s without a token', async () => { const rename = await SELF.fetch(`${ORIGIN}/thread/1/rename`, { method: 'POST' }) expect(rename.status).toBe(401) const leave = await SELF.fetch(`${ORIGIN}/thread/1/leave`, { method: 'POST' }) expect(leave.status).toBe(401) }) }) describe('POST /thread/:id/snooze', () => { async function snooze(caller: number, chatThreadId: number, body: string) { return SELF.fetch(`${ORIGIN}/thread/${chatThreadId}/snooze`, { method: 'POST', headers: { ...(await bearer(caller)), 'Content-Type': 'application/x-www-form-urlencoded', }, body, }) } it('snoozes and unsnoozes for the caller alone', async () => { const caller = 892001 const other = 892002 const chatThreadId = await createThread(env.DB, [caller, other], null, caller) const res = await snooze(caller, chatThreadId, 'snooze=True') expect(res.status).toBe(200) expect(await res.json()).toBe(0) expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.snoozedUntil).toBe( '9999-12-31T23:59:59Z' ) // Snoozing is per-member: the other player is untouched. expect((await getThreadForPlayer(env.DB, chatThreadId, other))?.snoozedUntil).toBeNull() await snooze(caller, chatThreadId, 'snooze=False') expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.snoozedUntil).toBeNull() }) it('refuses a thread the caller is not on', async () => { const chatThreadId = await createThread(env.DB, [892003, 892004], null, 892003) expect(await (await snooze(892005, chatThreadId, 'snooze=True')).json()).toBe(3) }) it('401s without a token', async () => { const res = await SELF.fetch(`${ORIGIN}/thread/1/snooze`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'snooze=True', }) expect(res.status).toBe(401) }) }) describe('PUT /thread/:id/favorite', () => { async function favorite(caller: number, chatThreadId: number, body: string, method = 'PUT') { return SELF.fetch(`${ORIGIN}/thread/${chatThreadId}/favorite`, { method, headers: { ...(await bearer(caller)), 'Content-Type': 'application/x-www-form-urlencoded', }, body, }) } it('favorites and unfavorites for the caller alone', async () => { const caller = 893001 const other = 893002 const chatThreadId = await createThread(env.DB, [caller, other], null, caller) const res = await favorite(caller, chatThreadId, 'favorite=True') expect(res.status).toBe(200) expect(await res.json()).toBe(0) expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.isFavorited).toBe(true) // Per-member, like snoozing: the other player's inbox is untouched. expect((await getThreadForPlayer(env.DB, chatThreadId, other))?.isFavorited).toBe(false) await favorite(caller, chatThreadId, 'favorite=False') expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.isFavorited).toBe(false) }) it('accepts POST as well as PUT', async () => { const caller = 893003 const chatThreadId = await createThread(env.DB, [caller, 893004], null, caller) expect(await (await favorite(caller, chatThreadId, 'favorite=True', 'POST')).json()).toBe(0) expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.isFavorited).toBe(true) }) it('refuses a thread the caller is not on', async () => { const chatThreadId = await createThread(env.DB, [893005, 893006], null, 893005) expect(await (await favorite(893007, chatThreadId, 'favorite=True')).json()).toBe(3) }) it('401s without a token', async () => { const res = await SELF.fetch(`${ORIGIN}/thread/1/favorite`, { method: 'PUT', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'favorite=True', }) expect(res.status).toBe(401) }) }) describe('openapi', () => { it('GET /openapi.json documents every route', async () => { const res = await SELF.fetch(`${ORIGIN}/openapi.json`) expect(res.status).toBe(200) const spec = (await res.json()) as { openapi: string paths: Record> } expect(spec.openapi).toMatch(/^3\.1/) // The spec route hides itself. expect(spec.paths['/openapi.json']).toBeUndefined() // Every schema inlines — a `$ref` here means a schema picked up a `.meta({ id })` // and emitted a reference the framework didn't hoist into components.schemas. expect(JSON.stringify(spec).includes('"$ref"')).toBe(false) // Every route the worker serves is described. This is the drift guard: adding a // route without a describeRoute() block fails here rather than silently shipping // an incomplete spec. Hono's `:param` syntax becomes OpenAPI's `{param}`; the // `.on([...], …)` routes contribute every method they were registered for. const documented = new Set( Object.entries(spec.paths).flatMap(([path, ops]) => Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`) ) ) expect([...documented].sort()).toEqual([ 'DELETE /thread/{id}/leave', 'GET /', 'GET /settings/partyinvite', 'GET /thread', 'GET /thread/chatPrivacySetting', 'GET /thread/checkCanSendDirectMessageWithPrivacySetting', 'GET /thread/party', 'GET /thread/{id}', 'GET /thread/{id}/message', 'POST /thread', 'POST /thread/party', 'POST /thread/withmembers', 'POST /thread/{id}', 'POST /thread/{id}/favorite', 'POST /thread/{id}/leave', 'POST /thread/{id}/member/{playerId}', 'POST /thread/{id}/message', 'POST /thread/{id}/message/{messageId}/read', 'POST /thread/{id}/read', 'POST /thread/{id}/rename', 'POST /thread/{id}/snooze', 'PUT /thread/chatPrivacySetting', 'PUT /thread/{id}/favorite', 'PUT /thread/{id}/message/{messageId}/read', 'PUT /thread/{id}/read', 'PUT /thread/{id}/rename', 'PUT /thread/{id}/snooze', ]) // Every operation carries a summary — a path present but undescribed is not // documentation. for (const ops of Object.values(spec.paths)) { for (const op of Object.values(ops)) expect(op.summary).toBeTruthy() } }) })