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 = {}) => ({ 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'); }); });