misc rooms endpoints, friends

This commit is contained in:
Devin Zuczek
2026-07-08 13:22:06 -04:00
parent 390843c679
commit a1d9fc17f0
22 changed files with 823 additions and 123 deletions
+85
View File
@@ -153,6 +153,91 @@ export async function getImagesByPlayer(
.slice(skip, skip + take)
}
/** Default number of recent images the slideshow feed returns. */
export const SLIDESHOW_LIMIT = 130
/** The slideshow projection of an image — creator username + room name joined in. */
export interface SlideshowImage {
SavedImageId: number
ImageName: string
Username: string
RoomName: string | null
RoomId: number | null
SavedImageType: number
PlayerEventId: number | null
Accessibility: number
PlayerIds: number[]
}
/** Build the `?1,?2,…` placeholder list for an `IN (…)` clause. */
const placeholders = (n: number): string =>
Array.from({ length: n }, (_, i) => `?${i + 1}`).join(',')
/** Map account ids → username, resolved from the shared accounts table. */
async function getUsernames(db: D1Database, ids: number[]): Promise<Map<number, string>> {
if (ids.length === 0) return new Map()
const { results } = await db
.prepare(
`SELECT account_id AS id, json_extract(data, '$.username') AS username
FROM accounts WHERE account_id IN (${placeholders(ids.length)})`
)
.bind(...ids)
.all<{ id: number; username: string }>()
return new Map(results.map((r) => [r.id, r.username]))
}
/** Map room ids → room name, resolved from the shared rooms table. */
async function getRoomNames(db: D1Database, ids: number[]): Promise<Map<number, string>> {
if (ids.length === 0) return new Map()
const { results } = await db
.prepare(
`SELECT room_id AS id, json_extract(data, '$.Name') AS name
FROM room WHERE room_id IN (${placeholders(ids.length)})`
)
.bind(...ids)
.all<{ id: number; name: string }>()
return new Map(results.map((r) => [r.id, r.name]))
}
/**
* The global slideshow feed — the most recent publicly-listable images across all
* rooms (Accessibility 0 or 1), newest first, capped at `limit`. Each row is joined
* to its creator's username and (if any) its room's name. Returns the projected
* SlideshowImage shape. Usernames/room names are resolved in two batched lookups to
* avoid an N+1 across the (at most `limit`) images.
*/
export async function getSlideshowImages(
db: D1Database,
limit = SLIDESHOW_LIMIT
): Promise<SlideshowImage[]> {
const { results } = await db
.prepare(
`SELECT data FROM image
WHERE json_extract(data, '$.Accessibility') IN (0, 1)
ORDER BY id DESC LIMIT ?1`
)
.bind(limit)
.all<ImageRow>()
const images = results.map((r) => JSON.parse(r.data) as SavedImage)
const roomIds = [...new Set(images.map((i) => i.RoomId).filter((v): v is number => v != null))]
const usernames = await getUsernames(db, [...new Set(images.map((i) => i.PlayerId))])
const roomNames = await getRoomNames(db, roomIds)
return images.map((img) => ({
SavedImageId: img.Id,
ImageName: img.ImageName,
// Fall back to the synthesized "Player<id>" name for accounts not in the table.
Username: usernames.get(img.PlayerId) ?? `Player${img.PlayerId}`,
RoomName: img.RoomId != null ? (roomNames.get(img.RoomId) ?? null) : null,
RoomId: img.RoomId,
SavedImageType: img.Type,
PlayerEventId: img.PlayerEventId,
Accessibility: img.Accessibility,
PlayerIds: img.TaggedPlayerIds,
}))
}
/**
* A player's photo feed — the public images they took plus the ones they're
* tagged in (TaggedPlayerIds). Newest first, paginated via skip/take; returns a
+265
View File
@@ -0,0 +1,265 @@
/**
* Friendship / relationship storage on the shared `recflare` D1 database.
*
* Unlike the JSON-blob tables in this database (rooms/accounts/image), a
* relationship is genuinely columnar, so it gets a normal relational table
* (mirroring the Go/GORM `Relationship` model). Exactly ONE row exists per
* unordered pair of players: the player who initiated is the `requester`, the
* other is the `target`. `relationship_type` is stored from the requester's
* point of view; when we project the row for the *target* we flip
* Sent↔Received (Friend/None are symmetric).
*
* The `api` worker owns this schema/migration (migrations/0001_relationship.sql,
* applied under its own `migrations_table` so it doesn't clash with the other
* workers' migrations that share the database).
*/
/** Relationship state from the perspective of the player asking (mirror of the C# enum). */
export enum RelationshipType {
None = 0,
FriendRequestSent = 1,
FriendRequestReceived = 2,
Friend = 3,
}
/** Schema DDL (mirror of migrations/0001_relationship.sql, sans seed rows). */
export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS relationship (
id INTEGER PRIMARY KEY AUTOINCREMENT,
requester_id INTEGER NOT NULL,
target_id INTEGER NOT NULL,
relationship_type INTEGER NOT NULL DEFAULT 0,
requester_favorited INTEGER NOT NULL DEFAULT 0,
requester_ignored INTEGER NOT NULL DEFAULT 0,
requester_muted INTEGER NOT NULL DEFAULT 0,
target_favorited INTEGER NOT NULL DEFAULT 0,
target_ignored INTEGER NOT NULL DEFAULT 0,
target_muted INTEGER NOT NULL DEFAULT 0
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_relationship ON relationship (requester_id, target_id)`,
`CREATE INDEX IF NOT EXISTS idx_relationship_target ON relationship (target_id)`,
]
/** A stored relationship row (snake_case columns, one row per player pair). */
interface RelationshipRow {
requester_id: number
target_id: number
relationship_type: number
requester_favorited: number
requester_ignored: number
requester_muted: number
target_favorited: number
target_ignored: number
target_muted: number
}
/** The per-player relationship projection returned to the client (the C# RelationshipResponse). */
export interface RelationshipResponse {
Favorited: number
Ignored: number
Muted: number
PlayerID: number
RelationshipType: RelationshipType
}
/** 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
if (type === RelationshipType.FriendRequestReceived) return RelationshipType.FriendRequestSent
return type as RelationshipType
}
/**
* Project a stored row into the RelationshipResponse for `playerId` (who must be
* one of the pair). `PlayerID` is the *other* player; the type and the
* favorited/ignored/muted flags are taken from `playerId`'s side of the row.
*/
function toResponse(row: RelationshipRow, playerId: number): RelationshipResponse {
const isRequester = row.requester_id === playerId
return {
PlayerID: isRequester ? row.target_id : row.requester_id,
RelationshipType: isRequester ? (row.relationship_type as RelationshipType) : flipType(row.relationship_type),
Favorited: isRequester ? row.requester_favorited : row.target_favorited,
Ignored: isRequester ? row.requester_ignored : row.target_ignored,
Muted: isRequester ? row.requester_muted : row.target_muted,
}
}
/** 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
.prepare(
`SELECT * FROM relationship
WHERE (requester_id = ?1 AND target_id = ?2) OR (requester_id = ?2 AND target_id = ?1)`
)
.bind(a, b)
.first<RelationshipRow>()
}
/**
* 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).
*/
export async function getRelationshipsForPlayer(
db: D1Database,
playerId: number
): Promise<RelationshipResponse[]> {
const { results } = await db
.prepare(
`SELECT * FROM relationship
WHERE requester_id = ?1 OR target_id = ?1`
)
.bind(playerId)
.all<RelationshipRow>()
return results
.filter((row) => row.relationship_type !== RelationshipType.None)
.map((row) => toResponse(row, playerId))
}
/**
* Persist `type` for the pair, with `requesterId` recorded as the row's
* 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.
*/
async function upsertPair(
db: D1Database,
requesterId: number,
targetId: number,
type: RelationshipType
): Promise<RelationshipResponse> {
const existing = await findPair(db, requesterId, targetId)
if (!existing) {
await db
.prepare(
`INSERT INTO relationship (requester_id, target_id, relationship_type)
VALUES (?1, ?2, ?3)`
)
.bind(requesterId, targetId, type)
.run()
return { PlayerID: targetId, RelationshipType: type, Favorited: 0, Ignored: 0, Muted: 0 }
}
// Keep each player's flags with that player as the row is normalized to
// requester = requesterId.
const reqIsRequester = existing.requester_id === requesterId
const reqFlags = {
favorited: reqIsRequester ? existing.requester_favorited : existing.target_favorited,
ignored: reqIsRequester ? existing.requester_ignored : existing.target_ignored,
muted: reqIsRequester ? existing.requester_muted : existing.target_muted,
}
const tgtFlags = {
favorited: reqIsRequester ? existing.target_favorited : existing.requester_favorited,
ignored: reqIsRequester ? existing.target_ignored : existing.requester_ignored,
muted: reqIsRequester ? existing.target_muted : existing.requester_muted,
}
await db
.prepare(
`UPDATE relationship
SET requester_id = ?1, target_id = ?2, relationship_type = ?3,
requester_favorited = ?4, requester_ignored = ?5, requester_muted = ?6,
target_favorited = ?7, target_ignored = ?8, target_muted = ?9
WHERE (requester_id = ?1 AND target_id = ?2) OR (requester_id = ?2 AND target_id = ?1)`
)
.bind(
requesterId,
targetId,
type,
reqFlags.favorited,
reqFlags.ignored,
reqFlags.muted,
tgtFlags.favorited,
tgtFlags.ignored,
tgtFlags.muted
)
.run()
return {
PlayerID: targetId,
RelationshipType: type,
Favorited: reqFlags.favorited,
Ignored: reqFlags.ignored,
Muted: reqFlags.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.
*/
export async function sendFriendRequest(
db: D1Database,
requesterId: number,
targetId: number
): Promise<RelationshipResponse> {
const existing = await findPair(db, requesterId, targetId)
if (existing) {
if (existing.relationship_type === RelationshipType.Friend) {
return toResponse(existing, requesterId)
}
// 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)
}
}
return upsertPair(db, requesterId, targetId, RelationshipType.FriendRequestSent)
}
/**
* `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.
*/
export async function acceptFriendRequest(
db: D1Database,
accepterId: number,
otherId: number
): Promise<RelationshipResponse> {
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)
}
return existing
? toResponse(existing, accepterId)
: { PlayerID: otherId, RelationshipType: RelationshipType.None, Favorited: 0, Ignored: 0, Muted: 0 }
}
/**
* 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)
}
/**
* Remove any relationship between the two players (unfriend / cancel request /
* decline). Deletes the row entirely so neither side reports a relationship.
*/
export async function removeFriend(db: D1Database, a: number, b: number): Promise<void> {
await db
.prepare(
`DELETE FROM relationship
WHERE (requester_id = ?1 AND target_id = ?2) OR (requester_id = ?2 AND target_id = ?1)`
)
.bind(a, b)
.run()
}
+1 -1
View File
@@ -17,6 +17,6 @@ const parseOne = (row: RoomRow | null): Room | null => (row ? (JSON.parse(row.da
export async function getRoomById(db: D1Database, roomId: number): Promise<Room | null> {
return parseOne(
await db.prepare('SELECT data FROM rooms WHERE room_id = ?1').bind(roomId).first<RoomRow>()
await db.prepare('SELECT data FROM room WHERE room_id = ?1').bind(roomId).first<RoomRow>()
)
}
+2 -2
View File
@@ -26,13 +26,13 @@ export const configRoutes = new Hono<App>({ strict: false })
c.json({
ReportBudget: 125,
FilterType: 0,
SampleRate: 0.025,
SampleRate: 1,
LogLineCount: 50,
CaptureNativeCrashes: 1,
AMRThresholdMS: 0,
MessageCount: 1000,
MessageRegex:
"^Cannot set the parent of the GameObject .* while its new parent|^\\\\>\\\\x2010x\\\\:\\\\x20|\\\\'LabelTheme\\\\' contains missing PaletteTheme reference on",
"^.*$",
VersionRegex: '.*',
})
)
+13
View File
@@ -6,6 +6,7 @@ import {
getImagesByPlayer,
getImagesByRoom,
getPlayerFeed,
getSlideshowImages,
} from '../images-db'
import { authedId, unauthorized } from '../http'
@@ -133,6 +134,18 @@ export const imageRoutes = new Hono<App>({ strict: false })
return c.json(await getPlayerFeed(c.env.DB, playerId, skip, take))
})
// Global slideshow feed — the most recent publicly-listable images (Accessibility
// 0 or 1) across all rooms, newest first, each joined to its creator's username
// and room name. Auth-gated. Returns `{ Images, ValidTill }`, where ValidTill is a
// short (2-minute) cache hint the client refreshes against.
.get('/api/images/v1/slideshow', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const Images = await getSlideshowImages(c.env.DB)
const ValidTill = new Date(Date.now() + 2 * 60 * 1000).toISOString()
return c.json({ Images, ValidTill })
})
// Image metadata by filename. Returns the stored SavedImage record, or 404 when
// there's no metadata row for that name.
.get('/api/images/v6', async (c) => {
+84 -1
View File
@@ -1,9 +1,92 @@
import { Hono } from 'hono'
import {
acceptFriendRequest,
addFriend,
getRelationshipsForPlayer,
removeFriend,
sendFriendRequest,
} from '../relationships-db'
import { authedId, unauthorized } from '../http'
import type { Context } from 'hono'
import type { App } from '../context'
/**
* Read the other player's id from a relationship-mutation request. The exact wire
* shape is still TBD, so this is liberal: it accepts `playerId`/`id` as a query
* param and `PlayerId`/`playerId`/`Id` from a JSON or form body. Returns null when
* no integer id is present.
*/
async function targetPlayerId(c: Context<App>): Promise<number | null> {
const fromQuery = c.req.query('playerId') ?? c.req.query('id')
if (fromQuery !== undefined) {
const n = Number.parseInt(fromQuery, 10)
if (!Number.isNaN(n)) return n
}
// Body may be JSON or form-encoded; Hono's parseBody only handles the latter.
const contentType = c.req.header('content-type') ?? ''
const body = contentType.includes('application/json')
? await c.req.json<Record<string, unknown>>().catch(() => ({}) as Record<string, unknown>)
: ((await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>)
const raw = body.PlayerId ?? body.playerId ?? body.Id
if (typeof raw === 'number') return Number.isNaN(raw) ? null : raw
if (typeof raw === 'string') {
const n = Number.parseInt(raw, 10)
if (!Number.isNaN(n)) return n
}
return null
}
// ---- Social ----------------------------------------------------------------
export const socialRoutes = new Hono<App>({ strict: false })
.get('/api/relationships/v2/get', (c) => c.json([]))
// The authed player's relationships, projected from their point of view — a bare
// array of RelationshipResponse. Auth-gated.
.get('/api/relationships/v2/get', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json(await getRelationshipsForPlayer(c.env.DB, id))
})
// Send a friend request to another player (the target arrives as `?id=`). The
// 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.
.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))
})
// Accept a pending friend request from another player (`?id=`). Auth-gated.
.on(['GET', 'POST'], '/api/relationships/v2/acceptfriendrequest', 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 acceptFriendRequest(c.env.DB, id, target))
})
// Remove a friend / cancel a request / decline a request (`?id=`). 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 })
})
// Directly add another player as a friend, no pending-request step (`?id=`). Auth-gated.
.on(['GET', 'POST'], '/api/relationships/v2/addfriend', 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 addFriend(c.env.DB, id, target))
})
.get('/api/messages/v2/get', (c) => c.json([]))
.get('/api/messages/v1/favoriteFriendOnlineStatus', (c) => c.json([]))
+134 -4
View File
@@ -5,6 +5,7 @@ import { beforeAll, describe, expect, test } from 'vitest'
import '../../api.app'
import { SCHEMA_DDL as IMAGES_SCHEMA_DDL } from '../../images-db'
import { SCHEMA_DDL as RELATIONSHIPS_SCHEMA_DDL } from '../../relationships-db'
import type { Env } from '../../context'
import type { SavedImage } from '../../images-db'
@@ -40,14 +41,14 @@ 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')
await env.DB.prepare(
`CREATE TABLE IF NOT EXISTS rooms (
`CREATE TABLE IF NOT EXISTS room (
data TEXT NOT NULL,
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL,
name_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.Name'))) VIRTUAL,
creator_account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorAccountId')) VIRTUAL
)`
).run()
const insert = env.DB.prepare('INSERT OR IGNORE INTO rooms (data) VALUES (?1)')
const insert = env.DB.prepare('INSERT OR IGNORE INTO room (data) VALUES (?1)')
await env.DB.batch(TEST_ROOMS.map((r) => insert.bind(JSON.stringify(r))))
// Accounts table (matching the auth worker's migration) — uploadsaved records
@@ -68,6 +69,9 @@ beforeAll(async () => {
// Images table (owned by the img worker) — uploadsaved records a row here.
for (const stmt of IMAGES_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Relationships table (owned by the api worker) — friendship endpoints use it.
for (const stmt of RELATIONSHIPS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
})
// Mint a token the way the `auth` worker does, signing with the shared test key seeded into the JWT_SECRET store, so the
@@ -131,8 +135,11 @@ describe('public endpoints', () => {
expect(await res.json()).toMatchObject({ VersionStatus: 0 })
})
test('GET /api/relationships/v2/get returns empty array', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/relationships/v2/get`)
test('GET /api/relationships/v2/get returns empty array for a player with none', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/relationships/v2/get`, {
headers: await bearer('99999'),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
@@ -335,6 +342,53 @@ describe('images', () => {
expect(meta.CheerCount).toBe(0)
})
test('GET /api/images/v1/slideshow is auth-gated and joins username + room name', async () => {
// No token → 401.
expect((await exports.default.fetch(`${ORIGIN}/api/images/v1/slideshow`)).status).toBe(401)
// Seed a public image (Accessibility 1) taken in RecCenter (room 2) by account 42.
await env.DB.prepare('INSERT INTO image (data) VALUES (?1)')
.bind(
JSON.stringify({
Id: 9001,
Type: 1,
Accessibility: 1,
AccessibilityLocked: false,
ImageName: 'slide9001.jpg',
Description: null,
PlayerId: 42,
TaggedPlayerIds: [7, 8],
RoomId: 2,
PlayerEventId: null,
CreatedAt: new Date().toISOString(),
CheerCount: 0,
CommentCount: 0,
})
)
.run()
const res = await exports.default.fetch(`${ORIGIN}/api/images/v1/slideshow`, {
headers: await bearer(),
})
expect(res.status).toBe(200)
const body = (await res.json()) as {
Images: Array<Record<string, unknown>>
ValidTill: string
}
expect(body.ValidTill).toMatch(/Z$/)
const slide = body.Images.find((i) => i.SavedImageId === 9001)
expect(slide).toMatchObject({
SavedImageId: 9001,
ImageName: 'slide9001.jpg',
Username: 'Tester', // account 42 seeded above
RoomName: 'RecCenter', // room 2
RoomId: 2,
SavedImageType: 1,
Accessibility: 1,
PlayerIds: [7, 8],
})
})
test('POST /api/images/v1/cheer is auth-gated and stubs success', async () => {
const body = JSON.stringify({ SavedImageId: 2, Cheer: true })
// No token → 401.
@@ -564,3 +618,79 @@ describe('images', () => {
).toEqual([])
})
})
describe('relationships', () => {
// RelationshipType: 0 None, 1 FriendRequestSent, 2 FriendRequestReceived, 3 Friend.
type Rel = { PlayerID: number; RelationshipType: number; Favorited: number }
// Call a relationship mutation as `sub`, targeting `playerId` — the real client
// shape: a GET with the target in `?id=`.
async function mutate(path: string, sub: string, playerId: number) {
return exports.default.fetch(`${ORIGIN}${path}?id=${playerId}`, {
headers: await bearer(sub),
})
}
// Fetch `sub`'s relationships, projected from their point of view.
async function relationships(sub: string): Promise<Rel[]> {
const res = await exports.default.fetch(`${ORIGIN}/api/relationships/v2/get`, {
headers: await bearer(sub),
})
return (await res.json()) as Rel[]
}
test('GET /api/relationships/v2/get is auth-gated', async () => {
expect((await exports.default.fetch(`${ORIGIN}/api/relationships/v2/get`)).status).toBe(401)
})
test('mutations are auth-gated', async () => {
for (const path of [
'/api/relationships/v2/sendfriendrequest',
'/api/relationships/v2/acceptfriendrequest',
'/api/relationships/v2/removefriend',
'/api/relationships/v2/addfriend',
]) {
const res = await exports.default.fetch(`${ORIGIN}${path}?id=1`)
expect(res.status).toBe(401)
}
})
test('send → the two sides see Sent / Received; accept → both Friend; remove → gone', async () => {
// 500 sends 501 a request.
const sent = (await (await mutate('/api/relationships/v2/sendfriendrequest', '500', 501)).json()) as Rel
expect(sent).toMatchObject({ PlayerID: 501, RelationshipType: 1 })
// 500 sees it as Sent (1); 501 sees the mirror as Received (2).
expect(await relationships('500')).toEqual([{ PlayerID: 501, RelationshipType: 1, Favorited: 0, Ignored: 0, Muted: 0 }])
expect(await relationships('501')).toEqual([{ PlayerID: 500, RelationshipType: 2, Favorited: 0, Ignored: 0, Muted: 0 }])
// 501 accepts → both are Friends (3).
const accepted = (await (await mutate('/api/relationships/v2/acceptfriendrequest', '501', 500)).json()) as Rel
expect(accepted).toMatchObject({ PlayerID: 500, RelationshipType: 3 })
expect(await relationships('500')).toEqual([{ PlayerID: 501, RelationshipType: 3, Favorited: 0, Ignored: 0, Muted: 0 }])
expect(await relationships('501')).toEqual([{ PlayerID: 500, RelationshipType: 3, Favorited: 0, Ignored: 0, Muted: 0 }])
// 500 removes → neither side has a relationship.
expect((await mutate('/api/relationships/v2/removefriend', '500', 501)).status).toBe(200)
expect(await relationships('500')).toEqual([])
expect(await relationships('501')).toEqual([])
})
test('addfriend makes them friends directly', async () => {
const res = (await (await mutate('/api/relationships/v2/addfriend', '510', 511)).json()) as Rel
expect(res).toMatchObject({ PlayerID: 511, RelationshipType: 3 })
expect(await relationships('511')).toEqual([{ PlayerID: 510, RelationshipType: 3, Favorited: 0, Ignored: 0, Muted: 0 }])
})
test('crossing friend requests become a friendship', async () => {
await mutate('/api/relationships/v2/sendfriendrequest', '520', 521)
// 521 sends back to 520 → the crossing requests resolve to Friend for both.
const crossed = (await (await mutate('/api/relationships/v2/sendfriendrequest', '521', 520)).json()) as Rel
expect(crossed).toMatchObject({ PlayerID: 520, RelationshipType: 3 })
expect(await relationships('520')).toEqual([{ PlayerID: 521, RelationshipType: 3, Favorited: 0, Ignored: 0, Muted: 0 }])
})
test('a self-targeted request is rejected', async () => {
expect((await mutate('/api/relationships/v2/sendfriendrequest', '530', 530)).status).toBe(400)
})
})