Files
recflare/apps/clubs/src/test/integration/api.test.ts
T
Devin Zuczek 2a043d0042 Revert "store images as int"
This reverts commit 56f7695c71.
2026-07-21 15:57:57 -04:00

1234 lines
48 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { adminSecretsStore, env } from 'cloudflare:test'
import { exports } from 'cloudflare:workers'
import { beforeAll, describe, expect, test } from 'vitest'
import '../../clubs.app'
import { SCHEMA_DDL } from '../../clubs-db'
import type { Env } from '../../context'
declare module 'cloudflare:test' {
interface ProvidedEnv extends Env {}
}
const ORIGIN = 'https://example.com'
beforeAll(async () => {
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
// Build the club / club_member tables (mirrors the migration).
for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Accounts table (owned by the auth worker) — a player's home club is a field on
// their account row, so /club/home/me reads and writes it here.
await env.DB.prepare(
`CREATE TABLE IF NOT EXISTS account (
data TEXT NOT NULL,
account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.accountId')) VIRTUAL
)`
).run()
// Image metadata (owned by the img worker, written by api on upload) — a club's
// gallery serves the whole image record behind each stored image name.
await env.DB.prepare(
`CREATE TABLE IF NOT EXISTS image (
data TEXT NOT NULL,
id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.Id')) VIRTUAL,
image_name TEXT GENERATED ALWAYS AS (json_extract(data, '$.ImageName')) VIRTUAL
)`
).run()
const insertAccount = env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
await env.DB.batch(
[42, 9100, 9101].map((accountId) =>
insertAccount.bind(JSON.stringify({ accountId, username: `Player${accountId}` }))
)
)
})
// 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 = '42'): Promise<Record<string, string>> {
const now = Math.floor(Date.now() / 1000)
const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
JSON.stringify({ 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)}` }
}
describe('clubs endpoints', () => {
test('GET /club/home/me 401s without a token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/club/home/me`)
expect(res.status).toBe(401)
})
test('GET /club/home/me 404s when the player has no home club', async () => {
// The client expects a 404 here, and errors on an empty object.
const res = await exports.default.fetch(`${ORIGIN}/club/home/me`, { headers: await bearer() })
expect(res.status).toBe(404)
})
test('GET /subscription/mine/member returns an empty array without a token', async () => {
// The client calls this on the clubs host with no /club prefix and no auth.
const res = await exports.default.fetch(`${ORIGIN}/subscription/mine/member`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
test('GET /subscription/details/:subscription returns an empty object', async () => {
const res = await exports.default.fetch(`${ORIGIN}/subscription/details/rrplus`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({})
})
test('GET /subscription/details/:accountId returns simulated details', async () => {
const res = await exports.default.fetch(`${ORIGIN}/subscription/details/2`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ accountId: 2, clubId: 0, subscriberCount: 0 })
})
test('GET /subscription/subscriberCount/:id returns 0', async () => {
const res = await exports.default.fetch(`${ORIGIN}/subscription/subscriberCount/2`)
expect(res.status).toBe(200)
expect(await res.json()).toBe(0)
})
test('GET /announcements/v2/mine/unread returns []', async () => {
const res = await exports.default.fetch(`${ORIGIN}/announcements/v2/mine/unread`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
test('GET /club/mine/member lists the callers clubs; signed out is an empty list', async () => {
// Signed out is "no clubs", not an error — a 401 here breaks the client's shelf.
const anon = await exports.default.fetch(`${ORIGIN}/club/mine/member`)
expect(anon.status).toBe(200)
expect(await anon.json()).toEqual([])
const res = await exports.default.fetch(`${ORIGIN}/club/mine/member`, {
headers: await bearer('4242'),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
test('GET /club/mine/created returns an empty list when signed out', async () => {
const res = await exports.default.fetch(`${ORIGIN}/club/mine/created`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
test('the my-clubs lists exclude subscription clubs (ClubType 1)', async () => {
// A subscription club the player both created and is a member of. It's reached
// through /subscription/*, so it must not show up among their clubs.
const club = {
ClubId: 9001,
Name: 'Subscribers',
Description: '',
Category: '',
Visibility: 1,
Joinability: 0,
AllowJuniors: true,
MainImageName: '',
ClubType: 1,
ClubhouseRoomId: null,
CreatorAccountId: 4343,
IsRRO: false,
MinLevel: 0,
State: 0,
MemberCount: 1,
CreatedAt: '2026-07-01T00:00:00Z',
}
await env.DB.prepare('INSERT INTO club (data) VALUES (?1)').bind(JSON.stringify(club)).run()
await env.DB.prepare(
'INSERT INTO club_member (club_id, account_id, membership_type, created_at) VALUES (?1, ?2, ?3, ?4)'
)
.bind(9001, 4343, 100, '2026-07-01T00:00:00Z')
.run()
const created = await exports.default.fetch(`${ORIGIN}/club/mine/created`, {
headers: await bearer('4343'),
})
expect(await created.json()).toEqual([])
const member = await exports.default.fetch(`${ORIGIN}/club/mine/member`, {
headers: await bearer('4343'),
})
expect(await member.json()).toEqual([])
})
test('POST /club/create takes the clients lowercase form and answers the envelope', async () => {
// The exact request the client sends: lowercase name/description/category.
const res = await exports.default.fetch(`${ORIGIN}/club/create`, {
method: 'POST',
headers: {
...(await bearer('5000')),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'name=clubz&description=da%20best%20club&category=Creative',
})
expect(res.status).toBe(200)
const body = (await res.json()) as {
error: string
success: boolean
value: {
Club: { Name: string; Description: string; Category: string; CreatorAccountId: number }
ClubId: number
CustomTags: string[]
AdditionalImages: unknown[]
MyMembershipType: number
CoownerPermissions: { Type: number; EditDetails: boolean }
ModeratorPermissions: { Type: number; BanUnban: boolean; EditDetails: boolean }
MemberPermissions: { Type: number; BanUnban: boolean }
}
}
expect(body.error).toBe('')
expect(body.success).toBe(true)
expect(body.value.Club).toMatchObject({
Name: 'clubz',
Description: 'da best club',
Category: 'Creative',
CreatorAccountId: 5000,
})
expect(body.value.ClubId).toBeGreaterThan(0)
expect(body.value.MyMembershipType).toBe(100) // creator
expect(body.value.CustomTags).toEqual([])
expect(body.value.AdditionalImages).toEqual([])
// Co-owners get everything, moderators approve/ban only, members nothing.
expect(body.value.CoownerPermissions).toMatchObject({ Type: 30, EditDetails: true })
expect(body.value.ModeratorPermissions).toMatchObject({
Type: 20,
BanUnban: true,
EditDetails: false,
})
expect(body.value.MemberPermissions).toMatchObject({ Type: 10, BanUnban: false })
})
test('POST /club/create defaults the category and rejects bad names', async () => {
const create = async (fields: Record<string, string>): Promise<Response> =>
exports.default.fetch(`${ORIGIN}/club/create`, {
method: 'POST',
headers: {
...(await bearer('5001')),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams(fields).toString(),
})
// No category → Social.
const defaulted = (await (await create({ name: 'Catless' })).json()) as {
value: { Club: { Category: string } }
}
expect(defaulted.value.Club.Category).toBe('Social')
// Emoji and other non-Latin scripts are rejected; the error rides the envelope.
const emoji = await create({ name: 'club 🎉' })
expect(emoji.status).toBe(400)
expect(await emoji.json()).toMatchObject({ success: false, value: null })
// Names cap at 16 characters.
expect((await create({ name: 'a'.repeat(17) })).status).toBe(400)
expect((await create({ name: 'a'.repeat(16) })).status).toBe(200)
// Basic punctuation is allowed.
expect((await create({ name: "Bob's Club (2)" })).status).toBe(200)
})
test('PUT /club/:id/modifydetails edits the club, co-owner only', async () => {
type Details = {
error: string
success: boolean
value: {
Club: {
Visibility: number
Joinability: number
AllowJuniors: boolean
Name: string
Description: string
}
CustomTags: string[]
}
}
const create = await exports.default.fetch(`${ORIGIN}/club/create`, {
method: 'POST',
headers: { ...(await bearer('6000')), 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'name=Editable&description=before',
})
const clubId = ((await create.json()) as { value: { ClubId: number } }).value.ClubId
const modify = async (body: string, sub = '6000'): Promise<Response> =>
exports.default.fetch(`${ORIGIN}/club/${clubId}/modifydetails`, {
method: 'PUT',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' },
body,
})
// The client's exact request: enums by name, and a custom tag.
const res = await modify('visibility=Public&joinability=Open&allowJuniors=True&customTags=devi')
expect(res.status).toBe(200)
const body = (await res.json()) as Details
expect(body).toMatchObject({ error: '', success: true })
expect(body.value.Club).toMatchObject({
Visibility: 1, // Public
Joinability: 0, // Open
AllowJuniors: true,
// Fields the request didn't mention are untouched.
Name: 'Editable',
Description: 'before',
})
expect(body.value.CustomTags).toEqual(['devi'])
// The other enum spellings land on the right numbers.
const priv = (await (
await modify('visibility=Private&joinability=AskToJoin&allowJuniors=False')
).json()) as Details
expect(priv.value.Club).toMatchObject({
Visibility: 0,
Joinability: 2,
AllowJuniors: false,
})
const invite = (await (await modify('joinability=InviteOnly')).json()) as Details
expect(invite.value.Club.Joinability).toBe(1)
// customTags replaces the set wholesale, de-duplicated case-insensitively.
const retagged = (await (
await modify('customTags=Alpha&customTags=alpha&customTags=Beta')
).json()) as Details
expect(retagged.value.CustomTags).toEqual(['Alpha', 'Beta'])
// Omitting customTags leaves the existing tags alone.
const untouched = (await (await modify('category=Social')).json()) as Details
expect(untouched.value.CustomTags).toEqual(['Alpha', 'Beta'])
// A plain member can't edit; a non-member can't either; signed out is a 401.
await exports.default.fetch(`${ORIGIN}/club/${clubId}/join`, {
method: 'POST',
headers: await bearer('6001'),
})
expect((await modify('name=Hijacked', '6001')).status).toBe(403)
expect((await modify('name=Hijacked', '6002')).status).toBe(403)
const anon = await exports.default.fetch(`${ORIGIN}/club/${clubId}/modifydetails`, {
method: 'PUT',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'name=Hijacked',
})
expect(anon.status).toBe(401)
// Editing a club that doesn't exist 404s.
const missing = await exports.default.fetch(`${ORIGIN}/club/99999/modifydetails`, {
method: 'PUT',
headers: { ...(await bearer('6000')), 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'name=Ghost',
})
expect(missing.status).toBe(404)
// /modify is the same endpoint under the client's shorter name.
const short = async (sub: string) =>
exports.default.fetch(`${ORIGIN}/club/${clubId}/modify`, {
method: 'PUT',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'name=my%20club&description=rock%20out&category=Casual',
})
const renamed = (await (await short('6000')).json()) as Details
expect(renamed.value.Club).toMatchObject({
Name: 'my club',
Description: 'rock out',
Category: 'Casual',
})
// ...and it's gated the same way.
expect((await short('6001')).status).toBe(403)
expect(
(await exports.default.fetch(`${ORIGIN}/club/${clubId}/modify`, { method: 'PUT' })).status
).toBe(401)
})
test('GET/PUT /club/:id/mainimage reads and sets the club image, co-owner only', async () => {
type Details = { error: string; success: boolean; value: { Club: { MainImageName: string } } }
const create = await exports.default.fetch(`${ORIGIN}/club/create`, {
method: 'POST',
headers: { ...(await bearer('7000')), 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'name=Picturesque',
})
const clubId = ((await create.json()) as { value: { ClubId: number } }).value.ClubId
// GET reads the current image (the create default) without auth.
const read = await exports.default.fetch(`${ORIGIN}/club/${clubId}/mainimage`)
expect(read.status).toBe(200)
expect(((await read.json()) as Details).value.Club.MainImageName).toBe('DefaultImgPurple')
// PUT sets it from an uploaded image's name.
const put = async (body: string, sub = '7000'): Promise<Response> =>
exports.default.fetch(`${ORIGIN}/club/${clubId}/mainimage`, {
method: 'PUT',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' },
body,
})
const set = await put('imageName=2026-07-12%2Fclub.jpg')
expect(set.status).toBe(200)
const body = (await set.json()) as Details
expect(body).toMatchObject({ error: '', success: true })
expect(body.value.Club.MainImageName).toBe('2026-07-12/club.jpg')
// It sticks, and the GET reflects it.
const reread = await exports.default.fetch(`${ORIGIN}/club/${clubId}/mainimage`)
expect(((await reread.json()) as Details).value.Club.MainImageName).toBe('2026-07-12/club.jpg')
// imageName is required; non-co-owners can't set it; signed out is a 401.
expect((await put('')).status).toBe(400)
expect((await put('imageName=x.jpg', '7001')).status).toBe(403)
const anon = await exports.default.fetch(`${ORIGIN}/club/${clubId}/mainimage`, {
method: 'PUT',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'imageName=x.jpg',
})
expect(anon.status).toBe(401)
// An unknown club 404s on both verbs.
expect((await exports.default.fetch(`${ORIGIN}/club/99999/mainimage`)).status).toBe(404)
})
test('GET /club/:id/members filters by membershipType and sorts', async () => {
type Member = {
ClubMemberId: number
ClubId: number
AccountId: number
MembershipType: number
}
type Body = { error: string; success: boolean; value: Member[] }
const create = await exports.default.fetch(`${ORIGIN}/club/create`, {
method: 'POST',
headers: { ...(await bearer('8000')), 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'name=Crowded',
})
const clubId = ((await create.json()) as { value: { ClubId: number } }).value.ClubId
// 8002 then 8001 join as plain members (Member = 10); 8000 is the Creator (100).
for (const sub of ['8002', '8001']) {
await exports.default.fetch(`${ORIGIN}/club/${clubId}/join`, {
method: 'POST',
headers: await bearer(sub),
})
}
const members = async (query = ''): Promise<Body> => {
const res = await exports.default.fetch(`${ORIGIN}/club/${clubId}/members${query}`)
expect(res.status).toBe(200)
return (await res.json()) as Body
}
// Default order: highest tier first, then oldest membership.
const all = await members()
expect(all).toMatchObject({ error: '', success: true })
expect(all.value.map((m) => m.AccountId)).toEqual([8000, 8002, 8001])
expect(all.value[0]).toMatchObject({ ClubId: clubId, MembershipType: 100 })
// sortBy=1 orders by account id; sortBy=2 by join time.
expect((await members('?sortBy=1')).value.map((m) => m.AccountId)).toEqual([8000, 8001, 8002])
expect((await members('?sortBy=2')).value.map((m) => m.AccountId)).toEqual([8000, 8002, 8001])
// membershipType is an exact match, not a threshold.
expect((await members('?membershipType=10')).value.map((m) => m.AccountId)).toEqual([
8002, 8001,
])
expect((await members('?membershipType=100')).value.map((m) => m.AccountId)).toEqual([8000])
// The client's request: co-owners only — nobody holds that tier here.
expect((await members('?membershipType=30&sortBy=0')).value).toEqual([])
// An unknown club is an empty list, not a 404.
const ghost = await exports.default.fetch(`${ORIGIN}/club/99999/members`)
expect(ghost.status).toBe(200)
expect(((await ghost.json()) as Body).value).toEqual([])
})
test('GET /club/:id/hasDisabledClubChat reports chat enabled', async () => {
// Nothing can disable club chat yet → always false, for any club.
const res = await exports.default.fetch(`${ORIGIN}/club/1/hasDisabledClubChat`)
expect(res.status).toBe(200)
expect(await res.json()).toBe(false)
})
test('PUT /club/home/me sets the home club; GET serves it once it has a clubhouse', async () => {
type Club = { ClubId: number; ClubhouseRoomId: number | null }
const form = async (path: string, method: string, body: string, sub: string) =>
exports.default.fetch(`${ORIGIN}${path}`, {
method,
headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' },
body,
})
const create = await exports.default.fetch(`${ORIGIN}/club/create`, {
method: 'POST',
headers: { ...(await bearer('9100')), 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'name=Homely',
})
const clubId = ((await create.json()) as { value: { ClubId: number } }).value.ClubId
// No home club yet → 404.
const before = await exports.default.fetch(`${ORIGIN}/club/home/me`, {
headers: await bearer('9100'),
})
expect(before.status).toBe(404)
// You must be a member of the club you're making your home.
expect((await form('/club/home/me', 'PUT', `clubId=${clubId}`, '9101')).status).toBe(403)
// A missing/zero clubId is a 400, an unknown club a 404.
expect((await form('/club/home/me', 'PUT', 'clubId=0', '9100')).status).toBe(400)
expect((await form('/club/home/me', 'PUT', 'clubId=99999', '9100')).status).toBe(404)
const set = await form('/club/home/me', 'PUT', `clubId=${clubId}`, '9100')
expect(set.status).toBe(200)
expect(await set.json()).toMatchObject({ error: '', success: true })
// The club has no clubhouse room yet, so there's still nowhere to spawn → 404.
const noClubhouse = await exports.default.fetch(`${ORIGIN}/club/home/me`, {
headers: await bearer('9100'),
})
expect(noClubhouse.status).toBe(404)
// Give it a clubhouse → the home club now resolves.
const clubhouse = await form(`/club/${clubId}/clubhouse`, 'PUT', 'roomId=77', '9100')
expect(clubhouse.status).toBe(200)
// The details envelope carries the new room back — the client re-renders from it.
const setRoom = (await clubhouse.json()) as {
error: string
success: boolean
value: { Club: { ClubhouseRoomId: number | null } }
}
expect(setRoom).toMatchObject({ error: '', success: true })
expect(setRoom.value.Club.ClubhouseRoomId).toBe(77)
const home = await exports.default.fetch(`${ORIGIN}/club/home/me`, {
headers: await bearer('9100'),
})
expect((await home.json()) as Club).toMatchObject({ ClubId: clubId, ClubhouseRoomId: 77 })
// Clearing the clubhouse takes the home club away again — and reports the cleared
// room, so the client doesn't keep showing the old one.
const clear = (await (await form(`/club/${clubId}/clubhouse`, 'PUT', '', '9100')).json()) as {
value: { Club: { ClubhouseRoomId: number | null } }
}
expect(clear.value.Club.ClubhouseRoomId).toBeNull()
const cleared = await exports.default.fetch(`${ORIGIN}/club/home/me`, {
headers: await bearer('9100'),
})
expect(cleared.status).toBe(404)
// DELETE clears it too, ignoring any body it's sent.
await form(`/club/${clubId}/clubhouse`, 'PUT', 'roomId=88', '9100')
const deleted = (await (
await form(`/club/${clubId}/clubhouse`, 'DELETE', 'roomId=99', '9100')
).json()) as { success: boolean; value: { Club: { ClubhouseRoomId: number | null } } }
expect(deleted.success).toBe(true)
expect(deleted.value.Club.ClubhouseRoomId).toBeNull()
// DELETE /club/home/me drops the home club without touching the membership, and
// is idempotent when there's none set.
await form(`/club/${clubId}/clubhouse`, 'PUT', 'roomId=77', '9100')
await form('/club/home/me', 'PUT', `clubId=${clubId}`, '9100')
const dropped = await exports.default.fetch(`${ORIGIN}/club/home/me`, {
method: 'DELETE',
headers: await bearer('9100'),
})
expect(dropped.status).toBe(200)
expect(await dropped.json()).toEqual({ error: '', success: true, value: null })
expect(
(await exports.default.fetch(`${ORIGIN}/club/home/me`, { headers: await bearer('9100') }))
.status
).toBe(404)
// Still a member of the club they'd made their home.
const mine = (await (
await exports.default.fetch(`${ORIGIN}/club/mine/member`, { headers: await bearer('9100') })
).json()) as Club[]
expect(mine.map((c) => c.ClubId)).toContain(clubId)
// Clearing again, and clearing when nothing was set, both succeed.
for (const sub of ['9100', '9101']) {
const again = await exports.default.fetch(`${ORIGIN}/club/home/me`, {
method: 'DELETE',
headers: await bearer(sub),
})
expect(again.status).toBe(200)
}
expect(
(await exports.default.fetch(`${ORIGIN}/club/home/me`, { method: 'DELETE' })).status
).toBe(401)
// Only co-owners may set or clear the clubhouse; signed out is a 401 on both.
expect((await form(`/club/${clubId}/clubhouse`, 'PUT', 'roomId=1', '9101')).status).toBe(403)
expect((await form(`/club/${clubId}/clubhouse`, 'DELETE', '', '9101')).status).toBe(403)
expect(
(await exports.default.fetch(`${ORIGIN}/club/${clubId}/clubhouse`, { method: 'DELETE' }))
.status
).toBe(401)
const anon = await exports.default.fetch(`${ORIGIN}/club/home/me`, { method: 'PUT' })
expect(anon.status).toBe(401)
})
test('PUT /club/:id/additionalimage/:index fills the clubs gallery slots', async () => {
type Image = { Id: number; ImageName: string; PlayerId: number }
type Details = { error: string; success: boolean; value: { AdditionalImages: Image[] } }
// The gallery is served as whole image records, joined from the image table the
// `api` worker writes on upload. Seed the rows those names point at.
const first = 'sharecamera/2026-07-21/e37fc41f-005e-4216-8f1e-a37dca953981.jpg'
const insertImage = env.DB.prepare('INSERT OR IGNORE INTO image (data) VALUES (?1)')
await env.DB.batch(
[first, 'b.jpg', 'c.jpg'].map((ImageName, i) =>
insertImage.bind(
JSON.stringify({
Id: 500 + i,
Type: 1,
Accessibility: 1,
AccessibilityLocked: false,
ImageName,
Description: null,
PlayerId: 7100,
TaggedPlayerIds: [],
RoomId: null,
PlayerEventId: null,
CreatedAt: '2026-07-21T00:00:00Z',
CheerCount: 0,
CommentCount: 0,
})
)
)
)
const create = await exports.default.fetch(`${ORIGIN}/club/create`, {
method: 'POST',
headers: { ...(await bearer('7100')), 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'name=Gallery',
})
const clubId = ((await create.json()) as { value: { ClubId: number } }).value.ClubId
const setImage = async (index: number, imageName: string, sub = '7100') =>
exports.default.fetch(`${ORIGIN}/club/${clubId}/additionalimage/${index}`, {
method: 'PUT',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ imageName }).toString(),
})
const names = (images: Image[]) => images.map((i) => i.ImageName)
// A fresh club has no gallery images.
const fresh = (await (
await exports.default.fetch(`${ORIGIN}/club/${clubId}/details`)
).json()) as { AdditionalImages: Image[] }
expect(fresh.AdditionalImages).toEqual([])
// The client's exact request: the storage worker's image name into slot 0. It
// comes back as the whole image record, not a bare name.
const set = (await (await setImage(0, first)).json()) as Details
expect(set).toMatchObject({ error: '', success: true })
expect(set.value.AdditionalImages).toEqual([
expect.objectContaining({ Id: 500, ImageName: first, PlayerId: 7100 }),
])
// Slots are positional: filling 2 while 1 is empty keeps them in that order, and
// the empty slot isn't served as a blank image.
const third = (await (await setImage(2, 'c.jpg')).json()) as Details
expect(names(third.value.AdditionalImages)).toEqual([first, 'c.jpg'])
const second = (await (await setImage(1, 'b.jpg')).json()) as Details
expect(names(second.value.AdditionalImages)).toEqual([first, 'b.jpg', 'c.jpg'])
// Re-PUTting a slot replaces just that image; an empty name clears it. A name with
// no image row still renders, as a placeholder record.
const replaced = (await (await setImage(0, 'a2.jpg')).json()) as Details
expect(names(replaced.value.AdditionalImages)).toEqual(['a2.jpg', 'b.jpg', 'c.jpg'])
expect(replaced.value.AdditionalImages[0]).toMatchObject({ Id: 0, ImageName: 'a2.jpg' })
const cleared = (await (await setImage(1, '')).json()) as Details
expect(names(cleared.value.AdditionalImages)).toEqual(['a2.jpg', 'c.jpg'])
// They're on the club's details payload, for everyone reading the club.
const details = (await (
await exports.default.fetch(`${ORIGIN}/club/${clubId}/details`)
).json()) as { AdditionalImages: Image[] }
expect(names(details.AdditionalImages)).toEqual(['a2.jpg', 'c.jpg'])
// There are only three slots, and only co-owners may set them.
expect((await setImage(3, 'd.jpg')).status).toBe(400)
expect((await setImage(0, 'hijack.jpg', '7101')).status).toBe(403)
expect(
(
await exports.default.fetch(`${ORIGIN}/club/${clubId}/additionalimage/0`, {
method: 'PUT',
})
).status
).toBe(401)
expect(
(
await exports.default.fetch(`${ORIGIN}/club/99999/additionalimage/0`, {
method: 'PUT',
headers: await bearer('7100'),
})
).status
).toBe(404)
})
test('GET /club/search filters by category/query and sorts', async () => {
type Result = {
Clubs: Array<{ ClubId: number; Name: string; Category: string }>
ContinuationToken: null
TotalClubs: number
}
const create = async (fields: Record<string, string>, sub: string): Promise<number> => {
const res = await exports.default.fetch(`${ORIGIN}/club/create`, {
method: 'POST',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(fields).toString(),
})
return ((await res.json()) as { value: { ClubId: number } }).value.ClubId
}
const search = async (query: string): Promise<Result> => {
const res = await exports.default.fetch(`${ORIGIN}/club/search?${query}`)
expect(res.status).toBe(200)
return (await res.json()) as Result
}
// Own category, so clubs the other tests created (which default to Social) don't
// wander into these assertions. The second club gains a member, so it outranks
// the first on the default sort.
const alpha = await create({ name: 'Chess Fans', category: 'Boardgames' }, '9200')
const beta = await create(
{ name: 'Board Gamers', description: 'we love chess', category: 'Boardgames' },
'9201'
)
await create({ name: 'Painters', category: 'Creative' }, '9202')
await exports.default.fetch(`${ORIGIN}/club/${beta}/join`, {
method: 'POST',
headers: await bearer('9203'),
})
// Default sort → most members first. Category filters out the Creative club.
const listed = await search('sort=0&category=Boardgames&count=32')
expect(listed.ContinuationToken).toBeNull()
expect(listed.Clubs.map((c) => c.ClubId)).toEqual([beta, alpha])
expect(listed.TotalClubs).toBe(2)
expect(listed.Clubs.every((c) => c.Category === 'Boardgames')).toBe(true)
// sort=2 orders by name.
expect((await search('sort=2&category=Boardgames')).Clubs.map((c) => c.Name)).toEqual([
'Board Gamers',
'Chess Fans',
])
// `query` matches the name or the description.
const chess = await search('query=chess&category=Boardgames')
expect(chess.Clubs.map((c) => c.ClubId).sort()).toEqual([alpha, beta].sort())
expect((await search('query=nomatch')).Clubs).toEqual([])
// `count` caps the page, but TotalClubs reports the full match count.
const capped = await search('category=Boardgames&count=1')
expect(capped.Clubs).toHaveLength(1)
expect(capped.TotalClubs).toBe(2)
// Private clubs never show up in search.
await exports.default.fetch(`${ORIGIN}/club/${alpha}/modifydetails`, {
method: 'PUT',
headers: { ...(await bearer('9200')), 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'visibility=Private',
})
expect((await search('category=Boardgames')).Clubs.map((c) => c.ClubId)).toEqual([beta])
// The client's own request shape still answers cleanly.
const clientRequest = await search('sort=0&category=Social&count=32')
expect(clientRequest.ContinuationToken).toBeNull()
expect(clientRequest.TotalClubs).toBeGreaterThanOrEqual(clientRequest.Clubs.length)
expect(clientRequest.Clubs.every((c) => c.Category === 'Social')).toBe(true)
})
test('GET /club/:id/details serves the bare details object', async () => {
type Details = {
Club: { ClubId: number; Name: string }
ClubId: number
CustomTags: string[]
AdditionalImages: unknown[]
MyMembershipType: number
CoownerPermissions: { Type: number }
ModeratorPermissions: { Type: number }
MemberPermissions: { Type: number }
}
const create = await exports.default.fetch(`${ORIGIN}/club/create`, {
method: 'POST',
headers: { ...(await bearer('9300')), 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'name=Detailed',
})
const clubId = ((await create.json()) as { value: { ClubId: number } }).value.ClubId
await exports.default.fetch(`${ORIGIN}/club/${clubId}/modifydetails`, {
method: 'PUT',
headers: { ...(await bearer('9300')), 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'customTags=tagged',
})
// Not enveloped — the details object is the whole body.
const res = await exports.default.fetch(`${ORIGIN}/club/${clubId}/details`, {
headers: await bearer('9300'),
})
expect(res.status).toBe(200)
const body = (await res.json()) as Details & { success?: boolean; value?: unknown }
expect(body.success).toBeUndefined()
expect(body.value).toBeUndefined()
expect(body.Club).toMatchObject({ ClubId: clubId, Name: 'Detailed' })
expect(body.ClubId).toBe(clubId)
expect(body.CustomTags).toEqual(['tagged'])
expect(body.AdditionalImages).toEqual([])
expect(body.MyMembershipType).toBe(100) // the creator
expect(body.CoownerPermissions.Type).toBe(30)
expect(body.ModeratorPermissions.Type).toBe(20)
expect(body.MemberPermissions.Type).toBe(10)
// Public: a signed-out viewer sees the club with no membership of their own.
const anon = await exports.default.fetch(`${ORIGIN}/club/${clubId}/details`)
expect(((await anon.json()) as Details).MyMembershipType).toBe(0)
// An unknown club 404s.
expect((await exports.default.fetch(`${ORIGIN}/club/99999/details`)).status).toBe(404)
})
test('GET/POST /announcements/club/:id serves and posts the noticeboard', async () => {
type Announcement = {
AnnouncementId: number
Title: string
Body: string
AccountId: number
ImageName: string
Meta: string
}
type Board = {
error: string
success: boolean
value: {
Announcements: Announcement[]
ClubId: number
LastAnnouncementId: number | null
LastReadAnnouncementId: number
}
}
const create = await exports.default.fetch(`${ORIGIN}/club/create`, {
method: 'POST',
headers: { ...(await bearer('9400')), 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'name=Newsy',
})
const clubId = ((await create.json()) as { value: { ClubId: number } }).value.ClubId
const board = async (): Promise<Board> => {
const res = await exports.default.fetch(`${ORIGIN}/announcements/club/${clubId}`)
expect(res.status).toBe(200)
return (await res.json()) as Board
}
const post = async (body: string, sub = '9400'): Promise<Response> =>
exports.default.fetch(`${ORIGIN}/announcements/club/${clubId}`, {
method: 'POST',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' },
body,
})
// An empty board: no announcements, and no "last" one.
const empty = await board()
expect(empty).toMatchObject({ error: '', success: true })
expect(empty.value).toMatchObject({
Announcements: [],
ClubId: clubId,
LastAnnouncementId: null,
LastReadAnnouncementId: 0,
})
// Posting returns the new announcement's id in the envelope.
const first = await post('title=Hello&body=First+post')
expect(first.status).toBe(200)
const firstId = ((await first.json()) as { value: number }).value
expect(firstId).toBeGreaterThan(0)
const secondId = ((await (await post('title=Again&body=Second')).json()) as { value: number })
.value
// Newest first, and LastAnnouncementId points at it.
const posted = await board()
expect(posted.value.Announcements.map((a) => a.AnnouncementId)).toEqual([secondId, firstId])
expect(posted.value.LastAnnouncementId).toBe(secondId)
expect(posted.value.Announcements[0]).toMatchObject({
Title: 'Again',
Body: 'Second',
AccountId: 9400,
})
// The client's exact post: an image name plus a `meta` JSON string, which is
// stored verbatim (it's an opaque blob to us, not something we re-serialize).
const real = await post(
'title=wooo&body=test&imageName=DefaultClubImage2k.jpg&meta=%7B%22Type%22%3A0%2C%22JsonData%22%3A%22%22%7D'
)
expect(real.status).toBe(200)
const realId = ((await real.json()) as { value: number }).value
const withMeta = (await board()).value.Announcements.find((a) => a.AnnouncementId === realId)
expect(withMeta).toMatchObject({
Title: 'wooo',
Body: 'test',
ImageName: 'DefaultClubImage2k.jpg',
Meta: '{"Type":0,"JsonData":""}',
})
// Only co-owners may post; signed out is a 401; an unknown club 404s.
expect((await post('title=Nope', '9401')).status).toBe(403)
const anon = await exports.default.fetch(`${ORIGIN}/announcements/club/${clubId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'title=Nope',
})
expect(anon.status).toBe(401)
const missing = await exports.default.fetch(`${ORIGIN}/announcements/club/99999`, {
method: 'POST',
headers: { ...(await bearer('9400')), 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'title=Ghost',
})
expect(missing.status).toBe(404)
})
test('PUT /club/:id/minlevel sets the join level, co-owner only', async () => {
type Details = { error: string; success: boolean; value: { Club: { MinLevel: number } } }
const create = await exports.default.fetch(`${ORIGIN}/club/create`, {
method: 'POST',
headers: { ...(await bearer('9500')), 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'name=Gated',
})
const clubId = ((await create.json()) as { value: { ClubId: number } }).value.ClubId
const put = async (body: string, sub = '9500'): Promise<Response> =>
exports.default.fetch(`${ORIGIN}/club/${clubId}/minlevel`, {
method: 'PUT',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' },
body,
})
const res = await put('minLevel=5')
expect(res.status).toBe(200)
const body = (await res.json()) as Details
expect(body).toMatchObject({ error: '', success: true })
expect(body.value.Club.MinLevel).toBe(5)
// It sticks on the club.
const details = await exports.default.fetch(`${ORIGIN}/club/${clubId}/details`)
expect(((await details.json()) as { Club: { MinLevel: number } }).Club.MinLevel).toBe(5)
// Zero is valid (no level requirement); garbage and negatives are not.
expect((await put('minLevel=0')).status).toBe(200)
expect((await put('minLevel=abc')).status).toBe(400)
expect((await put('minLevel=-1')).status).toBe(400)
// Non-co-owners can't; signed out is a 401; an unknown club 404s.
expect((await put('minLevel=5', '9501')).status).toBe(403)
const anon = await exports.default.fetch(`${ORIGIN}/club/${clubId}/minlevel`, {
method: 'PUT',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'minLevel=5',
})
expect(anon.status).toBe(401)
const missing = await exports.default.fetch(`${ORIGIN}/club/99999/minlevel`, {
method: 'PUT',
headers: { ...(await bearer('9500')), 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'minLevel=5',
})
expect(missing.status).toBe(404)
})
test('unknown routes 404', async () => {
const res = await exports.default.fetch(`${ORIGIN}/nope`)
expect(res.status).toBe(404)
})
// Full club lifecycle: create → get → other player joins/leaves, with the
// creator auto-membership and MemberCount upkeep, plus the mine/* lists.
test('create → get → join → leave a club, with MemberCount and my-clubs lists', async () => {
type Club = {
ClubId: number
Name: string
Category: string
Visibility: number
AllowJuniors: boolean
MainImageName: string
CreatorAccountId: number
MemberCount: number
}
const post = async (path: string, sub: string | null, fields: Record<string, string> = {}) =>
exports.default.fetch(`${ORIGIN}${path}`, {
method: 'POST',
headers: {
...(sub ? await bearer(sub) : {}),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams(fields).toString(),
})
// Create requires auth.
expect((await post('/club/create', null, { name: 'Nope' })).status).toBe(401)
// Create requires a name.
expect((await post('/club/create', '800', { name: ' ' })).status).toBe(400)
// 800 creates a club → defaults applied, creator auto-joined (MemberCount 1).
const createRes = (await (
await post('/club/create', '800', { name: 'Speedrunners', category: 'Competitive' })
).json()) as {
error: string
success: boolean
value: { Club: Club; MyMembershipType: number }
}
expect(createRes).toMatchObject({ error: '', success: true })
const created = createRes.value.Club
expect(created).toMatchObject({
Name: 'Speedrunners',
Category: 'Competitive',
Visibility: 1, // default
AllowJuniors: true, // default
MainImageName: 'DefaultImgPurple', // default
CreatorAccountId: 800,
MemberCount: 1,
})
// The creator's own membership comes back on the details.
expect(createRes.value.MyMembershipType).toBe(100)
const clubId = created.ClubId
// Public get by id returns it; unknown id 404s.
const fetched = (await (await exports.default.fetch(`${ORIGIN}/club/${clubId}`)).json()) as Club
expect(fetched.ClubId).toBe(clubId)
expect((await exports.default.fetch(`${ORIGIN}/club/99999`)).status).toBe(404)
// It shows in the creator's created + member lists.
const created800 = (await (
await exports.default.fetch(`${ORIGIN}/club/mine/created`, { headers: await bearer('800') })
).json()) as Club[]
expect(created800.map((c) => c.ClubId)).toContain(clubId)
// 801 joins → MemberCount 2, and the club appears in 801's member list (not created).
const joined = (await (await post(`/club/${clubId}/join`, '801')).json()) as Club
expect(joined.MemberCount).toBe(2)
const member801 = (await (
await exports.default.fetch(`${ORIGIN}/club/mine/member`, { headers: await bearer('801') })
).json()) as Club[]
expect(member801.map((c) => c.ClubId)).toContain(clubId)
const createdBy801 = (await (
await exports.default.fetch(`${ORIGIN}/club/mine/created`, { headers: await bearer('801') })
).json()) as Club[]
expect(createdBy801).toEqual([])
// Joining again is idempotent (still 2).
expect(((await (await post(`/club/${clubId}/join`, '801')).json()) as Club).MemberCount).toBe(2)
// 801 leaves → back to 1, and it drops out of their member list.
expect(((await (await post(`/club/${clubId}/leave`, '801')).json()) as Club).MemberCount).toBe(
1
)
const afterLeave = (await (
await exports.default.fetch(`${ORIGIN}/club/mine/member`, { headers: await bearer('801') })
).json()) as Club[]
expect(afterLeave.map((c) => c.ClubId)).not.toContain(clubId)
// Join/leave on a missing club 404s.
expect((await post('/club/99999/join', '801')).status).toBe(404)
})
test('joining a non-open club records a pending request, not a membership', async () => {
const post = async (path: string, sub: string, fields: Record<string, string> = {}) =>
exports.default.fetch(`${ORIGIN}${path}`, {
method: 'POST',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(fields).toString(),
})
type Club = { ClubId: number; Joinability: number; MemberCount: number }
// 810 creates an ask-to-join club (Joinability 2) → only the creator counts.
const club = (
(await (
await post('/club/create', '810', { name: 'Inner Circle', joinability: '2' })
).json()) as { value: { Club: Club } }
).value.Club
expect(club).toMatchObject({ Joinability: 2, MemberCount: 1 })
// 811 asks to join → pending, so MemberCount is unchanged and it isn't a membership.
const joined = (await (await post(`/club/${club.ClubId}/join`, '811')).json()) as Club
expect(joined.MemberCount).toBe(1)
const member811 = (await (
await exports.default.fetch(`${ORIGIN}/club/mine/member`, { headers: await bearer('811') })
).json()) as Club[]
expect(member811.map((c) => c.ClubId)).not.toContain(club.ClubId)
})
test('requesttojoin follows the club joinability', async () => {
const create = async (sub: string, fields: Record<string, string>) =>
(
(await (
await exports.default.fetch(`${ORIGIN}/club/create`, {
method: 'POST',
headers: {
...(await bearer(sub)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams(fields).toString(),
})
).json()) as { value: { Club: { ClubId: number } } }
).value.Club.ClubId
const request = async (clubId: number, sub: string) =>
exports.default.fetch(`${ORIGIN}/club/${clubId}/members/requesttojoin`, {
method: 'PUT',
headers: await bearer(sub),
})
type Details = { error: string; success: boolean; value: { MyMembershipType: number } | null }
const open = await create('820', { name: 'Open Doors', joinability: 'Open' })
const ask = await create('821', { name: 'Ask First', joinability: 'AskToJoin' })
const invite = await create('822', { name: 'Invite Only', joinability: 'InviteOnly' })
// Open → straight in as a Member (10).
const joined = (await (await request(open, '830')).json()) as Details
expect(joined).toMatchObject({ success: true })
expect(joined.value?.MyMembershipType).toBe(10)
// AskToJoin → PendingRequested (1), and a repeat request leaves it there.
const asked = (await (await request(ask, '830')).json()) as Details
expect(asked.value?.MyMembershipType).toBe(1)
expect(
(((await (await request(ask, '830')).json()) as Details).value ?? {}).MyMembershipType
).toBe(1)
// InviteOnly → refused, with no membership row created.
const refused = await request(invite, '830')
expect(refused.status).toBe(400)
expect(((await refused.json()) as Details).success).toBe(false)
// No token, and an unknown club.
expect(
(
await exports.default.fetch(`${ORIGIN}/club/${open}/members/requesttojoin`, {
method: 'PUT',
})
).status
).toBe(401)
expect((await request(99999, '830')).status).toBe(404)
})
test('members/leave drops a membership and withdraws a pending request', async () => {
const create = async (sub: string, fields: Record<string, string>) =>
(
(await (
await exports.default.fetch(`${ORIGIN}/club/create`, {
method: 'POST',
headers: {
...(await bearer(sub)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams(fields).toString(),
})
).json()) as { value: { Club: { ClubId: number } } }
).value.Club.ClubId
const call = async (clubId: number, sub: string, action: 'requesttojoin' | 'leave') =>
exports.default.fetch(`${ORIGIN}/club/${clubId}/members/${action}`, {
method: action === 'leave' ? 'POST' : 'PUT',
headers: await bearer(sub),
})
type Details = {
success: boolean
value: { Club: { MemberCount: number }; MyMembershipType: number } | null
}
const open = await create('840', { name: 'Revolving', joinability: 'Open' })
const ask = await create('841', { name: 'Waitlist', joinability: 'AskToJoin' })
// A member leaves → membership 0, and the count drops back to the creator alone.
await call(open, '850', 'requesttojoin')
const left = (await (await call(open, '850', 'leave')).json()) as Details
expect(left.success).toBe(true)
expect(left.value?.MyMembershipType).toBe(0)
expect(left.value?.Club.MemberCount).toBe(1)
// Leaving again is a no-op, not an error.
expect(
(((await (await call(open, '850', 'leave')).json()) as Details).value ?? {}).MyMembershipType
).toBe(0)
// Leaving withdraws a pending request too.
expect(
(((await (await call(ask, '850', 'requesttojoin')).json()) as Details).value ?? {})
.MyMembershipType
).toBe(1)
expect(
(((await (await call(ask, '850', 'leave')).json()) as Details).value ?? {}).MyMembershipType
).toBe(0)
// The creator can't leave their own club — they'd leave it ownerless.
for (const path of [`/club/${open}/members/leave`, `/club/${open}/leave`]) {
const res = await exports.default.fetch(`${ORIGIN}${path}`, {
method: 'POST',
headers: await bearer('840'),
})
expect(res.status).toBe(403)
}
// ...and they're still the creator afterwards.
const stillIn = (await (
await exports.default.fetch(`${ORIGIN}/club/${open}/details`, {
headers: await bearer('840'),
})
).json()) as { MyMembershipType: number }
expect(stillIn.MyMembershipType).toBe(100)
// No token, and an unknown club.
expect(
(await exports.default.fetch(`${ORIGIN}/club/${open}/members/leave`, { method: 'POST' }))
.status
).toBe(401)
expect((await call(99999, '850', 'leave')).status).toBe(404)
})
test('DELETE /club/:id is the creators only, and takes the memberships with it', async () => {
const create = async (sub: string, fields: Record<string, string>) =>
(
(await (
await exports.default.fetch(`${ORIGIN}/club/create`, {
method: 'POST',
headers: {
...(await bearer(sub)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams(fields).toString(),
})
).json()) as { value: { Club: { ClubId: number } } }
).value.Club.ClubId
const del = async (clubId: number, sub?: string) =>
exports.default.fetch(`${ORIGIN}/club/${clubId}`, {
method: 'DELETE',
...(sub === undefined ? {} : { headers: await bearer(sub) }),
})
const clubId = await create('860', { name: 'Doomed', joinability: 'Open' })
await exports.default.fetch(`${ORIGIN}/club/${clubId}/members/requesttojoin`, {
method: 'PUT',
headers: await bearer('861'),
})
// Signed out, a plain member, and an unknown club.
expect((await del(clubId)).status).toBe(401)
expect((await del(clubId, '861')).status).toBe(403)
expect((await del(99999, '860')).status).toBe(404)
// The creator can. The club, and its members, are gone.
const res = await del(clubId, '860')
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ error: '', success: true, value: null })
expect((await exports.default.fetch(`${ORIGIN}/club/${clubId}`)).status).toBe(404)
expect(
await (await exports.default.fetch(`${ORIGIN}/club/${clubId}/members`)).json()
).toMatchObject({ value: [] })
const member861 = (await (
await exports.default.fetch(`${ORIGIN}/club/mine/member`, { headers: await bearer('861') })
).json()) as Array<{ ClubId: number }>
expect(member861.map((c) => c.ClubId)).not.toContain(clubId)
// Deleting twice 404s rather than reporting success.
expect((await del(clubId, '860')).status).toBe(404)
})
})