Initial Commit
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
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> = {}): 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<number, unknown[]> = {},
|
||||
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();
|
||||
});
|
||||
Reference in New Issue
Block a user