From 00d849f7dc852d3eeb52ee8014f50192bb69fd83 Mon Sep 17 00:00:00 2001 From: "arrz.dev" <55147716+arrzdev@users.noreply.github.com> Date: Sun, 1 Nov 2026 23:46:47 +0000 Subject: [PATCH] test(backend): route and service tests for the invite API Moves the invite tests out of game.routes.test.ts into their own file and adds the permission cases: a stranger cannot see, decline or delete an invite, the sender cannot decline their own, and the recipient cannot delete one sent to them. The service tests reach the calls only the acceptance facade makes and D1 failing at each step of a send. --- .../src/http/routes/game.routes.test.ts | 148 ----- .../src/http/routes/invite.routes.test.ts | 510 ++++++++++++++++++ .../src/services/invite.service.test.ts | 182 +++++++ 3 files changed, 692 insertions(+), 148 deletions(-) create mode 100644 apps/backend/src/http/routes/invite.routes.test.ts create mode 100644 apps/backend/src/services/invite.service.test.ts diff --git a/apps/backend/src/http/routes/game.routes.test.ts b/apps/backend/src/http/routes/game.routes.test.ts index c311cb91..8b6f216c 100644 --- a/apps/backend/src/http/routes/game.routes.test.ts +++ b/apps/backend/src/http/routes/game.routes.test.ts @@ -163,154 +163,6 @@ describe("game routes", () => { }) } - //---- invites ---------------- - - it("sends an invite and shows it to both sides", async () => { - const sender = await signUp("caller") - const recipient = await signUp("callee") - - const { response, body } = await call<{ invite: Invite }>( - "POST", - "/invites", - sender.token, - { - username: recipient.username, - setupType: "belgian_daisy", - side: "white", - }, - ) - - expect(response.status).toBe(201) - expect(body.data?.invite).toMatchObject({ - setupType: "belgian_daisy", - side: "white", - status: "pending", - from: { name: sender.username }, - to: { name: recipient.username }, - }) - - const theirs = await call<{ invites: Invite[] }>( - "GET", - "/invites", - recipient.token, - ) - expect(theirs.body.data?.invites).toHaveLength(1) - }) - - it("finds a player whatever case the handle was typed in", async () => { - const sender = await signUp("typist") - const recipient = await signUp("target") - - const { response } = await call("POST", "/invites", sender.token, { - username: recipient.username.toUpperCase(), - setupType: "standard", - side: "random", - }) - - expect(response.status).toBe(201) - }) - - it("refuses an invite to a handle nobody holds", async () => { - const sender = await signUp("lonely") - - const { response, body } = await call( - "POST", - "/invites", - sender.token, - { - username: "nobody-at-all", - setupType: "standard", - side: "black", - }, - ) - - expect(response.status).toBe(404) - expect(body.error_code).toBe("player_not_found") - }) - - it("refuses an invite to yourself", async () => { - const solo = await signUp("solo") - - const { response, body } = await call("POST", "/invites", solo.token, { - username: solo.username, - setupType: "standard", - side: "black", - }) - - expect(response.status).toBe(400) - expect(body.error_code).toBe("invite_self") - }) - - it("refuses a second invite to the same player", async () => { - const sender = await signUp("keen") - const recipient = await signUp("popular") - await invite(sender.token, recipient.username) - - const { response, body } = await call( - "POST", - "/invites", - sender.token, - { - username: recipient.username, - setupType: "standard", - side: "black", - }, - ) - - expect(response.status).toBe(409) - expect(body.error_code).toBe("invite_exists") - }) - - it("lets the sender ask again after a decline", async () => { - const sender = await signUp("persistent") - const recipient = await signUp("reluctant") - const sent = await invite(sender.token, recipient.username) - - const declined = await call<{ invite: Invite }>( - "PATCH", - `/invites/${sent.id}`, - recipient.token, - { status: "declined" }, - ) - expect(declined.body.data?.invite.status).toBe("declined") - - const again = await call<{ invite: Invite }>( - "POST", - "/invites", - sender.token, - { - username: recipient.username, - setupType: "the_wall", - side: "white", - }, - ) - expect(again.response.status).toBe(201) - expect(again.body.data?.invite).toMatchObject({ - status: "pending", - setupType: "the_wall", - }) - }) - - it("lets the sender take an invite back", async () => { - const sender = await signUp("changed") - const recipient = await signUp("waiting") - const sent = await invite(sender.token, recipient.username) - - const { response } = await call( - "DELETE", - `/invites/${sent.id}`, - sender.token, - ) - expect(response.status).toBe(200) - - const theirs = await call<{ invites: Invite[] }>( - "GET", - "/invites", - recipient.token, - ) - expect(theirs.body.data?.invites).toHaveLength(0) - }) - //---- opening a game ---------------- it("opens a game on the invite's terms and clears the invite", async () => { diff --git a/apps/backend/src/http/routes/invite.routes.test.ts b/apps/backend/src/http/routes/invite.routes.test.ts new file mode 100644 index 00000000..3c82ff4c --- /dev/null +++ b/apps/backend/src/http/routes/invite.routes.test.ts @@ -0,0 +1,510 @@ +import { env } from "cloudflare:workers" +import { beforeAll, describe, expect, it } from "vitest" +import worker from "@/entrypoint" +import { envRegistry } from "@/env/registry" +import type { Invite } from "@/services/invite.service" +import { newExecutionContext } from "@/test-support/execution-context" + +type Envelope = { + status: string + error_code?: string + data?: Data +} + +/** + * Who may see, decline and delete an invite. + * + * Every refusal here answers `not_found`, not a 403: an invite the caller is not + * party to looks the same as one that never existed, so the answer leaks + * nothing about invites between other players. + */ +describe("invite routes", () => { + beforeAll(() => { + envRegistry.setEnv(env as unknown as Record) + }) + + //---- talking to the worker ---------------- + + function fetchWorker(request: Request) { + return worker.fetch(request, env as never, newExecutionContext()) + } + + async function call( + method: string, + path: string, + token: string, + body?: unknown, + ) { + const headers: Record = { + origin: "http://example.com", + "x-test-bypass": "true", + } + if (token) headers.authorization = `Bearer ${token}` + if (body !== undefined) headers["content-type"] = "application/json" + + const response = await fetchWorker( + new Request(`http://example.com/api/v1${path}`, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + }), + ) + return { + response, + body: (await response.json()) as Envelope, + } + } + + let handles = 0 + async function signUp(role: string) { + handles += 1 + const username = `${role}${handles}` + const response = await fetchWorker( + new Request("http://example.com/api/v1/auth/sign-up/email", { + method: "POST", + headers: { + "content-type": "application/json", + origin: "http://example.com", + "x-test-bypass": "true", + }, + body: JSON.stringify({ + username, + name: username, + email: `${username}@users.abalone.invalid`, + password: "correct-horse", + }), + }), + ) + return { + username, + token: response.headers.get("set-auth-token") ?? "", + } + } + + //---- the shortest path to an invite ---------------- + + async function invite(from: string, username: string) { + const { body } = await call<{ invite: Invite }>( + "POST", + "/invites", + from, + { username, setupType: "standard", side: "black" }, + ) + if (!body.data) throw new Error(`invite failed: ${body.error_code}`) + return body.data.invite + } + + /** A sender, a recipient, an invite pending between them, and a stranger. */ + async function pendingInvite() { + const sender = await signUp("sender") + const recipient = await signUp("recipient") + const stranger = await signUp("stranger") + const sent = await invite(sender.token, recipient.username) + return { sender, recipient, stranger, sent } + } + + function decline(token: string, inviteId: string) { + return call<{ invite: Invite }>( + "PATCH", + `/invites/${inviteId}`, + token, + { + status: "declined", + }, + ) + } + + function remove(token: string, inviteId: string) { + return call("DELETE", `/invites/${inviteId}`, token) + } + + async function listFor(token: string) { + const { body } = await call<{ invites: Invite[] }>( + "GET", + "/invites", + token, + ) + return body.data?.invites ?? [] + } + + //a well-formed id that was never issued + const UNKNOWN_ID = "00000000-0000-4000-8000-000000000000" + + //---- signed out ---------------- + + describe("without a session", () => { + it.each([ + ["GET", "/invites", undefined], + [ + "POST", + "/invites", + { username: "anyone", setupType: "standard", side: "black" }, + ], + ["PATCH", `/invites/${UNKNOWN_ID}`, { status: "declined" }], + ["DELETE", `/invites/${UNKNOWN_ID}`, undefined], + ])("refuses %s %s", async (method, path, body) => { + const { response, body: envelope } = await call( + method, + path, + "", + body, + ) + + expect(response.status).toBe(401) + expect(envelope.error_code).toBe("unauthorized") + }) + }) + + //---- list ---------------- + + describe("GET /invites", () => { + it("shows an invite to both sides", async () => { + const { sender, recipient, sent } = await pendingInvite() + + const theirs = await listFor(recipient.token) + const mine = await listFor(sender.token) + + expect(theirs.map((each) => each.id)).toEqual([sent.id]) + expect(mine.map((each) => each.id)).toEqual([sent.id]) + }) + + it("hides an invite from a player who is party to neither side", async () => { + const { stranger } = await pendingInvite() + + expect(await listFor(stranger.token)).toEqual([]) + }) + + it("lists sent and received invites oldest first", async () => { + const player = await signUp("busy") + const friend = await signUp("friend") + const rival = await signUp("rival") + const sent = await invite(player.token, friend.username) + const received = await invite(rival.token, player.username) + //two sends can land in the same millisecond, so the order is set by hand + await env.DB.prepare( + "UPDATE invites SET created_at = ? WHERE id = ?", + ) + .bind(sent.createdAt - 1000, sent.id) + .run() + + const ids = (await listFor(player.token)).map((each) => each.id) + + expect(ids).toEqual([sent.id, received.id]) + }) + }) + + //---- send ---------------- + + describe("POST /invites", () => { + it("sends an invite on the terms asked for", async () => { + const sender = await signUp("caller") + const recipient = await signUp("callee") + + const { response, body } = await call<{ invite: Invite }>( + "POST", + "/invites", + sender.token, + { + username: recipient.username, + setupType: "belgian_daisy", + side: "white", + }, + ) + + expect(response.status).toBe(201) + expect(body.data?.invite).toMatchObject({ + setupType: "belgian_daisy", + side: "white", + status: "pending", + from: { name: sender.username }, + to: { name: recipient.username }, + }) + }) + + it("finds a player whatever case the handle was typed in", async () => { + const sender = await signUp("typist") + const recipient = await signUp("target") + + const { response } = await call("POST", "/invites", sender.token, { + username: ` ${recipient.username.toUpperCase()} `, + setupType: "standard", + side: "random", + }) + + expect(response.status).toBe(201) + }) + + it.each([ + ["a handle too short", { username: "ab" }], + ["a handle too long", { username: "a".repeat(21) }], + ["a setup nobody plays", { setupType: "no-such-setup" }], + ["a side that is not one", { side: "green" }], + ])("refuses %s", async (_, override) => { + const sender = await signUp("sloppy") + + const { response, body } = await call( + "POST", + "/invites", + sender.token, + { + username: "someone", + setupType: "standard", + side: "black", + ...override, + }, + ) + + expect(response.status).toBe(400) + expect(body.error_code).toBe("invalid_input") + }) + + it("refuses an invite to a handle nobody holds", async () => { + const sender = await signUp("lonely") + + const { response, body } = await call( + "POST", + "/invites", + sender.token, + { + username: "nobody-at-all", + setupType: "standard", + side: "black", + }, + ) + + expect(response.status).toBe(404) + expect(body.error_code).toBe("player_not_found") + }) + + it("refuses an invite to yourself", async () => { + const solo = await signUp("solo") + + const { response, body } = await call( + "POST", + "/invites", + solo.token, + { + username: solo.username, + setupType: "standard", + side: "black", + }, + ) + + expect(response.status).toBe(400) + expect(body.error_code).toBe("invite_self") + }) + + it("refuses a second invite to the same player while one is pending", async () => { + const { sender, recipient } = await pendingInvite() + + const { response, body } = await call( + "POST", + "/invites", + sender.token, + { + username: recipient.username, + setupType: "standard", + side: "black", + }, + ) + + expect(response.status).toBe(409) + expect(body.error_code).toBe("invite_exists") + expect(await listFor(recipient.token)).toHaveLength(1) + }) + + //one row per direction: the other player asking back is a second invite + it("lets the recipient invite the sender back", async () => { + const { sender, recipient } = await pendingInvite() + + const { response } = await call( + "POST", + "/invites", + recipient.token, + { + username: sender.username, + setupType: "standard", + side: "white", + }, + ) + + expect(response.status).toBe(201) + expect(await listFor(sender.token)).toHaveLength(2) + }) + + it("lets the sender ask again after a decline, on the same row", async () => { + const { sender, recipient, sent } = await pendingInvite() + await decline(recipient.token, sent.id) + + const again = await call<{ invite: Invite }>( + "POST", + "/invites", + sender.token, + { + username: recipient.username, + setupType: "the_wall", + side: "white", + }, + ) + + expect(again.response.status).toBe(201) + expect(again.body.data?.invite).toMatchObject({ + id: sent.id, + status: "pending", + setupType: "the_wall", + side: "white", + }) + expect(await listFor(recipient.token)).toHaveLength(1) + }) + }) + + //---- decline ---------------- + + describe("PATCH /invites/:id", () => { + it("lets the recipient decline", async () => { + const { sender, recipient, sent } = await pendingInvite() + + const { response, body } = await decline(recipient.token, sent.id) + + expect(response.status).toBe(200) + expect(body.data?.invite).toMatchObject({ + id: sent.id, + status: "declined", + }) + const [seen] = await listFor(sender.token) + expect(seen.status).toBe("declined") + }) + + it("refuses the sender declining their own invite", async () => { + const { sender, recipient, sent } = await pendingInvite() + + const { response, body } = await decline(sender.token, sent.id) + + expect(response.status).toBe(404) + expect(body.error_code).toBe("not_found") + const [seen] = await listFor(recipient.token) + expect(seen.status).toBe("pending") + }) + + it("refuses a player who is party to neither side", async () => { + const { recipient, stranger, sent } = await pendingInvite() + + const { response, body } = await decline(stranger.token, sent.id) + + expect(response.status).toBe(404) + expect(body.error_code).toBe("not_found") + const [seen] = await listFor(recipient.token) + expect(seen.status).toBe("pending") + }) + + it("refuses declining an invite twice", async () => { + const { recipient, sent } = await pendingInvite() + await decline(recipient.token, sent.id) + + const { response, body } = await decline(recipient.token, sent.id) + + expect(response.status).toBe(404) + expect(body.error_code).toBe("not_found") + }) + + it("refuses an invite that does not exist", async () => { + const player = await signUp("hopeful") + + const { response, body } = await decline(player.token, UNKNOWN_ID) + + expect(response.status).toBe(404) + expect(body.error_code).toBe("not_found") + }) + + it("refuses an id that is not one", async () => { + const player = await signUp("garbled") + + const { response, body } = await decline(player.token, "not-a-uuid") + + expect(response.status).toBe(400) + expect(body.error_code).toBe("invalid_input") + }) + + //decline is the only change a recipient can make: nothing else gets in + it.each([ + ["any other status", { status: "accepted" }], + ["no status at all", {}], + ])("refuses %s", async (_, patch) => { + const { recipient, sent } = await pendingInvite() + + const { response, body } = await call( + "PATCH", + `/invites/${sent.id}`, + recipient.token, + patch, + ) + + expect(response.status).toBe(400) + expect(body.error_code).toBe("invalid_input") + const [seen] = await listFor(recipient.token) + expect(seen.status).toBe("pending") + }) + }) + + //---- delete ---------------- + + describe("DELETE /invites/:id", () => { + it("lets the sender take an invite back", async () => { + const { sender, recipient, sent } = await pendingInvite() + + const { response } = await remove(sender.token, sent.id) + + expect(response.status).toBe(200) + expect(await listFor(recipient.token)).toEqual([]) + expect(await listFor(sender.token)).toEqual([]) + }) + + it("lets the sender clear a decline", async () => { + const { sender, recipient, sent } = await pendingInvite() + await decline(recipient.token, sent.id) + + const { response } = await remove(sender.token, sent.id) + + expect(response.status).toBe(200) + expect(await listFor(sender.token)).toEqual([]) + }) + + it("refuses the recipient deleting an invite sent to them", async () => { + const { sender, recipient, sent } = await pendingInvite() + + const { response, body } = await remove(recipient.token, sent.id) + + expect(response.status).toBe(404) + expect(body.error_code).toBe("not_found") + expect(await listFor(sender.token)).toHaveLength(1) + }) + + it("refuses a player who is party to neither side", async () => { + const { sender, stranger, sent } = await pendingInvite() + + const { response, body } = await remove(stranger.token, sent.id) + + expect(response.status).toBe(404) + expect(body.error_code).toBe("not_found") + expect(await listFor(sender.token)).toHaveLength(1) + }) + + it("refuses an invite that does not exist", async () => { + const player = await signUp("tidy") + + const { response, body } = await remove(player.token, UNKNOWN_ID) + + expect(response.status).toBe(404) + expect(body.error_code).toBe("not_found") + }) + + it("refuses an id that is not one", async () => { + const player = await signUp("garbled") + + const { response, body } = await remove(player.token, "not-a-uuid") + + expect(response.status).toBe(400) + expect(body.error_code).toBe("invalid_input") + }) + }) +}) diff --git a/apps/backend/src/services/invite.service.test.ts b/apps/backend/src/services/invite.service.test.ts new file mode 100644 index 00000000..d19709ef --- /dev/null +++ b/apps/backend/src/services/invite.service.test.ts @@ -0,0 +1,182 @@ +import { env } from "cloudflare:workers" +import { beforeAll, describe, expect, it } from "vitest" +import { user } from "@/database/auth.schema" +import { getDb } from "@/database/client" +import { envRegistry } from "@/env/registry" +import { InviteService } from "@/services/invite.service" +import { failingD1 } from "@/test-support/failing-bindings" + +/** + * What the invite route tests cannot reach over http: the two calls only the + * acceptance facade makes, and D1 failing at each step of a send. + */ +describe("invite service", () => { + beforeAll(() => { + envRegistry.setEnv(env as unknown as Record) + }) + + const db = getDb(env.DB) + const inviteService = new InviteService(db) + + let players = 0 + async function seededPlayer(): Promise<{ id: string; handle: string }> { + players += 1 + const id = crypto.randomUUID() + const handle = `invited${players}` + const now = new Date() + await db.insert(user).values({ + id, + name: handle, + email: `${handle}@service.abalone.invalid`, + username: handle, + displayUsername: handle, + createdAt: now, + updatedAt: now, + }) + return { id, handle } + } + + async function pendingInvite() { + const sender = await seededPlayer() + const recipient = await seededPlayer() + const stranger = await seededPlayer() + const sent = await inviteService.create( + sender.id, + recipient.handle, + "standard", + "black", + ) + return { sender, recipient, stranger, sent } + } + + /** + * The real local D1, until the nth query, which it refuses. + * + * A send is up to four queries in a row, and `failingD1` refuses the first, + * so only this reaches the failure branches after it. + */ + function d1FailingAt(query: number): D1Database { + let seen = 0 + return new Proxy(env.DB, { + get(target, property) { + if (property !== "prepare") return Reflect.get(target, property) + return (sql: string) => { + seen += 1 + if (seen >= query) throw new Error("D1 is not answering") + return target.prepare(sql) + } + }, + }) + } + + //---- the acceptance facade's calls ---------------- + + describe("findAddressedTo", () => { + it("finds a pending invite for its recipient", async () => { + const { recipient, sent } = await pendingInvite() + + const found = await inviteService.findAddressedTo( + sent.id, + recipient.id, + ) + + expect(found).toEqual(sent) + }) + + it("finds nothing for the sender or a stranger", async () => { + const { sender, stranger, sent } = await pendingInvite() + + expect( + await inviteService.findAddressedTo(sent.id, sender.id), + ).toBeNull() + expect( + await inviteService.findAddressedTo(sent.id, stranger.id), + ).toBeNull() + }) + + it("finds nothing once the invite is declined", async () => { + const { recipient, sent } = await pendingInvite() + await inviteService.decline(sent.id, recipient.id) + + expect( + await inviteService.findAddressedTo(sent.id, recipient.id), + ).toBeNull() + }) + }) + + describe("discard", () => { + it("deletes the invite for both sides", async () => { + const { sender, recipient, sent } = await pendingInvite() + + await inviteService.discard(sent.id) + + expect(await inviteService.listForUser(sender.id)).toEqual([]) + expect(await inviteService.listForUser(recipient.id)).toEqual([]) + }) + }) + + //---- D1 failing ---------------- + + describe("when D1 refuses", () => { + const broken = new InviteService(getDb(failingD1())) + + it.each([ + ["a list", () => broken.listForUser("anyone")], + ["a decline", () => broken.decline(crypto.randomUUID(), "anyone")], + ["a delete", () => broken.removeOwn(crypto.randomUUID(), "anyone")], + [ + "an acceptance lookup", + () => broken.findAddressedTo(crypto.randomUUID(), "anyone"), + ], + [ + "the player lookup", + () => broken.create("a", "b", "standard", "black"), + ], + ])("answers internal_server_error on %s", async (_, act) => { + await expect(act()).rejects.toMatchObject({ + errorCode: "internal_server_error", + }) + }) + + it("answers internal_server_error when the existing invite read fails", async () => { + const sender = await seededPlayer() + const recipient = await seededPlayer() + const flaky = new InviteService(getDb(d1FailingAt(2))) + + await expect( + flaky.create(sender.id, recipient.handle, "standard", "black"), + ).rejects.toMatchObject({ errorCode: "internal_server_error" }) + }) + + //the pair index refusing a racing second send lands here too, which is + //why a failed write reads as the invite already being out + it("answers invite_exists when the write fails", async () => { + const sender = await seededPlayer() + const recipient = await seededPlayer() + const flaky = new InviteService(getDb(d1FailingAt(3))) + + await expect( + flaky.create(sender.id, recipient.handle, "standard", "black"), + ).rejects.toMatchObject({ errorCode: "invite_exists" }) + }) + + it("answers internal_server_error when reading the sent invite back fails", async () => { + const sender = await seededPlayer() + const recipient = await seededPlayer() + const flaky = new InviteService(getDb(d1FailingAt(4))) + + await expect( + flaky.create(sender.id, recipient.handle, "standard", "black"), + ).rejects.toMatchObject({ errorCode: "internal_server_error" }) + }) + + it("answers internal_server_error when reading a declined invite back fails", async () => { + const { recipient, sent } = await pendingInvite() + const flaky = new InviteService(getDb(d1FailingAt(2))) + + await expect( + flaky.decline(sent.id, recipient.id), + ).rejects.toMatchObject({ errorCode: "internal_server_error" }) + }) + }) +})