From a4faf9e41fcf2355868fdaaa548b31aa95e41798 Mon Sep 17 00:00:00 2001 From: friskyydev Date: Mon, 3 Aug 2026 21:24:47 -0400 Subject: [PATCH] test: make the companion talk to a real API, and fix what that found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every test in this repo tested a piece in isolation: the parser against fixtures, the watcher against a temp file, the compat rules against invented responses. All of it could pass while the app could not sign in, because nothing here had ever spoken to a server. That is the same gap that once shipped an app whose preload never loaded — every check green, the thing itself dead. So live.test.ts signs in for real against a local API. It builds a PKCE pair, plays the part of the consent page to get a code, exchanges it through the real client for a real token, confirms a code presented with the wrong verifier is refused, lists the guilds that token may write to, uploads a night, and checks that signing out actually revokes rather than merely forgetting. Skipped unless RAIDIFY_E2E_API_URL is set. A suite that needs a database running to pass is a suite people stop running. It cannot point at production either: test-login 404s there, so the first step fails loudly rather than quietly doing something to real data. It found a bug on its first run. Sign-out returns 204, and request parsed every successful response as JSON, so an empty body threw "Unexpected end of JSON input" *after* the server had already revoked the token. The caller's catch block then reported a sign-out that worked perfectly as "offline, or already revoked" — the opposite of what happened. Any future no-content endpoint had the same problem waiting. Also checked the hand-written contract against the live schema, field by field: every name, type and nullability matches, and the enums really are integers on the wire. And schema:pull now keeps only the companion's corner. The full document is 248 paths and 328 schemas — every admin route and internal DTO, a complete map of the private API. This repo may be made public so anyone wary of an unsigned binary can read what it does, and that map must not travel with it. Narrowing costs nothing: the point of the file is noticing a change to the eight endpoints we speak to, and 567KB shrinks to 17KB. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 44 ++- reference/openapi.json | 694 ++++++++++++++++++++++++++++++++++++++++ scripts/pull-schema.mjs | 61 +++- src/main/api.ts | 7 + src/main/live.test.ts | 173 ++++++++++ 5 files changed, 968 insertions(+), 11 deletions(-) create mode 100644 reference/openapi.json create mode 100644 src/main/live.test.ts diff --git a/README.md b/README.md index c2454d5..5d474bc 100644 --- a/README.md +++ b/README.md @@ -54,11 +54,43 @@ npm install npm run dev # electron-vite, hot reload npm test # vitest npm run typecheck +npm run smoke # starts the real app, asserts the bridge works and the window drew npm run package # unsigned installer into dist/ ``` Point at a local API with `RAIDIFY_API_URL=http://localhost:5001 npm run dev`. +### Running against a real API + +`src/main/live.test.ts` signs in for real, exchanges a code for a token, lists guilds and +uploads a night. It skips unless pointed at an API, because a suite that needs a database +running is a suite people stop running. + +```bash +# in the raidify repo +docker compose up -d +dotnet run --project apps/api/src/Raidify.Api --urls http://localhost:5001 + +# here +RAIDIFY_E2E_API_URL=http://localhost:5001 npm test +``` + +Needs `AllowTestLogin`, which `appsettings.Development.json` already sets. It cannot run +against production — test-login 404s there and the first step fails loudly rather than +quietly touching real data. + +Worth running after any change to `src/shared/contract.ts`. Every other test in this repo +can pass while the app cannot sign in; this is the one that would notice. It found the +204 sign-out bug on its first run. + +### Refreshing the API reference + +`npm run schema:pull [baseUrl]` rewrites `reference/openapi.json`, then read the diff. + +It keeps only the eight companion endpoints and the schemas they reach — 14 of the API's +328. The full document is a complete map of the private API, and this repo may be made +public so anyone wary of an unsigned binary can read what it does. + ## Releasing Tag `v*`. CI typechecks, tests, packages, publishes the installer and `latest.yml` to a @@ -87,6 +119,12 @@ only knows who it *saw*, so uploading would silently turn every no-show into not ## Not built yet -The sign-in flow proper — device-code pairing against the web app. `src/main/index.ts` -has the IPC shape so the boundary is visible; the browser handoff is next. After that, -the UI: guided first-run, upload history, and the consent screen. +**The Lua parse runs on the main process.** A forty-man file parses fast enough that +nobody notices, but it is still the UI thread doing it, and the file only grows. + +**Nothing is uploaded automatically.** Deliberate for now — see `NightCard.tsx` for why +the officer is the one who decides a session was the guild's raid. An "always send +finished nights for this guild" setting is reasonable once the matching has earned trust. + +**Linux `basic_text` detection.** `safeStorage` can silently fall back to plaintext on +Linux; `canPersist()` reports availability but not which backend answered. diff --git a/reference/openapi.json b/reference/openapi.json new file mode 100644 index 0000000..b2eca21 --- /dev/null +++ b/reference/openapi.json @@ -0,0 +1,694 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "Raidify.Api | v1", + "version": "1.0.0" + }, + "paths": { + "/api/v1/auth/companion/approve": { + "post": { + "tags": [ + "CompanionAuth" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApproveCompanionRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ApproveCompanionRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/ApproveCompanionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ApproveCompanionResponse" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApproveCompanionResponse" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ApproveCompanionResponse" + } + } + } + } + } + } + }, + "/api/v1/auth/companion/token": { + "post": { + "tags": [ + "CompanionAuth" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CompanionTokenRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/CompanionTokenRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/CompanionTokenRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/CompanionTokenResponse" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/CompanionTokenResponse" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/CompanionTokenResponse" + } + } + } + } + } + } + }, + "/api/v1/account/companion-tokens": { + "get": { + "tags": [ + "CompanionAuth" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CompanionTokenDto" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CompanionTokenDto" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CompanionTokenDto" + } + } + } + } + } + } + } + }, + "/api/v1/account/companion-tokens/{tokenId}": { + "delete": { + "tags": [ + "CompanionAuth" + ], + "parameters": [ + { + "name": "tokenId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/companion/sign-out": { + "post": { + "tags": [ + "CompanionAuth" + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/companion/compat": { + "get": { + "tags": [ + "Companion" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/CompanionCompatDto" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/CompanionCompatDto" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/CompanionCompatDto" + } + } + } + } + } + } + }, + "/api/v1/companion/guilds": { + "get": { + "tags": [ + "Companion" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CompanionGuildDto" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CompanionGuildDto" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CompanionGuildDto" + } + } + } + } + } + } + } + }, + "/api/v1/guilds/{guildId}/companion/attendance": { + "post": { + "tags": [ + "Companion" + ], + "parameters": [ + { + "name": "guildId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CompanionAttendanceUploadRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/CompanionAttendanceUploadRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/CompanionAttendanceUploadRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/CompanionAttendanceUploadResult" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/CompanionAttendanceUploadResult" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/CompanionAttendanceUploadResult" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "ApproveCompanionRequest": { + "required": [ + "codeChallenge", + "redirectPort" + ], + "type": "object", + "properties": { + "codeChallenge": { + "type": "string" + }, + "redirectPort": { + "type": "integer", + "format": "int32" + }, + "label": { + "type": "string", + "default": null, + "nullable": true + }, + "codeChallengeMethod": { + "type": "string", + "default": "S256" + } + } + }, + "ApproveCompanionResponse": { + "required": [ + "code", + "expiresAt" + ], + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "expiresAt": { + "type": "string", + "format": "date-time" + } + } + }, + "CompanionAttendanceBucket": { + "type": "integer" + }, + "CompanionAttendanceRow": { + "required": [ + "name", + "bucket" + ], + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "bucket": { + "$ref": "#/components/schemas/CompanionAttendanceBucket" + }, + "realm": { + "type": "string", + "default": null, + "nullable": true + }, + "firstSeen": { + "type": "string", + "format": "date-time", + "default": null, + "nullable": true + }, + "lastSeen": { + "type": "string", + "format": "date-time", + "default": null, + "nullable": true + }, + "markedBy": { + "type": "string", + "default": null, + "nullable": true + }, + "markedAt": { + "type": "string", + "format": "date-time", + "default": null, + "nullable": true + }, + "markRoute": { + "type": "string", + "default": null, + "nullable": true + } + } + }, + "CompanionAttendanceRowResult": { + "required": [ + "name", + "bucket", + "applied", + "skipped" + ], + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "bucket": { + "$ref": "#/components/schemas/CompanionAttendanceBucket" + }, + "applied": { + "type": "boolean" + }, + "skipped": { + "type": "string", + "nullable": true + } + } + }, + "CompanionAttendanceUploadRequest": { + "required": [ + "rows" + ], + "type": "object", + "properties": { + "rows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CompanionAttendanceRow" + } + }, + "startedAt": { + "type": "string", + "format": "date-time", + "default": null, + "nullable": true + }, + "endedAt": { + "type": "string", + "format": "date-time", + "default": null, + "nullable": true + }, + "raidIdHint": { + "type": "string", + "format": "uuid", + "default": null, + "nullable": true + }, + "clientVersion": { + "type": "string", + "default": null, + "nullable": true + }, + "addonVersion": { + "type": "string", + "default": null, + "nullable": true + }, + "dryRun": { + "type": "boolean", + "default": false + } + } + }, + "CompanionAttendanceUploadResult": { + "required": [ + "dryRun", + "match", + "raidId", + "raidTitle", + "eventKey", + "occurredAt", + "rowsReceived", + "recorded", + "updated", + "unchanged", + "unmatchedRaiders", + "rows", + "warnings" + ], + "type": "object", + "properties": { + "dryRun": { + "type": "boolean" + }, + "match": { + "$ref": "#/components/schemas/CompanionRaidMatch" + }, + "raidId": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "raidTitle": { + "type": "string", + "nullable": true + }, + "eventKey": { + "type": "string" + }, + "occurredAt": { + "type": "string", + "format": "date-time" + }, + "rowsReceived": { + "type": "integer", + "format": "int32" + }, + "recorded": { + "type": "integer", + "format": "int32" + }, + "updated": { + "type": "integer", + "format": "int32" + }, + "unchanged": { + "type": "integer", + "format": "int32" + }, + "unmatchedRaiders": { + "type": "array", + "items": { + "type": "string" + } + }, + "rows": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CompanionAttendanceRowResult" + } + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "CompanionCompatDto": { + "required": [ + "minimumClientVersion", + "latestClientVersion", + "minimumAddonVersion", + "payloadVersion", + "uploadEnabled", + "downloadUrl", + "notice" + ], + "type": "object", + "properties": { + "minimumClientVersion": { + "type": "string" + }, + "latestClientVersion": { + "type": "string" + }, + "minimumAddonVersion": { + "type": "string" + }, + "payloadVersion": { + "type": "integer", + "format": "int32" + }, + "uploadEnabled": { + "type": "boolean" + }, + "downloadUrl": { + "type": "string", + "nullable": true + }, + "notice": { + "type": "string", + "nullable": true + } + } + }, + "CompanionGuildDto": { + "required": [ + "id", + "name", + "slug", + "gameVersion", + "avatarUrl" + ], + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "gameVersion": { + "$ref": "#/components/schemas/GameVersion" + }, + "avatarUrl": { + "type": "string", + "nullable": true + } + } + }, + "CompanionRaidMatch": { + "type": "integer" + }, + "CompanionTokenDto": { + "required": [ + "id", + "label", + "createdAt", + "lastUsedAt", + "lastClientVersion" + ], + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "label": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "lastUsedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "lastClientVersion": { + "type": "string", + "nullable": true + } + } + }, + "CompanionTokenRequest": { + "required": [ + "code", + "codeVerifier" + ], + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "codeVerifier": { + "type": "string" + } + } + }, + "CompanionTokenResponse": { + "required": [ + "token", + "tokenId", + "label", + "createdAt" + ], + "type": "object", + "properties": { + "token": { + "type": "string" + }, + "tokenId": { + "type": "string", + "format": "uuid" + }, + "label": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + } + }, + "GameVersion": { + "type": "integer" + } + } + } +} diff --git a/scripts/pull-schema.mjs b/scripts/pull-schema.mjs index 6038115..ccb9f58 100644 --- a/scripts/pull-schema.mjs +++ b/scripts/pull-schema.mjs @@ -41,13 +41,58 @@ if (!document) { process.exit(1); } -// Pretty-printed and sorted so the diff is readable — the whole point is reviewing it. -await writeFile(target, `${JSON.stringify(document, null, 2)}\n`, 'utf8'); +const companionPaths = Object.keys(document.paths ?? {}).filter((p) => p.includes('companion')); +if (!companionPaths.length) { + console.error('No companion routes in this document — refusing to write a reference without them.'); + process.exit(1); +} + +/** + * Only the companion's own corner of the API. + * + * The full document is 248 paths and 328 schemas — every admin route, every internal DTO, + * the whole shape of Raidify. This repo may be made public so that anyone worried about + * an unsigned binary can read what it does, and a complete map of the private API is + * exactly the kind of detail that must not travel with it. + * + * Narrowing costs nothing: the point of this file is noticing a change to the eight + * endpoints the companion speaks to, and the other 240 could not produce a diff worth + * reading anyway. + */ +const reference = { + openapi: document.openapi, + info: { title: document.info?.title, version: document.info?.version }, + paths: Object.fromEntries(companionPaths.map((p) => [p, document.paths[p]])), + components: { schemas: collectSchemas(document, companionPaths) }, +}; + +await writeFile(target, `${JSON.stringify(reference, null, 2)}\n`, 'utf8'); console.log(`wrote ${target}`); +console.log(`companion routes:\n ${companionPaths.join('\n ')}`); +console.log(`schemas kept: ${Object.keys(reference.components.schemas).length}`); -const companionPaths = Object.keys(document.paths ?? {}).filter((p) => p.includes('companion')); -console.log( - companionPaths.length - ? `companion routes:\n ${companionPaths.join('\n ')}` - : 'WARNING: no companion routes in this document.', -); +/** Every schema those paths reach, following $ref transitively. */ +function collectSchemas(doc, paths) { + const all = doc.components?.schemas ?? {}; + const kept = {}; + const queue = paths.flatMap((p) => refsIn(doc.paths[p])); + + while (queue.length) { + const name = queue.pop(); + if (!all[name] || kept[name]) continue; + kept[name] = all[name]; + queue.push(...refsIn(all[name])); + } + + // Sorted, so a schema being added does not reshuffle the whole file and bury the change. + return Object.fromEntries(Object.keys(kept).sort().map((k) => [k, kept[k]])); +} + +function refsIn(node) { + if (node === null || typeof node !== 'object') return []; + if (Array.isArray(node)) return node.flatMap(refsIn); + + return Object.entries(node).flatMap(([key, value]) => + key === '$ref' && typeof value === 'string' ? [value.split('/').pop()] : refsIn(value), + ); +} diff --git a/src/main/api.ts b/src/main/api.ts index f4ac373..41337c1 100644 --- a/src/main/api.ts +++ b/src/main/api.ts @@ -143,6 +143,13 @@ export class ApiClient { throw new ApiError(message, response.status); } + // 204 is a success with no body, and sign-out returns one. Parsing it threw + // "Unexpected end of JSON input" *after* the server had already revoked the token, so + // a sign-out that worked perfectly surfaced as a failure — and the caller's catch + // block then reported it as "offline, or already revoked", which was the opposite of + // what had happened. + if (response.status === 204) return undefined as T; + return (await response.json()) as T; } } diff --git a/src/main/live.test.ts b/src/main/live.test.ts new file mode 100644 index 0000000..d5256e1 --- /dev/null +++ b/src/main/live.test.ts @@ -0,0 +1,173 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { ApiClient } from './api'; +import { createPkcePair } from './auth'; +import { AttendanceBucket, evaluateCompat, PAYLOAD_VERSION } from '../shared/contract'; + +/** + * The whole chain, against a real API. + * + * Everything else in this suite tests a piece in isolation: the parser against fixtures, + * the watcher against a temp file, the compat rules against invented responses. All of it + * can pass while the app cannot sign in, because nothing here had ever spoken to a server. + * That is the same gap that once shipped an app whose preload never loaded — every check + * green, the thing itself dead. + * + * So this signs in for real, exchanges a real code for a real token, asks which guilds it + * may write to, and uploads a night. It is skipped unless pointed at an API, because a + * test suite that needs a database running to pass is a test suite people stop running. + * + * docker compose up -d # in the raidify repo + * dotnet run --project apps/api/src/Raidify.Api --urls http://localhost:5001 + * RAIDIFY_E2E_API_URL=http://localhost:5001 npm test + * + * Needs `AllowTestLogin: true`, which appsettings.Development.json already sets. It will + * not run against production: test-login 404s there, and the first step would fail loudly + * rather than quietly doing something to real data. + */ + +const BASE_URL = process.env.RAIDIFY_E2E_API_URL; +const describeLive = BASE_URL ? describe : describe.skip; + +describeLive('the companion against a real API', () => { + let webJwt: string; + let guildId: string; + let companionToken: string | null = null; + + const api = new ApiClient({ + baseUrl: BASE_URL, + clientVersion: '0.1.0', + getToken: async () => companionToken, + }); + + /** The web app's half: an ordinary signed-in session, and a guild it can manage. */ + async function asWebUser(method: string, path: string, body?: unknown): Promise { + const response = await fetch(`${BASE_URL}${path}`, { + method, + headers: { + Accept: 'application/json', + Authorization: `Bearer ${webJwt}`, + ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + + if (!response.ok) { + throw new Error(`${method} ${path} → ${response.status} ${await response.text()}`); + } + return (await response.json()) as T; + } + + beforeAll(async () => { + const login = await fetch(`${BASE_URL}/api/v1/auth/test-login`, { method: 'POST' }); + if (!login.ok) { + throw new Error( + `test-login → ${login.status}. Set AllowTestLogin, and do not point this at production.`, + ); + } + webJwt = ((await login.json()) as { accessToken: string }).accessToken; + + // A fresh guild per run. Reusing one would make the upload assertions depend on + // whatever the previous run left behind. + const suffix = Date.now().toString(36); + const guild = await asWebUser<{ id: string }>('POST', '/api/v1/guilds', { + name: `Companion E2E ${suffix}`, + description: 'Created by the companion live test.', + gameVersion: 1, + }); + guildId = guild.id; + }, 60_000); + + it('is a version this server still accepts', async () => { + const compat = await api.compat(); + + expect(compat.payloadVersion).toBe(PAYLOAD_VERSION); + expect(evaluateCompat(compat, '0.1.0').kind).toBe('ok'); + }); + + /** + * The half of sign-in that has no browser in it. + * + * The consent page is what turns a session into an approval; here the test plays that + * page. What is being checked is the pair — a code that came back through the browser + * and a verifier that never left this machine — actually buying a token from the real + * endpoint, with the real PKCE implementation on both sides. + */ + it('trades an approved code for a token', async () => { + const { verifier, challenge } = createPkcePair(); + + const approved = await asWebUser<{ code: string }>('POST', '/api/v1/auth/companion/approve', { + codeChallenge: challenge, + redirectPort: 51789, + label: 'live test', + }); + + const exchanged = await api.exchangeCode(approved.code, verifier); + + expect(exchanged.token).toBeTruthy(); + companionToken = exchanged.token; + }); + + it('refuses a code presented with the wrong verifier', async () => { + const { challenge } = createPkcePair(); + const approved = await asWebUser<{ code: string }>('POST', '/api/v1/auth/companion/approve', { + codeChallenge: challenge, + redirectPort: 51790, + }); + + await expect(api.exchangeCode(approved.code, createPkcePair().verifier)).rejects.toThrow(); + }); + + it('lists the guild it may report for', async () => { + const guilds = await api.guilds(); + + expect(guilds.map((g) => g.id)).toContain(guildId); + }); + + /** + * The point of the whole app. + * + * Dry run, so this leaves nothing behind — and because preview and commit run the same + * server path, exercising the preview exercises the write. + */ + it('uploads a night', async () => { + const startedAt = new Date(); + startedAt.setHours(startedAt.getHours() - 3); + + const result = await api.uploadAttendance(guildId, { + rows: [ + { name: 'Torvald', bucket: AttendanceBucket.Present }, + { name: 'Kaya', bucket: AttendanceBucket.Late }, + { + name: 'Bren', + bucket: AttendanceBucket.Benched, + markedBy: 'Torvald', + markedAt: new Date().toISOString(), + markRoute: 'sweep', + }, + ], + startedAt: startedAt.toISOString(), + endedAt: new Date().toISOString(), + clientVersion: '0.1.0', + dryRun: true, + }); + + expect(result.dryRun).toBe(true); + expect(result.rowsReceived).toBe(3); + // Nobody in a brand-new guild has a linked character, so every name is unmatched. + // That is the correct answer, and it is also the shape the UI renders — a result the + // client cannot read is as bad as no result. + expect(result.unmatchedRaiders).toHaveLength(3); + expect(result.eventKey).toBeTruthy(); + }); + + /** + * Signing out has to reach the server. Deleting the local copy of a token is not + * disconnecting — that left the credential valid forever, so selling the laptop handed + * it on. + */ + it('revokes the token when it signs out', async () => { + await api.signOut(); + + await expect(api.guilds()).rejects.toThrow(); + }); +});