Files
eden/tests/inventory-query.test.ts

325 lines
22 KiB
TypeScript

import { readFileSync } from 'node:fs';
import { describe, expect, test } from 'bun:test';
import type { Asset, InventoryType, SelectedResource } from '../src/lib/domain/model';
import { createReferenceEnrichmentPin, type EnrichedLocationNode } from '../src/lib/domain/inventory-location';
import { buildInventoryProjection, validateInventoryProjection, type ProjectionAuthority } from '../src/lib/server/inventory/projection';
import { InMemoryInventoryQueryRepository } from '../src/lib/server/inventory/query-repository';
import { InventoryQueryService } from '../src/lib/server/inventory/query-service';
const fixture = JSON.parse(readFileSync('tests/fixtures/inventory/query-assets.json', 'utf8')) as Asset[];
const types: InventoryType[] = [
{ id: 100, name: 'Tritanium', categoryId: 4, groupId: 18 },
{ id: 101, name: 'Pyerite', categoryId: 4, groupId: 18 },
{ id: 102, name: 'Blueprint Copy', categoryId: 9, groupId: 100 },
];
const resource = (userId: string, characterId: number, revision = 1): SelectedResource => ({
principal: { kind: 'character', id: String(characterId), ownerUserId: userId },
requiredScopes: ['esi-assets.read_assets.v1'],
authorizationRevision: revision,
authorizationEpoch: 1,
});
const authority = (userId: string, characterId: number, revision = 1): ProjectionAuthority => ({ userId, characterId, grantRevision: revision, authorizationEpoch: 1, credentialVersion: 1, scopes: ['esi-assets.read_assets.v1'] });
const locationPin = (userId: string, characterId: number, generation: string, revision = 1) => createReferenceEnrichmentPin({
assetGeneration: generation,
authority: { userId, principal: `character:${characterId}`, characterId, scopes: ['esi-assets.read_assets.v1'], grantRevision: revision, authorizationEpoch: 1, credentialVersion: 1 },
referenceVersion: 'ref-v1',
});
function resolvedLocation(asset: Asset, name = 'Jita'): EnrichedLocationNode {
return {
...(asset as any),
parentItemId: null,
root: { kind: 'external', id: asset.locationId, locationType: asset.locationType, locationFlag: asset.locationFlag },
depth: 0,
breadcrumbs: [],
fitted: { classification: 'not_fitted', rawFlag: asset.locationFlag, reason: 'ordinary_location_flag' },
fittedClassification: 'not_fitted',
isFitted: false,
unresolved: [],
customName: null,
nameStatus: 'not_requested',
position: null,
locationStatus: 'not_requested',
location: {
kind: 'station', id: asset.locationId, name, parentId: '30000142', evidence: 'system_listed', sourceVerified: false,
referenceVersion: 'ref-v1', dockingPermission: 'unknown', system: {
kind: 'system', id: '30000142', name: 'Jita', parentId: '10000002', evidence: 'verified', sourceVerified: true,
referenceVersion: 'ref-v1', dockingPermission: 'unknown', system: null, region: {
kind: 'region', id: '10000002', name: 'The Forge', parentId: null, evidence: 'verified', sourceVerified: true,
referenceVersion: 'ref-v1', dockingPermission: 'unknown', system: null, region: null,
},
},
region: null,
},
} as EnrichedLocationNode;
}
function makeRepository(userId = 'user-a') {
const resources = [resource(userId, 42), resource(userId, 43)];
const manifests = resources.map((selected) => {
const characterId = Number((selected.principal as { id: string }).id);
const assets = fixture.filter((asset) => asset.characterId === characterId);
return buildInventoryProjection({
userId, resource: selected, authority: authority(userId, characterId), generation: `assets-${characterId}`,
referenceVersion: 'ref-v1', priceVersion: 'prices-v1', enrichmentVersion: `enrich-${characterId}`,
assets, types, prices: [{ typeId: 100, unitPrice: '100' }, { typeId: 101, unitPrice: '200' }],
locations: assets.filter((asset) => asset.itemId !== '9007199254741001' && asset.itemId !== '9007199254740997').map((asset) => resolvedLocation(asset)),
locationInputPin: locationPin(userId, characterId, `assets-${characterId}`),
});
});
return { repository: new InMemoryInventoryQueryRepository(manifests), resources, manifests };
}
const query = (cursor: { token: string } | null = null, extra: Record<string, unknown> = {}) => ({
callerUserId: 'user-a', filters: {}, sort: { field: 'name' as const, direction: 'ascending' as const, tieBreaker: 'item_id' as const }, pageSize: 2, cursor, ...extra,
});
describe('T09 inventory projection/query core', () => {
test('projects exact money, BPC/unpriced rows and legacy singleton unknown', async () => {
const { repository } = makeRepository();
const result = await new InventoryQueryService({ repository, now: () => 100 }).query({ ...query(), pageSize: 10 });
expect(result.status).toBe('ready');
if (result.status !== 'ready') return;
expect(result.summary).toMatchObject({ matchingRows: 5, matchingQuantity: '15', estimatedValue: '1200', scope: 'all_matching_rows' });
expect(result.rows.filter((row) => row.isSingleton === null)).toHaveLength(3);
expect(result.rows.filter((row) => row.isSingleton === false)).toHaveLength(1);
expect(result.rows.filter((row) => row.isSingleton === true)).toHaveLength(1);
expect((result.rows.find((row) => row.isBlueprintCopy === true) as any)?.estimatedValue).toBeNull();
expect((result.rows.find((row) => row.isBlueprintCopy === true) as any)?.pricing).toBe('unpriced');
});
test('includes unresolved locations only in the unfiltered view and explicit Unresolved filter', async () => {
const { repository } = makeRepository();
const service = new InventoryQueryService({ repository, now: () => 100 });
const all = await service.query({ ...query(), pageSize: 20 });
expect(all.status).toBe('ready');
if (all.status !== 'ready') return;
expect((all.rows as any[]).filter((row) => row.unresolvedLocation)).toHaveLength(2);
const quantityFiltered = await service.query({ ...query(), pageSize: 20, filters: { minQuantity: '3' } });
expect(quantityFiltered.status).toBe('ready');
const stations = await service.query({ ...query(), pageSize: 20, filters: { locationIds: ['60003760'] } });
expect(stations.status).toBe('ready');
if (stations.status !== 'ready') return;
expect(stations.rows).toHaveLength(3);
const unresolved = await service.query({ ...query(), pageSize: 20, filters: { location: 'unresolved' } });
expect(unresolved.status).toBe('ready');
if (unresolved.status !== 'ready') return;
expect(unresolved.rows).toHaveLength(2);
expect((unresolved.rows as any[]).every((row) => row.location.name === null)).toBe(true);
});
test('pages deterministically across characters and exposes a terminal after-end page', async () => {
const { repository } = makeRepository();
const service = new InventoryQueryService({ repository, now: () => 100 });
const seen: string[] = [];
let page = await service.query(query());
expect(page.status).toBe('ready');
while (page.status === 'ready' && page.pageState !== 'after_end') {
seen.push(...page.rows.map((row) => `${row.characterId}:${row.itemId}`));
if (!page.nextCursor) break;
page = await service.query(query(page.nextCursor));
}
expect(page).toMatchObject({ status: 'ready', pageState: 'after_end', nextCursor: null });
expect(new Set(seen).size).toBe(5);
const again: string[] = [];
let repeat = await service.query(query());
while (repeat.status === 'ready' && repeat.pageState !== 'after_end') {
again.push(...repeat.rows.map((row) => `${row.characterId}:${row.itemId}`));
if (!repeat.nextCursor) break;
repeat = await service.query(query(repeat.nextCursor));
}
expect(again).toEqual(seen);
});
test('pins tenant, authority, generations and cursor versions', async () => {
const { manifests } = makeRepository();
const other = makeRepository('user-b');
// Both tenants use one server-side cursor store, but their resources and
// projections remain separately owner-bound.
const sharedRepository = new InMemoryInventoryQueryRepository([...manifests, ...other.manifests]);
const service = new InventoryQueryService({ repository: sharedRepository, now: () => 100 });
const first = await service.query(query());
expect(first.status).toBe('ready');
if (first.status !== 'ready' || !first.nextCursor) return;
expect((await service.query({ ...query(first.nextCursor), filters: { typeIds: [100] } })).status).toBe('unavailable');
const borrowed = await new InventoryQueryService({ repository: sharedRepository, now: () => 100 }).query({ ...query(first.nextCursor), callerUserId: 'user-b' });
expect(borrowed.status).toBe('authorization_required');
let now = 100;
const expiring = new InventoryQueryService({ repository: sharedRepository, now: () => now, cursorTtlMs: 10 });
const expiringPage = await expiring.query(query());
expect(expiringPage.status).toBe('ready');
if (expiringPage.status !== 'ready' || !expiringPage.nextCursor) return;
now = 111;
expect((await expiring.query(query(expiringPage.nextCursor))).status).toBe('stale');
});
test('does not publish partial projection and reports unavailable coverage explicitly', async () => {
const { repository, manifests } = makeRepository();
const partial = buildInventoryProjection({
userId: 'user-a', resource: resource('user-a', 44), authority: authority('user-a', 44), generation: 'partial',
referenceVersion: 'ref-v1', assets: [], types, coverage: { complete: false, reason: 'name coverage unavailable' },
});
await repository.stageProjection(partial);
await expect(repository.publishProjection('user-a', resource('user-a', 44), 'partial')).rejects.toThrow('partial');
repository.seedResource({ userId: 'user-a', resource: resource('user-a', 44), characterId: 44, status: 'partial', generation: 'partial', referenceVersion: 'ref-v1', priceVersion: null, enrichmentVersion: null, grantRevision: 1, authorizationEpoch: 1, credentialVersion: 1, fetchedAt: null, upstreamEffectiveAt: null, reason: 'name coverage unavailable' });
const result = await new InventoryQueryService({ repository, now: () => 100 }).query(query(null, { pageSize: 10 }));
expect(result.status).toBe('partial');
expect(manifests).toHaveLength(2);
});
test('rejects arbitrary filters, malformed sorts, and oversized caps before reads', async () => {
const { repository } = makeRepository();
const service = new InventoryQueryService({ repository, now: () => 100, maxPageSize: 10 });
await expect(service.query({ ...query(), filters: { hack: 'all' } as any })).rejects.toThrow('unsupported inventory filter');
await expect(service.query({ ...query(), sort: { field: 'random' as any, direction: 'ascending', tieBreaker: 'item_id' } })).rejects.toThrow('sort field');
await expect(service.query({ ...query(), pageSize: 11 })).rejects.toThrow('page size');
await expect(service.query({ ...query(), filters: { typeIds: new Array(1_001).fill(1) } })).rejects.toThrow('filter cap');
await expect(service.query({ ...query(), filters: { minQuantity: '-1' } })).rejects.toThrow('bounded non-negative');
});
test('runtime-validates a persistence response instead of exposing cross-tenant rows', async () => {
const { repository } = makeRepository();
const original = repository.readPage.bind(repository);
repository.readPage = async (request: any) => {
const page = await original(request);
return { ...page, rows: [{}] } as any;
};
await expect(new InventoryQueryService({ repository, now: () => 100 }).query({ ...query(), pageSize: 1 })).rejects.toThrow('malformed row');
});
test('allowlists public rows instead of exposing persisted private fields', async () => {
const { repository } = makeRepository();
const original = repository.readPage.bind(repository);
repository.readPage = async (request: any) => {
const page = await original(request);
return { ...page, rows: page.rows.map((row: any) => ({ ...row, credential: 'secret', accessToken: 'secret' })) } as any;
};
const result = await new InventoryQueryService({ repository, now: () => 100 }).query({ ...query(), pageSize: 1 });
expect(result.status).toBe('ready');
if (result.status !== 'ready') return;
expect((result.rows[0] as any).credential).toBeUndefined();
expect((result.rows[0] as any).accessToken).toBeUndefined();
expect((result.rows[0] as any).resourceKey).toBeUndefined();
expect((result.rows[0] as any).location).toBeDefined();
});
test('keeps immutable projections and cursor pins idempotent but non-mutable', async () => {
const { repository, manifests } = makeRepository();
const manifest = manifests[0]!;
const malformedCounters = structuredClone(manifest);
malformedCounters.authority.grantRevision = -1;
malformedCounters.resource.authorizationRevision = -1;
await expect(repository.stageProjection(malformedCounters)).rejects.toThrow('projection authority is stale');
await repository.stageProjection(manifest);
const changed = structuredClone(manifest);
changed.fetchedAt = 123;
await expect(repository.stageProjection(changed)).rejects.toThrow('immutable projection snapshot conflict');
await repository.publishProjection('user-a', manifest.resource, manifest.generation);
const first = await new InventoryQueryService({ repository, now: () => 100 }).query(query());
expect(first.status).toBe('ready');
if (first.status !== 'ready' || !first.nextCursor) return;
const state = await repository.getCursor(first.nextCursor.token);
expect(state).not.toBeNull();
await expect(repository.replaceCursor({ ...state!, pin: { ...state!.pin, expiresAt: state!.pin.expiresAt + 1 } })).rejects.toThrow('cursor pin is immutable');
});
test('rejects page loops, skips, silent truncation, and unknown discriminants', async () => {
for (const mutate of [
(page: any) => ({ ...page, nextOffset: 999 }),
(page: any) => ({ ...page, nextOffset: null }),
(page: any) => ({ ...page, status: 'mystery' }),
]) {
const { repository } = makeRepository();
const original = repository.readPage.bind(repository);
repository.readPage = async (request: any) => mutate(await original(request));
await expect(new InventoryQueryService({ repository, now: () => 100 }).query(query())).rejects.toThrow(/repository.*(continuation|page|invalid)/);
}
});
test('rejects forged page pins and rows against authoritative selected snapshots', async () => {
const { repository } = makeRepository();
const original = repository.readPage.bind(repository);
repository.readPage = async (request: any) => {
const page = await original(request);
const resources = page.resources.map((snapshot: any, index: number) => index === 0 ? { ...snapshot, generation: 'foreign-generation' } : snapshot);
const rows = page.rows.map((row: any, index: number) => index === 0 ? { ...row, generation: 'foreign-generation' } : row);
return { ...page, resources, rows } as any;
};
await expect(new InventoryQueryService({ repository, now: () => 100 }).query(query())).rejects.toThrow('authoritative snapshot');
});
test('bounds raw explicit resource selection before traversing it and preserves explicit empty selection', async () => {
const { repository } = makeRepository();
let traversed = false;
const oversized = new Array(1_001);
Object.defineProperty(oversized, 0, { enumerable: true, get() { traversed = true; return resource('user-a', 42); } });
await expect(new InventoryQueryService({ repository, now: () => 100 }).query({ ...query(), selectedResources: oversized as any })).rejects.toThrow('selected resource cap');
expect(traversed).toBe(false);
const empty = await new InventoryQueryService({ repository, now: () => 100 }).query({ ...query(), selectedResources: [] });
expect(empty.status).toBe('authorization_required');
});
test('does not serve a ready generation after its authority revision changes', async () => {
const { repository } = makeRepository();
const current = (await repository.authorizedResources('user-a'))[0]!;
repository.seedResource({ ...current, resource: { ...current.resource, authorizationRevision: 2 }, grantRevision: 2 });
const result = await new InventoryQueryService({ repository, now: () => 100 }).query(query());
expect(result.status).toBe('stale');
});
test('requires exact T08 generation/reference/authority pins and rejects non-member rows', () => {
const assets = fixture.filter((asset) => asset.characterId === 42);
const selected = resource('user-a', 42);
const stale = resolvedLocation(assets[0]!);
(stale as any).typeId = 101;
expect(() => buildInventoryProjection({ userId: 'user-a', resource: selected, authority: authority('user-a', 42), generation: 'assets-42', referenceVersion: 'ref-v1', priceVersion: 'prices-v1', assets, types, prices: [{ typeId: 100, unitPrice: '100' }], locations: [resolvedLocation(assets[0]!)], locationInputPin: locationPin('user-a', 42, 'wrong') })).toThrow('generation does not match');
expect(() => buildInventoryProjection({ userId: 'user-a', resource: selected, authority: authority('user-a', 42), generation: 'assets-42', referenceVersion: 'ref-v1', priceVersion: 'prices-v1', assets: [assets[0]!], types, prices: [{ typeId: 100, unitPrice: '100' }], locations: [stale], locationInputPin: locationPin('user-a', 42, 'assets-42') })).toThrow('location row does not match');
const staleReference = resolvedLocation(assets[0]!);
(staleReference.location.system as any).referenceVersion = 'ref-v2';
expect(() => buildInventoryProjection({ userId: 'user-a', resource: selected, authority: authority('user-a', 42), generation: 'assets-42', referenceVersion: 'ref-v1', priceVersion: 'prices-v1', assets: [assets[0]!], types, prices: [{ typeId: 100, unitPrice: '100' }], locations: [staleReference], locationInputPin: locationPin('user-a', 42, 'assets-42') })).toThrow('location row does not match');
expect(() => buildInventoryProjection({ userId: 'user-a', resource: selected, authority: authority('user-a', 42), generation: 'assets-42', referenceVersion: 'ref-v1', priceVersion: 'prices-v1', assets: [assets[0]!], types, prices: [{ typeId: 100, unitPrice: '100' }], locations: [resolvedLocation(fixture.find((asset) => asset.characterId === 43)!)], locationInputPin: locationPin('user-a', 42, 'assets-42') })).toThrow('location row does not match');
const unresolved = buildInventoryProjection({ userId: 'user-a', resource: selected, authority: authority('user-a', 42), generation: 'assets-42', referenceVersion: 'ref-v1', priceVersion: null, assets: [assets[0]!], types, locations: [] , locationInputPin: locationPin('user-a', 42, 'assets-42') });
expect(unresolved.rows[0]!.location).toMatchObject({ kind: 'unknown', name: null, systemId: null, regionId: null, evidence: 'unknown' });
});
test('deep-validates stored arithmetic, BPC pricing, immutable price provenance, and money bounds', () => {
const { manifests } = makeRepository();
const bad = structuredClone(manifests[0]!);
bad.rows[0]!.estimatedValue = '999';
expect(() => validateInventoryProjection(bad)).toThrow('priced arithmetic');
expect(() => buildInventoryProjection({ userId: 'user-a', resource: resource('user-a', 42), authority: authority('user-a', 42), generation: 'no-price-version', referenceVersion: 'ref-v1', assets: [fixture[0]!], types, prices: [{ typeId: 100, unitPrice: '100' }] })).toThrow('price version');
expect(() => buildInventoryProjection({ userId: 'user-a', resource: resource('user-a', 42), authority: authority('user-a', 42), generation: 'huge-price', referenceVersion: 'ref-v1', priceVersion: 'prices-v1', assets: [fixture[0]!], types, prices: [{ typeId: 100, unitPrice: '1'.repeat(129) }] })).toThrow('money text bounds');
expect(() => buildInventoryProjection({ userId: 'user-a', resource: resource('user-a', 42), authority: authority('user-a', 42), generation: 'huge-type-name', referenceVersion: 'ref-v1', assets: [fixture[0]!], types: [{ id: 100, name: 'x'.repeat(513), categoryId: 4 }], prices: [] })).toThrow('type response is malformed');
const forgedStructure = structuredClone(manifests[0]!);
forgedStructure.rows[0]!.location = { ...forgedStructure.rows[0]!.location, kind: 'structure', evidence: 'private_structure_access' };
expect(() => validateInventoryProjection(forgedStructure)).toThrow('structure scope');
const builtStructure = resolvedLocation(fixture[0]!);
(builtStructure.location as any).kind = 'structure';
(builtStructure.location as any).evidence = 'private_structure_access';
expect(() => buildInventoryProjection({ userId: 'user-a', resource: resource('user-a', 42), authority: authority('user-a', 42), generation: 'structure-build', referenceVersion: 'ref-v1', assets: [fixture[0]!], types, prices: [{ typeId: 100, unitPrice: '100' }], locations: [builtStructure], locationInputPin: locationPin('user-a', 42, 'structure-build') })).toThrow('private structure evidence requires pinned structure scope');
});
test('rejects oversized cursor tokens before cursor storage access', async () => {
const { repository } = makeRepository();
let cursorReads = 0;
const original = repository.getCursor.bind(repository);
repository.getCursor = async (token: string) => { cursorReads += 1; return original(token); };
await expect(new InventoryQueryService({ repository, now: () => 100 }).query(query({ token: 'x'.repeat(257) }))).rejects.toThrow('oversized');
expect(cursorReads).toBe(0);
});
test('canonical resource ordering keeps equivalent cursor continuations valid and malformed cursor envelopes fail closed', async () => {
const { repository, resources } = makeRepository();
const service = new InventoryQueryService({ repository, now: () => 100 });
const first = await service.query({ ...query(), selectedResources: [resources[1]!, resources[0]!] });
expect(first.status).toBe('ready');
if (first.status !== 'ready' || !first.nextCursor) return;
const continued = await service.query({ ...query(first.nextCursor), selectedResources: [resources[0]!, resources[1]!] });
expect(continued.status).toBe('ready');
const original = repository.getCursor.bind(repository);
repository.getCursor = async (token: string) => ({ ...(await original(token))!, pin: undefined } as any);
expect((await service.query(query(first.nextCursor))).status).toBe('unavailable');
});
});