mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
relationship notifications (maybe)
This commit is contained in:
@@ -37,10 +37,10 @@ export const SCHEMA_DDL: string[] = [
|
||||
]
|
||||
|
||||
/**
|
||||
* Saved-image categories from the C# `SavedImageType` enum — the value of a stored
|
||||
* image's `Type` (and the client's `imgMeta.savedImageType` on upload). Lives here in
|
||||
* the image data layer so both the upload route and the slideshow query share one
|
||||
* definition.
|
||||
* Saved-image categories from the reference's `SavedImageType` enum — the value of a
|
||||
* stored image's `Type` (and the client's `imgMeta.savedImageType` on upload). Lives
|
||||
* here in the image data layer so both the upload route and the slideshow query share
|
||||
* one definition.
|
||||
*/
|
||||
export const SavedImageType = {
|
||||
None: 0,
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* workers' migrations that share the database).
|
||||
*/
|
||||
|
||||
/** Relationship state from the perspective of the player asking (mirror of the C# enum). */
|
||||
/** Relationship state from the perspective of the player asking (mirrors the reference). */
|
||||
export enum RelationshipType {
|
||||
None = 0,
|
||||
FriendRequestSent = 1,
|
||||
@@ -53,7 +53,7 @@ interface RelationshipRow {
|
||||
target_muted: number
|
||||
}
|
||||
|
||||
/** The per-player relationship projection returned to the client (the C# RelationshipResponse). */
|
||||
/** The per-player relationship projection returned to the client (RelationshipResponse). */
|
||||
export interface RelationshipResponse {
|
||||
Favorited: number
|
||||
Ignored: number
|
||||
@@ -62,6 +62,34 @@ export interface RelationshipResponse {
|
||||
RelationshipType: RelationshipType
|
||||
}
|
||||
|
||||
/**
|
||||
* The result of a friend-graph mutation. These changes are visible to BOTH players, and
|
||||
* each sees a different projection of the same row (the target of a request sees
|
||||
* `FriendRequestReceived` where the sender sees `Sent`), so callers get both — `self` for
|
||||
* the HTTP response and the acting player's notification, `other` for the target's.
|
||||
*
|
||||
* `changed` is false when the mutation was a no-op: re-sending a request that's already
|
||||
* outstanding, befriending someone you're already friends with, accepting something that
|
||||
* isn't pending. Nothing was written, so no RelationshipChanged notification should go out
|
||||
* (the reference server is likewise silent on its no-change branch).
|
||||
*/
|
||||
export interface RelationshipChange {
|
||||
self: RelationshipResponse
|
||||
other: RelationshipResponse
|
||||
changed: boolean
|
||||
}
|
||||
|
||||
/** The projection reported for a pair with no stored relationship. */
|
||||
function noneResponse(otherId: number): RelationshipResponse {
|
||||
return {
|
||||
PlayerID: otherId,
|
||||
RelationshipType: RelationshipType.None,
|
||||
Favorited: 0,
|
||||
Ignored: 0,
|
||||
Muted: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/** Flip a pending request to the other side's point of view; Friend/None are symmetric. */
|
||||
function flipType(type: number): RelationshipType {
|
||||
if (type === RelationshipType.FriendRequestSent) return RelationshipType.FriendRequestReceived
|
||||
@@ -85,6 +113,16 @@ function toResponse(row: RelationshipRow, playerId: number): RelationshipRespons
|
||||
}
|
||||
}
|
||||
|
||||
/** Project a written row for both players in the pair. */
|
||||
function toChange(
|
||||
row: RelationshipRow,
|
||||
playerId: number,
|
||||
otherId: number,
|
||||
changed: boolean
|
||||
): RelationshipChange {
|
||||
return { self: toResponse(row, playerId), other: toResponse(row, otherId), changed }
|
||||
}
|
||||
|
||||
/** Find the single row for an unordered pair (either direction), or null. */
|
||||
async function findPair(db: D1Database, a: number, b: number): Promise<RelationshipRow | null> {
|
||||
return db
|
||||
@@ -98,7 +136,10 @@ async function findPair(db: D1Database, a: number, b: number): Promise<Relations
|
||||
|
||||
/**
|
||||
* All of a player's relationships, projected from that player's point of view.
|
||||
* `None` rows are omitted (a removed friend leaves no relationship to report).
|
||||
*
|
||||
* `None` rows are included: they are how an unfriending, or an ignore/mute of someone you
|
||||
* were never friends with, is recorded, and they still carry that player's
|
||||
* favorited/ignored/muted flags. Dropping them would lose the flags on the client.
|
||||
*/
|
||||
export async function getRelationshipsForPlayer(
|
||||
db: D1Database,
|
||||
@@ -111,9 +152,7 @@ export async function getRelationshipsForPlayer(
|
||||
)
|
||||
.bind(playerId)
|
||||
.all<RelationshipRow>()
|
||||
return results
|
||||
.filter((row) => row.relationship_type !== RelationshipType.None)
|
||||
.map((row) => toResponse(row, playerId))
|
||||
return results.map((row) => toResponse(row, playerId))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -121,14 +160,14 @@ export async function getRelationshipsForPlayer(
|
||||
* requester. Inserts a new row or, if one already exists for the pair (either
|
||||
* direction), rewrites it so the requester is normalized to `requesterId` and
|
||||
* the flags are preserved for whichever side each player is on. Returns the
|
||||
* relationship from `requesterId`'s point of view.
|
||||
* row as written, for the caller to project onto whichever side it needs.
|
||||
*/
|
||||
async function upsertPair(
|
||||
db: D1Database,
|
||||
requesterId: number,
|
||||
targetId: number,
|
||||
type: RelationshipType
|
||||
): Promise<RelationshipResponse> {
|
||||
): Promise<RelationshipRow> {
|
||||
const existing = await findPair(db, requesterId, targetId)
|
||||
if (!existing) {
|
||||
await db
|
||||
@@ -138,7 +177,17 @@ async function upsertPair(
|
||||
)
|
||||
.bind(requesterId, targetId, type)
|
||||
.run()
|
||||
return { PlayerID: targetId, RelationshipType: type, Favorited: 0, Ignored: 0, Muted: 0 }
|
||||
return {
|
||||
requester_id: requesterId,
|
||||
target_id: targetId,
|
||||
relationship_type: type,
|
||||
requester_favorited: 0,
|
||||
requester_ignored: 0,
|
||||
requester_muted: 0,
|
||||
target_favorited: 0,
|
||||
target_ignored: 0,
|
||||
target_muted: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Keep each player's flags with that player as the row is normalized to
|
||||
@@ -175,93 +224,117 @@ async function upsertPair(
|
||||
)
|
||||
.run()
|
||||
return {
|
||||
PlayerID: targetId,
|
||||
RelationshipType: type,
|
||||
Favorited: reqFlags.favorited,
|
||||
Ignored: reqFlags.ignored,
|
||||
Muted: reqFlags.muted,
|
||||
requester_id: requesterId,
|
||||
target_id: targetId,
|
||||
relationship_type: type,
|
||||
requester_favorited: reqFlags.favorited,
|
||||
requester_ignored: reqFlags.ignored,
|
||||
requester_muted: reqFlags.muted,
|
||||
target_favorited: tgtFlags.favorited,
|
||||
target_ignored: tgtFlags.ignored,
|
||||
target_muted: tgtFlags.muted,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a friend request from `requesterId` to `targetId`. If the target already
|
||||
* has a pending request out to the requester, the two become friends instead
|
||||
* (the request crosses an existing one). Already-friends is left unchanged.
|
||||
* Returns the relationship from the requester's point of view.
|
||||
* (the request crosses an existing one). Already-friends, and re-sending a request
|
||||
* that's already outstanding, are no-ops.
|
||||
*/
|
||||
export async function sendFriendRequest(
|
||||
db: D1Database,
|
||||
requesterId: number,
|
||||
targetId: number
|
||||
): Promise<RelationshipResponse> {
|
||||
): Promise<RelationshipChange> {
|
||||
const existing = await findPair(db, requesterId, targetId)
|
||||
if (existing) {
|
||||
if (existing.relationship_type === RelationshipType.Friend) {
|
||||
return toResponse(existing, requesterId)
|
||||
// Already friends, or we already have a request out to them — nothing to write.
|
||||
if (
|
||||
existing.relationship_type === RelationshipType.Friend ||
|
||||
(existing.requester_id === requesterId &&
|
||||
existing.relationship_type === RelationshipType.FriendRequestSent)
|
||||
) {
|
||||
return toChange(existing, requesterId, targetId, false)
|
||||
}
|
||||
// The target already requested us → crossing requests become a friendship.
|
||||
if (
|
||||
existing.requester_id === targetId &&
|
||||
existing.relationship_type === RelationshipType.FriendRequestSent
|
||||
) {
|
||||
return upsertPair(db, requesterId, targetId, RelationshipType.Friend)
|
||||
const row = await upsertPair(db, requesterId, targetId, RelationshipType.Friend)
|
||||
return toChange(row, requesterId, targetId, true)
|
||||
}
|
||||
}
|
||||
return upsertPair(db, requesterId, targetId, RelationshipType.FriendRequestSent)
|
||||
const row = await upsertPair(db, requesterId, targetId, RelationshipType.FriendRequestSent)
|
||||
return toChange(row, requesterId, targetId, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* `accepterId` accepts a pending friend request from `otherId`. Only upgrades to
|
||||
* Friend when a request from `otherId` is actually pending; otherwise the
|
||||
* current state is returned unchanged. Returns the relationship from the
|
||||
* accepter's point of view.
|
||||
* Friend when a request from `otherId` is actually pending; otherwise the current
|
||||
* state is returned as a no-op. (The reference server answers 403 there instead;
|
||||
* we stay lenient, but either way nothing changed.)
|
||||
*/
|
||||
export async function acceptFriendRequest(
|
||||
db: D1Database,
|
||||
accepterId: number,
|
||||
otherId: number
|
||||
): Promise<RelationshipResponse> {
|
||||
): Promise<RelationshipChange> {
|
||||
const existing = await findPair(db, accepterId, otherId)
|
||||
if (
|
||||
existing &&
|
||||
existing.requester_id === otherId &&
|
||||
existing.relationship_type === RelationshipType.FriendRequestSent
|
||||
) {
|
||||
// upsertPair projects for the requester (otherId); the accepter is the target,
|
||||
// so re-project the written row from the accepter's point of view.
|
||||
await upsertPair(db, otherId, accepterId, RelationshipType.Friend)
|
||||
const updated = await findPair(db, accepterId, otherId)
|
||||
if (updated) return toResponse(updated, accepterId)
|
||||
const row = await upsertPair(db, otherId, accepterId, RelationshipType.Friend)
|
||||
return toChange(row, accepterId, otherId, true)
|
||||
}
|
||||
return existing
|
||||
? toResponse(existing, accepterId)
|
||||
: { PlayerID: otherId, RelationshipType: RelationshipType.None, Favorited: 0, Ignored: 0, Muted: 0 }
|
||||
? toChange(existing, accepterId, otherId, false)
|
||||
: { self: noneResponse(otherId), other: noneResponse(accepterId), changed: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Directly make `requesterId` and `targetId` friends (no pending request step).
|
||||
* Returns the relationship from the requester's point of view.
|
||||
*/
|
||||
export async function addFriend(
|
||||
db: D1Database,
|
||||
requesterId: number,
|
||||
targetId: number
|
||||
): Promise<RelationshipResponse> {
|
||||
return upsertPair(db, requesterId, targetId, RelationshipType.Friend)
|
||||
): Promise<RelationshipChange> {
|
||||
const existing = await findPair(db, requesterId, targetId)
|
||||
if (existing && existing.relationship_type === RelationshipType.Friend) {
|
||||
return toChange(existing, requesterId, targetId, false)
|
||||
}
|
||||
const row = await upsertPair(db, requesterId, targetId, RelationshipType.Friend)
|
||||
return toChange(row, requesterId, targetId, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove any relationship between the two players (unfriend / cancel request /
|
||||
* decline). Deletes the row entirely so neither side reports a relationship.
|
||||
* decline).
|
||||
*
|
||||
* The row is set to `None` rather than deleted, matching the reference server: the
|
||||
* per-player favorited/ignored/muted flags live on that row and must survive an
|
||||
* unfriending (someone you ignored stays ignored after you drop them as a friend).
|
||||
*/
|
||||
export async function removeFriend(db: D1Database, a: number, b: number): Promise<void> {
|
||||
export async function removeFriend(
|
||||
db: D1Database,
|
||||
playerId: number,
|
||||
otherId: number
|
||||
): Promise<RelationshipChange> {
|
||||
await db
|
||||
.prepare(
|
||||
`DELETE FROM relationship
|
||||
`UPDATE relationship SET relationship_type = ?3
|
||||
WHERE (requester_id = ?1 AND target_id = ?2) OR (requester_id = ?2 AND target_id = ?1)`
|
||||
)
|
||||
.bind(a, b)
|
||||
.bind(playerId, otherId, RelationshipType.None)
|
||||
.run()
|
||||
const updated = await findPair(db, playerId, otherId)
|
||||
return updated
|
||||
? toChange(updated, playerId, otherId, true)
|
||||
: { self: noneResponse(otherId), other: noneResponse(playerId), changed: true }
|
||||
}
|
||||
|
||||
/** A per-player relationship flag — each is stored on the player's own side of the row. */
|
||||
@@ -308,7 +381,5 @@ export async function setRelationshipFlag(
|
||||
}
|
||||
|
||||
const updated = await findPair(db, playerId, otherId)
|
||||
return updated
|
||||
? toResponse(updated, playerId)
|
||||
: { PlayerID: otherId, RelationshipType: RelationshipType.None, Favorited: 0, Ignored: 0, Muted: 0 }
|
||||
return updated ? toResponse(updated, playerId) : noneResponse(otherId)
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ export const imageRoutes = new Hono<App>({ strict: false })
|
||||
if (!(candidate instanceof File)) return c.json({ error: 'No file found in request' }, 400)
|
||||
const file = candidate
|
||||
|
||||
// `imgMeta` is a JSON blob describing the upload (the C# `SavedImageMetaDTO`),
|
||||
// `imgMeta` is a JSON blob describing the upload (`SavedImageMetaDTO`),
|
||||
// posted as a multipart field. It carries the metadata we record on the image
|
||||
// (savedImageType, roomId, accessibility, description, taggedPlayerIds, …).
|
||||
let meta: Record<string, unknown> = {}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { App } from '../context'
|
||||
import type { RelationshipFlag } from '../relationships-db'
|
||||
import type { RelationshipChange, RelationshipFlag, RelationshipResponse } from '../relationships-db'
|
||||
|
||||
/** The notifications hub is a single global DO instance (see the `notify` worker). */
|
||||
const HUB_INSTANCE = 'global'
|
||||
@@ -23,20 +23,15 @@ const HUB_INSTANCE = 'global'
|
||||
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.
|
||||
* Push a `RelationshipChanged` notification carrying `rel` to one player. Hub failures are
|
||||
* logged and swallowed — the DB write has already committed, so a hub hiccup must not fail
|
||||
* the request.
|
||||
*/
|
||||
async function applyFlag(
|
||||
async function notifyRelationship(
|
||||
c: Context<App>,
|
||||
playerId: number,
|
||||
otherId: number,
|
||||
flag: RelationshipFlag,
|
||||
value: boolean
|
||||
): Promise<Response> {
|
||||
const rel = await setRelationshipFlag(c.env.DB, playerId, otherId, flag, value)
|
||||
rel: RelationshipResponse
|
||||
): Promise<void> {
|
||||
try {
|
||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||||
playerId,
|
||||
@@ -49,6 +44,39 @@ async function applyFlag(
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify both players of a friend-graph change, each with the relationship projected from
|
||||
* their own point of view — the target of a request sees `FriendRequestReceived` where the
|
||||
* sender sees `Sent`, so the two payloads differ. A no-op mutation notifies nobody.
|
||||
*/
|
||||
async function notifyBoth(
|
||||
c: Context<App>,
|
||||
playerId: number,
|
||||
otherId: number,
|
||||
change: RelationshipChange
|
||||
): Promise<void> {
|
||||
if (!change.changed) return
|
||||
await notifyRelationship(c, playerId, change.self)
|
||||
await notifyRelationship(c, otherId, change.other)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a per-player relationship flag toggle (favorited/ignored/muted). The flags are
|
||||
* private to the caller's own side of the row, so only the caller is notified. The
|
||||
* resulting relationship rides the notification and the HTTP body is just the
|
||||
* `{ Success, Message }` ack.
|
||||
*/
|
||||
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)
|
||||
await notifyRelationship(c, playerId, rel)
|
||||
return c.json({ Success: true, Message: '' })
|
||||
}
|
||||
|
||||
@@ -92,12 +120,19 @@ export const socialRoutes = new Hono<App>({ strict: false })
|
||||
// client calls this as a GET; the mutations accept GET or POST (the Go handlers
|
||||
// matched any method). Auth-gated. Returns the resulting relationship from the
|
||||
// caller's point of view.
|
||||
//
|
||||
// The four friend-graph mutations below change state both players can see, so each
|
||||
// notifies BOTH sides with their own projection (see notifyBoth) on top of the HTTP
|
||||
// response. A no-op — re-sending an outstanding request, accepting nothing pending —
|
||||
// notifies nobody.
|
||||
.on(['GET', 'POST'], '/api/relationships/v2/sendfriendrequest', 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 c.json(await sendFriendRequest(c.env.DB, id, target))
|
||||
const change = await sendFriendRequest(c.env.DB, id, target)
|
||||
await notifyBoth(c, id, target, change)
|
||||
return c.json(change.self)
|
||||
})
|
||||
|
||||
// Accept a pending friend request from another player (`?id=`). Auth-gated.
|
||||
@@ -106,17 +141,21 @@ export const socialRoutes = new Hono<App>({ strict: false })
|
||||
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 c.json(await acceptFriendRequest(c.env.DB, id, target))
|
||||
const change = await acceptFriendRequest(c.env.DB, id, target)
|
||||
await notifyBoth(c, id, target, change)
|
||||
return c.json(change.self)
|
||||
})
|
||||
|
||||
// Remove a friend / cancel a request / decline a request (`?id=`). Auth-gated.
|
||||
// Remove a friend / cancel a request / decline a request (`?id=`). The row is kept as
|
||||
// a None relationship so the per-side flags survive (see removeFriend). Auth-gated.
|
||||
.on(['GET', 'POST'], '/api/relationships/v2/removefriend', 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)
|
||||
await removeFriend(c.env.DB, id, target)
|
||||
return c.json({ success: true })
|
||||
const change = await removeFriend(c.env.DB, id, target)
|
||||
await notifyBoth(c, id, target, change)
|
||||
return c.json(change.self)
|
||||
})
|
||||
|
||||
// Directly add another player as a friend, no pending-request step (`?id=`). Auth-gated.
|
||||
@@ -125,7 +164,9 @@ export const socialRoutes = new Hono<App>({ strict: false })
|
||||
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 c.json(await addFriend(c.env.DB, id, target))
|
||||
const change = await addFriend(c.env.DB, id, target)
|
||||
await notifyBoth(c, id, target, change)
|
||||
return c.json(change.self)
|
||||
})
|
||||
|
||||
// Ignore / mute another player, and their inverses unignore / unmute (target
|
||||
|
||||
@@ -1517,6 +1517,24 @@ describe('relationships', () => {
|
||||
// the relationship detail rides a RelationshipChanged hub notification instead.
|
||||
const ACK = { Success: true, Message: '' }
|
||||
|
||||
// The notify DO is stubbed to record every notifyPlayer call (see vitest.config).
|
||||
type Notification = {
|
||||
playerId: number
|
||||
notificationType: number
|
||||
data: { PlayerID: number; RelationshipType: number; Favorited: number; Ignored: number }
|
||||
}
|
||||
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
|
||||
|
||||
/** Drop everything the hub stub has recorded so far. */
|
||||
async function resetNotifications() {
|
||||
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||
}
|
||||
|
||||
/** Every notification pushed since the last reset, in order. */
|
||||
async function sentNotifications(): Promise<Notification[]> {
|
||||
return (await (await hub().fetch('http://do/all')).json()) as Notification[]
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -1529,9 +1547,8 @@ describe('relationships', () => {
|
||||
).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.
|
||||
// A player's own-side flags read straight from the relationship row — the flag
|
||||
// endpoints return only an ack, so the effect is verified against the row itself.
|
||||
async function ownFlags(playerId: number, otherId: number) {
|
||||
const row = (await env.DB.prepare(
|
||||
`SELECT requester_id, requester_favorited, requester_ignored, requester_muted,
|
||||
@@ -1597,10 +1614,27 @@ describe('relationships', () => {
|
||||
{ PlayerID: 500, RelationshipType: 3, Favorited: 0, Ignored: 0, Muted: 0 },
|
||||
])
|
||||
|
||||
// 500 removes → neither side has a relationship.
|
||||
// 500 removes → both sides drop to None. The row is kept (that's where the
|
||||
// per-side flags live), so v2/get still reports the pair, now as None (0).
|
||||
expect((await mutate('/api/relationships/v2/removefriend', '500', 501)).status).toBe(200)
|
||||
expect(await relationships('500')).toEqual([])
|
||||
expect(await relationships('501')).toEqual([])
|
||||
expect(await relationships('500')).toEqual([
|
||||
{ PlayerID: 501, RelationshipType: 0, Favorited: 0, Ignored: 0, Muted: 0 },
|
||||
])
|
||||
expect(await relationships('501')).toEqual([
|
||||
{ PlayerID: 500, RelationshipType: 0, Favorited: 0, Ignored: 0, Muted: 0 },
|
||||
])
|
||||
})
|
||||
|
||||
test('removefriend keeps the caller’s ignore flag', async () => {
|
||||
// 760 befriends 761 then ignores them; dropping the friendship must not
|
||||
// un-ignore them (the flag lives on the row the removal downgrades to None).
|
||||
await mutate('/api/relationships/v2/addfriend', '760', 761)
|
||||
await ackFlag('/api/relationships/v1/ignore', '760', 761)
|
||||
await mutate('/api/relationships/v2/removefriend', '760', 761)
|
||||
expect(await ownFlags(760, 761)).toMatchObject({ Ignored: 1 })
|
||||
expect(await relationships('760')).toEqual([
|
||||
{ PlayerID: 761, RelationshipType: 0, Favorited: 0, Ignored: 1, Muted: 0 },
|
||||
])
|
||||
})
|
||||
|
||||
test('addfriend makes them friends directly', async () => {
|
||||
@@ -1689,8 +1723,10 @@ describe('relationships', () => {
|
||||
// Mirrors ignore/mute: a bare None row is created with the caller's side flagged.
|
||||
expect(await (await mutate('/api/relationships/v1/favorite', '730', 731)).json()).toEqual(ACK)
|
||||
expect(await ownFlags(730, 731)).toMatchObject({ Favorited: 1 })
|
||||
// A None row is not reported as a relationship by v2/get.
|
||||
expect(await relationships('730')).toEqual([])
|
||||
// The bare None row is reported by v2/get — it carries the flag.
|
||||
expect(await relationships('730')).toEqual([
|
||||
{ PlayerID: 731, RelationshipType: 0, Favorited: 1, Ignored: 0, Muted: 0 },
|
||||
])
|
||||
})
|
||||
|
||||
test('a self-targeted favorite is rejected', async () => {
|
||||
@@ -1711,4 +1747,81 @@ describe('relationships', () => {
|
||||
expect(last.notificationType).toBe(1) // NotificationType.RelationshipChanged
|
||||
expect(last.data).toMatchObject({ PlayerID: 751, Favorited: 1, RelationshipType: 0 })
|
||||
})
|
||||
|
||||
test('sendfriendrequest notifies both players with their own projection', async () => {
|
||||
await resetNotifications()
|
||||
await mutate('/api/relationships/v2/sendfriendrequest', '770', 771)
|
||||
|
||||
// Both sides hear about it, each seeing the other player and their own side's
|
||||
// type: the sender Sent (1), the recipient Received (2).
|
||||
expect(await sentNotifications()).toEqual([
|
||||
{
|
||||
playerId: 770,
|
||||
notificationType: 1,
|
||||
data: { PlayerID: 771, RelationshipType: 1, Favorited: 0, Ignored: 0, Muted: 0 },
|
||||
},
|
||||
{
|
||||
playerId: 771,
|
||||
notificationType: 1,
|
||||
data: { PlayerID: 770, RelationshipType: 2, Favorited: 0, Ignored: 0, Muted: 0 },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('accepting notifies both players as Friend', async () => {
|
||||
await mutate('/api/relationships/v2/sendfriendrequest', '780', 781)
|
||||
await resetNotifications()
|
||||
await mutate('/api/relationships/v2/acceptfriendrequest', '781', 780)
|
||||
|
||||
const sent = await sentNotifications()
|
||||
expect(sent).toHaveLength(2)
|
||||
// Friend (3) is symmetric, so both sides see the same type, each pointing at the other.
|
||||
expect(sent).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
playerId: 780,
|
||||
data: expect.objectContaining({ PlayerID: 781, RelationshipType: 3 }),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
playerId: 781,
|
||||
data: expect.objectContaining({ PlayerID: 780, RelationshipType: 3 }),
|
||||
}),
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
test('removefriend notifies both players with None', async () => {
|
||||
await mutate('/api/relationships/v2/addfriend', '790', 791)
|
||||
await resetNotifications()
|
||||
await mutate('/api/relationships/v2/removefriend', '790', 791)
|
||||
|
||||
const sent = await sentNotifications()
|
||||
expect(sent).toHaveLength(2)
|
||||
expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([790, 791])
|
||||
for (const n of sent) expect(n.data.RelationshipType).toBe(0)
|
||||
})
|
||||
|
||||
test('a no-op friend request notifies nobody', async () => {
|
||||
await mutate('/api/relationships/v2/sendfriendrequest', '810', 811)
|
||||
await resetNotifications()
|
||||
|
||||
// Re-sending an already-outstanding request writes nothing, so nothing is pushed.
|
||||
await mutate('/api/relationships/v2/sendfriendrequest', '810', 811)
|
||||
expect(await sentNotifications()).toEqual([])
|
||||
|
||||
// Likewise accepting something that isn't pending (810 has no request to accept).
|
||||
await mutate('/api/relationships/v2/acceptfriendrequest', '810', 811)
|
||||
expect(await sentNotifications()).toEqual([])
|
||||
})
|
||||
|
||||
test('crossing requests notify both players as Friend', async () => {
|
||||
await mutate('/api/relationships/v2/sendfriendrequest', '820', 821)
|
||||
await resetNotifications()
|
||||
// 821's request crosses 820's → an immediate friendship, both sides told.
|
||||
await mutate('/api/relationships/v2/sendfriendrequest', '821', 820)
|
||||
|
||||
const sent = await sentNotifications()
|
||||
expect(sent).toHaveLength(2)
|
||||
for (const n of sent) expect(n.data.RelationshipType).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,17 +21,27 @@ export default defineConfig({
|
||||
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.
|
||||
// notifyPlayer records every call so tests can assert the notifications the
|
||||
// worker pushed (type + payload). GET the DO for the most recent one,
|
||||
// GET /all for the whole list (friend-graph changes notify both players),
|
||||
// DELETE to reset it between assertions.
|
||||
script: `
|
||||
import { DurableObject } from 'cloudflare:workers'
|
||||
export class NotificationsHub extends DurableObject {
|
||||
sent = []
|
||||
async notifyPlayer(playerId, notificationType, data) {
|
||||
this.last = { playerId, notificationType, data }
|
||||
this.sent.push({ playerId, notificationType, data })
|
||||
return { delivered: 0, queued: true }
|
||||
}
|
||||
async broadcast() { return { delivered: 0 } }
|
||||
async fetch() { return Response.json(this.last ?? null) }
|
||||
async fetch(request) {
|
||||
if (request.method === 'DELETE') {
|
||||
this.sent = []
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
if (new URL(request.url).pathname === '/all') return Response.json(this.sent)
|
||||
return Response.json(this.sent.at(-1) ?? null)
|
||||
}
|
||||
}
|
||||
export default { fetch() { return new Response('ok') } }
|
||||
`,
|
||||
|
||||
Reference in New Issue
Block a user