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 { createThread, 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): 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 }) )}` 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('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) }) }) describe('GET /thread/party', () => { it('answers an empty object', 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('401s without a token', async () => { const res = await SELF.fetch(`${ORIGIN}/thread/party?maxCount=1&mode=0`) 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', }) }) 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.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([caller, 886002, 886003]) expect(sent.every((n) => n.notificationType === NotificationType.ChatMessageReceived)).toBe( true ) expect(sent[0]!.data).toEqual({ chatMessageId: chatThread.latestMessage.chatMessageId, chatThreadId: chatThread.chatThreadId, senderPlayerId: caller, timeSent: expect.any(String), contents, moderationState: 0, }) }) it('pushes nothing when there is no message to push', async () => { await send(886004, 'ids=886005&messageContents=') 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/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() } }) })