[clubs] subscribers WIP

This commit is contained in:
Devin Zuczek
2026-09-24 14:09:42 -04:00
parent 13b80f9748
commit a1e89d6948
4 changed files with 614 additions and 54 deletions
+144 -27
View File
@@ -8,6 +8,7 @@ import {
ClubMembershipType,
ClubVisibility,
countClubsByCreator,
countClubSubscribers,
createClub,
createClubAnnouncement,
deleteClub,
@@ -19,6 +20,9 @@ import {
getClubsByMember,
getHomeClub,
getMembership,
getOrCreateSubscriptionClub,
getSubscriptionClub,
getSubscriptionMemberships,
glyphLength,
joinClub,
leaveClub,
@@ -30,6 +34,7 @@ import {
setClubAdditionalImage,
setHomeClub,
setMemberType,
subscribeToClub,
updateClub,
} from '@repo/domain'
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
@@ -62,7 +67,11 @@ import {
ModifyClubRequest,
NullEnvelope,
SubscriberCountResponse,
SubscribeRequest,
SubscriptionDetailsResponse,
SubscriptionErrorEnvelope,
SubscriptionIdEnvelope,
SubscriptionMembershipsResponse,
UNAUTHORIZED_RESPONSE,
} from './openapi'
@@ -73,8 +82,11 @@ import type { App } from './context'
* Clubs Worker. Hosts the club endpoints the game client calls on the `clubs` host:
* club creation and editing, membership (join / ask-to-join / leave / ban tiers),
* search, announcements, the club gallery, a club's clubhouse room, and each player's
* home club. Everything is D1-backed (the shared `recflare` database); the
* `/subscription/*` routes are stubs, since there are no subscription clubs yet.
* home club, plus subscriptions. A creator's subscribers are the members of their
* SUBSCRIPTION CLUB (`ClubType` 1, one per account, reached only through
* `/subscription/*`): subscribing is joining it. Everything is D1-backed (the shared
* `recflare` database). A subscription club is provisioned by the first
* `/subscription/details/:accountId` read or `POST /subscription/:accountId` for that account.
*
* Auth-gated routes validate the Bearer JWT issued by the `auth` worker.
*/
@@ -104,7 +116,6 @@ async function authedId(c: Context<App>): Promise<number | null> {
*/
const DEFAULT_MAX_CLUBS_PER_ACCOUNT = 10
/**
* The tiers `members/invite` may grant — the real member roles only. Creator (100) is
* excluded so an invite can't mint a second owner, and the pending/none/banned states
@@ -310,33 +321,56 @@ const app = new Hono<App>()
}
)
// A real Rec Room client endpoint with no backing implementation yet. The
// client calls it on the clubs host at /subscription/mine/member (no /club
// prefix) and sends no auth header, so it isn't gated. Returns an empty
// array = no club subscription memberships (the client chokes on null).
// The caller's subscriptions — the subscription clubs they're a member of, one row
// each with the creator, the club and its subscriber count; the subscription-club
// counterpart of `/club/mine/member`. The client calls it on the clubs host with no
// /club prefix, and has been seen calling it WITHOUT an auth header: a missing or
// invalid token answers `[]` rather than 401, since the client chokes on null and
// this is not worth failing a launch over.
.get(
'/subscription/mine/member',
describeRoute({
tags: ['Subscriptions'],
summary: 'The caller’s club-subscription memberships',
summary: 'The caller’s subscriptions',
description: [
'A real client endpoint with no backing implementation yet. The client calls it on',
'the clubs host at `/subscription/mine/member` (no `/club` prefix) and sends no auth',
'header, so it isn’t gated. Always `[]` — no subscription memberships (the client',
'chokes on null).',
'The subscription clubs the caller is a member of (`POST /subscription/{accountId}`),',
'oldest first — the creator, the club and its subscriber count; the subscription-club',
'counterpart of `/club/mine/member`. A creator is a member of their own subscription',
'club, so it is listed for them too. The client has been seen calling this without',
'an auth header, so a missing or invalid token answers `[]` rather than 401 (the',
'client chokes on null).',
].join(' '),
responses: { 200: json(JsonArray, 'Always empty for now') },
security: AUTHED,
responses: {
200: json(
SubscriptionMembershipsResponse,
'The caller’s subscriptions; `[]` when unauthenticated'
),
},
}),
(c) => c.json([])
async (c) => {
const id = await authedId(c)
if (id === null) return c.json([])
return c.json(await getSubscriptionMemberships(c.env.DB, id))
}
)
// Subscription details for an account (numeric id) — simulated: no club, no subs.
// Subscription details for an account: their subscription club and its subscriber
// count. This is where subscription clubs come from — the first read for an account
// that has none PROVISIONS it (named after the creator, with them as its Creator-tier
// member), so the client's "subscribe" button always has a club to post to. Only a
// real account gets one; an unknown id reads as club 0. Public.
.get(
'/subscription/details/:accountId{[0-9]+}',
describeRoute({
tags: ['Subscriptions'],
summary: 'Subscription details for an account',
description: 'Simulated — no subscription club, no subscribers.',
description: [
'The account’s subscription club and its subscriber count (the creator not counted).',
'The first read for an account with no subscription club PROVISIONS one — a',
'`ClubType` 1 club named after the creator — so `clubId` is only 0 for an id that',
'isn’t an account. Public.',
].join(' '),
parameters: [
{
name: 'accountId',
@@ -346,14 +380,17 @@ const app = new Hono<App>()
schema: { type: 'string' },
},
],
responses: { 200: json(SubscriptionDetailsResponse, 'Zeroed subscription details') },
responses: { 200: json(SubscriptionDetailsResponse, 'The account’s subscription details') },
}),
(c) =>
c.json({
accountId: Number.parseInt(c.req.param('accountId'), 10),
clubId: 0,
subscriberCount: 0,
async (c) => {
const accountId = Number.parseInt(c.req.param('accountId'), 10)
const club = await getOrCreateSubscriptionClub(c.env.DB, accountId)
return c.json({
accountId,
clubId: club?.ClubId ?? 0,
subscriberCount: club ? await countClubSubscribers(c.env.DB, club.ClubId) : 0,
})
}
)
// Details for a named subscription (e.g. `rrplus`). The client deserializes this
@@ -381,13 +418,85 @@ const app = new Hono<App>()
(c) => c.json({})
)
// Subscriber count for an account. No club subscriptions yet → 0.
// Subscribe to a creator: the caller joins the SUBSCRIPTION CLUB of the account
// `accountId` — the `ClubType` 1 club provisioned for them (here too, if nobody has
// read their `details` yet) — becoming a Member of it outright. Idempotent:
// resubscribing answers the existing membership's id. Refused for one's own club and
// for a banned account; an id that isn't an account is a 404. The `roomId` form field
// (the room the subscribe was posted from) is accepted and unused. The envelope is the
// reference's, with `error` NULL on success rather than the club routes' `""`; `value`
// is the membership row's id, which is a guess at what the reference's opaque id was.
.post(
'/subscription/:accountId{[0-9]+}',
describeRoute({
tags: ['Subscriptions'],
summary: 'Subscribe to a creator',
description: [
'The caller joins the subscription club of the account `accountId` as a Member,',
'provisioning the club first if that account has none yet. Idempotent: subscribing',
'again answers the existing membership’s id. Refused for the caller’s own club and',
'for a banned account; an id that isn’t an account is a 404. `roomId` is accepted',
'and unused. Note the envelope is the reference’s — `error` and `errorId` are null',
'on success, not `""`.',
].join(' '),
security: AUTHED,
parameters: [
{
name: 'accountId',
in: 'path',
required: true,
description: 'The creator to subscribe to (digits only)',
schema: { type: 'string' },
},
],
requestBody: form(SubscribeRequest, 'The room the subscribe was posted from'),
responses: {
200: json(SubscriptionIdEnvelope, 'The subscription’s id'),
400: json(
SubscriptionErrorEnvelope,
'The caller’s own club, or the caller is banned from it'
),
401: UNAUTHORIZED_RESPONSE,
404: { description: 'No such account' },
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const club = await getOrCreateSubscriptionClub(
c.env.DB,
Number.parseInt(c.req.param('accountId'), 10)
)
if (club === null) return c.notFound()
const result = await subscribeToClub(c.env.DB, club.ClubId, id)
if (result === null) return c.notFound()
const refusal: Record<string, string | undefined> = {
notSubscriptionClub: 'That is not a subscription club.',
own: 'You cannot subscribe to yourself.',
banned: 'You cannot subscribe to this creator.',
}
const message = refusal[result.outcome]
if (message !== undefined) {
return c.json({ value: null, success: false, errorId: null, error: message }, 400)
}
return c.json({ value: result.membershipId ?? 0, success: true, errorId: null, error: null })
}
)
// Subscriber count for an account: their subscription club's members, the creator
// not counted. Public, a bare JSON integer; an account with no subscription club yet
// (nobody has read its `details` or subscribed) has 0 — this read doesn't provision one.
.get(
'/subscription/subscriberCount/:accountId{[0-9]+}',
describeRoute({
tags: ['Subscriptions'],
summary: 'Subscriber count for an account',
description: 'No club subscriptions yet, so this is always 0. A bare JSON integer.',
description: [
'How many subscribers the account’s subscription club has, its creator not counted.',
'Public. A bare JSON integer; an account with no subscription club has 0.',
].join(' '),
parameters: [
{
name: 'accountId',
@@ -397,9 +506,15 @@ const app = new Hono<App>()
schema: { type: 'string' },
},
],
responses: { 200: json(SubscriberCountResponse, 'Always 0') },
responses: { 200: json(SubscriberCountResponse, 'The subscriber count') },
}),
(c) => c.json(0)
async (c) => {
const club = await getSubscriptionClub(
c.env.DB,
Number.parseInt(c.req.param('accountId'), 10)
)
return c.json(club ? await countClubSubscribers(c.env.DB, club.ClubId) : 0)
}
)
// The player's clubs that have unread announcements (MyClubsWithUnread-
@@ -1566,7 +1681,9 @@ app.get(
'membership (join / ask-to-join / leave, with the ban and pending tiers),',
'search, announcements, the club gallery and clubhouse room, and each player’s home',
'club. Everything is D1-backed on the shared `recflare` database; the',
'`/subscription/*` routes are stubs, since there are no subscription clubs yet.',
'A creator’s subscribers are the members of their subscription club (`ClubType` 1,',
'one per account, provisioned by the first `details` read or subscribe for that account);',
'the `/subscription/*` routes read and join those.',
'',
'Most writes answer the `{ error, success, value }` envelope with HTTP 200, and the',
'ones the client re-renders a club screen from carry the club’s FULL details as',
+55 -4
View File
@@ -245,11 +245,52 @@ export const ClubSearchResponse = z.object({
TotalClubs: z.int().describe('How many clubs matched, not the page size'),
})
/** `GET /subscription/details/:accountId` — simulated: no club, no subscribers. */
/**
* `GET /subscription/mine/member` — one of the caller's subscriptions (recovered from
* the client's decoder: `AccountId` int, `ClubId` long, `SubscriberCount` int).
*/
export const SubscriptionMembershipDto = z.object({
AccountId: z.int().describe('The creator subscribed to (the subscription club’s creator)'),
ClubId: z.int().describe('Their subscription club'),
SubscriberCount: z.int().describe('How many subscribers that club has, its creator not counted'),
})
/** `GET /subscription/mine/member` — the caller's subscriptions, oldest first. */
export const SubscriptionMembershipsResponse = z.array(SubscriptionMembershipDto)
/** `GET /subscription/details/:accountId` — an account's subscription club and its size. */
export const SubscriptionDetailsResponse = z.object({
accountId: z.int(),
clubId: z.int().describe('Always 0 — no subscription clubs yet'),
subscriberCount: z.int().describe('Always 0'),
clubId: z
.int()
.describe(
'The account’s subscription club, provisioned by this read or a subscribe; 0 only for a non-account'
),
subscriberCount: z.int().describe('Its subscribers, the creator not counted'),
})
/**
* `POST /subscription/:accountId` — the reference's envelope, which differs from the club
* one: `error` (and `errorId`) are NULL on success rather than `""`. `value` is the
* subscription's id.
*/
export const SubscriptionIdEnvelope = z.object({
value: z
.int()
.describe(
'The subscription’s id — the club membership row (the existing one when already subscribed)'
),
success: z.boolean(),
errorId: z.null(),
error: z.null(),
})
/** A rejected subscribe: the same envelope, carrying the message. */
export const SubscriptionErrorEnvelope = z.object({
value: z.null(),
success: z.boolean().describe('Always false'),
errorId: z.null(),
error: z.string().describe('The message shown to the player'),
})
/** The set of category tags a club can be filed under — a fixed list. */
@@ -258,7 +299,9 @@ export const CategoryTags = z.array(z.string())
/** `GET /subscription/subscriberCount/:accountId` — a bare JSON integer. */
export const SubscriberCountResponse = z
.int()
.describe('Always 0 — there are no club subscriptions yet')
.describe(
'The account’s subscription club’s subscribers, its creator not counted; 0 without a club'
)
/**
* `GET /club/:clubId/hasDisabledClubChat` — a bare JSON boolean, like the other
@@ -317,6 +360,14 @@ export const HomeClubRequest = z.object({
clubId: z.string().describe('The club to make home; the caller must be a member of it'),
})
/** `POST /subscription/:accountId` — the room the player is subscribing from. */
export const SubscribeRequest = z.object({
roomId: z
.string()
.optional()
.describe('The room the subscribe was posted from. Accepted and unused — nothing stores it'),
})
/** `PUT /club/:clubId/minlevel` form body. */
export const MinLevelRequest = z.object({
minLevel: z.string().describe('The minimum player level to join; negative/NaN is 400'),
+244 -16
View File
@@ -84,29 +84,256 @@ describe('clubs endpoints', () => {
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 })
})
describe('subscriptions', () => {
// A creator's subscribers are the members of their SUBSCRIPTION CLUB (ClubType 1).
// Nothing provisions those yet, so the tests seed them the way the eventual
// provisioner will: a ClubType 1 club with the creator as its Creator-tier member.
let clubId = 0
const CREATOR = 8800
beforeAll(async () => {
const idRow = await env.DB.prepare(
'SELECT COALESCE(MAX(club_id), 0) + 1 AS next FROM club'
).first<{
next: number
}>()
clubId = idRow?.next ?? 1
await env.DB.prepare('INSERT INTO club (data) VALUES (?1)')
.bind(
JSON.stringify({
ClubId: clubId,
Name: 'Player8800 subscribers',
Description: '',
Category: '',
Visibility: 1,
Joinability: 0,
AllowJuniors: true,
MainImageName: '',
ClubType: 1,
ClubhouseRoomId: null,
CreatorAccountId: CREATOR,
IsRRO: false,
MinLevel: 0,
State: 0,
MemberCount: 1,
CreatedAt: '2026-07-01T00:00:00Z',
})
)
.run()
await env.DB.prepare(
'INSERT INTO club_member (club_id, account_id, membership_type, created_at) VALUES (?1, ?2, ?3, ?4)'
)
.bind(clubId, CREATOR, 100, '2026-07-01T00:00:00Z')
.run()
})
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)
type Envelope = { value: number | null; success: boolean; errorId: null; error: string | null }
type Membership = { AccountId: number; ClubId: number; SubscriberCount: number }
type Details = { accountId: number; clubId: number; subscriberCount: number }
const subscribe = async (sub: string, id: number | string = CREATOR, body = 'roomId=78') =>
exports.default.fetch(`${ORIGIN}/subscription/${id}`, {
method: 'POST',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' },
body,
})
const details = async (accountId: number): Promise<Details> => {
const res = await exports.default.fetch(`${ORIGIN}/subscription/details/${accountId}`)
expect(res.status).toBe(200)
return (await res.json()) as Details
}
const count = async (accountId: number): Promise<number> => {
const res = await exports.default.fetch(`${ORIGIN}/subscription/subscriberCount/${accountId}`)
expect(res.status).toBe(200)
return (await res.json()) as number
}
const mine = async (headers: Record<string, string> = {}): Promise<Membership[]> => {
const res = await exports.default.fetch(`${ORIGIN}/subscription/mine/member`, { headers })
expect(res.status).toBe(200)
return (await res.json()) as Membership[]
}
test('an id that isn’t an account reads as club 0 and never gets one', async () => {
expect(await details(2)).toEqual({ accountId: 2, clubId: 0, subscriberCount: 0 })
expect(await details(2)).toEqual({ accountId: 2, clubId: 0, subscriberCount: 0 })
expect(await count(2)).toBe(0)
const { results } = await env.DB.prepare(
'SELECT club_id FROM club WHERE creator_account_id = 2'
).all()
expect(results).toEqual([])
})
test('the first details read for an account provisions its subscription club', async () => {
// Account 9101 exists (seeded) but has no subscription club: the count read
// doesn't make one …
expect(await count(9101)).toBe(0)
expect(
await env.DB.prepare('SELECT club_id FROM club WHERE creator_account_id = 9101').first()
).toBeNull()
// … the details read does, and every read after agrees on it.
const first = await details(9101)
expect(first.clubId).toBeGreaterThan(0)
expect(first).toEqual({ accountId: 9101, clubId: first.clubId, subscriberCount: 0 })
expect(await details(9101)).toEqual(first)
expect(await count(9101)).toBe(0)
// A ClubType 1 club named after the creator, Private, Open, with the creator as its
// Creator-tier (and only) member; out of the creator's own club lists and search.
const club = (await env.DB.prepare('SELECT data FROM club WHERE club_id = ?1')
.bind(first.clubId)
.first<{ data: string }>())!
expect(JSON.parse(club.data)).toMatchObject({
ClubId: first.clubId,
Name: 'Player9101',
ClubType: 1,
Visibility: 0,
Joinability: 0,
CreatorAccountId: 9101,
MemberCount: 1,
})
const { results: members } = await env.DB.prepare(
'SELECT account_id, membership_type FROM club_member WHERE club_id = ?1'
)
.bind(first.clubId)
.all()
expect(members).toEqual([{ account_id: 9101, membership_type: 100 }])
expect(await mine(await bearer('9101'))).toEqual([
{ AccountId: 9101, ClubId: first.clubId, SubscriberCount: 0 },
])
const created = await exports.default.fetch(`${ORIGIN}/club/mine/created`, {
headers: await bearer('9101'),
})
expect(await created.json()).toEqual([])
const search = await exports.default.fetch(`${ORIGIN}/club/search?query=Player9101`)
expect(((await search.json()) as { Clubs: unknown[] }).Clubs).toEqual([])
// Subscribing to the creator lands in the provisioned club.
expect((await subscribe('9100', 9101)).status).toBe(200)
expect(await details(9101)).toEqual({
accountId: 9101,
clubId: first.clubId,
subscriberCount: 1,
})
})
test('a fresh subscription club has its creator as a member but no subscribers', async () => {
expect(await details(CREATOR)).toEqual({ accountId: CREATOR, clubId, subscriberCount: 0 })
expect(await count(CREATOR)).toBe(0)
// The creator is a Creator-tier member of their own club, so it's listed for them.
expect(await mine(await bearer(String(CREATOR)))).toEqual([
{ AccountId: CREATOR, ClubId: clubId, SubscriberCount: 0 },
])
})
test('GET /subscription/mine/member answers [] without a token', async () => {
// The client has been seen calling this with no auth header: `[]`, not 401 (it
// chokes on null). A garbage token is the same; so is a player with none.
expect(await mine()).toEqual([])
expect(await mine({ Authorization: 'Bearer nope' })).toEqual([])
expect(await mine(await bearer('8801'))).toEqual([])
})
test('POST /subscription/:accountId joins the creator’s subscription club and the reads follow', async () => {
// Auth-gated.
const anon = await exports.default.fetch(`${ORIGIN}/subscription/${CREATOR}`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'roomId=78',
})
expect(anon.status).toBe(401)
// The reference envelope: `error`/`errorId` null on success, `value` an id.
const first = await subscribe('8801')
expect(first.status).toBe(200)
const created = (await first.json()) as Envelope
expect(created).toMatchObject({ success: true, errorId: null, error: null })
expect(created.value).toBeGreaterThan(0)
// The subscriber is a plain Member of the club; the club's count moved.
const row = await env.DB.prepare(
'SELECT club_member_id, membership_type FROM club_member WHERE club_id = ?1 AND account_id = 8801'
)
.bind(clubId)
.first<{ club_member_id: number; membership_type: number }>()
expect(row).toEqual({ club_member_id: created.value, membership_type: 10 })
// Idempotent: subscribing again (from another room, in the other casing) answers
// the same id and nothing doubles.
const again = await subscribe('8801', CREATOR, 'RoomId=99')
expect(again.status).toBe(200)
expect(((await again.json()) as Envelope).value).toBe(created.value)
// A second subscriber; every read agrees, and the creator isn't counted.
expect((await subscribe('8802', CREATOR, '')).status).toBe(200)
expect(await count(CREATOR)).toBe(2)
expect(await details(CREATOR)).toEqual({ accountId: CREATOR, clubId, subscriberCount: 2 })
expect(await mine(await bearer('8801'))).toEqual([
{ AccountId: CREATOR, ClubId: clubId, SubscriberCount: 2 },
])
expect(await mine(await bearer('8802'))).toEqual([
{ AccountId: CREATOR, ClubId: clubId, SubscriberCount: 2 },
])
// The club's MemberCount counts the creator too, and the subscription club stays
// out of the ordinary my-clubs lists.
const club = await env.DB.prepare('SELECT data FROM club WHERE club_id = ?1')
.bind(clubId)
.first<{ data: string }>()
expect((JSON.parse(club?.data ?? '{}') as { MemberCount: number }).MemberCount).toBe(3)
const listed = await exports.default.fetch(`${ORIGIN}/club/mine/member`, {
headers: await bearer('8801'),
})
expect(await listed.json()).toEqual([])
})
test('POST /subscription/:accountId refuses the creator and the banned, 404s a non-account', async () => {
const refused = async (res: Response, error: string): Promise<void> => {
expect(res.status).toBe(400)
expect(await res.json()).toEqual({ value: null, success: false, errorId: null, error })
}
await refused(await subscribe(String(CREATOR)), 'You cannot subscribe to yourself.')
// A ban sticks.
await env.DB.prepare(
'INSERT INTO club_member (club_id, account_id, membership_type, created_at) VALUES (?1, 8804, -1, ?2)'
)
.bind(clubId, '2026-07-01T00:00:00Z')
.run()
await refused(await subscribe('8804'), 'You cannot subscribe to this creator.')
// An id that isn't an account is a 404 and mints no club; a non-numeric id
// doesn't match the route.
expect((await subscribe('8801', 999999)).status).toBe(404)
expect(
await env.DB.prepare('SELECT club_id FROM club WHERE creator_account_id = 999999').first()
).toBeNull()
expect((await subscribe('8801', 'abc')).status).toBe(404)
})
test('subscribing to an account with no club yet provisions it', async () => {
// Account 9100 exists (seeded) and nobody has read its details.
expect(
await env.DB.prepare('SELECT club_id FROM club WHERE creator_account_id = 9100').first()
).toBeNull()
const res = await subscribe('8801', 9100)
expect(res.status).toBe(200)
expect(((await res.json()) as Envelope).success).toBe(true)
const d = await details(9100)
expect(d.clubId).toBeGreaterThan(0)
expect(d).toEqual({ accountId: 9100, clubId: d.clubId, subscriberCount: 1 })
expect(await mine(await bearer('8801'))).toContainEqual({
AccountId: 9100,
ClubId: d.clubId,
SubscriberCount: 1,
})
})
})
test('GET /announcements/v2/mine/unread returns []', async () => {
@@ -1441,6 +1668,7 @@ describe('clubs endpoints', () => {
'POST /club/{clubId}/join',
'POST /club/{clubId}/leave',
'POST /club/{clubId}/members/leave',
'POST /subscription/{accountId}',
'PUT /club/home/me',
'PUT /club/{clubId}/additionalimage/{index}',
'PUT /club/{clubId}/clubhouse',