mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
clean up some social endpoints, sockets not working yet
This commit is contained in:
@@ -1,5 +1,8 @@
|
|||||||
import type { HonoApp } from '@repo/hono-helpers'
|
import type { HonoApp } from '@repo/hono-helpers'
|
||||||
import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types'
|
import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types'
|
||||||
|
// Type-only import (erased at build) of the DO class owned by the `notify`
|
||||||
|
// worker, so the cross-worker RPC stub is fully typed.
|
||||||
|
import type { NotificationsHub } from '../../notify/src/notifications-hub'
|
||||||
|
|
||||||
export type Env = SharedHonoEnv & {
|
export type Env = SharedHonoEnv & {
|
||||||
// Shared Secrets Store binding for the HS256 JWT signing key. Resolve the value
|
// Shared Secrets Store binding for the HS256 JWT signing key. Resolve the value
|
||||||
@@ -18,6 +21,9 @@ export type Env = SharedHonoEnv & {
|
|||||||
// Image bucket (shared with the `img` worker, which serves objects back by
|
// Image bucket (shared with the `img` worker, which serves objects back by
|
||||||
// key). Uploaded saved images are written here.
|
// key). Uploaded saved images are written here.
|
||||||
IMAGES: R2Bucket
|
IMAGES: R2Bucket
|
||||||
|
// SignalR notifications hub (DO owned by the `notify` worker). Bound here to
|
||||||
|
// push RelationshipChanged notifications when a player's relationship changes.
|
||||||
|
RECFLARE_NOTIFICATIONS_HUB: DurableObjectNamespace<NotificationsHub>
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Variables can be extended */
|
/** Variables can be extended */
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
|
|
||||||
|
import { logger } from '@repo/hono-helpers'
|
||||||
|
|
||||||
import { authedId, unauthorized } from '../http'
|
import { authedId, unauthorized } from '../http'
|
||||||
import {
|
import {
|
||||||
acceptFriendRequest,
|
acceptFriendRequest,
|
||||||
@@ -12,6 +14,43 @@ import {
|
|||||||
|
|
||||||
import type { Context } from 'hono'
|
import type { Context } from 'hono'
|
||||||
import type { App } from '../context'
|
import type { App } from '../context'
|
||||||
|
import type { RelationshipFlag } from '../relationships-db'
|
||||||
|
|
||||||
|
/** The notifications hub is a single global DO instance (see the `notify` worker). */
|
||||||
|
const HUB_INSTANCE = 'global'
|
||||||
|
|
||||||
|
/** NotificationType.RelationshipChanged (see apps/notify/src/notification-types.ts). */
|
||||||
|
const RELATIONSHIP_CHANGED = 1
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a per-player relationship flag toggle (favorited/ignored/muted) and hand the
|
||||||
|
* result to the client the way the Go server does: the resulting relationship rides a
|
||||||
|
* `RelationshipChanged` hub notification to the caller, and the HTTP body is just the
|
||||||
|
* `{ Success, Message }` ack. Hub failures are logged and swallowed — the DB write has
|
||||||
|
* already committed, so a hub hiccup must not fail the request.
|
||||||
|
*/
|
||||||
|
async function applyFlag(
|
||||||
|
c: Context<App>,
|
||||||
|
playerId: number,
|
||||||
|
otherId: number,
|
||||||
|
flag: RelationshipFlag,
|
||||||
|
value: boolean
|
||||||
|
): Promise<Response> {
|
||||||
|
const rel = await setRelationshipFlag(c.env.DB, playerId, otherId, flag, value)
|
||||||
|
try {
|
||||||
|
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||||||
|
playerId,
|
||||||
|
RELATIONSHIP_CHANGED,
|
||||||
|
{ ...rel }
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('failed to push RelationshipChanged notification', {
|
||||||
|
playerId,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return c.json({ Success: true, Message: '' })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read the other player's id from a relationship-mutation request. The exact wire
|
* Read the other player's id from a relationship-mutation request. The exact wire
|
||||||
@@ -89,43 +128,60 @@ export const socialRoutes = new Hono<App>({ strict: false })
|
|||||||
return c.json(await addFriend(c.env.DB, id, target))
|
return c.json(await addFriend(c.env.DB, id, target))
|
||||||
})
|
})
|
||||||
|
|
||||||
// Ignore / mute another player (target arrives as `PlayerId` in the POST body).
|
// Ignore / mute another player, and their inverses unignore / unmute (target
|
||||||
// These set a per-player flag on the *caller's* side of the relationship row,
|
// arrives as `PlayerId` in the POST body). These set a per-player flag on the
|
||||||
// creating a bare (None) row when the pair aren't otherwise related — so you can
|
// *caller's* side of the relationship row, creating a bare (None) row when the
|
||||||
// ignore/mute someone you've never friended. Auth-gated. Returns the resulting
|
// pair aren't otherwise related — so you can ignore/mute someone you've never
|
||||||
// relationship from the caller's point of view.
|
// friended. The un- variants just clear the same flag. Auth-gated. The resulting
|
||||||
|
// relationship is delivered via a RelationshipChanged hub notification (see
|
||||||
|
// applyFlag); the HTTP body is just the { Success, Message } ack.
|
||||||
.on(['GET', 'POST'], '/api/relationships/v1/ignore', async (c) => {
|
.on(['GET', 'POST'], '/api/relationships/v1/ignore', async (c) => {
|
||||||
const id = await authedId(c)
|
const id = await authedId(c)
|
||||||
if (id === null) return unauthorized(c)
|
if (id === null) return unauthorized(c)
|
||||||
const target = await targetPlayerId(c)
|
const target = await targetPlayerId(c)
|
||||||
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
|
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
|
||||||
return c.json(await setRelationshipFlag(c.env.DB, id, target, 'ignored', true))
|
return applyFlag(c, id, target, 'ignored', true)
|
||||||
|
})
|
||||||
|
.on(['GET', 'POST'], '/api/relationships/v1/unignore', async (c) => {
|
||||||
|
const id = await authedId(c)
|
||||||
|
if (id === null) return unauthorized(c)
|
||||||
|
const target = await targetPlayerId(c)
|
||||||
|
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
|
||||||
|
return applyFlag(c, id, target, 'ignored', false)
|
||||||
})
|
})
|
||||||
.on(['GET', 'POST'], '/api/relationships/v1/mute', async (c) => {
|
.on(['GET', 'POST'], '/api/relationships/v1/mute', async (c) => {
|
||||||
const id = await authedId(c)
|
const id = await authedId(c)
|
||||||
if (id === null) return unauthorized(c)
|
if (id === null) return unauthorized(c)
|
||||||
const target = await targetPlayerId(c)
|
const target = await targetPlayerId(c)
|
||||||
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
|
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
|
||||||
return c.json(await setRelationshipFlag(c.env.DB, id, target, 'muted', true))
|
return applyFlag(c, id, target, 'muted', true)
|
||||||
|
})
|
||||||
|
.on(['GET', 'POST'], '/api/relationships/v1/unmute', async (c) => {
|
||||||
|
const id = await authedId(c)
|
||||||
|
if (id === null) return unauthorized(c)
|
||||||
|
const target = await targetPlayerId(c)
|
||||||
|
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
|
||||||
|
return applyFlag(c, id, target, 'muted', false)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Favorite / unfavorite another player (the client calls these as a GET with the
|
// Favorite / unfavorite another player (the client calls these as a GET with the
|
||||||
// target in `?id=`). Same per-side flag mechanics as ignore/mute above: the write
|
// target in `?id=`). Same per-side flag mechanics as ignore/mute above: the write
|
||||||
// lands on the *caller's* side of the row, and favoriting someone you have no
|
// lands on the *caller's* side of the row, and favoriting someone you have no
|
||||||
// relationship with creates a bare (None) row. Auth-gated.
|
// relationship with creates a bare (None) row. Auth-gated. Result rides a
|
||||||
|
// RelationshipChanged notification; the body is the { Success, Message } ack.
|
||||||
.on(['GET', 'POST'], '/api/relationships/v1/favorite', async (c) => {
|
.on(['GET', 'POST'], '/api/relationships/v1/favorite', async (c) => {
|
||||||
const id = await authedId(c)
|
const id = await authedId(c)
|
||||||
if (id === null) return unauthorized(c)
|
if (id === null) return unauthorized(c)
|
||||||
const target = await targetPlayerId(c)
|
const target = await targetPlayerId(c)
|
||||||
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
|
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
|
||||||
return c.json(await setRelationshipFlag(c.env.DB, id, target, 'favorited', true))
|
return applyFlag(c, id, target, 'favorited', true)
|
||||||
})
|
})
|
||||||
.on(['GET', 'POST'], '/api/relationships/v1/unfavorite', async (c) => {
|
.on(['GET', 'POST'], '/api/relationships/v1/unfavorite', async (c) => {
|
||||||
const id = await authedId(c)
|
const id = await authedId(c)
|
||||||
if (id === null) return unauthorized(c)
|
if (id === null) return unauthorized(c)
|
||||||
const target = await targetPlayerId(c)
|
const target = await targetPlayerId(c)
|
||||||
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
|
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
|
||||||
return c.json(await setRelationshipFlag(c.env.DB, id, target, 'favorited', false))
|
return applyFlag(c, id, target, 'favorited', false)
|
||||||
})
|
})
|
||||||
|
|
||||||
.get('/api/messages/v2/get', (c) => c.json([]))
|
.get('/api/messages/v2/get', (c) => c.json([]))
|
||||||
|
|||||||
@@ -1458,6 +1458,43 @@ describe('relationships', () => {
|
|||||||
return (await res.json()) as Rel[]
|
return (await res.json()) as Rel[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Standard ack the flag endpoints (favorite/ignore/mute + inverses) now return —
|
||||||
|
// the relationship detail rides a RelationshipChanged hub notification instead.
|
||||||
|
const ACK = { Success: true, Message: '' }
|
||||||
|
|
||||||
|
// POST a flag mutation the real client way (form body `PlayerId=<id>`), returning
|
||||||
|
// the parsed ack body.
|
||||||
|
async function ackFlag(path: string, sub: string, playerId: number) {
|
||||||
|
return (await (
|
||||||
|
await exports.default.fetch(`${ORIGIN}${path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: `PlayerId=${playerId}`,
|
||||||
|
})
|
||||||
|
).json()) as { Success: boolean; Message: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
// A player's own-side flags read straight from the relationship row. The None row a
|
||||||
|
// flag can create for an otherwise-unrelated pair isn't reported by v2/get, so the
|
||||||
|
// flag effect is verified here instead of through the (now ack-only) response.
|
||||||
|
async function ownFlags(playerId: number, otherId: number) {
|
||||||
|
const row = (await env.DB.prepare(
|
||||||
|
`SELECT requester_id, requester_favorited, requester_ignored, requester_muted,
|
||||||
|
target_favorited, target_ignored, target_muted
|
||||||
|
FROM relationship
|
||||||
|
WHERE (requester_id = ?1 AND target_id = ?2) OR (requester_id = ?2 AND target_id = ?1)`
|
||||||
|
)
|
||||||
|
.bind(playerId, otherId)
|
||||||
|
.first()) as Record<string, number> | null
|
||||||
|
if (!row) return null
|
||||||
|
const isRequester = row.requester_id === playerId
|
||||||
|
return {
|
||||||
|
Favorited: isRequester ? row.requester_favorited : row.target_favorited,
|
||||||
|
Ignored: isRequester ? row.requester_ignored : row.target_ignored,
|
||||||
|
Muted: isRequester ? row.requester_muted : row.target_muted,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
test('GET /api/relationships/v2/get is auth-gated', async () => {
|
test('GET /api/relationships/v2/get is auth-gated', async () => {
|
||||||
expect((await exports.default.fetch(`${ORIGIN}/api/relationships/v2/get`)).status).toBe(401)
|
expect((await exports.default.fetch(`${ORIGIN}/api/relationships/v2/get`)).status).toBe(401)
|
||||||
})
|
})
|
||||||
@@ -1537,64 +1574,57 @@ describe('relationships', () => {
|
|||||||
|
|
||||||
test('v1 ignore/mute set the caller’s own side of the relationship', async () => {
|
test('v1 ignore/mute set the caller’s own side of the relationship', async () => {
|
||||||
type FullRel = { PlayerID: number; RelationshipType: number; Ignored: number; Muted: number }
|
type FullRel = { PlayerID: number; RelationshipType: number; Ignored: number; Muted: number }
|
||||||
// POST the real client shape: form body `PlayerId=<id>`.
|
|
||||||
const flag = async (path: string, sub: string, playerId: number) =>
|
|
||||||
(await (
|
|
||||||
await exports.default.fetch(`${ORIGIN}${path}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
...(await bearer(sub)),
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
},
|
|
||||||
body: `PlayerId=${playerId}`,
|
|
||||||
})
|
|
||||||
).json()) as FullRel
|
|
||||||
|
|
||||||
// 700 ignores 701 with no prior relationship → a bare None row, the caller's side flagged.
|
// 700 ignores 701 with no prior relationship → a bare None row, the caller's side
|
||||||
expect(await flag('/api/relationships/v1/ignore', '700', 701)).toMatchObject({
|
// flagged. The response is now just the ack; the flag is verified on the row.
|
||||||
PlayerID: 701,
|
expect(await ackFlag('/api/relationships/v1/ignore', '700', 701)).toEqual(ACK)
|
||||||
RelationshipType: 0,
|
expect(await ownFlags(700, 701)).toMatchObject({ Ignored: 1, Muted: 0 })
|
||||||
Ignored: 1,
|
|
||||||
Muted: 0,
|
|
||||||
})
|
|
||||||
// 700 then mutes 701 → same row, mute added, the earlier ignore preserved.
|
// 700 then mutes 701 → same row, mute added, the earlier ignore preserved.
|
||||||
expect(await flag('/api/relationships/v1/mute', '700', 701)).toMatchObject({
|
expect(await ackFlag('/api/relationships/v1/mute', '700', 701)).toEqual(ACK)
|
||||||
PlayerID: 701,
|
expect(await ownFlags(700, 701)).toMatchObject({ Ignored: 1, Muted: 1 })
|
||||||
Ignored: 1,
|
|
||||||
Muted: 1,
|
|
||||||
})
|
|
||||||
|
|
||||||
// The tricky case: the caller is the row's TARGET. 710 sends 711 a request
|
// The tricky case: the caller is the row's TARGET. 710 sends 711 a request
|
||||||
// (710 = requester); 711 ignoring 710 must flag the target side, not the requester's.
|
// (710 = requester); 711 ignoring 710 must flag the target side, not the requester's.
|
||||||
await mutate('/api/relationships/v2/sendfriendrequest', '710', 711)
|
await mutate('/api/relationships/v2/sendfriendrequest', '710', 711)
|
||||||
expect(await flag('/api/relationships/v1/ignore', '711', 710)).toMatchObject({
|
expect(await ackFlag('/api/relationships/v1/ignore', '711', 710)).toEqual(ACK)
|
||||||
PlayerID: 710,
|
// 711 sees 710's request as Received (2) with their own Ignored set.
|
||||||
RelationshipType: 2, // 711 sees 710's request as Received
|
expect((await relationships('711')) as unknown as FullRel[]).toEqual([
|
||||||
Ignored: 1,
|
expect.objectContaining({ PlayerID: 710, RelationshipType: 2, Ignored: 1 }),
|
||||||
})
|
])
|
||||||
// 710's own side is untouched — the requester never ignored anyone.
|
// 710's own side is untouched — the requester never ignored anyone.
|
||||||
const view710 = (await relationships('710')) as unknown as FullRel[]
|
expect((await relationships('710')) as unknown as FullRel[]).toEqual([
|
||||||
expect(view710).toEqual([
|
|
||||||
expect.objectContaining({ PlayerID: 711, RelationshipType: 1, Ignored: 0 }),
|
expect.objectContaining({ PlayerID: 711, RelationshipType: 1, Ignored: 0 }),
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('v1 unignore/unmute clear the caller’s own flags independently', async () => {
|
||||||
|
// 800 ignores and mutes 801 (bare None row, both flags on the caller's side).
|
||||||
|
await ackFlag('/api/relationships/v1/ignore', '800', 801)
|
||||||
|
await ackFlag('/api/relationships/v1/mute', '800', 801)
|
||||||
|
expect(await ownFlags(800, 801)).toMatchObject({ Ignored: 1, Muted: 1 })
|
||||||
|
// unignore clears only Ignored; the mute is left in place.
|
||||||
|
expect(await ackFlag('/api/relationships/v1/unignore', '800', 801)).toEqual(ACK)
|
||||||
|
expect(await ownFlags(800, 801)).toMatchObject({ Ignored: 0, Muted: 1 })
|
||||||
|
// unmute then clears Muted too.
|
||||||
|
expect(await ackFlag('/api/relationships/v1/unmute', '800', 801)).toEqual(ACK)
|
||||||
|
expect(await ownFlags(800, 801)).toMatchObject({ Ignored: 0, Muted: 0 })
|
||||||
|
})
|
||||||
|
|
||||||
test('v1 favorite/unfavorite toggle the caller’s own side, leaving the friendship intact', async () => {
|
test('v1 favorite/unfavorite toggle the caller’s own side, leaving the friendship intact', async () => {
|
||||||
// 720 and 721 are friends; 720 favorites 721 — the real client shape, a GET with `?id=`.
|
// 720 and 721 are friends; 720 favorites 721 — the real client shape, a GET with `?id=`.
|
||||||
await mutate('/api/relationships/v2/addfriend', '720', 721)
|
await mutate('/api/relationships/v2/addfriend', '720', 721)
|
||||||
expect(
|
expect(await (await mutate('/api/relationships/v1/favorite', '720', 721)).json()).toEqual(ACK)
|
||||||
(await (await mutate('/api/relationships/v1/favorite', '720', 721)).json()) as Rel
|
// 720's own side is favorited; the friendship is intact.
|
||||||
).toMatchObject({ PlayerID: 721, RelationshipType: 3, Favorited: 1 })
|
expect(await relationships('720')).toEqual([
|
||||||
|
{ PlayerID: 721, RelationshipType: 3, Favorited: 1, Ignored: 0, Muted: 0 },
|
||||||
|
])
|
||||||
// Favoriting is one-sided: 721 does not see themselves as having favorited 720.
|
// Favoriting is one-sided: 721 does not see themselves as having favorited 720.
|
||||||
expect(await relationships('721')).toEqual([
|
expect(await relationships('721')).toEqual([
|
||||||
{ PlayerID: 720, RelationshipType: 3, Favorited: 0, Ignored: 0, Muted: 0 },
|
{ PlayerID: 720, RelationshipType: 3, Favorited: 0, Ignored: 0, Muted: 0 },
|
||||||
])
|
])
|
||||||
|
|
||||||
// Unfavorite clears the flag but keeps the friendship.
|
// Unfavorite clears the flag but keeps the friendship.
|
||||||
expect(
|
expect(await (await mutate('/api/relationships/v1/unfavorite', '720', 721)).json()).toEqual(ACK)
|
||||||
(await (await mutate('/api/relationships/v1/unfavorite', '720', 721)).json()) as Rel
|
|
||||||
).toMatchObject({ PlayerID: 721, RelationshipType: 3, Favorited: 0 })
|
|
||||||
expect(await relationships('720')).toEqual([
|
expect(await relationships('720')).toEqual([
|
||||||
{ PlayerID: 721, RelationshipType: 3, Favorited: 0, Ignored: 0, Muted: 0 },
|
{ PlayerID: 721, RelationshipType: 3, Favorited: 0, Ignored: 0, Muted: 0 },
|
||||||
])
|
])
|
||||||
@@ -1602,9 +1632,8 @@ describe('relationships', () => {
|
|||||||
|
|
||||||
test('favoriting a player you have no relationship with is allowed', async () => {
|
test('favoriting a player you have no relationship with is allowed', async () => {
|
||||||
// Mirrors ignore/mute: a bare None row is created with the caller's side flagged.
|
// Mirrors ignore/mute: a bare None row is created with the caller's side flagged.
|
||||||
expect(
|
expect(await (await mutate('/api/relationships/v1/favorite', '730', 731)).json()).toEqual(ACK)
|
||||||
(await (await mutate('/api/relationships/v1/favorite', '730', 731)).json()) as Rel
|
expect(await ownFlags(730, 731)).toMatchObject({ Favorited: 1 })
|
||||||
).toMatchObject({ PlayerID: 731, RelationshipType: 0, Favorited: 1 })
|
|
||||||
// A None row is not reported as a relationship by v2/get.
|
// A None row is not reported as a relationship by v2/get.
|
||||||
expect(await relationships('730')).toEqual([])
|
expect(await relationships('730')).toEqual([])
|
||||||
})
|
})
|
||||||
@@ -1612,4 +1641,19 @@ describe('relationships', () => {
|
|||||||
test('a self-targeted favorite is rejected', async () => {
|
test('a self-targeted favorite is rejected', async () => {
|
||||||
expect((await mutate('/api/relationships/v1/favorite', '740', 740)).status).toBe(400)
|
expect((await mutate('/api/relationships/v1/favorite', '740', 740)).status).toBe(400)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('a flag change pushes a RelationshipChanged notification with the relationship', async () => {
|
||||||
|
// The relationship detail now rides a hub notification instead of the response.
|
||||||
|
// The notify DO is stubbed to record its last notifyPlayer call (see vitest.config).
|
||||||
|
await ackFlag('/api/relationships/v1/favorite', '750', 751)
|
||||||
|
const res = await env.RECFLARE_NOTIFICATIONS_HUB.getByName('global').fetch('http://do/last')
|
||||||
|
const last = (await res.json()) as {
|
||||||
|
playerId: number
|
||||||
|
notificationType: number
|
||||||
|
data: { PlayerID: number; Favorited: number; RelationshipType: number }
|
||||||
|
}
|
||||||
|
expect(last.playerId).toBe(750) // sent to the caller
|
||||||
|
expect(last.notificationType).toBe(1) // NotificationType.RelationshipChanged
|
||||||
|
expect(last.data).toMatchObject({ PlayerID: 751, Favorited: 1, RelationshipType: 0 })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,6 +9,34 @@ export default defineConfig({
|
|||||||
bindings: {
|
bindings: {
|
||||||
ENVIRONMENT: 'VITEST',
|
ENVIRONMENT: 'VITEST',
|
||||||
},
|
},
|
||||||
|
// The worker's RECFLARE_NOTIFICATIONS_HUB binding points at the `notify`
|
||||||
|
// worker's DO (script_name: "notify"). That worker isn't part of this
|
||||||
|
// isolated test, so provide a minimal stub service exposing the same
|
||||||
|
// NotificationsHub RPC surface — enough for the runtime to start and for
|
||||||
|
// notification sends to no-op.
|
||||||
|
workers: [
|
||||||
|
{
|
||||||
|
name: 'notify',
|
||||||
|
modules: true,
|
||||||
|
compatibilityDate: '2026-06-16',
|
||||||
|
compatibilityFlags: ['nodejs_compat'],
|
||||||
|
durableObjects: { RECFLARE_NOTIFICATIONS_HUB: 'NotificationsHub' },
|
||||||
|
// notifyPlayer records its last call so tests can assert the notification
|
||||||
|
// the worker pushed (type + payload); GET the DO to read it back.
|
||||||
|
script: `
|
||||||
|
import { DurableObject } from 'cloudflare:workers'
|
||||||
|
export class NotificationsHub extends DurableObject {
|
||||||
|
async notifyPlayer(playerId, notificationType, data) {
|
||||||
|
this.last = { playerId, notificationType, data }
|
||||||
|
return { delivered: 0, queued: true }
|
||||||
|
}
|
||||||
|
async broadcast() { return { delivered: 0 } }
|
||||||
|
async fetch() { return Response.json(this.last ?? null) }
|
||||||
|
}
|
||||||
|
export default { fetch() { return new Response('ok') } }
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -26,6 +26,17 @@
|
|||||||
"bucket_name": "recflare-img"
|
"bucket_name": "recflare-img"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
// Cross-worker binding to the SignalR notifications hub DO (owned/migrated by
|
||||||
|
// the `notify` worker). We only invoke its RPC methods; no migration here.
|
||||||
|
"durable_objects": {
|
||||||
|
"bindings": [
|
||||||
|
{
|
||||||
|
"name": "RECFLARE_NOTIFICATIONS_HUB",
|
||||||
|
"class_name": "NotificationsHub",
|
||||||
|
"script_name": "notify"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"logpush": false,
|
"logpush": false,
|
||||||
// Shared Secrets Store holding the HS256 JWT signing key. Every worker binds the
|
// Shared Secrets Store holding the HS256 JWT signing key. Every worker binds the
|
||||||
// same store as JWT_SECRET so tokens signed by `auth` verify here. The "local"
|
// same store as JWT_SECRET so tokens signed by `auth` verify here. The "local"
|
||||||
|
|||||||
@@ -426,6 +426,11 @@ const app = new Hono<App>()
|
|||||||
return c.body(null, 200)
|
return c.body(null, 200)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Fire-and-forget disconnect notification (form body `PlayerId`/`RoomInstanceId`).
|
||||||
|
// The client posts this when it drops a room; we don't act on it — presence is
|
||||||
|
// cleared by logout and otherwise expires on its own TTL — so just ack with 200.
|
||||||
|
.post('/player/notifydisconnect', (c) => c.body(null, 200))
|
||||||
|
|
||||||
.get('/player', async (c) => {
|
.get('/player', async (c) => {
|
||||||
// Returns each requested player's presence. Reads the `id` query param(s);
|
// Returns each requested player's presence. Reads the `id` query param(s);
|
||||||
// with none it serves the static getplayer.json default.
|
// with none it serves the static getplayer.json default.
|
||||||
|
|||||||
@@ -150,6 +150,15 @@ describe('public endpoints', () => {
|
|||||||
expect(await res.json()).toEqual({ errorCode: 0 })
|
expect(await res.json()).toEqual({ errorCode: 0 })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('POST /player/notifydisconnect returns 200', async () => {
|
||||||
|
const res = await exports.default.fetch(`${ORIGIN}/player/notifydisconnect`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: 'PlayerId=155&RoomInstanceId=1000001',
|
||||||
|
})
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
})
|
||||||
|
|
||||||
test('GET /player?id=N synthesizes a player payload for that id', async () => {
|
test('GET /player?id=N synthesizes a player payload for that id', async () => {
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/player?id=99`)
|
const res = await exports.default.fetch(`${ORIGIN}/player?id=99`)
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
|
|||||||
Reference in New Issue
Block a user