import { describe, expect, test } from 'bun:test'; import { AssetService } from '../src/lib/domain/assets'; import { ESI_ASSETS_SCOPE, manufacturingCapabilityGate } from '../src/lib/domain/capabilities'; import { addIsk, multiplyIsk, sumIsk, validateQuantity } from '../src/lib/domain/money'; import { distanceLightYears, EVE_METERS_PER_LIGHT_YEAR } from '../src/lib/domain/routing'; import type { Character, CharacterAssetRepository, MarketEstimates, ReferenceRepository, } from '../src/lib/domain/model'; const now = 1_000; function character(id: number, overrides: Partial = {}): Character { return { id, userId: 'user-a', name: `Pilot ${id}`, scopes: [ESI_ASSETS_SCOPE], assets: { status: 'ready', syncedAt: 900, expiresAt: 2_000 }, ...overrides, }; } function ports( characters: Character[], assets: Record = {}, market: unknown = { prices: [], source: 'fixture', asOf: 900, expiresAt: 2_000 }, ) { let assetCalls = 0; const repository: CharacterAssetRepository = { characters: async () => characters, assets: async (_userId, id) => { assetCalls += 1; return (assets[id] ?? []) as never[]; }, }; const reference: ReferenceRepository = { types: async (ids) => ids.map((id) => ({ id, name: `Type ${id}`, categoryId: 1 })), version: async () => 'fixture-v1', }; const markets: MarketEstimates = { estimates: async () => market as never }; return { repository, reference, markets, get assetCalls() { return assetCalls; } }; } const asset = (characterId: number, itemId: string, typeId: number, quantity: number, isBlueprintCopy?: boolean) => ({ characterId, itemId, typeId, quantity, locationId: '60000001', locationType: 'station', locationFlag: 'hangar', ...(isBlueprintCopy === undefined ? {} : { isBlueprintCopy }), }); describe('money domain helpers', () => { test('uses integer cents and BigInt accumulation', () => { expect(addIsk('100', '25')).toBe('125'); expect(sumIsk(['9007199254740992', '8'])).toBe('9007199254741000'); expect(multiplyIsk('125', 3)).toBe('375'); }); test('rejects unsafe, fractional, and negative inventory quantities', () => { expect(() => validateQuantity(Number.MAX_SAFE_INTEGER + 1)).toThrow(); expect(() => validateQuantity(1.5)).toThrow(); expect(() => validateQuantity(-1)).toThrow(); }); }); describe('AssetService', () => { test('returns an auth gate before touching lazy repositories', async () => { const service = new AssetService( { characters: async () => { throw new Error('must not run'); }, assets: async () => { throw new Error('must not run'); } }, { version: async () => { throw new Error('must not run'); }, types: async () => { throw new Error('must not run'); } }, { estimates: async () => { throw new Error('must not run'); } }, now, ); const result = await service.report(null); expect(result.status).toBe('authentication_required'); }); test('a logged-in user with no characters must connect, not receive a zero valuation', async () => { const p = ports([]); const result = await new AssetService(p.repository, p.reference, p.markets, now).report('user-a'); expect(result).toMatchObject({ status: 'requirements_missing', missing: [{ requirement: 'assets', reason: 'authorization' }] }); expect(p.assetCalls).toBe(0); }); test('duplicate instance IDs in one character snapshot cannot inflate totals', async () => { const row = asset(1, '123', 34, 2); const p = ports([character(1)], { 1: [row, row] }); expect(await new AssetService(p.repository, p.reference, p.markets, now).report('user-a')).toMatchObject({ status: 'unavailable' }); }); test('requires the exact assets scope and refuses partial character coverage', async () => { const p = ports([ character(1), character(2, { scopes: ['esi-assets.read_assets.v1.extra'], assets: { status: 'syncing' } }), ]); const result = await new AssetService(p.repository, p.reference, p.markets, now).report('user-a'); expect(result.status).toBe('requirements_missing'); if (result.status === 'requirements_missing') { expect(result.missing).toEqual([ { requirement: 'assets', characterId: 2, reason: 'authorization' }, { requirement: 'assets', characterId: 2, reason: 'synchronization' }, ]); } expect(p.assetCalls).toBe(0); }); test('rejects stale snapshots rather than aggregating ready characters only', async () => { const p = ports([character(1), character(2, { assets: { status: 'ready', syncedAt: 100, expiresAt: 1_000 } })]); const result = await new AssetService(p.repository, p.reference, p.markets, now).report('user-a'); expect(result.status).toBe('requirements_missing'); if (result.status === 'requirements_missing') { expect(result.missing).toContainEqual({ requirement: 'assets', characterId: 2, reason: 'stale' }); } }); test('aggregates characters and explicitly excludes unknown, missing-price, and BPC rows', async () => { const p = ports( [character(2), character(1)], { 1: [asset(1, '2', 1, 2), asset(1, '3', 99, 4)], 2: [asset(2, '1', 2, 3), asset(2, '4', 3, 1, true)], }, { prices: [{ typeId: 1, unitPrice: '125' }, { typeId: 2, unitPrice: '300' }], source: 'fixture', asOf: 900, expiresAt: 2_000 }, ); p.reference.types = async (ids) => ids.filter((id) => id !== 99).map((id) => ({ id, name: `Type ${id}`, categoryId: 1 })); const result = await new AssetService(p.repository, p.reference, p.markets, now).report('user-a'); expect(result.status).toBe('ready'); if (result.status === 'ready') { expect(result.report.total).toBe('1150'); expect(result.report.items.map((item) => item.exclusion)).toEqual([undefined, 'unknown_type', undefined, 'blueprint_copy']); expect(result.report.unpricedItems).toBe(2); expect(result.report.prices.source).toBe('fixture'); } }); test('fails closed for stale and malformed market data', async () => { const stale = ports([character(1)], { 1: [asset(1, '1', 1, 1)] }, { prices: [{ typeId: 1, unitPrice: '1' }], source: 'fixture', asOf: 0, expiresAt: now }); const staleResult = await new AssetService(stale.repository, stale.reference, stale.markets, now).report('user-a'); expect(staleResult).toMatchObject({ status: 'requirements_missing', missing: [{ requirement: 'market_data', reason: 'stale' }] }); const malformed = ports([character(1)], { 1: [asset(1, '1', 1, 1)] }, { prices: [{ typeId: 1, unitPrice: '-1' }], source: 'fixture', asOf: 900, expiresAt: 2_000 }); const malformedResult = await new AssetService(malformed.repository, malformed.reference, malformed.markets, now).report('user-a'); expect(malformedResult).toMatchObject({ status: 'unavailable' }); }); test('rejects cross-user character data', async () => { const p = ports([character(1, { userId: 'other-user' })]); const result = await new AssetService(p.repository, p.reference, p.markets, now).report('user-a'); expect(result).toMatchObject({ status: 'unavailable' }); }); }); test('manufacturing gate names unimplemented skills and blueprints', () => { expect(manufacturingCapabilityGate('user-a')).toEqual({ status: 'requirements_missing', capability: 'manufacturing_opportunities', missing: [ { requirement: 'skills', reason: 'not_implemented' }, { requirement: 'blueprints', reason: 'not_implemented' }, ], actions: [], }); }); test('routing uses the exact EVE light-year constant and finite XYZ', () => { expect(EVE_METERS_PER_LIGHT_YEAR).toBe(9_460_000_000_000_000); expect(distanceLightYears({ systemId: 1, x: 0, y: 0, z: 0 }, { systemId: 2, x: EVE_METERS_PER_LIGHT_YEAR, y: 0, z: 0 })).toBe(1); expect(() => distanceLightYears({ systemId: 1, x: Number.NaN, y: 0, z: 0 }, { systemId: 2, x: 0, y: 0, z: 0 })).toThrow(); });