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

234 lines
22 KiB
TypeScript

import { describe, expect, test } from 'bun:test';
import type { Asset } from '../src/lib/domain/model';
import { createReferenceEnrichmentPin } from '../src/lib/domain/inventory-location';
import { InventoryLocationService } from '../src/lib/server/inventory/location-service';
import { EsiPrivateStructurePort, PrivateStructureCache, privateStructureCacheKey } from '../src/lib/server/inventory/private-structure-cache';
import { EsiHttpError } from '../src/lib/server/esi';
import type { ReferenceReaderPort } from '../src/lib/domain/reference';
const authority = { userId: 'user-a', principal: 'character:42', characterId: 42, scopes: ['esi-assets.read_assets.v1'], grantRevision: 3, authorizationEpoch: 2, credentialVersion: 4 } as const;
const assets: Asset[] = [
{ itemId: '9007199254740993', characterId: 42, typeId: 100, quantity: 1, locationId: '60003760', locationType: 'station', locationFlag: 'Hangar', isSingleton: true },
{ itemId: '9007199254740995', characterId: 42, typeId: 101, quantity: 2, locationId: '9007199254740993', locationType: 'item', locationFlag: 'Cargo', isSingleton: false },
{ itemId: '9007199254740997', characterId: 42, typeId: 102, quantity: 1, locationId: '8000000000000001', locationType: 'other', locationFlag: 'Hangar', isSingleton: true },
];
function reference(): ReferenceReaderPort {
const reader: any = {
pinnedVersion: 'ref-v1',
async types() { return []; }, async version() { return 'ref-v1'; },
async searchTypes() { return []; }, async searchTypesByName() { return []; }, async categories() { return []; }, async groups() { return []; },
async regions(selection: any) { return selection.ids?.includes(10000002) ? [{ id: 10000002, name: 'The Forge' }] : []; },
async constellations() { return []; },
async systems(selection: any) { return selection.ids?.includes(30000142) ? [{ id: 30000142, name: 'Jita', constellationId: 20000020, regionId: 10000002, x: 0, y: 0, z: 0, securityStatus: 0.9, securityClass: 'highsec' }] : []; },
async stations(selection: any) { return selection.ids?.includes(60003760) ? [{ id: 60003760, systemId: 30000142, systemName: 'Jita', regionId: 10000002, name: 'Jita IV - Moon 4 - Caldari Navy Assembly Plant', typeId: 1531, ownerId: 1000035, evidence: 'system_listed', sourceVerified: false, x: null, y: null, z: null }] : []; },
pin() { return this; }, close() {},
};
return reader as ReferenceReaderPort;
}
function namesObservation() {
return { requestedIds: assets.map((asset) => asset.itemId), names: [{ itemId: assets[0]!.itemId, name: 'Same name' }, { itemId: assets[1]!.itemId, name: 'Same name' }], missing: [assets[2]!.itemId], retrievedAt: 100, expiresAt: 1_000, complete: false };
}
function locationsObservation() {
return { requestedIds: assets.map((asset) => asset.itemId), locations: assets.map((asset, index) => ({ itemId: asset.itemId, position: { x: index, y: index === 0 ? 0 : -index, z: 0 } })), missing: [], retrievedAt: 100, expiresAt: 1_000, complete: true };
}
describe('T08 location enrichment seam', () => {
test('pins authority, reference, helper freshness and system-listed evidence', async () => {
let now = 200;
const service = new InventoryLocationService({
reference: reference(), now: () => now,
structures: { async access(_context, structureId) { return { structureId, characterId: 42, status: 'accessible', evidence: 'esi_structure_access', name: 'Private Keep', observedAt: 100, expiresAt: 900, dockingPermission: 'unknown' }; } },
});
const result = await service.enrich({ assets, authority, assetGeneration: 'gen-7', names: namesObservation(), locations: locationsObservation(), structureRevision: 'structures-4' });
expect(result.status).toBe('complete');
expect(result.enrichmentVersion).toMatch(/^containment-v1-/);
expect(result.inputPin).toMatchObject({ assetGeneration: 'gen-7', referenceVersion: 'ref-v1', structureRevision: 'structures-4', authority: { userId: 'user-a', characterId: 42, grantRevision: 3 } });
expect(result.items[0]).toMatchObject({ customName: 'Same name', location: { kind: 'station', evidence: 'system_listed', sourceVerified: false, system: { name: 'Jita', region: { name: 'The Forge' } } } });
expect(result.items[2]).toMatchObject({ location: { kind: 'structure', name: 'Private Keep', evidence: 'private_structure_access', dockingPermission: 'unknown', observedAt: 100, expiresAt: 900 } });
expect(result.inputPin.structures).toEqual([{ structureId: assets[2]!.locationId, characterId: 42, status: 'accessible', evidence: 'esi_structure_access', name: 'Private Keep', solarSystemId: null, ownerId: null, typeId: null, observedAt: 100, expiresAt: 900, referenceVersion: 'ref-v1' }]);
expect(result.items[0]?.position).toEqual({ x: 0, y: 0, z: 0 });
expect(result.items[1]?.breadcrumbs.map((entry) => entry.name)).toEqual(['Same name']);
now = 1_001;
const stale = await service.enrich({ assets, authority, assetGeneration: 'gen-7', names: namesObservation(), locations: locationsObservation() });
expect(stale.complete).toBe(false);
expect(stale.items[0]?.nameStatus).toBe('expired');
expect(stale.items[0]?.customName).toBeNull();
expect(stale.items[0]?.position).toBeNull();
expect(stale.unresolved.some((entry) => entry.reasons.includes('name_expired'))).toBe(true);
});
test('normalizes a direct single helper batch into the generation pin', () => {
const pin = createReferenceEnrichmentPin({ assetGeneration: 'single-batch', authority, referenceVersion: 'ref-v1', namesBatches: [namesObservation()], locationsBatches: [locationsObservation()] });
expect(pin.names.requestedIds).toEqual(assets.map((asset) => asset.itemId).sort());
expect(pin.locations.resolvedIds).toHaveLength(assets.length);
});
test('keeps 50k-row enrichment indexed and bounded across helper batches', async () => {
const largeAssets: Asset[] = Array.from({ length: 50_000 }, (_, index) => ({ itemId: String(index + 1), characterId: 42, typeId: 100, quantity: 1, locationId: '60003760', locationType: 'station', locationFlag: 'Hangar' }));
const nameBatches = Array.from({ length: 50 }, (_, batch) => { const ids = largeAssets.slice(batch * 1_000, (batch + 1) * 1_000).map((asset) => asset.itemId); return { requestedIds: ids, names: [], missing: ids, retrievedAt: 100, expiresAt: 1_000, complete: false }; });
const locationBatches = nameBatches.map((batch) => ({ requestedIds: batch.requestedIds, locations: [], missing: batch.requestedIds, retrievedAt: batch.retrievedAt, expiresAt: batch.expiresAt, complete: batch.complete }));
const result = await new InventoryLocationService({ reference: reference(), now: () => 200 }).enrich({ assets: largeAssets, authority, assetGeneration: 'large-generation', namesBatches: nameBatches, locationsBatches: locationBatches });
expect(result.items).toHaveLength(50_000);
expect(result.status).toBe('complete');
expect(result.inputPin.names.requestedIds).toHaveLength(50_000);
expect(result.inputPin.locations.requestedIds).toHaveLength(50_000);
});
test('retains every bounded helper batch in one generation-level pin', async () => {
const manyAssets: Asset[] = Array.from({ length: 1_001 }, (_, index) => ({ itemId: String(10_000 + index), characterId: 42, typeId: 100, quantity: 1, locationId: '30000142', locationType: 'solar_system', locationFlag: 'Hangar' }));
const makeNames = (slice: Asset[]) => ({ requestedIds: slice.map((asset) => asset.itemId), names: slice.map((asset) => ({ itemId: asset.itemId, name: `Item ${asset.itemId}` })), missing: [], retrievedAt: 100, expiresAt: 1_000, complete: true });
const makeLocations = (slice: Asset[]) => ({ requestedIds: slice.map((asset) => asset.itemId), locations: slice.map((asset) => ({ itemId: asset.itemId, position: { x: 0, y: 0, z: 0 } })), missing: [], retrievedAt: 100, expiresAt: 1_000, complete: true });
const namesBatches = [makeNames(manyAssets.slice(0, 1_000)), makeNames(manyAssets.slice(1_000))];
const locationsBatches = [makeLocations(manyAssets.slice(0, 1_000)), makeLocations(manyAssets.slice(1_000))];
const result = await new InventoryLocationService({ reference: reference(), now: () => 200 }).enrich({ assets: manyAssets, authority, assetGeneration: 'many-batches', namesBatches, locationsBatches });
expect(result.items).toHaveLength(1_001);
expect(result.items[1_000]?.customName).toBe('Item 11000');
expect(result.inputPin.names.requestedIds).toHaveLength(1_001);
expect(result.inputPin.locations.resolvedIds).toHaveLength(1_001);
expect(result.complete).toBe(true);
});
test('retains resolved IDs when a later helper batch is forbidden or unavailable', async () => {
const first = assets[0]!;
const second = { ...assets[1]!, itemId: '9007199254740999', locationId: '30000142', locationType: 'solar_system' as const };
const observedNames = { requestedIds: [first.itemId], names: [{ itemId: first.itemId, name: 'Observed' }], missing: [], retrievedAt: 100, expiresAt: 1_000, complete: true };
const forbiddenNames = { state: 'forbidden' as const, requestedIds: [second.itemId], names: [], missing: [], retrievedAt: 200, expiresAt: 1_000, complete: false };
const observedLocations = { requestedIds: [first.itemId], locations: [{ itemId: first.itemId, position: { x: 1, y: 2, z: 3 } }], missing: [], retrievedAt: 100, expiresAt: 1_000, complete: true };
const unavailableLocations = { state: 'unavailable' as const, requestedIds: [second.itemId], locations: [], missing: [], retrievedAt: 200, expiresAt: 1_000, complete: false };
const result = await new InventoryLocationService({ reference: reference(), now: () => 200 }).enrich({ assets: [first, second], authority, assetGeneration: 'mixed-batches', namesBatches: [observedNames, forbiddenNames], locationsBatches: [observedLocations, unavailableLocations] });
expect(result.inputPin.names).toMatchObject({ state: 'forbidden', resolvedIds: [first.itemId], batches: [{ state: 'observed' }, { state: 'forbidden' }] });
expect(result.inputPin.locations).toMatchObject({ state: 'unavailable', resolvedIds: [first.itemId], batches: [{ state: 'observed' }, { state: 'unavailable' }] });
expect(result.items[0]).toMatchObject({ customName: 'Observed', position: { x: 1, y: 2, z: 3 } });
expect(result.items[1]).toMatchObject({ nameStatus: 'forbidden', locationStatus: 'unavailable' });
expect(result.complete).toBe(false);
});
test('distinguishes helper forbidden/unavailable and never fabricates names', async () => {
const service = new InventoryLocationService({ reference: reference(), now: () => 100 });
const forbidden = await service.enrich({ assets: assets.slice(0, 1), authority, assetGeneration: 'g1', namesState: 'forbidden', namesRequestedIds: [assets[0]!.itemId], locationsState: 'unavailable', locationsRequestedIds: [assets[0]!.itemId] });
expect(forbidden.items[0]).toMatchObject({ customName: null, nameStatus: 'forbidden', locationStatus: 'unavailable', location: { name: 'Jita IV - Moon 4 - Caldari Navy Assembly Plant' } });
expect(forbidden.unresolved[0]?.reasons).toEqual(expect.arrayContaining(['name_forbidden', 'location_unavailable']));
});
test('forbidden structures are explicit and do not become docking ACL claims', async () => {
const service = new InventoryLocationService({ reference: reference(), now: () => 100, structures: { async access(_context, structureId) { return { structureId, characterId: 42, status: 'forbidden', evidence: 'esi_forbidden', name: null, observedAt: 100, expiresAt: 200, dockingPermission: 'unknown' }; } } });
const result = await service.enrich({ assets: [assets[2]!], authority, assetGeneration: 'g2' });
expect(result.complete).toBe(false);
expect(result.items[0]?.location).toMatchObject({ kind: 'structure', evidence: 'unknown', name: null, dockingPermission: 'unknown' });
expect(result.unresolved[0]?.reasons).toContain('forbidden_structure');
expect(result.inputPin.structures).toEqual([{ structureId: assets[2]!.locationId, characterId: 42, status: 'forbidden', evidence: 'esi_forbidden', name: null, solarSystemId: null, ownerId: null, typeId: null, observedAt: 100, expiresAt: 200, referenceVersion: 'ref-v1' }]);
});
test('not-requested observations never expose body data, and pins match rows', async () => {
const result = await new InventoryLocationService({ reference: reference(), now: () => 200 }).enrich({
assets: assets.slice(0, 1), authority, assetGeneration: 'not-requested',
names: { ...namesObservation(), state: 'not_requested' }, locations: { ...locationsObservation(), state: 'not_requested' },
});
expect(result.items[0]).toMatchObject({ customName: null, nameStatus: 'not_requested', position: null, locationStatus: 'not_requested' });
expect(result.inputPin.names).toMatchObject({ state: 'not_requested', resolvedIds: [] });
expect(result.inputPin.locations).toMatchObject({ state: 'not_requested', resolvedIds: [] });
});
test('oversized helper arrays reject before traversing elements', async () => {
let nameReads = 0;
const oversizedNames: any[] = new Array(1_001);
Object.defineProperty(oversizedNames, 0, { enumerable: true, get() { nameReads += 1; return { itemId: assets[0]!.itemId, name: 'should-not-read' }; } });
await expect(new InventoryLocationService({ reference: reference(), now: () => 200 }).enrich({ assets: assets.slice(0, 1), authority, assetGeneration: 'oversized-names', names: { ...namesObservation(), names: oversizedNames } as any })).rejects.toThrow('exceeds');
expect(nameReads).toBe(0);
let locationReads = 0;
const oversizedLocations: any[] = new Array(1_001);
Object.defineProperty(oversizedLocations, 0, { enumerable: true, get() { locationReads += 1; return { itemId: assets[0]!.itemId, position: { x: 0, y: 0, z: 0 } }; } });
await expect(new InventoryLocationService({ reference: reference(), now: () => 200 }).enrich({ assets: assets.slice(0, 1), authority, assetGeneration: 'oversized-locations', locations: { ...locationsObservation(), locations: oversizedLocations } as any })).rejects.toThrow('exceeds');
expect(locationReads).toBe(0);
const oversizedRequested = new Array(1_001).fill('1');
await expect(new InventoryLocationService({ reference: reference(), now: () => 200 }).enrich({ assets: assets.slice(0, 1), authority, assetGeneration: 'oversized-requested', namesRequestedIds: oversizedRequested as any })).rejects.toThrow('namesRequestedIds exceeds');
});
test('unknown and malformed helper states fail closed', async () => {
const unknownNames = { ...namesObservation(), state: 'unknown' };
const unknownLocations = { ...locationsObservation(), state: 'unknown' };
await expect(new InventoryLocationService({ reference: reference(), now: () => 200 }).enrich({ assets: assets.slice(0, 1), authority, assetGeneration: 'bad-name-state', names: unknownNames as any })).rejects.toThrow('invalid enrichment observation state');
await expect(new InventoryLocationService({ reference: reference(), now: () => 200 }).enrich({ assets: assets.slice(0, 1), authority, assetGeneration: 'bad-location-state', locations: unknownLocations as any })).rejects.toThrow('invalid enrichment observation state');
const malformedNames = { ...namesObservation(), names: [{ itemId: assets[0]!.itemId, name: '' }] };
const malformedLocations = { ...locationsObservation(), locations: locationsObservation().locations.map((entry, index) => index === 0 ? { ...entry, position: { x: Number.NaN, y: 0, z: 0 } } : entry) };
await expect(new InventoryLocationService({ reference: reference(), now: () => 200 }).enrich({ assets: assets.slice(0, 1), authority, assetGeneration: 'bad-name', names: malformedNames as any })).rejects.toThrow('names observation contains invalid');
await expect(new InventoryLocationService({ reference: reference(), now: () => 200 }).enrich({ assets: assets.slice(0, 1), authority, assetGeneration: 'bad-position', locations: malformedLocations as any })).rejects.toThrow('locations observation contains invalid');
});
test('requested IDs without observations are pinned unavailable, not not-requested', async () => {
const result = await new InventoryLocationService({ reference: reference(), now: () => 200 }).enrich({ assets: assets.slice(0, 1), authority, assetGeneration: 'missing-observation', namesRequestedIds: [assets[0]!.itemId], locationsRequestedIds: [assets[0]!.itemId] });
expect(result.items[0]).toMatchObject({ nameStatus: 'unavailable', locationStatus: 'unavailable', customName: null, position: null });
expect(result.inputPin.names).toMatchObject({ state: 'unavailable', requestedIds: [assets[0]!.itemId] });
expect(result.inputPin.locations).toMatchObject({ state: 'unavailable', requestedIds: [assets[0]!.itemId] });
});
test('consolidates containment and helper failures to one unresolved record per item', async () => {
const broken = { itemId: '1', characterId: 42, typeId: 1, quantity: 1, locationId: '99', locationType: 'item', locationFlag: 'Cargo' } as Asset;
const result = await new InventoryLocationService({ reference: reference(), now: () => 200 }).enrich({ assets: [broken], authority, assetGeneration: 'broken', namesRequestedIds: ['1'], locationsRequestedIds: ['1'] });
expect(result.unresolved).toHaveLength(1);
expect(result.unresolved[0]).toMatchObject({ itemId: '1', reasons: expect.arrayContaining(['missing_parent', 'name_unavailable', 'location_unavailable']) });
});
test('closes an acquired reference handle when validation throws', async () => {
let closes = 0;
const owner: any = reference();
const handle: any = { ...owner, pinnedVersion: 'ref-v1', close() { closes += 1; } };
owner.pin = () => handle;
const service = new InventoryLocationService({ reference: owner, now: () => 200 });
await expect(service.enrich({ assets: assets.slice(0, 1), authority, assetGeneration: 'throws', maxDepth: 65 })).rejects.toThrow('containment depth');
expect(closes).toBe(1);
});
test('closes a malformed handle returned by pin()', async () => {
let closes = 0;
const owner: any = reference();
const handle: any = { ...owner, pinnedVersion: undefined, close() { closes += 1; } };
owner.pin = () => handle;
await expect(new InventoryLocationService({ reference: owner, now: () => 200 }).enrich({ assets: assets.slice(0, 1), authority, assetGeneration: 'bad-pin' })).rejects.toThrow('reference pin did not provide a version');
expect(closes).toBe(1);
});
});
describe('T08 private structure cache', () => {
test('typed ESI structure adapter preserves access evidence and maps only 403/404', async () => {
const context = { ...authority, scopes: ['esi-assets.read_assets.v1', 'esi-universe.read_structures.v1'], referenceVersion: 'ref-v1' };
const accessible = new EsiPrivateStructurePort({ getStructure: async () => ({ structureId: '8000000000000001', name: 'Keep', solarSystemId: '30000142', ownerId: '99000001', typeId: '35832', position: null, retrievedAt: 100, expiresAt: 500 }) } as any, 'token', () => 100);
await expect(accessible.access(context, '8000000000000001')).resolves.toMatchObject({ status: 'accessible', evidence: 'esi_structure_access', solarSystemId: '30000142', dockingPermission: 'unknown' });
const denied = new EsiPrivateStructurePort({ getStructure: async () => { throw new EsiHttpError(403, '/universe/structures/8000000000000001'); } } as any, 'token', () => 100);
await expect(denied.access(context, '8000000000000001')).resolves.toMatchObject({ status: 'forbidden', evidence: 'esi_forbidden', name: null, dockingPermission: 'unknown' });
const unavailable = new EsiPrivateStructurePort({ getStructure: async () => { throw new EsiHttpError(500, '/universe/structures/8000000000000001'); } } as any, 'token', () => 100);
await expect(unavailable.access(context, '8000000000000001')).rejects.toBeInstanceOf(EsiHttpError);
});
test('isolates user/character/revision/reference and expires with upstream', async () => {
let now = 0;
const cache = new PrivateStructureCache({ now: () => now, maxEntries: 4, maxBytes: 16_000 });
const context = { ...authority, referenceVersion: 'ref-v1' };
const other = { ...context, userId: 'user-b', principal: 'character:42' };
const value = { structureId: '8000000000000001', characterId: 42, status: 'accessible' as const, evidence: 'esi_structure_access' as const, name: 'Keep', observedAt: 0, expiresAt: 100, dockingPermission: 'unknown' as const };
expect(cache.set(context, value.structureId, value)).toBe(true);
expect(cache.get(context, value.structureId)?.name).toBe('Keep');
expect(cache.get(other, value.structureId)).toBeNull();
expect(cache.get({ ...context, grantRevision: 4 }, value.structureId)).toBeNull();
expect(cache.get({ ...context, referenceVersion: 'ref-v2' }, value.structureId)).toBeNull();
expect(privateStructureCacheKey(context, value.structureId)).not.toContain('token');
now = 100;
expect(cache.get(context, value.structureId)).toBeNull();
});
test('bounds entries and invalidates every authority revision', () => {
let now = 0;
const cache = new PrivateStructureCache({ now: () => now, maxEntries: 2, maxBytes: 16_000 });
const context = { ...authority, referenceVersion: 'r' };
const make = (id: string) => ({ structureId: id, characterId: 42, status: 'forbidden' as const, evidence: 'esi_forbidden' as const, name: null, observedAt: 0, expiresAt: 50, dockingPermission: 'unknown' as const });
cache.set(context, '1', make('1')); cache.set({ ...context, grantRevision: 2 }, '2', make('2')); cache.set({ ...context, authorizationEpoch: 3 }, '3', make('3'));
expect(cache.size).toBeLessThanOrEqual(2);
expect(cache.invalidateAuthority(context)).toBeGreaterThan(0);
expect(cache.size).toBe(0);
});
});