mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 07:01:27 -07:00
inventions
This commit is contained in:
@@ -0,0 +1,18 @@
|
|||||||
|
-- Saved-invention metadata storage. Like the image/rooms/accounts tables in this
|
||||||
|
-- shared database, an invention is a single JSON blob in the `data` column, with
|
||||||
|
-- queryable fields (Id, CreatorPlayerId) exposed as SQLite generated (virtual)
|
||||||
|
-- columns extracted from that JSON. Owned by the `api` worker; generated from
|
||||||
|
-- src/inventions-db.ts (SCHEMA_DDL) — keep in sync.
|
||||||
|
--
|
||||||
|
-- The invention's data file is uploaded separately through the `storage` worker
|
||||||
|
-- (under the `invention/` prefix) and referenced here by `CurrentVersion.BlobName`;
|
||||||
|
-- only the metadata lives in this table. The DTO mirrors Rec Room's PascalCase
|
||||||
|
-- `RRInvention` shape.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS invention (
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.InventionId')) VIRTUAL,
|
||||||
|
creator_player_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorPlayerId')) VIRTUAL
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_invention_id ON invention (id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invention_creator ON invention (creator_player_id);
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
/**
|
||||||
|
* Saved-invention storage on the shared `recflare` D1 database. Each invention is
|
||||||
|
* a single JSON blob in the `data` column; queryable fields (Id, CreatorPlayerId)
|
||||||
|
* are SQLite generated (virtual) columns extracted from that JSON — the same
|
||||||
|
* JSON-blob pattern the image/rooms/accounts tables use.
|
||||||
|
*
|
||||||
|
* The `api` worker owns this schema/migration (migrations/0002_invention.sql,
|
||||||
|
* applied under its own `migrations_table`). The invention's data file itself is
|
||||||
|
* uploaded separately through the `storage` worker (under the `invention/` prefix)
|
||||||
|
* and referenced here by `CurrentVersion.BlobName`; only the metadata lives here.
|
||||||
|
*
|
||||||
|
* The stored/returned DTO mirrors Rec Room's `RRInvention` (PascalCase), including
|
||||||
|
* the nested `CurrentVersion` that carries the blob name and per-version costs —
|
||||||
|
* shaped after a real `GET /api/inventions/v1?inventionId=…` response.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Schema DDL (mirror of migrations/0002_invention.sql, sans any seed rows). */
|
||||||
|
export const SCHEMA_DDL: string[] = [
|
||||||
|
`CREATE TABLE IF NOT EXISTS invention (
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.InventionId')) VIRTUAL,
|
||||||
|
creator_player_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorPlayerId')) VIRTUAL
|
||||||
|
)`,
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_invention_id ON invention (id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_invention_creator ON invention (creator_player_id)`,
|
||||||
|
]
|
||||||
|
|
||||||
|
/** A single saved version of an invention (Rec Room's `RRInventionVersion`). */
|
||||||
|
export interface InventionVersion {
|
||||||
|
InventionId: number
|
||||||
|
ReplicationId: string
|
||||||
|
VersionNumber: number
|
||||||
|
BlobName: string
|
||||||
|
BlobHash: string | null
|
||||||
|
InstantiationCost: number
|
||||||
|
LightsCost: number
|
||||||
|
ChipsCost: number
|
||||||
|
CloudVariablesCost: number
|
||||||
|
AICost: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A stored invention record (Rec Room's `RRInvention`; returned by save / mine). */
|
||||||
|
export interface SavedInvention {
|
||||||
|
InventionId: number
|
||||||
|
ReplicationId: string
|
||||||
|
CreatorPlayerId: number
|
||||||
|
Name: string
|
||||||
|
Description: string
|
||||||
|
ImageName: string
|
||||||
|
CurrentVersionNumber: number
|
||||||
|
CurrentVersion: InventionVersion
|
||||||
|
Accessibility: number
|
||||||
|
IsPublished: boolean
|
||||||
|
IsFeatured: boolean
|
||||||
|
ModifiedAt: string
|
||||||
|
CreatedAt: string
|
||||||
|
FirstPublishedAt: string | null
|
||||||
|
CreationRoomId: number
|
||||||
|
NumPlayersHaveUsedInRoom: number
|
||||||
|
NumDownloads: number
|
||||||
|
CheerCount: number
|
||||||
|
CreatorPermission: number
|
||||||
|
GeneralPermission: number
|
||||||
|
IsAGInvention: boolean
|
||||||
|
IsCertifiedInvention: boolean
|
||||||
|
Price: number
|
||||||
|
AllowTrial: boolean
|
||||||
|
HideFromPlayer: boolean
|
||||||
|
ReferencedInventions: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InventionRow {
|
||||||
|
data: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fields the client supplies on save (camelCase); everything else is defaulted here. */
|
||||||
|
export interface NewInvention {
|
||||||
|
creatorPlayerId: number
|
||||||
|
name: string
|
||||||
|
description?: string | null
|
||||||
|
imageName?: string | null
|
||||||
|
instantiationCost?: number
|
||||||
|
lightsCost?: number
|
||||||
|
chipsCost?: number
|
||||||
|
cloudVariablesCost?: number
|
||||||
|
aiCost?: number
|
||||||
|
creationRoomId?: number | null
|
||||||
|
inventionDataFilename?: string | null
|
||||||
|
referencedInventions?: number[]
|
||||||
|
creatorAccountRole?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert a new invention record, returning the stored row. A freshly saved
|
||||||
|
* invention is private/unpublished — it shows up only in the creator's own list
|
||||||
|
* until they publish it, so Accessibility/IsPublished/FirstPublishedAt reflect that.
|
||||||
|
*/
|
||||||
|
export async function createInvention(
|
||||||
|
db: D1Database,
|
||||||
|
input: NewInvention
|
||||||
|
): Promise<SavedInvention> {
|
||||||
|
// Sequential id: one past the current max (the table starts empty).
|
||||||
|
const row = await db
|
||||||
|
.prepare('SELECT COALESCE(MAX(id), 0) + 1 AS next FROM invention')
|
||||||
|
.first<{ next: number }>()
|
||||||
|
const inventionId = row?.next ?? 1
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const invention: SavedInvention = {
|
||||||
|
InventionId: inventionId,
|
||||||
|
ReplicationId: crypto.randomUUID(),
|
||||||
|
CreatorPlayerId: input.creatorPlayerId,
|
||||||
|
Name: input.name,
|
||||||
|
Description: input.description ?? '',
|
||||||
|
ImageName: input.imageName ?? '',
|
||||||
|
CurrentVersionNumber: 1,
|
||||||
|
CurrentVersion: {
|
||||||
|
InventionId: inventionId,
|
||||||
|
ReplicationId: crypto.randomUUID(),
|
||||||
|
VersionNumber: 1,
|
||||||
|
BlobName: input.inventionDataFilename ?? '',
|
||||||
|
BlobHash: null,
|
||||||
|
InstantiationCost: input.instantiationCost ?? 0,
|
||||||
|
LightsCost: input.lightsCost ?? 0,
|
||||||
|
ChipsCost: input.chipsCost ?? 0,
|
||||||
|
CloudVariablesCost: input.cloudVariablesCost ?? 0,
|
||||||
|
AICost: input.aiCost ?? 0,
|
||||||
|
},
|
||||||
|
Accessibility: 0,
|
||||||
|
IsPublished: false,
|
||||||
|
IsFeatured: false,
|
||||||
|
ModifiedAt: now,
|
||||||
|
CreatedAt: now,
|
||||||
|
FirstPublishedAt: null,
|
||||||
|
CreationRoomId: input.creationRoomId ?? 0,
|
||||||
|
NumPlayersHaveUsedInRoom: 0,
|
||||||
|
NumDownloads: 0,
|
||||||
|
CheerCount: 0,
|
||||||
|
CreatorPermission: input.creatorAccountRole ?? 0,
|
||||||
|
GeneralPermission: 0,
|
||||||
|
IsAGInvention: false,
|
||||||
|
IsCertifiedInvention: false,
|
||||||
|
Price: 0,
|
||||||
|
AllowTrial: false,
|
||||||
|
HideFromPlayer: false,
|
||||||
|
ReferencedInventions: input.referencedInventions ?? [],
|
||||||
|
}
|
||||||
|
await db.prepare('INSERT INTO invention (data) VALUES (?1)').bind(JSON.stringify(invention)).run()
|
||||||
|
return invention
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The inventions a player has created — their "my inventions" list, newest first.
|
||||||
|
* Uses the creator_player_id index; the per-player set is small, so ordering is
|
||||||
|
* done in memory. Returns a bare array of SavedInvention.
|
||||||
|
*/
|
||||||
|
export async function getInventionsByCreator(
|
||||||
|
db: D1Database,
|
||||||
|
creatorPlayerId: number
|
||||||
|
): Promise<SavedInvention[]> {
|
||||||
|
const { results } = await db
|
||||||
|
.prepare('SELECT data FROM invention WHERE creator_player_id = ?1')
|
||||||
|
.bind(creatorPlayerId)
|
||||||
|
.all<InventionRow>()
|
||||||
|
return results
|
||||||
|
.map((r) => JSON.parse(r.data) as SavedInvention)
|
||||||
|
.sort((a, b) => b.CreatedAt.localeCompare(a.CreatedAt) || b.InventionId - a.InventionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Look up a single invention by its numeric id, or null when there's no such row. */
|
||||||
|
export async function getInventionById(
|
||||||
|
db: D1Database,
|
||||||
|
inventionId: number
|
||||||
|
): Promise<SavedInvention | null> {
|
||||||
|
const row = await db
|
||||||
|
.prepare('SELECT data FROM invention WHERE id = ?1')
|
||||||
|
.bind(inventionId)
|
||||||
|
.first<InventionRow>()
|
||||||
|
return row ? (JSON.parse(row.data) as SavedInvention) : null
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
|
|
||||||
import { authedId, unauthorized } from '../http'
|
import { authedId, unauthorized } from '../http'
|
||||||
|
import { createInvention, getInventionById, getInventionsByCreator } from '../inventions-db'
|
||||||
|
|
||||||
import type { App } from '../context'
|
import type { App } from '../context'
|
||||||
|
|
||||||
@@ -68,5 +69,55 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
|||||||
c.json({ Results: [], TotalResults: 0 })
|
c.json({ Results: [], TotalResults: 0 })
|
||||||
)
|
)
|
||||||
|
|
||||||
// Saved inventions — empty list with no DB.
|
// A single invention by id (`?inventionId=…`). Returns the stored RRInvention,
|
||||||
.get('/api/inventions/v2/mine', (c) => c.json([]))
|
// or 404 when there's no such invention.
|
||||||
|
.get('/api/inventions/v1', async (c) => {
|
||||||
|
const inventionId = Number.parseInt(c.req.query('inventionId') ?? '', 10)
|
||||||
|
if (Number.isNaN(inventionId)) return c.json({ error: 'inventionId is required' }, 400)
|
||||||
|
const invention = await getInventionById(c.env.DB, inventionId)
|
||||||
|
return invention ? c.json(invention) : c.notFound()
|
||||||
|
})
|
||||||
|
|
||||||
|
// The signed-in player's saved inventions ("my inventions"), newest first.
|
||||||
|
// Auth-gated; returns a bare array (empty when the player has saved none).
|
||||||
|
.get('/api/inventions/v2/mine', async (c) => {
|
||||||
|
const id = await authedId(c)
|
||||||
|
if (id === null) return unauthorized(c)
|
||||||
|
return c.json(await getInventionsByCreator(c.env.DB, id))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Save an invention's metadata. The data file itself is uploaded separately
|
||||||
|
// through the `storage` worker and referenced here by `inventionDataFilename`.
|
||||||
|
// Auth-gated; returns the stored invention (with its assigned inventionId).
|
||||||
|
.post('/api/inventions/v6/save', async (c) => {
|
||||||
|
const id = await authedId(c)
|
||||||
|
if (id === null) return unauthorized(c)
|
||||||
|
|
||||||
|
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
|
||||||
|
if (body === null) return c.json({ error: 'Invalid request body' }, 400)
|
||||||
|
|
||||||
|
const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
|
||||||
|
const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined)
|
||||||
|
|
||||||
|
const name = str(body.name)
|
||||||
|
if (name === undefined) return c.json({ error: 'name is required' }, 400)
|
||||||
|
|
||||||
|
const invention = await createInvention(c.env.DB, {
|
||||||
|
creatorPlayerId: id,
|
||||||
|
name,
|
||||||
|
description: str(body.description),
|
||||||
|
imageName: str(body.imageName),
|
||||||
|
instantiationCost: num(body.instantiationCost),
|
||||||
|
lightsCost: num(body.lightsCost),
|
||||||
|
chipsCost: num(body.chipsCost),
|
||||||
|
cloudVariablesCost: num(body.cloudVariablesCost),
|
||||||
|
aiCost: num(body.aiCost),
|
||||||
|
creationRoomId: num(body.creationRoomId),
|
||||||
|
inventionDataFilename: str(body.inventionDataFilename),
|
||||||
|
referencedInventions: Array.isArray(body.referencedInventions)
|
||||||
|
? body.referencedInventions.filter((v): v is number => typeof v === 'number')
|
||||||
|
: undefined,
|
||||||
|
creatorAccountRole: num(body.creatorAccountRole),
|
||||||
|
})
|
||||||
|
return c.json(invention)
|
||||||
|
})
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import { beforeAll, describe, expect, test } from 'vitest'
|
|||||||
import '../../api.app'
|
import '../../api.app'
|
||||||
|
|
||||||
import { SCHEMA_DDL as IMAGES_SCHEMA_DDL } from '../../images-db'
|
import { SCHEMA_DDL as IMAGES_SCHEMA_DDL } from '../../images-db'
|
||||||
|
import { SCHEMA_DDL as INVENTIONS_SCHEMA_DDL } from '../../inventions-db'
|
||||||
import { SCHEMA_DDL as RELATIONSHIPS_SCHEMA_DDL } from '../../relationships-db'
|
import { SCHEMA_DDL as RELATIONSHIPS_SCHEMA_DDL } from '../../relationships-db'
|
||||||
|
|
||||||
import type { Env } from '../../context'
|
import type { Env } from '../../context'
|
||||||
import type { SavedImage } from '../../images-db'
|
import type { SavedImage } from '../../images-db'
|
||||||
|
import type { SavedInvention } from '../../inventions-db'
|
||||||
|
|
||||||
declare module 'cloudflare:test' {
|
declare module 'cloudflare:test' {
|
||||||
interface ProvidedEnv extends Env {}
|
interface ProvidedEnv extends Env {}
|
||||||
@@ -72,6 +74,9 @@ beforeAll(async () => {
|
|||||||
|
|
||||||
// Relationships table (owned by the api worker) — friendship endpoints use it.
|
// Relationships table (owned by the api worker) — friendship endpoints use it.
|
||||||
for (const stmt of RELATIONSHIPS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
for (const stmt of RELATIONSHIPS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
|
|
||||||
|
// Inventions table (owned by the api worker) — invention save/mine use it.
|
||||||
|
for (const stmt of INVENTIONS_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
|
// Mint a token the way the `auth` worker does, signing with the shared test key seeded into the JWT_SECRET store, so the
|
||||||
@@ -247,12 +252,94 @@ describe('public endpoints', () => {
|
|||||||
expect(await res.json()).toEqual({})
|
expect(await res.json()).toEqual({})
|
||||||
})
|
})
|
||||||
|
|
||||||
test('GET /api/inventions/v2/mine returns []', async () => {
|
test('GET /api/inventions/v2/mine 401s without a bearer token', async () => {
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/mine`)
|
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/mine`)
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('GET /api/inventions/v2/mine returns [] for a player with none', async () => {
|
||||||
|
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/mine`, {
|
||||||
|
headers: await bearer('7777'),
|
||||||
|
})
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
expect(await res.json()).toEqual([])
|
expect(await res.json()).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('POST /api/inventions/v6/save persists the invention and lists it in mine', async () => {
|
||||||
|
const body = {
|
||||||
|
name: '071126 13:10:50',
|
||||||
|
description: 'No description yet',
|
||||||
|
imageName: '2026-07-11/0ff3d5f9-e544-422d-84a0-dec46195a82b.jpg',
|
||||||
|
instantiationCost: 103,
|
||||||
|
lightsCost: 0,
|
||||||
|
chipsCost: 0,
|
||||||
|
cloudVariablesCost: 0,
|
||||||
|
aiCost: 0,
|
||||||
|
creationRoomId: 73,
|
||||||
|
inventionDataFilename: '2026-07-11/cc15a7fa-2e81-4da0-b8f1-2a4dcd8ae1a3',
|
||||||
|
referencedInventions: [],
|
||||||
|
creatorAccountRole: 255,
|
||||||
|
}
|
||||||
|
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...(await bearer('5150')), 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const saved = (await res.json()) as SavedInvention
|
||||||
|
expect(saved.InventionId).toBeGreaterThan(0)
|
||||||
|
expect(saved.CreatorPlayerId).toBe(5150)
|
||||||
|
expect(saved.Name).toBe(body.name)
|
||||||
|
expect(saved.Description).toBe(body.description)
|
||||||
|
expect(saved.ImageName).toBe(body.imageName)
|
||||||
|
// Costs + the data blob live on the nested CurrentVersion.
|
||||||
|
expect(saved.CurrentVersion.InstantiationCost).toBe(103)
|
||||||
|
expect(saved.CurrentVersion.BlobName).toBe(body.inventionDataFilename)
|
||||||
|
expect(saved.CreationRoomId).toBe(73)
|
||||||
|
expect(saved.CreatorPermission).toBe(255)
|
||||||
|
// Freshly saved → private/unpublished until the player publishes it.
|
||||||
|
expect(saved.IsPublished).toBe(false)
|
||||||
|
expect(saved.FirstPublishedAt).toBeNull()
|
||||||
|
expect(typeof saved.CreatedAt).toBe('string')
|
||||||
|
|
||||||
|
const mine = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/mine`, {
|
||||||
|
headers: await bearer('5150'),
|
||||||
|
})
|
||||||
|
expect(mine.status).toBe(200)
|
||||||
|
const list = (await mine.json()) as SavedInvention[]
|
||||||
|
expect(list.map((i) => i.InventionId)).toContain(saved.InventionId)
|
||||||
|
|
||||||
|
// The saved invention is fetchable by id via the v1 lookup.
|
||||||
|
const one = await exports.default.fetch(
|
||||||
|
`${ORIGIN}/api/inventions/v1?inventionId=${saved.InventionId}`
|
||||||
|
)
|
||||||
|
expect(one.status).toBe(200)
|
||||||
|
expect((await one.json()) as SavedInvention).toMatchObject({ InventionId: saved.InventionId })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('POST /api/inventions/v6/save 401s without a bearer token', async () => {
|
||||||
|
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: 'x' }),
|
||||||
|
})
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('POST /api/inventions/v6/save 400s without a name', async () => {
|
||||||
|
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...(await bearer()), 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ description: 'no name' }),
|
||||||
|
})
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('GET /api/inventions/v1 404s for an unknown invention', async () => {
|
||||||
|
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v1?inventionId=999999`)
|
||||||
|
expect(res.status).toBe(404)
|
||||||
|
})
|
||||||
|
|
||||||
test('POST /api/sanitize/v1 echoes the value; isPure reports true', async () => {
|
test('POST /api/sanitize/v1 echoes the value; isPure reports true', async () => {
|
||||||
const san = await exports.default.fetch(`${ORIGIN}/api/sanitize/v1`, {
|
const san = await exports.default.fetch(`${ORIGIN}/api/sanitize/v1`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -663,18 +750,30 @@ describe('relationships', () => {
|
|||||||
|
|
||||||
test('send → the two sides see Sent / Received; accept → both Friend; remove → gone', async () => {
|
test('send → the two sides see Sent / Received; accept → both Friend; remove → gone', async () => {
|
||||||
// 500 sends 501 a request.
|
// 500 sends 501 a request.
|
||||||
const sent = (await (await mutate('/api/relationships/v2/sendfriendrequest', '500', 501)).json()) as Rel
|
const sent = (await (
|
||||||
|
await mutate('/api/relationships/v2/sendfriendrequest', '500', 501)
|
||||||
|
).json()) as Rel
|
||||||
expect(sent).toMatchObject({ PlayerID: 501, RelationshipType: 1 })
|
expect(sent).toMatchObject({ PlayerID: 501, RelationshipType: 1 })
|
||||||
|
|
||||||
// 500 sees it as Sent (1); 501 sees the mirror as Received (2).
|
// 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('500')).toEqual([
|
||||||
expect(await relationships('501')).toEqual([{ PlayerID: 500, RelationshipType: 2, Favorited: 0, Ignored: 0, Muted: 0 }])
|
{ 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).
|
// 501 accepts → both are Friends (3).
|
||||||
const accepted = (await (await mutate('/api/relationships/v2/acceptfriendrequest', '501', 500)).json()) as Rel
|
const accepted = (await (
|
||||||
|
await mutate('/api/relationships/v2/acceptfriendrequest', '501', 500)
|
||||||
|
).json()) as Rel
|
||||||
expect(accepted).toMatchObject({ PlayerID: 500, RelationshipType: 3 })
|
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('500')).toEqual([
|
||||||
expect(await relationships('501')).toEqual([{ PlayerID: 500, RelationshipType: 3, Favorited: 0, Ignored: 0, Muted: 0 }])
|
{ 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.
|
// 500 removes → neither side has a relationship.
|
||||||
expect((await mutate('/api/relationships/v2/removefriend', '500', 501)).status).toBe(200)
|
expect((await mutate('/api/relationships/v2/removefriend', '500', 501)).status).toBe(200)
|
||||||
@@ -685,15 +784,21 @@ describe('relationships', () => {
|
|||||||
test('addfriend makes them friends directly', async () => {
|
test('addfriend makes them friends directly', async () => {
|
||||||
const res = (await (await mutate('/api/relationships/v2/addfriend', '510', 511)).json()) as Rel
|
const res = (await (await mutate('/api/relationships/v2/addfriend', '510', 511)).json()) as Rel
|
||||||
expect(res).toMatchObject({ PlayerID: 511, RelationshipType: 3 })
|
expect(res).toMatchObject({ PlayerID: 511, RelationshipType: 3 })
|
||||||
expect(await relationships('511')).toEqual([{ PlayerID: 510, RelationshipType: 3, Favorited: 0, Ignored: 0, Muted: 0 }])
|
expect(await relationships('511')).toEqual([
|
||||||
|
{ PlayerID: 510, RelationshipType: 3, Favorited: 0, Ignored: 0, Muted: 0 },
|
||||||
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('crossing friend requests become a friendship', async () => {
|
test('crossing friend requests become a friendship', async () => {
|
||||||
await mutate('/api/relationships/v2/sendfriendrequest', '520', 521)
|
await mutate('/api/relationships/v2/sendfriendrequest', '520', 521)
|
||||||
// 521 sends back to 520 → the crossing requests resolve to Friend for both.
|
// 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
|
const crossed = (await (
|
||||||
|
await mutate('/api/relationships/v2/sendfriendrequest', '521', 520)
|
||||||
|
).json()) as Rel
|
||||||
expect(crossed).toMatchObject({ PlayerID: 520, RelationshipType: 3 })
|
expect(crossed).toMatchObject({ PlayerID: 520, RelationshipType: 3 })
|
||||||
expect(await relationships('520')).toEqual([{ PlayerID: 521, RelationshipType: 3, Favorited: 0, Ignored: 0, Muted: 0 }])
|
expect(await relationships('520')).toEqual([
|
||||||
|
{ PlayerID: 521, RelationshipType: 3, Favorited: 0, Ignored: 0, Muted: 0 },
|
||||||
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('a self-targeted request is rejected', async () => {
|
test('a self-targeted request is rejected', async () => {
|
||||||
@@ -739,6 +844,8 @@ describe('relationships', () => {
|
|||||||
})
|
})
|
||||||
// 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[]
|
const view710 = (await relationships('710')) as unknown as FullRel[]
|
||||||
expect(view710).toEqual([expect.objectContaining({ PlayerID: 711, RelationshipType: 1, Ignored: 0 })])
|
expect(view710).toEqual([
|
||||||
|
expect.objectContaining({ PlayerID: 711, RelationshipType: 1, Ignored: 0 }),
|
||||||
|
])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user