fix(api): guard task-queue name resolution against corrupted rows (#44)
This commit is contained in:
@@ -550,6 +550,20 @@ describe('snapshot task queue (world / system / crew)', () => {
|
|||||||
// buildWorldFixture (4a): a save (seed '42') + Origin (s0, current) / Outpost (s1) +
|
// buildWorldFixture (4a): a save (seed '42') + Origin (s0, current) / Outpost (s1) +
|
||||||
// Alder (p0) / Basalt (p1) + the route pair — no crew, no tasks; this block adds its own.
|
// Alder (p0) / Basalt (p1) + the route pair — no crew, no tasks; this block adds its own.
|
||||||
|
|
||||||
|
// insertTaskRow JSON-encodes its arguments, so the corruption tests seed raw strings straight
|
||||||
|
// into the tasks table (same row shape, no encoding) to reach states the encoder can't.
|
||||||
|
async function insertRawTaskRow(
|
||||||
|
db: GameDb,
|
||||||
|
over: { type: string; payload: string; helpers: string; leadCrewId: number; duration: number; remaining: number; status: string; queuedAtTurn: number },
|
||||||
|
): Promise<number> {
|
||||||
|
const [save] = await db.db.select().from(schema.saves).limit(1)
|
||||||
|
const [row] = await db.db
|
||||||
|
.insert(schema.tasks)
|
||||||
|
.values({ saveId: save.id, ...over })
|
||||||
|
.returning({ id: schema.tasks.id })
|
||||||
|
return row.id
|
||||||
|
}
|
||||||
|
|
||||||
it('empty queue → every GET snapshot carries tasks: []', async () => {
|
it('empty queue → every GET snapshot carries tasks: []', async () => {
|
||||||
const { db } = await freshFile()
|
const { db } = await freshFile()
|
||||||
await buildWorldFixture(db)
|
await buildWorldFixture(db)
|
||||||
@@ -585,6 +599,41 @@ describe('snapshot task queue (world / system / crew)', () => {
|
|||||||
expect(snap.tasks).toHaveLength(1)
|
expect(snap.tasks).toHaveLength(1)
|
||||||
expect(snap.tasks[0].type).toBe('EXPLORE') // the DONE row is filtered out, not reordered to the back
|
expect(snap.tasks[0].type).toBe('EXPLORE') // the DONE row is filtered out, not reordered to the back
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('a corrupted payload degrades to targetLabel `unknown` — the snapshot still builds', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
const f = await buildWorldFixture(db)
|
||||||
|
const [c1, c2] = await addCrew(db, 2)
|
||||||
|
const badId = await insertRawTaskRow(db, { type: 'EXPLORE', payload: 'not-json', helpers: '[]', leadCrewId: c1, duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 2 })
|
||||||
|
const exploreId = await insertTaskRow(db, { type: 'EXPLORE', payload: { planetId: f.p0 }, leadCrewId: c2, helpers: [], duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 3 })
|
||||||
|
const snap = await getWorldState(db) // must not throw out of the read
|
||||||
|
expect(snap.tasks).toEqual([
|
||||||
|
{ id: badId, type: 'EXPLORE', targetLabel: 'unknown', leadName: 'CREW 1', helperNames: [], remaining: 1 },
|
||||||
|
{ id: exploreId, type: 'EXPLORE', targetLabel: 'Alder', leadName: 'CREW 2', helperNames: [], remaining: 1 }, // the well-formed sibling still resolves
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a corrupted helpers blob degrades to helperNames [] — no throw', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
const f = await buildWorldFixture(db)
|
||||||
|
const [c1] = await addCrew(db, 1)
|
||||||
|
const badId = await insertRawTaskRow(db, { type: 'JUMP', payload: JSON.stringify({ systemId: f.sys1, hops: 1 }), helpers: '[not-json', leadCrewId: c1, duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 3 })
|
||||||
|
const snap = await getWorldState(db)
|
||||||
|
expect(snap.tasks).toEqual([
|
||||||
|
{ id: badId, type: 'JUMP', targetLabel: 'Outpost', leadName: 'CREW 1', helperNames: [], remaining: 1 },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a corrupted type string degrades to targetLabel `unknown` — no throw', async () => {
|
||||||
|
const { db } = await freshFile()
|
||||||
|
const f = await buildWorldFixture(db)
|
||||||
|
const [c1] = await addCrew(db, 1)
|
||||||
|
const badId = await insertRawTaskRow(db, { type: 'TELEPORT', payload: JSON.stringify({ systemId: f.sys1, hops: 1 }), helpers: '[]', leadCrewId: c1, duration: 1, remaining: 1, status: 'ACTIVE', queuedAtTurn: 3 })
|
||||||
|
const snap = await getWorldState(db)
|
||||||
|
expect(snap.tasks).toEqual([
|
||||||
|
{ id: badId, type: 'TELEPORT', targetLabel: 'unknown', leadName: 'CREW 1', helperNames: [], remaining: 1 },
|
||||||
|
])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('backfill (old saves)', () => {
|
describe('backfill (old saves)', () => {
|
||||||
|
|||||||
+16
-10
@@ -223,26 +223,32 @@ async function taskQueue(db: GameDb): Promise<QueueDto[]> {
|
|||||||
const techName = new Map(tech.map((t): [number, string] => [t.id, t.name]))
|
const techName = new Map(tech.map((t): [number, string] => [t.id, t.name]))
|
||||||
const crewName = (id: number) => crew.find((c) => c.id === id)?.name ?? 'unknown'
|
const crewName = (id: number) => crew.find((c) => c.id === id)?.name ?? 'unknown'
|
||||||
|
|
||||||
|
// Corrupted-row tolerance: every JSON read goes through a guarded parse — a malformed
|
||||||
|
// payload / helpers / type degrades to `unknown` / [] instead of throwing out of this
|
||||||
|
// pure read (one bad row must not 500 the /api/world, /api/system and /api/crew routes).
|
||||||
|
const parse = <T>(raw: string): T | null => { try { return JSON.parse(raw) as T } catch { return null } }
|
||||||
|
const parseHelpers = (raw: string): number[] => { const v = parse<unknown>(raw); return Array.isArray(v) ? (v as number[]) : [] }
|
||||||
|
|
||||||
// targetLabel from the 4a payload shapes, per type (the Task interface in tasks.ts): the
|
// targetLabel from the 4a payload shapes, per type (the Task interface in tasks.ts): the
|
||||||
// target's display name (the jump destination, the scanned body, the researched tech), the
|
// target's display name (the jump destination, the scanned body, the researched tech), the
|
||||||
// ship system's name with the verb suffixed for upgrade/expand, and the SHIP_TYPES string
|
// ship system's name with the verb suffixed for upgrade/expand, and the SHIP_TYPES string
|
||||||
// as-is for a build (its label lives in the payload, so it never misses). A deleted lookup
|
// as-is for a build. A deleted lookup target, a malformed payload, or a missing field
|
||||||
// target falls back to `unknown`.
|
// falls back to `unknown`.
|
||||||
const labelFor: Record<TaskType, (payload: string) => string> = {
|
const labelFor: Record<TaskType, (payload: string) => string> = {
|
||||||
JUMP: (payload) => systemName.get((JSON.parse(payload) as { systemId: number }).systemId) ?? 'unknown',
|
JUMP: (payload) => { const p = parse<{ systemId: number }>(payload); return p ? (systemName.get(p.systemId) ?? 'unknown') : 'unknown' },
|
||||||
EXPLORE: (payload) => planetName.get((JSON.parse(payload) as { planetId: number }).planetId) ?? 'unknown',
|
EXPLORE: (payload) => { const p = parse<{ planetId: number }>(payload); return p ? (planetName.get(p.planetId) ?? 'unknown') : 'unknown' },
|
||||||
SHIP_UPGRADE: (payload) => `${systemName.get((JSON.parse(payload) as { systemId: number }).systemId) ?? 'unknown'} — upgrade`,
|
SHIP_UPGRADE: (payload) => { const p = parse<{ systemId: number }>(payload); return p ? `${systemName.get(p.systemId) ?? 'unknown'} — upgrade` : 'unknown' },
|
||||||
SHIP_EXPAND: (payload) => `${systemName.get((JSON.parse(payload) as { systemId: number }).systemId) ?? 'unknown'} — expand`,
|
SHIP_EXPAND: (payload) => { const p = parse<{ systemId: number }>(payload); return p ? `${systemName.get(p.systemId) ?? 'unknown'} — expand` : 'unknown' },
|
||||||
SHIP_BUILD: (payload) => (JSON.parse(payload) as { type: string }).type,
|
SHIP_BUILD: (payload) => parse<{ type: string }>(payload)?.type ?? 'unknown',
|
||||||
RESEARCH: (payload) => techName.get((JSON.parse(payload) as { techId: number }).techId) ?? 'unknown',
|
RESEARCH: (payload) => { const p = parse<{ techId: number }>(payload); return p ? (techName.get(p.techId) ?? 'unknown') : 'unknown' },
|
||||||
}
|
}
|
||||||
|
|
||||||
const queue = taskRows.map((t) => ({
|
const queue = taskRows.map((t) => ({
|
||||||
id: t.id,
|
id: t.id,
|
||||||
type: t.type as TaskType,
|
type: t.type as TaskType,
|
||||||
targetLabel: labelFor[t.type as TaskType](t.payload),
|
targetLabel: labelFor[t.type as TaskType]?.(t.payload) ?? 'unknown',
|
||||||
leadName: crewName(t.leadCrewId),
|
leadName: crewName(t.leadCrewId),
|
||||||
helperNames: (JSON.parse(t.helpers) as number[]).map(crewName),
|
helperNames: parseHelpers(t.helpers).map(crewName),
|
||||||
remaining: t.remaining,
|
remaining: t.remaining,
|
||||||
status: t.status as TaskStatus,
|
status: t.status as TaskStatus,
|
||||||
queuedAtTurn: t.queuedAtTurn,
|
queuedAtTurn: t.queuedAtTurn,
|
||||||
|
|||||||
Reference in New Issue
Block a user