adapter tests
Some checks failed
CI / lint-typecheck (pull_request) Failing after 4m51s
CI / tests (pull_request) Has been skipped
CI / contract-tests (pull_request) Has been skipped
CI / e2e-tests (pull_request) Has been skipped
CI / comment-pr (pull_request) Has been skipped
CI / commit-types (pull_request) Has been skipped

This commit is contained in:
2026-01-24 21:39:59 +01:00
parent 1e821c4a5c
commit 838f1602de
29 changed files with 4518 additions and 1 deletions

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import { TypeOrmAnalyticsSchemaError } from './TypeOrmAnalyticsSchemaError';
describe('TypeOrmAnalyticsSchemaError', () => {
it('contains entity, field, and reason', () => {
// Given
const params = {
entityName: 'AnalyticsSnapshot',
fieldName: 'metrics.pageViews',
reason: 'not_number' as const,
message: 'Custom message',
};
// When
const error = new TypeOrmAnalyticsSchemaError(params);
// Then
expect(error.name).toBe('TypeOrmAnalyticsSchemaError');
expect(error.entityName).toBe(params.entityName);
expect(error.fieldName).toBe(params.fieldName);
expect(error.reason).toBe(params.reason);
expect(error.message).toBe(params.message);
});
it('works without optional message', () => {
// Given
const params = {
entityName: 'EngagementEvent',
fieldName: 'id',
reason: 'missing' as const,
};
// When
const error = new TypeOrmAnalyticsSchemaError(params);
// Then
expect(error.message).toBe('');
expect(error.entityName).toBe(params.entityName);
});
});

View File

@@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest';
import { AnalyticsSnapshot } from '@core/analytics/domain/entities/AnalyticsSnapshot';
import { AnalyticsSnapshotOrmEntity } from '../entities/AnalyticsSnapshotOrmEntity';
import { TypeOrmAnalyticsSchemaError } from '../errors/TypeOrmAnalyticsSchemaError';
import { AnalyticsSnapshotOrmMapper } from './AnalyticsSnapshotOrmMapper';
describe('AnalyticsSnapshotOrmMapper', () => {
const mapper = new AnalyticsSnapshotOrmMapper();
it('maps domain -> orm -> domain (round-trip)', () => {
// Given
const domain = AnalyticsSnapshot.create({
id: 'snap_1',
entityType: 'league',
entityId: 'league-1',
period: 'daily',
startDate: new Date('2025-01-01T00:00:00.000Z'),
endDate: new Date('2025-01-01T23:59:59.999Z'),
metrics: {
pageViews: 100,
uniqueVisitors: 50,
avgSessionDuration: 120,
bounceRate: 0.4,
engagementScore: 75,
sponsorClicks: 10,
sponsorUrlClicks: 5,
socialShares: 2,
leagueJoins: 1,
raceRegistrations: 3,
exposureValue: 150.5,
},
createdAt: new Date('2025-01-02T00:00:00.000Z'),
});
// When
const orm = mapper.toOrmEntity(domain);
const rehydrated = mapper.toDomain(orm);
// Then
expect(orm).toBeInstanceOf(AnalyticsSnapshotOrmEntity);
expect(orm.id).toBe(domain.id);
expect(rehydrated.id).toBe(domain.id);
expect(rehydrated.entityType).toBe(domain.entityType);
expect(rehydrated.entityId).toBe(domain.entityId);
expect(rehydrated.period).toBe(domain.period);
expect(rehydrated.startDate.toISOString()).toBe(domain.startDate.toISOString());
expect(rehydrated.endDate.toISOString()).toBe(domain.endDate.toISOString());
expect(rehydrated.metrics).toEqual(domain.metrics);
expect(rehydrated.createdAt.toISOString()).toBe(domain.createdAt.toISOString());
});
it('throws TypeOrmAnalyticsSchemaError for invalid persisted shape', () => {
// Given
const orm = new AnalyticsSnapshotOrmEntity();
orm.id = ''; // Invalid: empty
orm.entityType = 'league' as any;
orm.entityId = 'league-1';
orm.period = 'daily' as any;
orm.startDate = new Date();
orm.endDate = new Date();
orm.metrics = {} as any; // Invalid: missing fields
orm.createdAt = new Date();
// When / Then
expect(() => mapper.toDomain(orm)).toThrow(TypeOrmAnalyticsSchemaError);
});
it('throws TypeOrmAnalyticsSchemaError when metrics are missing required fields', () => {
// Given
const orm = new AnalyticsSnapshotOrmEntity();
orm.id = 'snap_1';
orm.entityType = 'league' as any;
orm.entityId = 'league-1';
orm.period = 'daily' as any;
orm.startDate = new Date();
orm.endDate = new Date();
orm.metrics = { pageViews: 100 } as any; // Missing other metrics
orm.createdAt = new Date();
// When / Then
expect(() => mapper.toDomain(orm)).toThrow(TypeOrmAnalyticsSchemaError);
try {
mapper.toDomain(orm);
} catch (e: any) {
expect(e.fieldName).toContain('metrics.');
}
});
});

View File

@@ -0,0 +1,103 @@
import { describe, expect, it } from 'vitest';
import { EngagementEvent } from '@core/analytics/domain/entities/EngagementEvent';
import { EngagementEventOrmEntity } from '../entities/EngagementEventOrmEntity';
import { TypeOrmAnalyticsSchemaError } from '../errors/TypeOrmAnalyticsSchemaError';
import { EngagementEventOrmMapper } from './EngagementEventOrmMapper';
describe('EngagementEventOrmMapper', () => {
const mapper = new EngagementEventOrmMapper();
it('maps domain -> orm -> domain (round-trip)', () => {
// Given
const domain = EngagementEvent.create({
id: 'eng_1',
action: 'click_sponsor_logo',
entityType: 'sponsor',
entityId: 'sponsor-1',
actorType: 'driver',
actorId: 'driver-1',
sessionId: 'sess-1',
metadata: { key: 'value', num: 123, bool: true },
timestamp: new Date('2025-01-01T10:00:00.000Z'),
});
// When
const orm = mapper.toOrmEntity(domain);
const rehydrated = mapper.toDomain(orm);
// Then
expect(orm).toBeInstanceOf(EngagementEventOrmEntity);
expect(orm.id).toBe(domain.id);
expect(rehydrated.id).toBe(domain.id);
expect(rehydrated.action).toBe(domain.action);
expect(rehydrated.entityType).toBe(domain.entityType);
expect(rehydrated.entityId).toBe(domain.entityId);
expect(rehydrated.actorType).toBe(domain.actorType);
expect(rehydrated.actorId).toBe(domain.actorId);
expect(rehydrated.sessionId).toBe(domain.sessionId);
expect(rehydrated.metadata).toEqual(domain.metadata);
expect(rehydrated.timestamp.toISOString()).toBe(domain.timestamp.toISOString());
});
it('maps domain -> orm -> domain with nulls', () => {
// Given
const domain = EngagementEvent.create({
id: 'eng_2',
action: 'view_standings',
entityType: 'league',
entityId: 'league-1',
actorType: 'anonymous',
sessionId: 'sess-2',
timestamp: new Date('2025-01-01T11:00:00.000Z'),
});
// When
const orm = mapper.toOrmEntity(domain);
const rehydrated = mapper.toDomain(orm);
// Then
expect(orm.actorId).toBeNull();
expect(orm.metadata).toBeNull();
expect(rehydrated.actorId).toBeUndefined();
expect(rehydrated.metadata).toBeUndefined();
});
it('throws TypeOrmAnalyticsSchemaError for invalid persisted shape', () => {
// Given
const orm = new EngagementEventOrmEntity();
orm.id = ''; // Invalid
orm.action = 'invalid_action' as any;
orm.entityType = 'league' as any;
orm.entityId = 'league-1';
orm.actorType = 'anonymous' as any;
orm.sessionId = 'sess-1';
orm.timestamp = new Date();
// When / Then
expect(() => mapper.toDomain(orm)).toThrow(TypeOrmAnalyticsSchemaError);
});
it('throws TypeOrmAnalyticsSchemaError for invalid metadata values', () => {
// Given
const orm = new EngagementEventOrmEntity();
orm.id = 'eng_1';
orm.action = 'click_sponsor_logo' as any;
orm.entityType = 'sponsor' as any;
orm.entityId = 'sponsor-1';
orm.actorType = 'driver' as any;
orm.sessionId = 'sess-1';
orm.timestamp = new Date();
orm.metadata = { invalid: { nested: 'object' } } as any;
// When / Then
expect(() => mapper.toDomain(orm)).toThrow(TypeOrmAnalyticsSchemaError);
try {
mapper.toDomain(orm);
} catch (e: any) {
expect(e.reason).toBe('invalid_shape');
expect(e.fieldName).toBe('metadata');
}
});
});

View File

@@ -0,0 +1,102 @@
import type { Repository } from 'typeorm';
import { describe, expect, it, vi } from 'vitest';
import { AnalyticsSnapshot } from '@core/analytics/domain/entities/AnalyticsSnapshot';
import { AnalyticsSnapshotOrmEntity } from '../entities/AnalyticsSnapshotOrmEntity';
import { AnalyticsSnapshotOrmMapper } from '../mappers/AnalyticsSnapshotOrmMapper';
import { TypeOrmAnalyticsSnapshotRepository } from './TypeOrmAnalyticsSnapshotRepository';
describe('TypeOrmAnalyticsSnapshotRepository', () => {
it('saves mapped entities via injected mapper', async () => {
// Given
const orm = new AnalyticsSnapshotOrmEntity();
orm.id = 'snap_1';
const mapper: AnalyticsSnapshotOrmMapper = {
toOrmEntity: vi.fn().mockReturnValue(orm),
toDomain: vi.fn(),
} as unknown as AnalyticsSnapshotOrmMapper;
const repo: Repository<AnalyticsSnapshotOrmEntity> = {
save: vi.fn().mockResolvedValue(orm),
} as unknown as Repository<AnalyticsSnapshotOrmEntity>;
const sut = new TypeOrmAnalyticsSnapshotRepository(repo, mapper);
const domain = AnalyticsSnapshot.create({
id: 'snap_1',
entityType: 'league',
entityId: 'league-1',
period: 'daily',
startDate: new Date(),
endDate: new Date(),
metrics: {} as any,
createdAt: new Date(),
});
// When
await sut.save(domain);
// Then
expect(mapper.toOrmEntity).toHaveBeenCalledWith(domain);
expect(repo.save).toHaveBeenCalledWith(orm);
});
it('findById maps entity -> domain', async () => {
// Given
const orm = new AnalyticsSnapshotOrmEntity();
orm.id = 'snap_1';
const domain = AnalyticsSnapshot.create({
id: 'snap_1',
entityType: 'league',
entityId: 'league-1',
period: 'daily',
startDate: new Date(),
endDate: new Date(),
metrics: {} as any,
createdAt: new Date(),
});
const mapper: AnalyticsSnapshotOrmMapper = {
toOrmEntity: vi.fn(),
toDomain: vi.fn().mockReturnValue(domain),
} as unknown as AnalyticsSnapshotOrmMapper;
const repo: Repository<AnalyticsSnapshotOrmEntity> = {
findOneBy: vi.fn().mockResolvedValue(orm),
} as unknown as Repository<AnalyticsSnapshotOrmEntity>;
const sut = new TypeOrmAnalyticsSnapshotRepository(repo, mapper);
// When
const result = await sut.findById('snap_1');
// Then
expect(repo.findOneBy).toHaveBeenCalledWith({ id: 'snap_1' });
expect(mapper.toDomain).toHaveBeenCalledWith(orm);
expect(result?.id).toBe('snap_1');
});
it('findLatest uses correct query options', async () => {
// Given
const orm = new AnalyticsSnapshotOrmEntity();
const mapper: AnalyticsSnapshotOrmMapper = {
toDomain: vi.fn().mockReturnValue({ id: 'snap_1' } as any),
} as any;
const repo: Repository<AnalyticsSnapshotOrmEntity> = {
findOne: vi.fn().mockResolvedValue(orm),
} as any;
const sut = new TypeOrmAnalyticsSnapshotRepository(repo, mapper);
// When
await sut.findLatest('league', 'league-1', 'daily');
// Then
expect(repo.findOne).toHaveBeenCalledWith({
where: { entityType: 'league', entityId: 'league-1', period: 'daily' },
order: { endDate: 'DESC' },
});
});
});

View File

@@ -0,0 +1,100 @@
import type { Repository } from 'typeorm';
import { describe, expect, it, vi } from 'vitest';
import { EngagementEvent } from '@core/analytics/domain/entities/EngagementEvent';
import { EngagementEventOrmEntity } from '../entities/EngagementEventOrmEntity';
import { EngagementEventOrmMapper } from '../mappers/EngagementEventOrmMapper';
import { TypeOrmEngagementRepository } from './TypeOrmEngagementRepository';
describe('TypeOrmEngagementRepository', () => {
it('saves mapped entities via injected mapper', async () => {
// Given
const orm = new EngagementEventOrmEntity();
orm.id = 'eng_1';
const mapper: EngagementEventOrmMapper = {
toOrmEntity: vi.fn().mockReturnValue(orm),
toDomain: vi.fn(),
} as unknown as EngagementEventOrmMapper;
const repo: Repository<EngagementEventOrmEntity> = {
save: vi.fn().mockResolvedValue(orm),
} as unknown as Repository<EngagementEventOrmEntity>;
const sut = new TypeOrmEngagementRepository(repo, mapper);
const domain = EngagementEvent.create({
id: 'eng_1',
action: 'click_sponsor_logo',
entityType: 'sponsor',
entityId: 'sponsor-1',
actorType: 'anonymous',
sessionId: 'sess-1',
timestamp: new Date(),
});
// When
await sut.save(domain);
// Then
expect(mapper.toOrmEntity).toHaveBeenCalledWith(domain);
expect(repo.save).toHaveBeenCalledWith(orm);
});
it('findById maps entity -> domain', async () => {
// Given
const orm = new EngagementEventOrmEntity();
orm.id = 'eng_1';
const domain = EngagementEvent.create({
id: 'eng_1',
action: 'click_sponsor_logo',
entityType: 'sponsor',
entityId: 'sponsor-1',
actorType: 'anonymous',
sessionId: 'sess-1',
timestamp: new Date(),
});
const mapper: EngagementEventOrmMapper = {
toOrmEntity: vi.fn(),
toDomain: vi.fn().mockReturnValue(domain),
} as unknown as EngagementEventOrmMapper;
const repo: Repository<EngagementEventOrmEntity> = {
findOneBy: vi.fn().mockResolvedValue(orm),
} as unknown as Repository<EngagementEventOrmEntity>;
const sut = new TypeOrmEngagementRepository(repo, mapper);
// When
const result = await sut.findById('eng_1');
// Then
expect(repo.findOneBy).toHaveBeenCalledWith({ id: 'eng_1' });
expect(mapper.toDomain).toHaveBeenCalledWith(orm);
expect(result?.id).toBe('eng_1');
});
it('countByAction uses correct where clause', async () => {
// Given
const repo: Repository<EngagementEventOrmEntity> = {
count: vi.fn().mockResolvedValue(5),
} as any;
const sut = new TypeOrmEngagementRepository(repo, {} as any);
const since = new Date();
// When
await sut.countByAction('click_sponsor_logo', 'sponsor-1', since);
// Then
expect(repo.count).toHaveBeenCalledWith({
where: expect.objectContaining({
action: 'click_sponsor_logo',
entityId: 'sponsor-1',
timestamp: expect.anything(),
}),
});
});
});

View File

@@ -0,0 +1,141 @@
import { describe, expect, it } from 'vitest';
import { TypeOrmAnalyticsSchemaError } from '../errors/TypeOrmAnalyticsSchemaError';
import {
assertBoolean,
assertDate,
assertEnumValue,
assertInteger,
assertNonEmptyString,
assertNumber,
assertOptionalIntegerOrNull,
assertOptionalNumberOrNull,
assertOptionalStringOrNull,
assertRecord,
} from './TypeOrmAnalyticsSchemaGuards';
describe('TypeOrmAnalyticsSchemaGuards', () => {
const entity = 'TestEntity';
describe('assertNonEmptyString', () => {
it('accepts valid string', () => {
expect(() => assertNonEmptyString(entity, 'field', 'valid')).not.toThrow();
});
it('rejects null/undefined', () => {
expect(() => assertNonEmptyString(entity, 'field', null)).toThrow(TypeOrmAnalyticsSchemaError);
expect(() => assertNonEmptyString(entity, 'field', undefined)).toThrow(TypeOrmAnalyticsSchemaError);
});
it('rejects empty/whitespace string', () => {
expect(() => assertNonEmptyString(entity, 'field', '')).toThrow(TypeOrmAnalyticsSchemaError);
expect(() => assertNonEmptyString(entity, 'field', ' ')).toThrow(TypeOrmAnalyticsSchemaError);
});
it('rejects non-string', () => {
expect(() => assertNonEmptyString(entity, 'field', 123)).toThrow(TypeOrmAnalyticsSchemaError);
});
});
describe('assertOptionalStringOrNull', () => {
it('accepts valid string, null, or undefined', () => {
expect(() => assertOptionalStringOrNull(entity, 'field', 'valid')).not.toThrow();
expect(() => assertOptionalStringOrNull(entity, 'field', null)).not.toThrow();
expect(() => assertOptionalStringOrNull(entity, 'field', undefined)).not.toThrow();
});
it('rejects non-string', () => {
expect(() => assertOptionalStringOrNull(entity, 'field', 123)).toThrow(TypeOrmAnalyticsSchemaError);
});
});
describe('assertNumber', () => {
it('accepts valid number', () => {
expect(() => assertNumber(entity, 'field', 123.45)).not.toThrow();
expect(() => assertNumber(entity, 'field', 0)).not.toThrow();
});
it('rejects NaN', () => {
expect(() => assertNumber(entity, 'field', NaN)).toThrow(TypeOrmAnalyticsSchemaError);
});
it('rejects non-number', () => {
expect(() => assertNumber(entity, 'field', '123')).toThrow(TypeOrmAnalyticsSchemaError);
});
});
describe('assertOptionalNumberOrNull', () => {
it('accepts valid number, null, or undefined', () => {
expect(() => assertOptionalNumberOrNull(entity, 'field', 123)).not.toThrow();
expect(() => assertOptionalNumberOrNull(entity, 'field', null)).not.toThrow();
expect(() => assertOptionalNumberOrNull(entity, 'field', undefined)).not.toThrow();
});
});
describe('assertInteger', () => {
it('accepts valid integer', () => {
expect(() => assertInteger(entity, 'field', 123)).not.toThrow();
});
it('rejects float', () => {
expect(() => assertInteger(entity, 'field', 123.45)).toThrow(TypeOrmAnalyticsSchemaError);
});
});
describe('assertOptionalIntegerOrNull', () => {
it('accepts valid integer, null, or undefined', () => {
expect(() => assertOptionalIntegerOrNull(entity, 'field', 123)).not.toThrow();
expect(() => assertOptionalIntegerOrNull(entity, 'field', null)).not.toThrow();
});
});
describe('assertBoolean', () => {
it('accepts boolean', () => {
expect(() => assertBoolean(entity, 'field', true)).not.toThrow();
expect(() => assertBoolean(entity, 'field', false)).not.toThrow();
});
it('rejects non-boolean', () => {
expect(() => assertBoolean(entity, 'field', 'true')).toThrow(TypeOrmAnalyticsSchemaError);
});
});
describe('assertDate', () => {
it('accepts valid Date', () => {
expect(() => assertDate(entity, 'field', new Date())).not.toThrow();
});
it('rejects invalid Date', () => {
expect(() => assertDate(entity, 'field', new Date('invalid'))).toThrow(TypeOrmAnalyticsSchemaError);
});
it('rejects non-Date', () => {
expect(() => assertDate(entity, 'field', '2025-01-01')).toThrow(TypeOrmAnalyticsSchemaError);
});
});
describe('assertEnumValue', () => {
const allowed = ['a', 'b'] as const;
it('accepts allowed value', () => {
expect(() => assertEnumValue(entity, 'field', 'a', allowed)).not.toThrow();
});
it('rejects disallowed value', () => {
expect(() => assertEnumValue(entity, 'field', 'c', allowed)).toThrow(TypeOrmAnalyticsSchemaError);
});
});
describe('assertRecord', () => {
it('accepts object', () => {
expect(() => assertRecord(entity, 'field', { a: 1 })).not.toThrow();
});
it('rejects array', () => {
expect(() => assertRecord(entity, 'field', [])).toThrow(TypeOrmAnalyticsSchemaError);
});
it('rejects null', () => {
expect(() => assertRecord(entity, 'field', null)).toThrow(TypeOrmAnalyticsSchemaError);
});
});
});