inmemory to postgres
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
import { Column, CreateDateColumn, Entity, PrimaryColumn } from 'typeorm';
|
||||
|
||||
@Entity({ name: 'achievements' })
|
||||
export class AchievementOrmEntity {
|
||||
@PrimaryColumn({ type: 'text' })
|
||||
id!: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
name!: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
description!: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
category!: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
rarity!: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
iconUrl!: string | null;
|
||||
|
||||
@Column({ type: 'int' })
|
||||
points!: number;
|
||||
|
||||
@Column({ type: 'jsonb' })
|
||||
requirements!: Array<{ type: string; value: number; operator: string }>;
|
||||
|
||||
@Column({ type: 'boolean' })
|
||||
isSecret!: boolean;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Column, CreateDateColumn, Entity, Index, PrimaryColumn } from 'typeorm';
|
||||
|
||||
@Entity({ name: 'user_achievements' })
|
||||
@Index(['userId', 'achievementId'], { unique: true })
|
||||
export class UserAchievementOrmEntity {
|
||||
@PrimaryColumn({ type: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
userId!: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
achievementId!: string;
|
||||
|
||||
@Column({ type: 'int', default: 100 })
|
||||
progress!: number;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
earnedAt!: Date;
|
||||
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
notifiedAt!: Date | null;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export class TypeOrmPersistenceSchemaError extends Error {
|
||||
public readonly entityName: string;
|
||||
public readonly fieldName: string;
|
||||
public readonly reason: string;
|
||||
public readonly message: string;
|
||||
|
||||
constructor(params: {
|
||||
entityName: string;
|
||||
fieldName: string;
|
||||
reason: string;
|
||||
message?: string;
|
||||
}) {
|
||||
const errorMessage = params.message || `Schema validation failed for ${params.entityName}.${params.fieldName}: ${params.reason}`;
|
||||
super(errorMessage);
|
||||
this.name = 'TypeOrmPersistenceSchemaError';
|
||||
this.entityName = params.entityName;
|
||||
this.fieldName = params.fieldName;
|
||||
this.reason = params.reason;
|
||||
this.message = errorMessage;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { Achievement, AchievementCategory, AchievementRequirement } from '@core/identity/domain/entities/Achievement';
|
||||
import { UserAchievement } from '@core/identity/domain/entities/UserAchievement';
|
||||
|
||||
import { AchievementOrmEntity } from '../entities/AchievementOrmEntity';
|
||||
import { UserAchievementOrmEntity } from '../entities/UserAchievementOrmEntity';
|
||||
import { TypeOrmPersistenceSchemaError } from '../errors/TypeOrmPersistenceSchemaError';
|
||||
import {
|
||||
assertArray,
|
||||
assertBoolean,
|
||||
assertDate,
|
||||
assertEnumValue,
|
||||
assertInteger,
|
||||
assertNonEmptyString,
|
||||
assertOptionalStringOrNull,
|
||||
} from '../schema/AchievementSchemaGuard';
|
||||
|
||||
const VALID_CATEGORIES = ['driver', 'steward', 'admin', 'community'] as const satisfies readonly AchievementCategory[];
|
||||
const VALID_RARITIES = ['common', 'uncommon', 'rare', 'epic', 'legendary'] as const;
|
||||
const VALID_OPERATORS = ['>=', '>', '=', '<', '<='] as const;
|
||||
|
||||
export class AchievementOrmMapper {
|
||||
toOrmEntity(domain: Achievement): AchievementOrmEntity {
|
||||
const entity = new AchievementOrmEntity();
|
||||
entity.id = domain.id;
|
||||
entity.name = domain.name;
|
||||
entity.description = domain.description;
|
||||
entity.category = domain.category;
|
||||
entity.rarity = domain.rarity;
|
||||
entity.iconUrl = domain.iconUrl || null;
|
||||
entity.points = domain.points;
|
||||
entity.requirements = domain.requirements;
|
||||
entity.isSecret = domain.isSecret;
|
||||
entity.createdAt = domain.createdAt;
|
||||
return entity;
|
||||
}
|
||||
|
||||
toDomain(entity: AchievementOrmEntity): Achievement {
|
||||
const entityName = 'Achievement';
|
||||
|
||||
// Validate all required fields
|
||||
assertNonEmptyString(entityName, 'id', entity.id);
|
||||
assertNonEmptyString(entityName, 'name', entity.name);
|
||||
assertNonEmptyString(entityName, 'description', entity.description);
|
||||
assertEnumValue(entityName, 'category', entity.category, VALID_CATEGORIES);
|
||||
assertEnumValue(entityName, 'rarity', entity.rarity, VALID_RARITIES);
|
||||
assertOptionalStringOrNull(entityName, 'iconUrl', entity.iconUrl);
|
||||
assertInteger(entityName, 'points', entity.points);
|
||||
assertArray(entityName, 'requirements', entity.requirements);
|
||||
assertBoolean(entityName, 'isSecret', entity.isSecret);
|
||||
assertDate(entityName, 'createdAt', entity.createdAt);
|
||||
|
||||
// Validate requirements structure
|
||||
for (let i = 0; i < entity.requirements.length; i++) {
|
||||
const req = entity.requirements[i];
|
||||
const reqField = `requirements[${i}]`;
|
||||
|
||||
if (!req || typeof req !== 'object') {
|
||||
throw new TypeOrmPersistenceSchemaError({
|
||||
entityName,
|
||||
fieldName: reqField,
|
||||
reason: 'invalid_requirement_object',
|
||||
});
|
||||
}
|
||||
|
||||
assertNonEmptyString(entityName, `${reqField}.type`, req.type);
|
||||
assertInteger(entityName, `${reqField}.value`, req.value);
|
||||
assertEnumValue(entityName, `${reqField}.operator`, req.operator, VALID_OPERATORS);
|
||||
}
|
||||
|
||||
try {
|
||||
const createProps: any = {
|
||||
id: entity.id,
|
||||
name: entity.name,
|
||||
description: entity.description,
|
||||
category: entity.category as AchievementCategory,
|
||||
rarity: entity.rarity as any,
|
||||
points: entity.points,
|
||||
requirements: entity.requirements as AchievementRequirement[],
|
||||
isSecret: entity.isSecret,
|
||||
createdAt: entity.createdAt,
|
||||
};
|
||||
|
||||
if (entity.iconUrl !== null) {
|
||||
createProps.iconUrl = entity.iconUrl;
|
||||
}
|
||||
|
||||
return Achievement.create(createProps);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Invalid persisted Achievement';
|
||||
throw new TypeOrmPersistenceSchemaError({ entityName, fieldName: 'unknown', reason: 'invalid_shape', message });
|
||||
}
|
||||
}
|
||||
|
||||
toUserAchievementOrmEntity(domain: UserAchievement): UserAchievementOrmEntity {
|
||||
const entity = new UserAchievementOrmEntity();
|
||||
entity.id = domain.id;
|
||||
entity.userId = domain.userId;
|
||||
entity.achievementId = domain.achievementId;
|
||||
entity.progress = domain.progress;
|
||||
entity.earnedAt = domain.earnedAt;
|
||||
entity.notifiedAt = domain.notifiedAt || null;
|
||||
return entity;
|
||||
}
|
||||
|
||||
toUserAchievementDomain(entity: UserAchievementOrmEntity): UserAchievement {
|
||||
const entityName = 'UserAchievement';
|
||||
|
||||
// Validate all required fields
|
||||
assertNonEmptyString(entityName, 'id', entity.id);
|
||||
assertNonEmptyString(entityName, 'userId', entity.userId);
|
||||
assertNonEmptyString(entityName, 'achievementId', entity.achievementId);
|
||||
assertInteger(entityName, 'progress', entity.progress);
|
||||
assertDate(entityName, 'earnedAt', entity.earnedAt);
|
||||
assertOptionalStringOrNull(entityName, 'notifiedAt', entity.notifiedAt);
|
||||
|
||||
try {
|
||||
return UserAchievement.create({
|
||||
id: entity.id,
|
||||
userId: entity.userId,
|
||||
achievementId: entity.achievementId,
|
||||
progress: entity.progress,
|
||||
earnedAt: entity.earnedAt,
|
||||
...(entity.notifiedAt ? { notifiedAt: entity.notifiedAt } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Invalid persisted UserAchievement';
|
||||
throw new TypeOrmPersistenceSchemaError({ entityName, fieldName: 'unknown', reason: 'invalid_shape', message });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import type { DataSource } from 'typeorm';
|
||||
|
||||
import type { IAchievementRepository } from '@core/identity/domain/repositories/IAchievementRepository';
|
||||
import type { AchievementCategory } from '@core/identity/domain/entities/Achievement';
|
||||
import { Achievement } from '@core/identity/domain/entities/Achievement';
|
||||
import { UserAchievement } from '@core/identity/domain/entities/UserAchievement';
|
||||
|
||||
import { AchievementOrmEntity } from '../entities/AchievementOrmEntity';
|
||||
import { UserAchievementOrmEntity } from '../entities/UserAchievementOrmEntity';
|
||||
import { AchievementOrmMapper } from '../mappers/AchievementOrmMapper';
|
||||
|
||||
export class TypeOrmAchievementRepository implements IAchievementRepository {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly mapper: AchievementOrmMapper,
|
||||
) {}
|
||||
|
||||
// Achievement operations
|
||||
async findAchievementById(id: string): Promise<Achievement | null> {
|
||||
const repo = this.dataSource.getRepository(AchievementOrmEntity);
|
||||
const entity = await repo.findOne({ where: { id } });
|
||||
return entity ? this.mapper.toDomain(entity) : null;
|
||||
}
|
||||
|
||||
async findAllAchievements(): Promise<Achievement[]> {
|
||||
const repo = this.dataSource.getRepository(AchievementOrmEntity);
|
||||
const entities = await repo.find();
|
||||
return entities.map((e) => this.mapper.toDomain(e));
|
||||
}
|
||||
|
||||
async findAchievementsByCategory(category: AchievementCategory): Promise<Achievement[]> {
|
||||
const repo = this.dataSource.getRepository(AchievementOrmEntity);
|
||||
const entities = await repo.find({ where: { category } });
|
||||
return entities.map((e) => this.mapper.toDomain(e));
|
||||
}
|
||||
|
||||
async createAchievement(achievement: Achievement): Promise<Achievement> {
|
||||
const repo = this.dataSource.getRepository(AchievementOrmEntity);
|
||||
const entity = this.mapper.toOrmEntity(achievement);
|
||||
await repo.save(entity);
|
||||
return achievement;
|
||||
}
|
||||
|
||||
// UserAchievement operations
|
||||
async findUserAchievementById(id: string): Promise<UserAchievement | null> {
|
||||
const repo = this.dataSource.getRepository(UserAchievementOrmEntity);
|
||||
const entity = await repo.findOne({ where: { id } });
|
||||
return entity ? this.mapper.toUserAchievementDomain(entity) : null;
|
||||
}
|
||||
|
||||
async findUserAchievementsByUserId(userId: string): Promise<UserAchievement[]> {
|
||||
const repo = this.dataSource.getRepository(UserAchievementOrmEntity);
|
||||
const entities = await repo.find({ where: { userId } });
|
||||
return entities.map((e) => this.mapper.toUserAchievementDomain(e));
|
||||
}
|
||||
|
||||
async findUserAchievementByUserAndAchievement(userId: string, achievementId: string): Promise<UserAchievement | null> {
|
||||
const repo = this.dataSource.getRepository(UserAchievementOrmEntity);
|
||||
const entity = await repo.findOne({ where: { userId, achievementId } });
|
||||
return entity ? this.mapper.toUserAchievementDomain(entity) : null;
|
||||
}
|
||||
|
||||
async hasUserEarnedAchievement(userId: string, achievementId: string): Promise<boolean> {
|
||||
const ua = await this.findUserAchievementByUserAndAchievement(userId, achievementId);
|
||||
return ua !== null && ua.isComplete();
|
||||
}
|
||||
|
||||
async createUserAchievement(userAchievement: UserAchievement): Promise<UserAchievement> {
|
||||
const repo = this.dataSource.getRepository(UserAchievementOrmEntity);
|
||||
const entity = this.mapper.toUserAchievementOrmEntity(userAchievement);
|
||||
await repo.save(entity);
|
||||
return userAchievement;
|
||||
}
|
||||
|
||||
async updateUserAchievement(userAchievement: UserAchievement): Promise<UserAchievement> {
|
||||
const repo = this.dataSource.getRepository(UserAchievementOrmEntity);
|
||||
const entity = this.mapper.toUserAchievementOrmEntity(userAchievement);
|
||||
await repo.save(entity);
|
||||
return userAchievement;
|
||||
}
|
||||
|
||||
// Stats
|
||||
async getAchievementLeaderboard(limit: number): Promise<{ userId: string; points: number; count: number }[]> {
|
||||
const userAchievementRepo = this.dataSource.getRepository(UserAchievementOrmEntity);
|
||||
const achievementRepo = this.dataSource.getRepository(AchievementOrmEntity);
|
||||
|
||||
// Get all completed user achievements
|
||||
const userAchievements = await userAchievementRepo.find({
|
||||
where: { progress: 100 },
|
||||
});
|
||||
|
||||
// Build stats map
|
||||
const userStats = new Map<string, { points: number; count: number }>();
|
||||
|
||||
for (const ua of userAchievements) {
|
||||
const achievement = await achievementRepo.findOne({ where: { id: ua.achievementId } });
|
||||
if (!achievement) continue;
|
||||
|
||||
const existing = userStats.get(ua.userId) || { points: 0, count: 0 };
|
||||
userStats.set(ua.userId, {
|
||||
points: existing.points + achievement.points,
|
||||
count: existing.count + 1,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort and return top N
|
||||
return Array.from(userStats.entries())
|
||||
.map(([userId, stats]) => ({ userId, ...stats }))
|
||||
.sort((a, b) => b.points - a.points || b.count - a.count)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
async getUserAchievementStats(userId: string): Promise<{
|
||||
total: number;
|
||||
points: number;
|
||||
byCategory: Record<AchievementCategory, number>;
|
||||
}> {
|
||||
const userAchievementRepo = this.dataSource.getRepository(UserAchievementOrmEntity);
|
||||
const achievementRepo = this.dataSource.getRepository(AchievementOrmEntity);
|
||||
|
||||
const userAchievements = await userAchievementRepo.find({
|
||||
where: { userId, progress: 100 },
|
||||
});
|
||||
|
||||
const byCategory: Record<AchievementCategory, number> = {
|
||||
driver: 0,
|
||||
steward: 0,
|
||||
admin: 0,
|
||||
community: 0,
|
||||
};
|
||||
|
||||
let points = 0;
|
||||
|
||||
for (const ua of userAchievements) {
|
||||
const achievement = await achievementRepo.findOne({ where: { id: ua.achievementId } });
|
||||
if (achievement) {
|
||||
points += achievement.points;
|
||||
if (achievement.category in byCategory) {
|
||||
byCategory[achievement.category as AchievementCategory]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
total: userAchievements.length,
|
||||
points,
|
||||
byCategory,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { TypeOrmPersistenceSchemaError } from '../errors/TypeOrmPersistenceSchemaError';
|
||||
|
||||
export function assertNonEmptyString(entityName: string, fieldName: string, value: unknown): asserts value is string {
|
||||
if (typeof value !== 'string') {
|
||||
throw new TypeOrmPersistenceSchemaError({ entityName, fieldName, reason: 'not_string' });
|
||||
}
|
||||
|
||||
if (value.trim().length === 0) {
|
||||
throw new TypeOrmPersistenceSchemaError({ entityName, fieldName, reason: 'empty_string' });
|
||||
}
|
||||
}
|
||||
|
||||
export function assertDate(entityName: string, fieldName: string, value: unknown): asserts value is Date {
|
||||
if (!(value instanceof Date)) {
|
||||
throw new TypeOrmPersistenceSchemaError({ entityName, fieldName, reason: 'not_date' });
|
||||
}
|
||||
if (Number.isNaN(value.getTime())) {
|
||||
throw new TypeOrmPersistenceSchemaError({ entityName, fieldName, reason: 'invalid_date' });
|
||||
}
|
||||
}
|
||||
|
||||
export function assertEnumValue<TAllowed extends string>(
|
||||
entityName: string,
|
||||
fieldName: string,
|
||||
value: unknown,
|
||||
allowed: readonly TAllowed[],
|
||||
): asserts value is TAllowed {
|
||||
if (typeof value !== 'string') {
|
||||
throw new TypeOrmPersistenceSchemaError({ entityName, fieldName, reason: 'not_string' });
|
||||
}
|
||||
|
||||
if (!allowed.includes(value as TAllowed)) {
|
||||
throw new TypeOrmPersistenceSchemaError({ entityName, fieldName, reason: 'invalid_enum_value' });
|
||||
}
|
||||
}
|
||||
|
||||
export function assertArray(entityName: string, fieldName: string, value: unknown): asserts value is unknown[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new TypeOrmPersistenceSchemaError({ entityName, fieldName, reason: 'not_array' });
|
||||
}
|
||||
}
|
||||
|
||||
export function assertNumber(entityName: string, fieldName: string, value: unknown): asserts value is number {
|
||||
if (typeof value !== 'number' || Number.isNaN(value)) {
|
||||
throw new TypeOrmPersistenceSchemaError({ entityName, fieldName, reason: 'not_number' });
|
||||
}
|
||||
}
|
||||
|
||||
export function assertInteger(entityName: string, fieldName: string, value: unknown): asserts value is number {
|
||||
if (typeof value !== 'number' || !Number.isInteger(value)) {
|
||||
throw new TypeOrmPersistenceSchemaError({ entityName, fieldName, reason: 'not_integer' });
|
||||
}
|
||||
}
|
||||
|
||||
export function assertBoolean(entityName: string, fieldName: string, value: unknown): asserts value is boolean {
|
||||
if (typeof value !== 'boolean') {
|
||||
throw new TypeOrmPersistenceSchemaError({ entityName, fieldName, reason: 'not_boolean' });
|
||||
}
|
||||
}
|
||||
|
||||
export function assertOptionalStringOrNull(
|
||||
entityName: string,
|
||||
fieldName: string,
|
||||
value: unknown,
|
||||
): asserts value is string | null | undefined {
|
||||
if (value === null || value === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
throw new TypeOrmPersistenceSchemaError({ entityName, fieldName, reason: 'not_string' });
|
||||
}
|
||||
}
|
||||
|
||||
export function assertRecord(entityName: string, fieldName: string, value: unknown): asserts value is Record<string, unknown> {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
throw new TypeOrmPersistenceSchemaError({ entityName, fieldName, reason: 'not_object' });
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ describe('UserOrmMapper', () => {
|
||||
entity.email = 'alice@example.com';
|
||||
entity.displayName = 'Alice';
|
||||
entity.passwordHash = 'bcrypt-hash';
|
||||
entity.salt = '';
|
||||
entity.salt = 'test-salt';
|
||||
entity.primaryDriverId = null;
|
||||
entity.createdAt = new Date('2025-01-01T00:00:00.000Z');
|
||||
|
||||
@@ -45,7 +45,7 @@ describe('UserOrmMapper', () => {
|
||||
entity.email = 123 as unknown as string;
|
||||
entity.displayName = 'Alice';
|
||||
entity.passwordHash = 'bcrypt-hash';
|
||||
entity.salt = '';
|
||||
entity.salt = 'test-salt';
|
||||
entity.primaryDriverId = null;
|
||||
entity.createdAt = new Date('2025-01-01T00:00:00.000Z');
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import type { DataSource, Repository } from 'typeorm';
|
||||
|
||||
import { TypeOrmAuthRepository } from './TypeOrmAuthRepository';
|
||||
import { UserOrmEntity } from '../entities/UserOrmEntity';
|
||||
import { UserOrmMapper } from '../mappers/UserOrmMapper';
|
||||
import { EmailAddress } from '@core/identity/domain/value-objects/EmailAddress';
|
||||
import { User } from '@core/identity/domain/entities/User';
|
||||
import { PasswordHash } from '@core/identity/domain/value-objects/PasswordHash';
|
||||
import { UserId } from '@core/identity/domain/value-objects/UserId';
|
||||
|
||||
describe('TypeOrmAuthRepository', () => {
|
||||
it('does not construct its own mapper dependencies', () => {
|
||||
const sourcePath = path.resolve(__dirname, 'TypeOrmAuthRepository.ts');
|
||||
const source = fs.readFileSync(sourcePath, 'utf8');
|
||||
|
||||
expect(source).not.toMatch(/new\s+UserOrmMapper\s*\(/);
|
||||
expect(source).not.toMatch(/=\s*new\s+UserOrmMapper\s*\(/);
|
||||
});
|
||||
|
||||
it('requires mapper injection via constructor (no default mapper)', () => {
|
||||
expect(TypeOrmAuthRepository.length).toBe(2);
|
||||
});
|
||||
|
||||
it('uses the injected mapper at runtime (DB-free)', async () => {
|
||||
const ormRepo = {
|
||||
findOne: vi.fn().mockResolvedValue({ id: 'u1', email: 'test@example.com' }),
|
||||
save: vi.fn().mockResolvedValue({ id: 'u1' }),
|
||||
} as unknown as Repository<UserOrmEntity>;
|
||||
|
||||
const dataSource = {
|
||||
getRepository: vi.fn().mockReturnValue(ormRepo),
|
||||
} as unknown as DataSource;
|
||||
|
||||
const mapper = {
|
||||
toDomain: vi.fn().mockReturnValue({
|
||||
getId: () => ({ value: 'u1' }),
|
||||
getEmail: () => 'test@example.com',
|
||||
getDisplayName: () => 'Test User',
|
||||
getPasswordHash: () => ({ value: 'hash' }),
|
||||
getPrimaryDriverId: () => null,
|
||||
}),
|
||||
toOrmEntity: vi.fn().mockReturnValue({ id: 'u1', email: 'test@example.com' }),
|
||||
} as unknown as UserOrmMapper;
|
||||
|
||||
const repo = new TypeOrmAuthRepository(dataSource, mapper);
|
||||
|
||||
// Test findByEmail
|
||||
const email = EmailAddress.create('TEST@EXAMPLE.COM');
|
||||
const user = await repo.findByEmail(email);
|
||||
|
||||
expect(dataSource.getRepository).toHaveBeenCalledTimes(1);
|
||||
expect(ormRepo.findOne).toHaveBeenCalledWith({ where: { email: 'test@example.com' } });
|
||||
expect(mapper.toDomain).toHaveBeenCalledTimes(1);
|
||||
expect(user).toBeDefined();
|
||||
|
||||
// Test save
|
||||
const userId = UserId.create();
|
||||
const passwordHash = PasswordHash.fromHash('hash');
|
||||
const domainUser = User.create({
|
||||
id: userId,
|
||||
email: 'test@example.com',
|
||||
displayName: 'Test User',
|
||||
passwordHash,
|
||||
});
|
||||
|
||||
await repo.save(domainUser);
|
||||
expect(ormRepo.save).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -4,8 +4,8 @@ import { User } from '@core/identity/domain/entities/User';
|
||||
import type { IAuthRepository } from '@core/identity/domain/repositories/IAuthRepository';
|
||||
import type { EmailAddress } from '@core/identity/domain/value-objects/EmailAddress';
|
||||
|
||||
import { UserOrmEntity } from './entities/UserOrmEntity';
|
||||
import { UserOrmMapper } from './mappers/UserOrmMapper';
|
||||
import { UserOrmEntity } from '../entities/UserOrmEntity';
|
||||
import { UserOrmMapper } from '../mappers/UserOrmMapper';
|
||||
|
||||
export class TypeOrmAuthRepository implements IAuthRepository {
|
||||
constructor(
|
||||
@@ -1,8 +1,11 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import type { DataSource, Repository } from 'typeorm';
|
||||
|
||||
import { TypeOrmUserRepository } from './TypeOrmUserRepository';
|
||||
import { UserOrmEntity } from '../entities/UserOrmEntity';
|
||||
import { UserOrmMapper } from '../mappers/UserOrmMapper';
|
||||
|
||||
describe('TypeOrmUserRepository', () => {
|
||||
it('does not construct its own mapper dependencies', () => {
|
||||
@@ -20,18 +23,18 @@ describe('TypeOrmUserRepository', () => {
|
||||
it('uses the injected mapper at runtime (DB-free)', async () => {
|
||||
const ormRepo = {
|
||||
findOne: vi.fn().mockResolvedValue({ id: 'u1' }),
|
||||
};
|
||||
} as unknown as Repository<UserOrmEntity>;
|
||||
|
||||
const dataSource = {
|
||||
getRepository: vi.fn().mockReturnValue(ormRepo),
|
||||
};
|
||||
} as unknown as DataSource;
|
||||
|
||||
const mapper = {
|
||||
toStored: vi.fn().mockReturnValue({ id: 'stored-u1' }),
|
||||
toOrmEntity: vi.fn(),
|
||||
};
|
||||
} as unknown as UserOrmMapper;
|
||||
|
||||
const repo = new TypeOrmUserRepository(dataSource as any, mapper as any);
|
||||
const repo = new TypeOrmUserRepository(dataSource, mapper);
|
||||
|
||||
const user = await repo.findByEmail('ALICE@EXAMPLE.COM');
|
||||
|
||||
@@ -2,8 +2,8 @@ import type { DataSource } from 'typeorm';
|
||||
|
||||
import type { IUserRepository, StoredUser } from '@core/identity/domain/repositories/IUserRepository';
|
||||
|
||||
import { UserOrmEntity } from './entities/UserOrmEntity';
|
||||
import { UserOrmMapper } from './mappers/UserOrmMapper';
|
||||
import { UserOrmEntity } from '../entities/UserOrmEntity';
|
||||
import { UserOrmMapper } from '../mappers/UserOrmMapper';
|
||||
|
||||
export class TypeOrmUserRepository implements IUserRepository {
|
||||
constructor(
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Column, CreateDateColumn, Entity, Index, PrimaryColumn, UpdateDateColumn } from 'typeorm';
|
||||
|
||||
@Entity({ name: 'avatar_generation_requests' })
|
||||
export class AvatarGenerationRequestOrmEntity {
|
||||
@PrimaryColumn({ type: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@Index()
|
||||
@Column({ type: 'text' })
|
||||
userId!: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
facePhotoUrl!: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
suitColor!: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
style!: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
status!: string;
|
||||
|
||||
@Column({ type: 'jsonb' })
|
||||
generatedAvatarUrls!: string[];
|
||||
|
||||
@Column({ type: 'integer', nullable: true })
|
||||
selectedAvatarIndex!: number | null;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
errorMessage!: string | null;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ type: 'timestamptz' })
|
||||
updatedAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Column, CreateDateColumn, Entity, Index, PrimaryColumn } from 'typeorm';
|
||||
|
||||
@Entity({ name: 'avatars' })
|
||||
export class AvatarOrmEntity {
|
||||
@PrimaryColumn({ type: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@Index()
|
||||
@Column({ type: 'text' })
|
||||
driverId!: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
mediaUrl!: string;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
selectedAt!: Date;
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
isActive!: boolean;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Column, CreateDateColumn, Entity, Index, PrimaryColumn } from 'typeorm';
|
||||
|
||||
@Entity({ name: 'media_files' })
|
||||
export class MediaOrmEntity {
|
||||
@PrimaryColumn({ type: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
filename!: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
originalName!: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
mimeType!: string;
|
||||
|
||||
@Column({ type: 'integer' })
|
||||
size!: number;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
url!: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
type!: string;
|
||||
|
||||
@Index()
|
||||
@Column({ type: 'text' })
|
||||
uploadedBy!: string;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
uploadedAt!: Date;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
metadata!: Record<string, unknown> | null;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
export type TypeOrmMediaSchemaErrorReason =
|
||||
| 'missing'
|
||||
| 'not_string'
|
||||
| 'empty_string'
|
||||
| 'not_number'
|
||||
| 'not_integer'
|
||||
| 'not_boolean'
|
||||
| 'not_date'
|
||||
| 'invalid_date'
|
||||
| 'not_iso_date'
|
||||
| 'not_array'
|
||||
| 'not_object'
|
||||
| 'invalid_enum_value'
|
||||
| 'invalid_shape';
|
||||
|
||||
export class TypeOrmMediaSchemaError extends Error {
|
||||
readonly entityName: string;
|
||||
readonly fieldName: string;
|
||||
readonly reason: TypeOrmMediaSchemaErrorReason | (string & {});
|
||||
|
||||
constructor(params: {
|
||||
entityName: string;
|
||||
fieldName: string;
|
||||
reason: TypeOrmMediaSchemaError['reason'];
|
||||
message?: string;
|
||||
}) {
|
||||
const message = params.message ?? `Invalid persisted ${params.entityName}.${params.fieldName}: ${params.reason}`;
|
||||
super(message);
|
||||
this.name = 'TypeOrmMediaSchemaError';
|
||||
this.entityName = params.entityName;
|
||||
this.fieldName = params.fieldName;
|
||||
this.reason = params.reason;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AvatarGenerationRequest } from '@core/media/domain/entities/AvatarGenerationRequest';
|
||||
|
||||
import { AvatarGenerationRequestOrmEntity } from '../entities/AvatarGenerationRequestOrmEntity';
|
||||
import { TypeOrmMediaSchemaError } from '../errors/TypeOrmMediaSchemaError';
|
||||
import { AvatarGenerationRequestOrmMapper } from './AvatarGenerationRequestOrmMapper';
|
||||
|
||||
describe('AvatarGenerationRequestOrmMapper', () => {
|
||||
it('toDomain preserves persisted identity and uses reconstitute semantics', () => {
|
||||
const mapper = new AvatarGenerationRequestOrmMapper();
|
||||
|
||||
const entity = new AvatarGenerationRequestOrmEntity();
|
||||
entity.id = '00000000-0000-4000-8000-000000000001';
|
||||
entity.userId = 'user-123';
|
||||
entity.facePhotoUrl = 'https://cdn.example.com/faces/face-1.png';
|
||||
entity.suitColor = 'red';
|
||||
entity.style = 'realistic';
|
||||
entity.status = 'completed';
|
||||
entity.generatedAvatarUrls = ['https://cdn.example.com/avatars/av-1.png', 'https://cdn.example.com/avatars/av-2.png'];
|
||||
entity.selectedAvatarIndex = 0;
|
||||
entity.errorMessage = null;
|
||||
entity.createdAt = new Date('2025-01-01T00:00:00.000Z');
|
||||
entity.updatedAt = new Date('2025-01-01T01:00:00.000Z');
|
||||
|
||||
const reconstituteSpy = vi.spyOn(AvatarGenerationRequest as unknown as { reconstitute: (...args: unknown[]) => unknown }, 'reconstitute');
|
||||
|
||||
const domain = mapper.toDomain(entity);
|
||||
|
||||
expect(domain.id).toBe(entity.id);
|
||||
expect(domain.userId).toBe(entity.userId);
|
||||
expect(domain.facePhotoUrl.value).toBe(entity.facePhotoUrl);
|
||||
expect(domain.suitColor).toBe(entity.suitColor);
|
||||
expect(domain.status).toBe(entity.status);
|
||||
expect(domain.generatedAvatarUrls).toEqual(entity.generatedAvatarUrls);
|
||||
expect(domain.selectedAvatarIndex).toBe(entity.selectedAvatarIndex);
|
||||
|
||||
expect(reconstituteSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('toDomain validates persisted shape', () => {
|
||||
const mapper = new AvatarGenerationRequestOrmMapper();
|
||||
|
||||
const entity = new AvatarGenerationRequestOrmEntity();
|
||||
entity.id = '00000000-0000-4000-8000-000000000001';
|
||||
entity.userId = 123 as unknown as string;
|
||||
entity.facePhotoUrl = 'https://cdn.example.com/faces/face-1.png';
|
||||
entity.suitColor = 'red';
|
||||
entity.style = 'realistic';
|
||||
entity.status = 'completed';
|
||||
entity.generatedAvatarUrls = [];
|
||||
entity.selectedAvatarIndex = null;
|
||||
entity.errorMessage = null;
|
||||
entity.createdAt = new Date('2025-01-01T00:00:00.000Z');
|
||||
entity.updatedAt = new Date('2025-01-01T01:00:00.000Z');
|
||||
|
||||
try {
|
||||
mapper.toDomain(entity);
|
||||
throw new Error('expected-to-throw');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(TypeOrmMediaSchemaError);
|
||||
expect(error).toMatchObject({
|
||||
entityName: 'AvatarGenerationRequest',
|
||||
fieldName: 'userId',
|
||||
reason: 'not_string',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('toOrmEntity converts domain entity to ORM entity', () => {
|
||||
const mapper = new AvatarGenerationRequestOrmMapper();
|
||||
|
||||
const domain = AvatarGenerationRequest.create({
|
||||
id: '00000000-0000-4000-8000-000000000001',
|
||||
userId: 'user-123',
|
||||
facePhotoUrl: 'https://cdn.example.com/faces/face-1.png',
|
||||
suitColor: 'blue',
|
||||
style: 'cartoon',
|
||||
});
|
||||
|
||||
// Simulate completion
|
||||
domain.completeWithAvatars(['https://cdn.example.com/avatars/av-1.png']);
|
||||
|
||||
const entity = mapper.toOrmEntity(domain);
|
||||
|
||||
expect(entity.id).toBe(domain.id);
|
||||
expect(entity.userId).toBe(domain.userId);
|
||||
expect(entity.facePhotoUrl).toBe(domain.facePhotoUrl.value);
|
||||
expect(entity.suitColor).toBe(domain.suitColor);
|
||||
expect(entity.style).toBe(domain.style);
|
||||
expect(entity.status).toBe(domain.status);
|
||||
expect(entity.generatedAvatarUrls).toEqual(domain.generatedAvatarUrls);
|
||||
expect(entity.selectedAvatarIndex).toBeNull();
|
||||
expect(entity.errorMessage).toBeNull();
|
||||
});
|
||||
|
||||
it('toDomain handles optional fields correctly', () => {
|
||||
const mapper = new AvatarGenerationRequestOrmMapper();
|
||||
|
||||
const entity = new AvatarGenerationRequestOrmEntity();
|
||||
entity.id = '00000000-0000-4000-8000-000000000001';
|
||||
entity.userId = 'user-123';
|
||||
entity.facePhotoUrl = 'https://cdn.example.com/faces/face-1.png';
|
||||
entity.suitColor = 'red';
|
||||
entity.style = 'realistic';
|
||||
entity.status = 'failed';
|
||||
entity.generatedAvatarUrls = [];
|
||||
entity.selectedAvatarIndex = null;
|
||||
entity.errorMessage = 'Generation failed';
|
||||
entity.createdAt = new Date('2025-01-01T00:00:00.000Z');
|
||||
entity.updatedAt = new Date('2025-01-01T01:00:00.000Z');
|
||||
|
||||
const domain = mapper.toDomain(entity);
|
||||
|
||||
expect(domain.errorMessage).toBe('Generation failed');
|
||||
expect(domain.selectedAvatarIndex).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { AvatarGenerationRequest } from '@core/media/domain/entities/AvatarGenerationRequest';
|
||||
import { AvatarGenerationRequestOrmEntity } from '../entities/AvatarGenerationRequestOrmEntity';
|
||||
import { TypeOrmMediaSchemaError } from '../errors/TypeOrmMediaSchemaError';
|
||||
import {
|
||||
assertNonEmptyString,
|
||||
assertDate,
|
||||
assertStringArray,
|
||||
assertOptionalIntegerOrNull,
|
||||
assertOptionalStringOrNull,
|
||||
assertRacingSuitColor,
|
||||
assertAvatarStyle,
|
||||
assertAvatarGenerationStatus,
|
||||
} from '../schema/TypeOrmMediaSchemaGuards';
|
||||
|
||||
export class AvatarGenerationRequestOrmMapper {
|
||||
toDomain(entity: AvatarGenerationRequestOrmEntity): AvatarGenerationRequest {
|
||||
const entityName = 'AvatarGenerationRequest';
|
||||
|
||||
try {
|
||||
assertNonEmptyString(entityName, 'id', entity.id);
|
||||
assertNonEmptyString(entityName, 'userId', entity.userId);
|
||||
assertNonEmptyString(entityName, 'facePhotoUrl', entity.facePhotoUrl);
|
||||
assertRacingSuitColor(entityName, 'suitColor', entity.suitColor);
|
||||
assertAvatarStyle(entityName, 'style', entity.style);
|
||||
assertAvatarGenerationStatus(entityName, 'status', entity.status);
|
||||
assertStringArray(entityName, 'generatedAvatarUrls', entity.generatedAvatarUrls);
|
||||
assertOptionalIntegerOrNull(entityName, 'selectedAvatarIndex', entity.selectedAvatarIndex);
|
||||
assertOptionalStringOrNull(entityName, 'errorMessage', entity.errorMessage);
|
||||
assertDate(entityName, 'createdAt', entity.createdAt);
|
||||
assertDate(entityName, 'updatedAt', entity.updatedAt);
|
||||
} catch (error) {
|
||||
if (error instanceof TypeOrmMediaSchemaError) {
|
||||
throw error;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : 'Invalid persisted AvatarGenerationRequest';
|
||||
throw new TypeOrmMediaSchemaError({ entityName, fieldName: 'unknown', reason: 'invalid_shape', message });
|
||||
}
|
||||
|
||||
try {
|
||||
const props: any = {
|
||||
id: entity.id,
|
||||
userId: entity.userId,
|
||||
facePhotoUrl: entity.facePhotoUrl,
|
||||
suitColor: entity.suitColor,
|
||||
style: entity.style,
|
||||
status: entity.status,
|
||||
generatedAvatarUrls: entity.generatedAvatarUrls,
|
||||
createdAt: entity.createdAt,
|
||||
updatedAt: entity.updatedAt,
|
||||
};
|
||||
|
||||
if (entity.selectedAvatarIndex !== null && entity.selectedAvatarIndex !== undefined) {
|
||||
props.selectedAvatarIndex = entity.selectedAvatarIndex;
|
||||
}
|
||||
|
||||
if (entity.errorMessage !== null && entity.errorMessage !== undefined) {
|
||||
props.errorMessage = entity.errorMessage;
|
||||
}
|
||||
|
||||
return AvatarGenerationRequest.reconstitute(props);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Invalid persisted AvatarGenerationRequest';
|
||||
throw new TypeOrmMediaSchemaError({ entityName, fieldName: 'unknown', reason: 'invalid_shape', message });
|
||||
}
|
||||
}
|
||||
|
||||
toOrmEntity(request: AvatarGenerationRequest): AvatarGenerationRequestOrmEntity {
|
||||
const entity = new AvatarGenerationRequestOrmEntity();
|
||||
const props = request.toProps();
|
||||
|
||||
entity.id = props.id;
|
||||
entity.userId = props.userId;
|
||||
entity.facePhotoUrl = props.facePhotoUrl;
|
||||
entity.suitColor = props.suitColor;
|
||||
entity.style = props.style;
|
||||
entity.status = props.status;
|
||||
entity.generatedAvatarUrls = props.generatedAvatarUrls;
|
||||
entity.selectedAvatarIndex = props.selectedAvatarIndex ?? null;
|
||||
entity.errorMessage = props.errorMessage ?? null;
|
||||
entity.createdAt = props.createdAt;
|
||||
entity.updatedAt = props.updatedAt;
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
toStored(entity: AvatarGenerationRequestOrmEntity): AvatarGenerationRequest {
|
||||
return this.toDomain(entity);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { Avatar } from '@core/media/domain/entities/Avatar';
|
||||
|
||||
import { AvatarOrmEntity } from '../entities/AvatarOrmEntity';
|
||||
import { TypeOrmMediaSchemaError } from '../errors/TypeOrmMediaSchemaError';
|
||||
import { AvatarOrmMapper } from './AvatarOrmMapper';
|
||||
|
||||
describe('AvatarOrmMapper', () => {
|
||||
it('toDomain preserves persisted identity and uses reconstitute semantics', () => {
|
||||
const mapper = new AvatarOrmMapper();
|
||||
|
||||
const entity = new AvatarOrmEntity();
|
||||
entity.id = '00000000-0000-4000-8000-000000000001';
|
||||
entity.driverId = 'driver-123';
|
||||
entity.mediaUrl = 'https://cdn.example.com/avatars/avatar-1.png';
|
||||
entity.selectedAt = new Date('2025-01-01T00:00:00.000Z');
|
||||
entity.isActive = true;
|
||||
|
||||
const reconstituteSpy = vi.spyOn(Avatar as unknown as { reconstitute: (...args: unknown[]) => unknown }, 'reconstitute');
|
||||
|
||||
const domain = mapper.toDomain(entity);
|
||||
|
||||
expect(domain.id).toBe(entity.id);
|
||||
expect(domain.driverId).toBe(entity.driverId);
|
||||
expect(domain.mediaUrl.value).toBe(entity.mediaUrl);
|
||||
expect(domain.isActive).toBe(entity.isActive);
|
||||
|
||||
expect(reconstituteSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('toDomain validates persisted shape', () => {
|
||||
const mapper = new AvatarOrmMapper();
|
||||
|
||||
const entity = new AvatarOrmEntity();
|
||||
entity.id = '00000000-0000-4000-8000-000000000001';
|
||||
entity.driverId = 123 as unknown as string;
|
||||
entity.mediaUrl = 'https://cdn.example.com/avatars/avatar-1.png';
|
||||
entity.selectedAt = new Date('2025-01-01T00:00:00.000Z');
|
||||
entity.isActive = true;
|
||||
|
||||
try {
|
||||
mapper.toDomain(entity);
|
||||
throw new Error('expected-to-throw');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(TypeOrmMediaSchemaError);
|
||||
expect(error).toMatchObject({
|
||||
entityName: 'Avatar',
|
||||
fieldName: 'driverId',
|
||||
reason: 'not_string',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('toOrmEntity converts domain entity to ORM entity', () => {
|
||||
const mapper = new AvatarOrmMapper();
|
||||
|
||||
const domain = Avatar.create({
|
||||
id: '00000000-0000-4000-8000-000000000001',
|
||||
driverId: 'driver-123',
|
||||
mediaUrl: 'https://cdn.example.com/avatars/avatar-1.png',
|
||||
});
|
||||
|
||||
const entity = mapper.toOrmEntity(domain);
|
||||
|
||||
expect(entity.id).toBe(domain.id);
|
||||
expect(entity.driverId).toBe(domain.driverId);
|
||||
expect(entity.mediaUrl).toBe(domain.mediaUrl.value);
|
||||
expect(entity.isActive).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Avatar } from '@core/media/domain/entities/Avatar';
|
||||
import { AvatarOrmEntity } from '../entities/AvatarOrmEntity';
|
||||
import { TypeOrmMediaSchemaError } from '../errors/TypeOrmMediaSchemaError';
|
||||
import {
|
||||
assertNonEmptyString,
|
||||
assertDate,
|
||||
assertBoolean,
|
||||
} from '../schema/TypeOrmMediaSchemaGuards';
|
||||
|
||||
export class AvatarOrmMapper {
|
||||
toDomain(entity: AvatarOrmEntity): Avatar {
|
||||
const entityName = 'Avatar';
|
||||
|
||||
try {
|
||||
assertNonEmptyString(entityName, 'id', entity.id);
|
||||
assertNonEmptyString(entityName, 'driverId', entity.driverId);
|
||||
assertNonEmptyString(entityName, 'mediaUrl', entity.mediaUrl);
|
||||
assertDate(entityName, 'selectedAt', entity.selectedAt);
|
||||
assertBoolean(entityName, 'isActive', entity.isActive);
|
||||
} catch (error) {
|
||||
if (error instanceof TypeOrmMediaSchemaError) {
|
||||
throw error;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : 'Invalid persisted Avatar';
|
||||
throw new TypeOrmMediaSchemaError({ entityName, fieldName: 'unknown', reason: 'invalid_shape', message });
|
||||
}
|
||||
|
||||
try {
|
||||
return Avatar.reconstitute({
|
||||
id: entity.id,
|
||||
driverId: entity.driverId,
|
||||
mediaUrl: entity.mediaUrl,
|
||||
selectedAt: entity.selectedAt,
|
||||
isActive: entity.isActive,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Invalid persisted Avatar';
|
||||
throw new TypeOrmMediaSchemaError({ entityName, fieldName: 'unknown', reason: 'invalid_shape', message });
|
||||
}
|
||||
}
|
||||
|
||||
toOrmEntity(avatar: Avatar): AvatarOrmEntity {
|
||||
const entity = new AvatarOrmEntity();
|
||||
const props = avatar.toProps();
|
||||
|
||||
entity.id = props.id;
|
||||
entity.driverId = props.driverId;
|
||||
entity.mediaUrl = props.mediaUrl;
|
||||
entity.selectedAt = props.selectedAt;
|
||||
entity.isActive = props.isActive;
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
toStored(entity: AvatarOrmEntity): Avatar {
|
||||
return this.toDomain(entity);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { Media } from '@core/media/domain/entities/Media';
|
||||
|
||||
import { MediaOrmEntity } from '../entities/MediaOrmEntity';
|
||||
import { TypeOrmMediaSchemaError } from '../errors/TypeOrmMediaSchemaError';
|
||||
import { MediaOrmMapper } from './MediaOrmMapper';
|
||||
|
||||
describe('MediaOrmMapper', () => {
|
||||
it('toDomain preserves persisted identity and uses reconstitute semantics (does not call create)', () => {
|
||||
const mapper = new MediaOrmMapper();
|
||||
|
||||
const entity = new MediaOrmEntity();
|
||||
entity.id = '00000000-0000-4000-8000-000000000001';
|
||||
entity.filename = 'test-image.png';
|
||||
entity.originalName = 'original.png';
|
||||
entity.mimeType = 'image/png';
|
||||
entity.size = 12345;
|
||||
entity.url = 'https://cdn.example.com/test-image.png';
|
||||
entity.type = 'image';
|
||||
entity.uploadedBy = 'user-123';
|
||||
entity.uploadedAt = new Date('2025-01-01T00:00:00.000Z');
|
||||
entity.metadata = { width: 800, height: 600 };
|
||||
|
||||
if (typeof (Media as unknown as { reconstitute?: unknown }).reconstitute !== 'function') {
|
||||
throw new Error('reconstitute-missing');
|
||||
}
|
||||
|
||||
const reconstituteSpy = vi.spyOn(Media as unknown as { reconstitute: (...args: unknown[]) => unknown }, 'reconstitute');
|
||||
|
||||
const domain = mapper.toDomain(entity);
|
||||
|
||||
expect(domain.id).toBe(entity.id);
|
||||
expect(domain.filename).toBe(entity.filename);
|
||||
expect(domain.url.value).toBe(entity.url);
|
||||
|
||||
expect(reconstituteSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('toDomain validates persisted shape and throws adapter-scoped base schema error type', () => {
|
||||
const mapper = new MediaOrmMapper();
|
||||
|
||||
const entity = new MediaOrmEntity();
|
||||
entity.id = '00000000-0000-4000-8000-000000000001';
|
||||
entity.filename = 123 as unknown as string;
|
||||
entity.originalName = 'original.png';
|
||||
entity.mimeType = 'image/png';
|
||||
entity.size = 12345;
|
||||
entity.url = 'https://cdn.example.com/test-image.png';
|
||||
entity.type = 'image';
|
||||
entity.uploadedBy = 'user-123';
|
||||
entity.uploadedAt = new Date('2025-01-01T00:00:00.000Z');
|
||||
entity.metadata = null;
|
||||
|
||||
try {
|
||||
mapper.toDomain(entity);
|
||||
throw new Error('expected-to-throw');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(TypeOrmMediaSchemaError);
|
||||
expect(error).toMatchObject({
|
||||
entityName: 'Media',
|
||||
fieldName: 'filename',
|
||||
reason: 'not_string',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('toOrmEntity converts domain entity to ORM entity', () => {
|
||||
const mapper = new MediaOrmMapper();
|
||||
|
||||
const domain = Media.create({
|
||||
id: '00000000-0000-4000-8000-000000000001',
|
||||
filename: 'test-image.png',
|
||||
originalName: 'original.png',
|
||||
mimeType: 'image/png',
|
||||
size: 12345,
|
||||
url: 'https://cdn.example.com/test-image.png',
|
||||
type: 'image',
|
||||
uploadedBy: 'user-123',
|
||||
metadata: { width: 800, height: 600 },
|
||||
});
|
||||
|
||||
const entity = mapper.toOrmEntity(domain);
|
||||
|
||||
expect(entity.id).toBe(domain.id);
|
||||
expect(entity.filename).toBe(domain.filename);
|
||||
expect(entity.url).toBe(domain.url.value);
|
||||
expect(entity.metadata).toEqual({ width: 800, height: 600 });
|
||||
});
|
||||
|
||||
it('toDomain handles null metadata', () => {
|
||||
const mapper = new MediaOrmMapper();
|
||||
|
||||
const entity = new MediaOrmEntity();
|
||||
entity.id = '00000000-0000-4000-8000-000000000001';
|
||||
entity.filename = 'test-image.png';
|
||||
entity.originalName = 'original.png';
|
||||
entity.mimeType = 'image/png';
|
||||
entity.size = 12345;
|
||||
entity.url = 'https://cdn.example.com/test-image.png';
|
||||
entity.type = 'image';
|
||||
entity.uploadedBy = 'user-123';
|
||||
entity.uploadedAt = new Date('2025-01-01T00:00:00.000Z');
|
||||
entity.metadata = null;
|
||||
|
||||
const domain = mapper.toDomain(entity);
|
||||
|
||||
expect(domain.id).toBe(entity.id);
|
||||
expect(domain.metadata).toBeUndefined();
|
||||
});
|
||||
});
|
||||
78
adapters/media/persistence/typeorm/mappers/MediaOrmMapper.ts
Normal file
78
adapters/media/persistence/typeorm/mappers/MediaOrmMapper.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Media } from '@core/media/domain/entities/Media';
|
||||
import { MediaOrmEntity } from '../entities/MediaOrmEntity';
|
||||
import { TypeOrmMediaSchemaError } from '../errors/TypeOrmMediaSchemaError';
|
||||
import {
|
||||
assertNonEmptyString,
|
||||
assertDate,
|
||||
assertInteger,
|
||||
assertMediaType,
|
||||
} from '../schema/TypeOrmMediaSchemaGuards';
|
||||
|
||||
export class MediaOrmMapper {
|
||||
toDomain(entity: MediaOrmEntity): Media {
|
||||
const entityName = 'Media';
|
||||
|
||||
try {
|
||||
assertNonEmptyString(entityName, 'id', entity.id);
|
||||
assertNonEmptyString(entityName, 'filename', entity.filename);
|
||||
assertNonEmptyString(entityName, 'originalName', entity.originalName);
|
||||
assertNonEmptyString(entityName, 'mimeType', entity.mimeType);
|
||||
assertInteger(entityName, 'size', entity.size);
|
||||
assertNonEmptyString(entityName, 'url', entity.url);
|
||||
assertMediaType(entityName, 'type', entity.type);
|
||||
assertNonEmptyString(entityName, 'uploadedBy', entity.uploadedBy);
|
||||
assertDate(entityName, 'uploadedAt', entity.uploadedAt);
|
||||
} catch (error) {
|
||||
if (error instanceof TypeOrmMediaSchemaError) {
|
||||
throw error;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : 'Invalid persisted Media';
|
||||
throw new TypeOrmMediaSchemaError({ entityName, fieldName: 'unknown', reason: 'invalid_shape', message });
|
||||
}
|
||||
|
||||
try {
|
||||
const domainProps: any = {
|
||||
id: entity.id,
|
||||
filename: entity.filename,
|
||||
originalName: entity.originalName,
|
||||
mimeType: entity.mimeType,
|
||||
size: entity.size,
|
||||
url: entity.url,
|
||||
type: entity.type as 'image' | 'video' | 'document',
|
||||
uploadedBy: entity.uploadedBy,
|
||||
uploadedAt: entity.uploadedAt,
|
||||
};
|
||||
|
||||
if (entity.metadata !== null && entity.metadata !== undefined) {
|
||||
domainProps.metadata = entity.metadata as Record<string, unknown>;
|
||||
}
|
||||
|
||||
return Media.reconstitute(domainProps);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Invalid persisted Media';
|
||||
throw new TypeOrmMediaSchemaError({ entityName, fieldName: 'unknown', reason: 'invalid_shape', message });
|
||||
}
|
||||
}
|
||||
|
||||
toOrmEntity(media: Media): MediaOrmEntity {
|
||||
const entity = new MediaOrmEntity();
|
||||
const props = media.toProps();
|
||||
|
||||
entity.id = props.id;
|
||||
entity.filename = props.filename;
|
||||
entity.originalName = props.originalName;
|
||||
entity.mimeType = props.mimeType;
|
||||
entity.size = props.size;
|
||||
entity.url = props.url;
|
||||
entity.type = props.type;
|
||||
entity.uploadedBy = props.uploadedBy;
|
||||
entity.uploadedAt = props.uploadedAt;
|
||||
entity.metadata = props.metadata ?? null;
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
toStored(entity: MediaOrmEntity): Media {
|
||||
return this.toDomain(entity);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { TypeOrmAvatarGenerationRepository } from './TypeOrmAvatarGenerationRepository';
|
||||
|
||||
describe('TypeOrmAvatarGenerationRepository', () => {
|
||||
it('does not construct its own mapper dependencies', () => {
|
||||
const sourcePath = path.resolve(__dirname, 'TypeOrmAvatarGenerationRepository.ts');
|
||||
const source = fs.readFileSync(sourcePath, 'utf8');
|
||||
|
||||
expect(source).not.toMatch(/new\s+AvatarGenerationRequestOrmMapper\s*\(/);
|
||||
expect(source).not.toMatch(/=\s*new\s+AvatarGenerationRequestOrmMapper\s*\(/);
|
||||
});
|
||||
|
||||
it('requires mapper injection via constructor (no default mapper)', () => {
|
||||
expect(TypeOrmAvatarGenerationRepository.length).toBe(2);
|
||||
});
|
||||
|
||||
it('uses the injected mapper at runtime (DB-free)', async () => {
|
||||
const ormRepo = {
|
||||
findOne: vi.fn().mockResolvedValue({ id: 'request-1' }),
|
||||
find: vi.fn().mockResolvedValue([{ id: 'request-1' }, { id: 'request-2' }]),
|
||||
save: vi.fn().mockResolvedValue({ id: 'request-1' }),
|
||||
delete: vi.fn().mockResolvedValue({ affected: 1 }),
|
||||
};
|
||||
|
||||
const dataSource = {
|
||||
getRepository: vi.fn().mockReturnValue(ormRepo),
|
||||
};
|
||||
|
||||
const mapper = {
|
||||
toStored: vi.fn().mockReturnValue({ id: 'stored-request-1' }),
|
||||
toDomain: vi.fn().mockReturnValue({ id: 'domain-request-1' }),
|
||||
toOrmEntity: vi.fn().mockReturnValue({ id: 'orm-request-1' }),
|
||||
};
|
||||
|
||||
const repo = new TypeOrmAvatarGenerationRepository(dataSource as any, mapper as any);
|
||||
|
||||
// Test findById
|
||||
const request = await repo.findById('request-1');
|
||||
expect(dataSource.getRepository).toHaveBeenCalledTimes(1);
|
||||
expect(ormRepo.findOne).toHaveBeenCalledWith({ where: { id: 'request-1' } });
|
||||
expect(mapper.toDomain).toHaveBeenCalledTimes(1);
|
||||
expect(request).toEqual({ id: 'domain-request-1' });
|
||||
|
||||
// Test findByUserId
|
||||
const requests = await repo.findByUserId('user-1');
|
||||
expect(ormRepo.find).toHaveBeenCalledWith({
|
||||
where: { userId: 'user-1' },
|
||||
order: { createdAt: 'DESC' }
|
||||
});
|
||||
expect(mapper.toDomain).toHaveBeenCalledTimes(3); // 1 from findById + 2 from findByUserId
|
||||
expect(requests).toHaveLength(2);
|
||||
|
||||
// Test findLatestByUserId
|
||||
await repo.findLatestByUserId('user-1');
|
||||
expect(ormRepo.findOne).toHaveBeenCalledWith({
|
||||
where: { userId: 'user-1' },
|
||||
order: { createdAt: 'DESC' }
|
||||
});
|
||||
|
||||
// Test save
|
||||
const domainRequest = { id: 'new-request', toProps: () => ({ id: 'new-request' }) };
|
||||
await repo.save(domainRequest as any);
|
||||
expect(mapper.toOrmEntity).toHaveBeenCalledWith(domainRequest);
|
||||
expect(ormRepo.save).toHaveBeenCalledWith({ id: 'orm-request-1' });
|
||||
|
||||
// Test delete
|
||||
await repo.delete('request-1');
|
||||
expect(ormRepo.delete).toHaveBeenCalledWith({ id: 'request-1' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { DataSource } from 'typeorm';
|
||||
import type { IAvatarGenerationRepository } from '@core/media/domain/repositories/IAvatarGenerationRepository';
|
||||
import type { AvatarGenerationRequest } from '@core/media/domain/entities/AvatarGenerationRequest';
|
||||
import { AvatarGenerationRequestOrmEntity } from '../entities/AvatarGenerationRequestOrmEntity';
|
||||
import { AvatarGenerationRequestOrmMapper } from '../mappers/AvatarGenerationRequestOrmMapper';
|
||||
|
||||
export class TypeOrmAvatarGenerationRepository implements IAvatarGenerationRepository {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly mapper: AvatarGenerationRequestOrmMapper,
|
||||
) {}
|
||||
|
||||
async save(request: AvatarGenerationRequest): Promise<void> {
|
||||
const repo = this.dataSource.getRepository(AvatarGenerationRequestOrmEntity);
|
||||
const entity = this.mapper.toOrmEntity(request);
|
||||
await repo.save(entity);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<AvatarGenerationRequest | null> {
|
||||
const repo = this.dataSource.getRepository(AvatarGenerationRequestOrmEntity);
|
||||
const entity = await repo.findOne({ where: { id } });
|
||||
return entity ? this.mapper.toDomain(entity) : null;
|
||||
}
|
||||
|
||||
async findByUserId(userId: string): Promise<AvatarGenerationRequest[]> {
|
||||
const repo = this.dataSource.getRepository(AvatarGenerationRequestOrmEntity);
|
||||
const entities = await repo.find({
|
||||
where: { userId },
|
||||
order: { createdAt: 'DESC' }
|
||||
});
|
||||
return entities.map(entity => this.mapper.toDomain(entity));
|
||||
}
|
||||
|
||||
async findLatestByUserId(userId: string): Promise<AvatarGenerationRequest | null> {
|
||||
const repo = this.dataSource.getRepository(AvatarGenerationRequestOrmEntity);
|
||||
const entity = await repo.findOne({
|
||||
where: { userId },
|
||||
order: { createdAt: 'DESC' }
|
||||
});
|
||||
return entity ? this.mapper.toDomain(entity) : null;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const repo = this.dataSource.getRepository(AvatarGenerationRequestOrmEntity);
|
||||
await repo.delete({ id });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { TypeOrmAvatarRepository } from './TypeOrmAvatarRepository';
|
||||
|
||||
describe('TypeOrmAvatarRepository', () => {
|
||||
it('does not construct its own mapper dependencies', () => {
|
||||
const sourcePath = path.resolve(__dirname, 'TypeOrmAvatarRepository.ts');
|
||||
const source = fs.readFileSync(sourcePath, 'utf8');
|
||||
|
||||
expect(source).not.toMatch(/new\s+AvatarOrmMapper\s*\(/);
|
||||
expect(source).not.toMatch(/=\s*new\s+AvatarOrmMapper\s*\(/);
|
||||
});
|
||||
|
||||
it('requires mapper injection via constructor (no default mapper)', () => {
|
||||
expect(TypeOrmAvatarRepository.length).toBe(2);
|
||||
});
|
||||
|
||||
it('uses the injected mapper at runtime (DB-free)', async () => {
|
||||
const ormRepo = {
|
||||
findOne: vi.fn().mockResolvedValue({ id: 'avatar-1' }),
|
||||
find: vi.fn().mockResolvedValue([{ id: 'avatar-1' }, { id: 'avatar-2' }]),
|
||||
save: vi.fn().mockResolvedValue({ id: 'avatar-1' }),
|
||||
delete: vi.fn().mockResolvedValue({ affected: 1 }),
|
||||
};
|
||||
|
||||
const dataSource = {
|
||||
getRepository: vi.fn().mockReturnValue(ormRepo),
|
||||
};
|
||||
|
||||
const mapper = {
|
||||
toStored: vi.fn().mockReturnValue({ id: 'stored-avatar-1' }),
|
||||
toDomain: vi.fn().mockReturnValue({ id: 'domain-avatar-1' }),
|
||||
toOrmEntity: vi.fn().mockReturnValue({ id: 'orm-avatar-1' }),
|
||||
};
|
||||
|
||||
const repo = new TypeOrmAvatarRepository(dataSource as any, mapper as any);
|
||||
|
||||
// Test findById
|
||||
const avatar = await repo.findById('avatar-1');
|
||||
expect(dataSource.getRepository).toHaveBeenCalledTimes(1);
|
||||
expect(ormRepo.findOne).toHaveBeenCalledWith({ where: { id: 'avatar-1' } });
|
||||
expect(mapper.toDomain).toHaveBeenCalledTimes(1);
|
||||
expect(avatar).toEqual({ id: 'domain-avatar-1' });
|
||||
|
||||
// Test findActiveByDriverId
|
||||
await repo.findActiveByDriverId('driver-1');
|
||||
expect(ormRepo.findOne).toHaveBeenCalledWith({
|
||||
where: { driverId: 'driver-1', isActive: true },
|
||||
order: { selectedAt: 'DESC' }
|
||||
});
|
||||
|
||||
// Test findByDriverId
|
||||
const avatars = await repo.findByDriverId('driver-1');
|
||||
expect(ormRepo.find).toHaveBeenCalledWith({
|
||||
where: { driverId: 'driver-1' },
|
||||
order: { selectedAt: 'DESC' }
|
||||
});
|
||||
expect(mapper.toDomain).toHaveBeenCalledTimes(4); // 1 from findById + 1 from findActiveByDriverId + 2 from findByDriverId
|
||||
expect(avatars).toHaveLength(2);
|
||||
|
||||
// Test save
|
||||
const domainAvatar = { id: 'new-avatar', toProps: () => ({ id: 'new-avatar' }) };
|
||||
await repo.save(domainAvatar as any);
|
||||
expect(mapper.toOrmEntity).toHaveBeenCalledWith(domainAvatar);
|
||||
expect(ormRepo.save).toHaveBeenCalledWith({ id: 'orm-avatar-1' });
|
||||
|
||||
// Test delete
|
||||
await repo.delete('avatar-1');
|
||||
expect(ormRepo.delete).toHaveBeenCalledWith({ id: 'avatar-1' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { DataSource } from 'typeorm';
|
||||
import type { IAvatarRepository } from '@core/media/domain/repositories/IAvatarRepository';
|
||||
import type { Avatar } from '@core/media/domain/entities/Avatar';
|
||||
import { AvatarOrmEntity } from '../entities/AvatarOrmEntity';
|
||||
import { AvatarOrmMapper } from '../mappers/AvatarOrmMapper';
|
||||
|
||||
export class TypeOrmAvatarRepository implements IAvatarRepository {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly mapper: AvatarOrmMapper,
|
||||
) {}
|
||||
|
||||
async save(avatar: Avatar): Promise<void> {
|
||||
const repo = this.dataSource.getRepository(AvatarOrmEntity);
|
||||
const entity = this.mapper.toOrmEntity(avatar);
|
||||
await repo.save(entity);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Avatar | null> {
|
||||
const repo = this.dataSource.getRepository(AvatarOrmEntity);
|
||||
const entity = await repo.findOne({ where: { id } });
|
||||
return entity ? this.mapper.toDomain(entity) : null;
|
||||
}
|
||||
|
||||
async findActiveByDriverId(driverId: string): Promise<Avatar | null> {
|
||||
const repo = this.dataSource.getRepository(AvatarOrmEntity);
|
||||
const entity = await repo.findOne({
|
||||
where: { driverId, isActive: true },
|
||||
order: { selectedAt: 'DESC' }
|
||||
});
|
||||
return entity ? this.mapper.toDomain(entity) : null;
|
||||
}
|
||||
|
||||
async findByDriverId(driverId: string): Promise<Avatar[]> {
|
||||
const repo = this.dataSource.getRepository(AvatarOrmEntity);
|
||||
const entities = await repo.find({
|
||||
where: { driverId },
|
||||
order: { selectedAt: 'DESC' }
|
||||
});
|
||||
return entities.map(entity => this.mapper.toDomain(entity));
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const repo = this.dataSource.getRepository(AvatarOrmEntity);
|
||||
await repo.delete({ id });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { TypeOrmMediaRepository } from './TypeOrmMediaRepository';
|
||||
|
||||
describe('TypeOrmMediaRepository', () => {
|
||||
it('does not construct its own mapper dependencies', () => {
|
||||
const sourcePath = path.resolve(__dirname, 'TypeOrmMediaRepository.ts');
|
||||
const source = fs.readFileSync(sourcePath, 'utf8');
|
||||
|
||||
expect(source).not.toMatch(/new\s+MediaOrmMapper\s*\(/);
|
||||
expect(source).not.toMatch(/=\s*new\s+MediaOrmMapper\s*\(/);
|
||||
});
|
||||
|
||||
it('requires mapper injection via constructor (no default mapper)', () => {
|
||||
expect(TypeOrmMediaRepository.length).toBe(2);
|
||||
});
|
||||
|
||||
it('uses the injected mapper at runtime (DB-free)', async () => {
|
||||
const ormRepo = {
|
||||
findOne: vi.fn().mockResolvedValue({ id: 'media-1' }),
|
||||
find: vi.fn().mockResolvedValue([{ id: 'media-1' }, { id: 'media-2' }]),
|
||||
save: vi.fn().mockResolvedValue({ id: 'media-1' }),
|
||||
delete: vi.fn().mockResolvedValue({ affected: 1 }),
|
||||
};
|
||||
|
||||
const dataSource = {
|
||||
getRepository: vi.fn().mockReturnValue(ormRepo),
|
||||
};
|
||||
|
||||
const mapper = {
|
||||
toStored: vi.fn().mockReturnValue({ id: 'stored-media-1' }),
|
||||
toDomain: vi.fn().mockReturnValue({ id: 'domain-media-1' }),
|
||||
toOrmEntity: vi.fn().mockReturnValue({ id: 'orm-media-1' }),
|
||||
};
|
||||
|
||||
const repo = new TypeOrmMediaRepository(dataSource as any, mapper as any);
|
||||
|
||||
// Test findById
|
||||
const media = await repo.findById('media-1');
|
||||
expect(dataSource.getRepository).toHaveBeenCalledTimes(1);
|
||||
expect(ormRepo.findOne).toHaveBeenCalledWith({ where: { id: 'media-1' } });
|
||||
expect(mapper.toDomain).toHaveBeenCalledTimes(1);
|
||||
expect(media).toEqual({ id: 'domain-media-1' });
|
||||
|
||||
// Test findByUploadedBy
|
||||
const medias = await repo.findByUploadedBy('user-1');
|
||||
expect(ormRepo.find).toHaveBeenCalledWith({ where: { uploadedBy: 'user-1' } });
|
||||
expect(mapper.toDomain).toHaveBeenCalledTimes(3); // 1 from findById + 2 from findByUploadedBy
|
||||
expect(medias).toHaveLength(2);
|
||||
|
||||
// Test save
|
||||
const domainMedia = { id: 'new-media', toProps: () => ({ id: 'new-media' }) };
|
||||
await repo.save(domainMedia as any);
|
||||
expect(mapper.toOrmEntity).toHaveBeenCalledWith(domainMedia);
|
||||
expect(ormRepo.save).toHaveBeenCalledWith({ id: 'orm-media-1' });
|
||||
|
||||
// Test delete
|
||||
await repo.delete('media-1');
|
||||
expect(ormRepo.delete).toHaveBeenCalledWith({ id: 'media-1' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { DataSource } from 'typeorm';
|
||||
import type { IMediaRepository } from '@core/media/domain/repositories/IMediaRepository';
|
||||
import type { Media } from '@core/media/domain/entities/Media';
|
||||
import { MediaOrmEntity } from '../entities/MediaOrmEntity';
|
||||
import { MediaOrmMapper } from '../mappers/MediaOrmMapper';
|
||||
|
||||
export class TypeOrmMediaRepository implements IMediaRepository {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly mapper: MediaOrmMapper,
|
||||
) {}
|
||||
|
||||
async save(media: Media): Promise<void> {
|
||||
const repo = this.dataSource.getRepository(MediaOrmEntity);
|
||||
const entity = this.mapper.toOrmEntity(media);
|
||||
await repo.save(entity);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Media | null> {
|
||||
const repo = this.dataSource.getRepository(MediaOrmEntity);
|
||||
const entity = await repo.findOne({ where: { id } });
|
||||
return entity ? this.mapper.toDomain(entity) : null;
|
||||
}
|
||||
|
||||
async findByUploadedBy(uploadedBy: string): Promise<Media[]> {
|
||||
const repo = this.dataSource.getRepository(MediaOrmEntity);
|
||||
const entities = await repo.find({ where: { uploadedBy } });
|
||||
return entities.map(entity => this.mapper.toDomain(entity));
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const repo = this.dataSource.getRepository(MediaOrmEntity);
|
||||
await repo.delete({ id });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { TypeOrmMediaSchemaError } from '../errors/TypeOrmMediaSchemaError';
|
||||
|
||||
export function assertNonEmptyString(entityName: string, fieldName: string, value: unknown): asserts value is string {
|
||||
if (typeof value !== 'string') {
|
||||
throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'not_string' });
|
||||
}
|
||||
|
||||
if (value.trim().length === 0) {
|
||||
throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'empty_string' });
|
||||
}
|
||||
}
|
||||
|
||||
export function assertDate(entityName: string, fieldName: string, value: unknown): asserts value is Date {
|
||||
if (!(value instanceof Date)) {
|
||||
throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'not_date' });
|
||||
}
|
||||
if (Number.isNaN(value.getTime())) {
|
||||
throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'invalid_date' });
|
||||
}
|
||||
}
|
||||
|
||||
export function assertNumber(entityName: string, fieldName: string, value: unknown): asserts value is number {
|
||||
if (typeof value !== 'number') {
|
||||
throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'not_number' });
|
||||
}
|
||||
if (Number.isNaN(value)) {
|
||||
throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'not_number' });
|
||||
}
|
||||
}
|
||||
|
||||
export function assertInteger(entityName: string, fieldName: string, value: unknown): asserts value is number {
|
||||
if (typeof value !== 'number') {
|
||||
throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'not_number' });
|
||||
}
|
||||
if (!Number.isInteger(value)) {
|
||||
throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'not_integer' });
|
||||
}
|
||||
}
|
||||
|
||||
export function assertBoolean(entityName: string, fieldName: string, value: unknown): asserts value is boolean {
|
||||
if (typeof value !== 'boolean') {
|
||||
throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'not_boolean' });
|
||||
}
|
||||
}
|
||||
|
||||
export function assertOptionalStringOrNull(
|
||||
entityName: string,
|
||||
fieldName: string,
|
||||
value: unknown,
|
||||
): asserts value is string | null | undefined {
|
||||
if (value === null || value === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'not_string' });
|
||||
}
|
||||
}
|
||||
|
||||
export function assertOptionalNumberOrNull(
|
||||
entityName: string,
|
||||
fieldName: string,
|
||||
value: unknown,
|
||||
): asserts value is number | null | undefined {
|
||||
if (value === null || value === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof value !== 'number') {
|
||||
throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'not_number' });
|
||||
}
|
||||
if (Number.isNaN(value)) {
|
||||
throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'not_number' });
|
||||
}
|
||||
}
|
||||
|
||||
export function assertStringArray(
|
||||
entityName: string,
|
||||
fieldName: string,
|
||||
value: unknown,
|
||||
): asserts value is string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'not_array' });
|
||||
}
|
||||
if (!value.every(item => typeof item === 'string')) {
|
||||
throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'not_array' });
|
||||
}
|
||||
}
|
||||
|
||||
export function assertMediaType(entityName: string, fieldName: string, value: unknown): asserts value is 'image' | 'video' | 'document' {
|
||||
if (typeof value !== 'string') {
|
||||
throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'not_string' });
|
||||
}
|
||||
if (!['image', 'video', 'document'].includes(value)) {
|
||||
throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'invalid_enum_value' });
|
||||
}
|
||||
}
|
||||
|
||||
export function assertRacingSuitColor(
|
||||
entityName: string,
|
||||
fieldName: string,
|
||||
value: unknown,
|
||||
): asserts value is 'red' | 'blue' | 'green' | 'yellow' | 'orange' | 'purple' | 'black' | 'white' | 'pink' | 'cyan' {
|
||||
if (typeof value !== 'string') {
|
||||
throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'not_string' });
|
||||
}
|
||||
if (
|
||||
!['red', 'blue', 'green', 'yellow', 'orange', 'purple', 'black', 'white', 'pink', 'cyan'].includes(value)
|
||||
) {
|
||||
throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'invalid_enum_value' });
|
||||
}
|
||||
}
|
||||
|
||||
export function assertAvatarStyle(
|
||||
entityName: string,
|
||||
fieldName: string,
|
||||
value: unknown,
|
||||
): asserts value is 'realistic' | 'cartoon' | 'pixel-art' {
|
||||
if (typeof value !== 'string') {
|
||||
throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'not_string' });
|
||||
}
|
||||
if (!['realistic', 'cartoon', 'pixel-art'].includes(value)) {
|
||||
throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'invalid_enum_value' });
|
||||
}
|
||||
}
|
||||
|
||||
export function assertAvatarGenerationStatus(
|
||||
entityName: string,
|
||||
fieldName: string,
|
||||
value: unknown,
|
||||
): asserts value is 'pending' | 'validating' | 'generating' | 'completed' | 'failed' {
|
||||
if (typeof value !== 'string') {
|
||||
throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'not_string' });
|
||||
}
|
||||
if (!['pending', 'validating', 'generating', 'completed', 'failed'].includes(value)) {
|
||||
throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'invalid_enum_value' });
|
||||
}
|
||||
}
|
||||
|
||||
export function assertOptionalIntegerOrNull(
|
||||
entityName: string,
|
||||
fieldName: string,
|
||||
value: unknown,
|
||||
): asserts value is number | null | undefined {
|
||||
if (value === null || value === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof value !== 'number') {
|
||||
throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'not_number' });
|
||||
}
|
||||
if (!Number.isInteger(value)) {
|
||||
throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'not_integer' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Column, CreateDateColumn, Entity, Index, PrimaryColumn, UpdateDateColumn } from 'typeorm';
|
||||
|
||||
@Entity({ name: 'notifications' })
|
||||
export class NotificationOrmEntity {
|
||||
@PrimaryColumn({ type: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@Index()
|
||||
@Column({ type: 'text' })
|
||||
recipientId!: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
type!: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
title!: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
body!: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
channel!: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
status!: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
urgency!: string;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
data!: Record<string, unknown> | null;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
actionUrl!: string | null;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
actions!: Array<{ label: string; type: string; href?: string; actionId?: string }> | null;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
requiresResponse!: boolean;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
readAt!: Date | null;
|
||||
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
respondedAt!: Date | null;
|
||||
|
||||
@UpdateDateColumn({ type: 'timestamptz' })
|
||||
updatedAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Column, CreateDateColumn, Entity, Index, PrimaryColumn, UpdateDateColumn } from 'typeorm';
|
||||
|
||||
@Entity({ name: 'notification_preferences' })
|
||||
export class NotificationPreferenceOrmEntity {
|
||||
@PrimaryColumn({ type: 'text' })
|
||||
id!: string;
|
||||
|
||||
@Index()
|
||||
@Column({ type: 'text' })
|
||||
driverId!: string;
|
||||
|
||||
@Column({ type: 'jsonb' })
|
||||
channels!: {
|
||||
in_app: { enabled: boolean; settings?: Record<string, string> };
|
||||
email: { enabled: boolean; settings?: Record<string, string> };
|
||||
discord: { enabled: boolean; settings?: Record<string, string> };
|
||||
push: { enabled: boolean; settings?: Record<string, string> };
|
||||
};
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
typePreferences!: Record<string, { enabled: boolean; channels?: string[] }> | null;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
digestMode!: boolean;
|
||||
|
||||
@Column({ type: 'integer', nullable: true })
|
||||
digestFrequencyHours!: number | null;
|
||||
|
||||
@Column({ type: 'integer', nullable: true })
|
||||
quietHoursStart!: number | null;
|
||||
|
||||
@Column({ type: 'integer', nullable: true })
|
||||
quietHoursEnd!: number | null;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ type: 'timestamptz' })
|
||||
updatedAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export interface TypeOrmPersistenceSchemaErrorProps {
|
||||
entityName: string;
|
||||
fieldName: string;
|
||||
reason: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export class TypeOrmPersistenceSchemaError extends Error {
|
||||
readonly entityName: string;
|
||||
readonly fieldName: string;
|
||||
readonly reason: string;
|
||||
|
||||
constructor(props: TypeOrmPersistenceSchemaErrorProps) {
|
||||
const message = props.message || `Invalid schema for ${props.entityName}.${props.fieldName}: ${props.reason}`;
|
||||
super(message);
|
||||
this.name = 'TypeOrmPersistenceSchemaError';
|
||||
this.entityName = props.entityName;
|
||||
this.fieldName = props.fieldName;
|
||||
this.reason = props.reason;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { Notification } from '@core/notifications/domain/entities/Notification';
|
||||
|
||||
import { NotificationOrmEntity } from '../entities/NotificationOrmEntity';
|
||||
import { TypeOrmPersistenceSchemaError } from '../errors/TypeOrmPersistenceSchemaError';
|
||||
import { NotificationOrmMapper } from './NotificationOrmMapper';
|
||||
|
||||
describe('NotificationOrmMapper', () => {
|
||||
it('toDomain preserves persisted identity and uses reconstitute semantics', () => {
|
||||
const mapper = new NotificationOrmMapper();
|
||||
|
||||
const entity = new NotificationOrmEntity();
|
||||
entity.id = '00000000-0000-4000-8000-000000000001';
|
||||
entity.recipientId = 'driver-123';
|
||||
entity.type = 'race_reminder';
|
||||
entity.title = 'Race Starting Soon';
|
||||
entity.body = 'Your race starts in 15 minutes';
|
||||
entity.channel = 'in_app';
|
||||
entity.status = 'unread';
|
||||
entity.urgency = 'silent';
|
||||
entity.data = { raceId: 'race-456' };
|
||||
entity.actionUrl = null;
|
||||
entity.actions = null;
|
||||
entity.requiresResponse = false;
|
||||
entity.createdAt = new Date('2025-01-01T00:00:00.000Z');
|
||||
entity.readAt = null;
|
||||
entity.respondedAt = null;
|
||||
entity.updatedAt = new Date('2025-01-01T00:00:00.000Z');
|
||||
|
||||
const domain = mapper.toDomain(entity);
|
||||
|
||||
expect(domain.id).toBe(entity.id);
|
||||
expect(domain.recipientId).toBe(entity.recipientId);
|
||||
expect(domain.type).toBe(entity.type);
|
||||
expect(domain.title).toBe(entity.title);
|
||||
expect(domain.body).toBe(entity.body);
|
||||
expect(domain.channel).toBe(entity.channel);
|
||||
expect(domain.status).toBe(entity.status);
|
||||
expect(domain.urgency).toBe(entity.urgency);
|
||||
expect(domain.data).toEqual({ raceId: 'race-456' });
|
||||
expect(domain.createdAt).toEqual(entity.createdAt);
|
||||
expect(domain.readAt).toBeUndefined();
|
||||
expect(domain.respondedAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('toDomain validates persisted shape and throws adapter-scoped schema error', () => {
|
||||
const mapper = new NotificationOrmMapper();
|
||||
|
||||
const entity = new NotificationOrmEntity();
|
||||
entity.id = '00000000-0000-4000-8000-000000000001';
|
||||
entity.recipientId = 123 as unknown as string; // Invalid
|
||||
entity.type = 'race_reminder';
|
||||
entity.title = 'Race Starting Soon';
|
||||
entity.body = 'Your race starts in 15 minutes';
|
||||
entity.channel = 'in_app';
|
||||
entity.status = 'unread';
|
||||
entity.urgency = 'silent';
|
||||
entity.data = null;
|
||||
entity.actionUrl = null;
|
||||
entity.actions = null;
|
||||
entity.requiresResponse = false;
|
||||
entity.createdAt = new Date('2025-01-01T00:00:00.000Z');
|
||||
entity.readAt = null;
|
||||
entity.respondedAt = null;
|
||||
entity.updatedAt = new Date('2025-01-01T00:00:00.000Z');
|
||||
|
||||
try {
|
||||
mapper.toDomain(entity);
|
||||
throw new Error('expected-to-throw');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(TypeOrmPersistenceSchemaError);
|
||||
expect(error).toMatchObject({
|
||||
entityName: 'Notification',
|
||||
fieldName: 'recipientId',
|
||||
reason: 'invalid_string',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('toOrmEntity converts domain entity to ORM entity', () => {
|
||||
const mapper = new NotificationOrmMapper();
|
||||
|
||||
const domain = Notification.create({
|
||||
id: '00000000-0000-4000-8000-000000000001',
|
||||
recipientId: 'driver-123',
|
||||
type: 'race_reminder',
|
||||
title: 'Race Starting Soon',
|
||||
body: 'Your race starts in 15 minutes',
|
||||
channel: 'in_app',
|
||||
data: { raceId: 'race-456' },
|
||||
urgency: 'silent',
|
||||
});
|
||||
|
||||
const entity = mapper.toOrmEntity(domain);
|
||||
|
||||
expect(entity.id).toBe(domain.id);
|
||||
expect(entity.recipientId).toBe(domain.recipientId);
|
||||
expect(entity.type).toBe(domain.type);
|
||||
expect(entity.title).toBe(domain.title);
|
||||
expect(entity.body).toBe(domain.body);
|
||||
expect(entity.channel).toBe(domain.channel);
|
||||
expect(entity.status).toBe(domain.status);
|
||||
expect(entity.urgency).toBe(domain.urgency);
|
||||
expect(entity.data).toEqual({ raceId: 'race-456' });
|
||||
expect(entity.requiresResponse).toBe(false);
|
||||
expect(entity.createdAt).toEqual(domain.createdAt);
|
||||
});
|
||||
|
||||
it('toDomain handles optional fields correctly', () => {
|
||||
const mapper = new NotificationOrmMapper();
|
||||
|
||||
const entity = new NotificationOrmEntity();
|
||||
entity.id = '00000000-0000-4000-8000-000000000001';
|
||||
entity.recipientId = 'driver-123';
|
||||
entity.type = 'protest_filed';
|
||||
entity.title = 'Protest Filed';
|
||||
entity.body = 'A protest has been filed against you';
|
||||
entity.channel = 'email';
|
||||
entity.status = 'action_required';
|
||||
entity.urgency = 'modal';
|
||||
entity.data = { protestId: 'protest-789', deadline: new Date('2025-01-02T00:00:00.000Z') };
|
||||
entity.actionUrl = '/protests/protest-789';
|
||||
entity.actions = [
|
||||
{ label: 'Submit Defense', type: 'primary', href: '/protests/protest-789/defense' },
|
||||
{ label: 'Dismiss', type: 'secondary', actionId: 'dismiss' },
|
||||
];
|
||||
entity.requiresResponse = true;
|
||||
entity.createdAt = new Date('2025-01-01T00:00:00.000Z');
|
||||
entity.readAt = new Date('2025-01-01T01:00:00.000Z');
|
||||
entity.respondedAt = null;
|
||||
entity.updatedAt = new Date('2025-01-01T01:00:00.000Z');
|
||||
|
||||
const domain = mapper.toDomain(entity);
|
||||
|
||||
expect(domain.actionUrl).toBe('/protests/protest-789');
|
||||
expect(domain.actions).toHaveLength(2);
|
||||
expect(domain.requiresResponse).toBe(true);
|
||||
expect(domain.readAt).toEqual(new Date('2025-01-01T01:00:00.000Z'));
|
||||
expect(domain.respondedAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('toDomain handles action_required status with deadline', () => {
|
||||
const mapper = new NotificationOrmMapper();
|
||||
|
||||
const entity = new NotificationOrmEntity();
|
||||
entity.id = '00000000-0000-4000-8000-000000000001';
|
||||
entity.recipientId = 'driver-123';
|
||||
entity.type = 'protest_filed';
|
||||
entity.title = 'Protest Filed';
|
||||
entity.body = 'A protest has been filed against you';
|
||||
entity.channel = 'in_app';
|
||||
entity.status = 'action_required';
|
||||
entity.urgency = 'modal';
|
||||
entity.data = { protestId: 'protest-789', deadline: '2025-01-02T00:00:00.000Z' };
|
||||
entity.actionUrl = null;
|
||||
entity.actions = [{ label: 'Submit Defense', type: 'primary', href: '/defense' }];
|
||||
entity.requiresResponse = true;
|
||||
entity.createdAt = new Date('2025-01-01T00:00:00.000Z');
|
||||
entity.readAt = null;
|
||||
entity.respondedAt = null;
|
||||
entity.updatedAt = new Date('2025-01-01T00:00:00.000Z');
|
||||
|
||||
const domain = mapper.toDomain(entity);
|
||||
|
||||
expect(domain.status).toBe('action_required');
|
||||
expect(domain.requiresResponse).toBe(true);
|
||||
expect(domain.urgency).toBe('modal');
|
||||
expect(domain.data?.deadline).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Notification } from '@core/notifications/domain/entities/Notification';
|
||||
import { NotificationOrmEntity } from '../entities/NotificationOrmEntity';
|
||||
import { TypeOrmPersistenceSchemaError } from '../errors/TypeOrmPersistenceSchemaError';
|
||||
import {
|
||||
assertNonEmptyString,
|
||||
assertDate,
|
||||
assertOptionalDate,
|
||||
assertBoolean,
|
||||
assertNotificationType,
|
||||
assertNotificationChannel,
|
||||
assertNotificationStatus,
|
||||
assertNotificationUrgency,
|
||||
assertOptionalStringOrNull,
|
||||
assertOptionalObject,
|
||||
assertNotificationActions,
|
||||
} from '../schema/NotificationSchemaGuards';
|
||||
|
||||
export class NotificationOrmMapper {
|
||||
toDomain(entity: NotificationOrmEntity): Notification {
|
||||
const entityName = 'Notification';
|
||||
|
||||
try {
|
||||
assertNonEmptyString(entityName, 'id', entity.id);
|
||||
assertNonEmptyString(entityName, 'recipientId', entity.recipientId);
|
||||
assertNotificationType(entityName, 'type', entity.type);
|
||||
assertNonEmptyString(entityName, 'title', entity.title);
|
||||
assertNonEmptyString(entityName, 'body', entity.body);
|
||||
assertNotificationChannel(entityName, 'channel', entity.channel);
|
||||
assertNotificationStatus(entityName, 'status', entity.status);
|
||||
assertNotificationUrgency(entityName, 'urgency', entity.urgency);
|
||||
assertDate(entityName, 'createdAt', entity.createdAt);
|
||||
assertOptionalDate(entityName, 'readAt', entity.readAt);
|
||||
assertOptionalDate(entityName, 'respondedAt', entity.respondedAt);
|
||||
assertOptionalStringOrNull(entityName, 'actionUrl', entity.actionUrl);
|
||||
assertOptionalObject(entityName, 'data', entity.data);
|
||||
assertNotificationActions(entityName, 'actions', entity.actions);
|
||||
assertBoolean(entityName, 'requiresResponse', entity.requiresResponse);
|
||||
} catch (error) {
|
||||
if (error instanceof TypeOrmPersistenceSchemaError) {
|
||||
throw error;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : 'Invalid persisted Notification';
|
||||
throw new TypeOrmPersistenceSchemaError({ entityName, fieldName: 'unknown', reason: 'invalid_shape', message });
|
||||
}
|
||||
|
||||
try {
|
||||
const domainProps: any = {
|
||||
id: entity.id,
|
||||
recipientId: entity.recipientId,
|
||||
type: entity.type,
|
||||
title: entity.title,
|
||||
body: entity.body,
|
||||
channel: entity.channel,
|
||||
status: entity.status,
|
||||
urgency: entity.urgency,
|
||||
createdAt: entity.createdAt,
|
||||
requiresResponse: entity.requiresResponse,
|
||||
};
|
||||
|
||||
if (entity.data !== null && entity.data !== undefined) {
|
||||
domainProps.data = entity.data as Record<string, unknown>;
|
||||
}
|
||||
|
||||
if (entity.actionUrl !== null && entity.actionUrl !== undefined) {
|
||||
domainProps.actionUrl = entity.actionUrl;
|
||||
}
|
||||
|
||||
if (entity.actions !== null && entity.actions !== undefined) {
|
||||
domainProps.actions = entity.actions;
|
||||
}
|
||||
|
||||
if (entity.readAt !== null && entity.readAt !== undefined) {
|
||||
domainProps.readAt = entity.readAt;
|
||||
}
|
||||
|
||||
if (entity.respondedAt !== null && entity.respondedAt !== undefined) {
|
||||
domainProps.respondedAt = entity.respondedAt;
|
||||
}
|
||||
|
||||
return Notification.create(domainProps);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Invalid persisted Notification';
|
||||
throw new TypeOrmPersistenceSchemaError({ entityName, fieldName: 'unknown', reason: 'invalid_shape', message });
|
||||
}
|
||||
}
|
||||
|
||||
toOrmEntity(notification: Notification): NotificationOrmEntity {
|
||||
const entity = new NotificationOrmEntity();
|
||||
const props = notification.toJSON();
|
||||
|
||||
entity.id = props.id;
|
||||
entity.recipientId = props.recipientId;
|
||||
entity.type = props.type;
|
||||
entity.title = props.title;
|
||||
entity.body = props.body;
|
||||
entity.channel = props.channel;
|
||||
entity.status = props.status;
|
||||
entity.urgency = props.urgency;
|
||||
entity.data = props.data ?? null;
|
||||
entity.actionUrl = props.actionUrl ?? null;
|
||||
entity.actions = props.actions ?? null;
|
||||
entity.requiresResponse = props.requiresResponse ?? false;
|
||||
entity.createdAt = props.createdAt;
|
||||
entity.readAt = props.readAt ?? null;
|
||||
entity.respondedAt = props.respondedAt ?? null;
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
toStored(entity: NotificationOrmEntity): Notification {
|
||||
return this.toDomain(entity);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { NotificationPreference } from '@core/notifications/domain/entities/NotificationPreference';
|
||||
|
||||
import { NotificationPreferenceOrmEntity } from '../entities/NotificationPreferenceOrmEntity';
|
||||
import { TypeOrmPersistenceSchemaError } from '../errors/TypeOrmPersistenceSchemaError';
|
||||
import { NotificationPreferenceOrmMapper } from './NotificationPreferenceOrmMapper';
|
||||
|
||||
describe('NotificationPreferenceOrmMapper', () => {
|
||||
it('toDomain preserves persisted identity and uses reconstitute semantics', () => {
|
||||
const mapper = new NotificationPreferenceOrmMapper();
|
||||
|
||||
const entity = new NotificationPreferenceOrmEntity();
|
||||
entity.id = 'driver-123';
|
||||
entity.driverId = 'driver-123';
|
||||
entity.channels = {
|
||||
in_app: { enabled: true },
|
||||
email: { enabled: false },
|
||||
discord: { enabled: false },
|
||||
push: { enabled: false },
|
||||
};
|
||||
entity.typePreferences = {
|
||||
race_reminder: { enabled: true, channels: ['in_app'] },
|
||||
};
|
||||
entity.digestMode = false;
|
||||
entity.digestFrequencyHours = null;
|
||||
entity.quietHoursStart = null;
|
||||
entity.quietHoursEnd = null;
|
||||
entity.createdAt = new Date('2025-01-01T00:00:00.000Z');
|
||||
entity.updatedAt = new Date('2025-01-01T00:00:00.000Z');
|
||||
|
||||
const domain = mapper.toDomain(entity);
|
||||
|
||||
expect(domain.id).toBe(entity.id);
|
||||
expect(domain.driverId).toBe(entity.driverId);
|
||||
expect(domain.channels).toEqual(entity.channels);
|
||||
expect(domain.typePreferences).toEqual(entity.typePreferences);
|
||||
expect(domain.digestMode).toBe(false);
|
||||
});
|
||||
|
||||
it('toDomain validates persisted shape and throws adapter-scoped schema error', () => {
|
||||
const mapper = new NotificationPreferenceOrmMapper();
|
||||
|
||||
const entity = new NotificationPreferenceOrmEntity();
|
||||
entity.id = 'driver-123';
|
||||
entity.driverId = 123 as unknown as string; // Invalid
|
||||
entity.channels = {
|
||||
in_app: { enabled: true },
|
||||
email: { enabled: false },
|
||||
discord: { enabled: false },
|
||||
push: { enabled: false },
|
||||
};
|
||||
entity.typePreferences = null;
|
||||
entity.digestMode = false;
|
||||
entity.digestFrequencyHours = null;
|
||||
entity.quietHoursStart = null;
|
||||
entity.quietHoursEnd = null;
|
||||
entity.createdAt = new Date('2025-01-01T00:00:00.000Z');
|
||||
entity.updatedAt = new Date('2025-01-01T00:00:00.000Z');
|
||||
|
||||
try {
|
||||
mapper.toDomain(entity);
|
||||
throw new Error('expected-to-throw');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(TypeOrmPersistenceSchemaError);
|
||||
expect(error).toMatchObject({
|
||||
entityName: 'NotificationPreference',
|
||||
fieldName: 'driverId',
|
||||
reason: 'invalid_string',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('toOrmEntity converts domain entity to ORM entity', () => {
|
||||
const mapper = new NotificationPreferenceOrmMapper();
|
||||
|
||||
const domain = NotificationPreference.create({
|
||||
id: 'driver-123',
|
||||
driverId: 'driver-123',
|
||||
channels: {
|
||||
in_app: { enabled: true },
|
||||
email: { enabled: false },
|
||||
discord: { enabled: false },
|
||||
push: { enabled: false },
|
||||
},
|
||||
typePreferences: {
|
||||
race_reminder: { enabled: true, channels: ['in_app'] },
|
||||
},
|
||||
digestMode: false,
|
||||
updatedAt: new Date('2025-01-01T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
const entity = mapper.toOrmEntity(domain);
|
||||
|
||||
expect(entity.id).toBe(domain.id);
|
||||
expect(entity.driverId).toBe(domain.driverId);
|
||||
expect(entity.channels).toEqual(domain.channels);
|
||||
expect(entity.typePreferences).toEqual(domain.typePreferences);
|
||||
expect(entity.digestMode).toBe(false);
|
||||
expect(entity.updatedAt).toEqual(domain.updatedAt);
|
||||
});
|
||||
|
||||
it('toDomain handles all optional fields as null', () => {
|
||||
const mapper = new NotificationPreferenceOrmMapper();
|
||||
|
||||
const entity = new NotificationPreferenceOrmEntity();
|
||||
entity.id = 'driver-123';
|
||||
entity.driverId = 'driver-123';
|
||||
entity.channels = {
|
||||
in_app: { enabled: true },
|
||||
email: { enabled: true, settings: { emailAddress: 'test@example.com' } },
|
||||
discord: { enabled: false },
|
||||
push: { enabled: false },
|
||||
};
|
||||
entity.typePreferences = null;
|
||||
entity.digestMode = true;
|
||||
entity.digestFrequencyHours = 24;
|
||||
entity.quietHoursStart = 22;
|
||||
entity.quietHoursEnd = 8;
|
||||
entity.createdAt = new Date('2025-01-01T00:00:00.000Z');
|
||||
entity.updatedAt = new Date('2025-01-01T00:00:00.000Z');
|
||||
|
||||
const domain = mapper.toDomain(entity);
|
||||
|
||||
expect(domain.typePreferences).toBeUndefined();
|
||||
expect(domain.digestMode).toBe(true);
|
||||
expect(domain.digestFrequencyHours).toBe(24);
|
||||
expect(domain.quietHoursStart).toBe(22);
|
||||
expect(domain.quietHoursEnd).toBe(8);
|
||||
expect(domain.channels.email.settings?.emailAddress).toBe('test@example.com');
|
||||
});
|
||||
|
||||
it('toDomain handles default preferences', () => {
|
||||
const mapper = new NotificationPreferenceOrmMapper();
|
||||
|
||||
const entity = new NotificationPreferenceOrmEntity();
|
||||
entity.id = 'driver-456';
|
||||
entity.driverId = 'driver-456';
|
||||
entity.channels = {
|
||||
in_app: { enabled: true },
|
||||
email: { enabled: false },
|
||||
discord: { enabled: false },
|
||||
push: { enabled: false },
|
||||
};
|
||||
entity.typePreferences = null;
|
||||
entity.digestMode = false;
|
||||
entity.digestFrequencyHours = null;
|
||||
entity.quietHoursStart = null;
|
||||
entity.quietHoursEnd = null;
|
||||
entity.createdAt = new Date('2025-01-01T00:00:00.000Z');
|
||||
entity.updatedAt = new Date('2025-01-01T00:00:00.000Z');
|
||||
|
||||
const domain = mapper.toDomain(entity);
|
||||
|
||||
expect(domain.isChannelEnabled('in_app')).toBe(true);
|
||||
expect(domain.isChannelEnabled('email')).toBe(false);
|
||||
expect(domain.isTypeEnabled('race_reminder')).toBe(true); // Default to enabled
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { NotificationPreference } from '@core/notifications/domain/entities/NotificationPreference';
|
||||
import { NotificationPreferenceOrmEntity } from '../entities/NotificationPreferenceOrmEntity';
|
||||
import { TypeOrmPersistenceSchemaError } from '../errors/TypeOrmPersistenceSchemaError';
|
||||
import {
|
||||
assertNonEmptyString,
|
||||
assertDate,
|
||||
assertBoolean,
|
||||
assertOptionalInteger,
|
||||
assertChannelPreferences,
|
||||
assertOptionalObject,
|
||||
} from '../schema/NotificationSchemaGuards';
|
||||
|
||||
export class NotificationPreferenceOrmMapper {
|
||||
toDomain(entity: NotificationPreferenceOrmEntity): NotificationPreference {
|
||||
const entityName = 'NotificationPreference';
|
||||
|
||||
try {
|
||||
assertNonEmptyString(entityName, 'id', entity.id);
|
||||
assertNonEmptyString(entityName, 'driverId', entity.driverId);
|
||||
assertChannelPreferences(entityName, 'channels', entity.channels);
|
||||
assertOptionalObject(entityName, 'typePreferences', entity.typePreferences);
|
||||
assertBoolean(entityName, 'digestMode', entity.digestMode);
|
||||
assertOptionalInteger(entityName, 'digestFrequencyHours', entity.digestFrequencyHours);
|
||||
assertOptionalInteger(entityName, 'quietHoursStart', entity.quietHoursStart);
|
||||
assertOptionalInteger(entityName, 'quietHoursEnd', entity.quietHoursEnd);
|
||||
assertDate(entityName, 'createdAt', entity.createdAt);
|
||||
assertDate(entityName, 'updatedAt', entity.updatedAt);
|
||||
} catch (error) {
|
||||
if (error instanceof TypeOrmPersistenceSchemaError) {
|
||||
throw error;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : 'Invalid persisted NotificationPreference';
|
||||
throw new TypeOrmPersistenceSchemaError({ entityName, fieldName: 'unknown', reason: 'invalid_shape', message });
|
||||
}
|
||||
|
||||
try {
|
||||
const domainProps: any = {
|
||||
id: entity.id,
|
||||
driverId: entity.driverId,
|
||||
channels: entity.channels,
|
||||
digestMode: entity.digestMode,
|
||||
updatedAt: entity.updatedAt,
|
||||
};
|
||||
|
||||
if (entity.typePreferences !== null && entity.typePreferences !== undefined) {
|
||||
domainProps.typePreferences = entity.typePreferences;
|
||||
}
|
||||
|
||||
if (entity.digestFrequencyHours !== null && entity.digestFrequencyHours !== undefined) {
|
||||
domainProps.digestFrequencyHours = entity.digestFrequencyHours;
|
||||
}
|
||||
|
||||
if (entity.quietHoursStart !== null && entity.quietHoursStart !== undefined) {
|
||||
domainProps.quietHoursStart = entity.quietHoursStart;
|
||||
}
|
||||
|
||||
if (entity.quietHoursEnd !== null && entity.quietHoursEnd !== undefined) {
|
||||
domainProps.quietHoursEnd = entity.quietHoursEnd;
|
||||
}
|
||||
|
||||
return NotificationPreference.create(domainProps);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Invalid persisted NotificationPreference';
|
||||
throw new TypeOrmPersistenceSchemaError({ entityName, fieldName: 'unknown', reason: 'invalid_shape', message });
|
||||
}
|
||||
}
|
||||
|
||||
toOrmEntity(preference: NotificationPreference): NotificationPreferenceOrmEntity {
|
||||
const entity = new NotificationPreferenceOrmEntity();
|
||||
const props = preference.toJSON();
|
||||
|
||||
entity.id = props.id;
|
||||
entity.driverId = props.driverId;
|
||||
entity.channels = props.channels;
|
||||
entity.typePreferences = props.typePreferences ?? null;
|
||||
entity.digestMode = props.digestMode ?? false;
|
||||
entity.digestFrequencyHours = props.digestFrequencyHours ?? null;
|
||||
entity.quietHoursStart = props.quietHoursStart ?? null;
|
||||
entity.quietHoursEnd = props.quietHoursEnd ?? null;
|
||||
entity.updatedAt = props.updatedAt;
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
toStored(entity: NotificationPreferenceOrmEntity): NotificationPreference {
|
||||
return this.toDomain(entity);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { TypeOrmNotificationPreferenceRepository } from './TypeOrmNotificationPreferenceRepository';
|
||||
|
||||
describe('TypeOrmNotificationPreferenceRepository', () => {
|
||||
it('does not construct its own mapper dependencies', () => {
|
||||
// Check that the repository doesn't create its own mapper
|
||||
const sourcePath = require.resolve('./TypeOrmNotificationPreferenceRepository.ts');
|
||||
const fs = require('fs');
|
||||
const source = fs.readFileSync(sourcePath, 'utf8');
|
||||
|
||||
expect(source).not.toMatch(/new\s+NotificationPreferenceOrmMapper\s*\(/);
|
||||
expect(source).not.toMatch(/=\s*new\s+NotificationPreferenceOrmMapper\s*\(/);
|
||||
});
|
||||
|
||||
it('requires mapper injection via constructor (no default mapper)', () => {
|
||||
expect(TypeOrmNotificationPreferenceRepository.length).toBe(2);
|
||||
});
|
||||
|
||||
it('uses the injected mapper at runtime (DB-free)', async () => {
|
||||
const ormRepo = {
|
||||
findOne: vi.fn().mockResolvedValue({ id: 'driver-123' }),
|
||||
save: vi.fn().mockResolvedValue({ id: 'driver-123' }),
|
||||
delete: vi.fn().mockResolvedValue({ affected: 1 }),
|
||||
};
|
||||
|
||||
const dataSource = {
|
||||
getRepository: vi.fn().mockReturnValue(ormRepo),
|
||||
};
|
||||
|
||||
const mapper = {
|
||||
toDomain: vi.fn().mockReturnValue({ id: 'domain-preference-1' }),
|
||||
toOrmEntity: vi.fn().mockReturnValue({ id: 'orm-preference-1' }),
|
||||
};
|
||||
|
||||
const repo = new TypeOrmNotificationPreferenceRepository(dataSource as any, mapper as any);
|
||||
|
||||
// Test findByDriverId
|
||||
const preference = await repo.findByDriverId('driver-123');
|
||||
expect(dataSource.getRepository).toHaveBeenCalledTimes(1);
|
||||
expect(ormRepo.findOne).toHaveBeenCalledWith({ where: { driverId: 'driver-123' } });
|
||||
expect(mapper.toDomain).toHaveBeenCalledTimes(1);
|
||||
expect(preference).toEqual({ id: 'domain-preference-1' });
|
||||
|
||||
// Test save
|
||||
const domainPreference = { id: 'driver-123', driverId: 'driver-123', toJSON: () => ({ id: 'driver-123', driverId: 'driver-123' }) };
|
||||
await repo.save(domainPreference as any);
|
||||
expect(mapper.toOrmEntity).toHaveBeenCalledWith(domainPreference);
|
||||
expect(ormRepo.save).toHaveBeenCalledWith({ id: 'orm-preference-1' });
|
||||
|
||||
// Test delete
|
||||
await repo.delete('driver-123');
|
||||
expect(ormRepo.delete).toHaveBeenCalledWith({ driverId: 'driver-123' });
|
||||
|
||||
// Test getOrCreateDefault - existing
|
||||
ormRepo.findOne.mockResolvedValue({ id: 'existing' });
|
||||
const existing = await repo.getOrCreateDefault('driver-123');
|
||||
expect(existing).toEqual({ id: 'domain-preference-1' });
|
||||
expect(ormRepo.save).toHaveBeenCalledTimes(1); // Only from previous save test
|
||||
|
||||
// Test getOrCreateDefault - new
|
||||
ormRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
// The getOrCreateDefault should create default preferences and save them
|
||||
await repo.getOrCreateDefault('driver-456');
|
||||
expect(ormRepo.findOne).toHaveBeenCalledWith({ where: { driverId: 'driver-456' } });
|
||||
expect(ormRepo.save).toHaveBeenCalled(); // Should save the new default preferences
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { DataSource } from 'typeorm';
|
||||
import type { INotificationPreferenceRepository } from '@core/notifications/domain/repositories/INotificationPreferenceRepository';
|
||||
import { NotificationPreference } from '@core/notifications/domain/entities/NotificationPreference';
|
||||
import { NotificationPreferenceOrmEntity } from '../entities/NotificationPreferenceOrmEntity';
|
||||
import { NotificationPreferenceOrmMapper } from '../mappers/NotificationPreferenceOrmMapper';
|
||||
|
||||
export class TypeOrmNotificationPreferenceRepository implements INotificationPreferenceRepository {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly mapper: NotificationPreferenceOrmMapper,
|
||||
) {}
|
||||
|
||||
async findByDriverId(driverId: string): Promise<NotificationPreference | null> {
|
||||
const repo = this.dataSource.getRepository(NotificationPreferenceOrmEntity);
|
||||
const entity = await repo.findOne({ where: { driverId } });
|
||||
return entity ? this.mapper.toDomain(entity) : null;
|
||||
}
|
||||
|
||||
async save(preference: NotificationPreference): Promise<void> {
|
||||
const repo = this.dataSource.getRepository(NotificationPreferenceOrmEntity);
|
||||
const entity = this.mapper.toOrmEntity(preference);
|
||||
await repo.save(entity);
|
||||
}
|
||||
|
||||
async delete(driverId: string): Promise<void> {
|
||||
const repo = this.dataSource.getRepository(NotificationPreferenceOrmEntity);
|
||||
await repo.delete({ driverId });
|
||||
}
|
||||
|
||||
async getOrCreateDefault(driverId: string): Promise<NotificationPreference> {
|
||||
const existing = await this.findByDriverId(driverId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const defaultPrefs = NotificationPreference.createDefault(driverId);
|
||||
await this.save(defaultPrefs);
|
||||
return defaultPrefs;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { TypeOrmNotificationRepository } from './TypeOrmNotificationRepository';
|
||||
|
||||
describe('TypeOrmNotificationRepository', () => {
|
||||
it('does not construct its own mapper dependencies', () => {
|
||||
// Check that the repository doesn't create its own mapper
|
||||
const sourcePath = require.resolve('./TypeOrmNotificationRepository.ts');
|
||||
const fs = require('fs');
|
||||
const source = fs.readFileSync(sourcePath, 'utf8');
|
||||
|
||||
expect(source).not.toMatch(/new\s+NotificationOrmMapper\s*\(/);
|
||||
expect(source).not.toMatch(/=\s*new\s+NotificationOrmMapper\s*\(/);
|
||||
});
|
||||
|
||||
it('requires mapper injection via constructor (no default mapper)', () => {
|
||||
expect(TypeOrmNotificationRepository.length).toBe(2);
|
||||
});
|
||||
|
||||
it('uses the injected mapper at runtime (DB-free)', async () => {
|
||||
const ormRepo = {
|
||||
findOne: vi.fn().mockResolvedValue({ id: 'notification-1' }),
|
||||
find: vi.fn().mockResolvedValue([{ id: 'notification-1' }, { id: 'notification-2' }]),
|
||||
save: vi.fn().mockResolvedValue({ id: 'notification-1' }),
|
||||
delete: vi.fn().mockResolvedValue({ affected: 1 }),
|
||||
update: vi.fn().mockResolvedValue({ affected: 1 }),
|
||||
count: vi.fn().mockResolvedValue(1),
|
||||
};
|
||||
|
||||
const dataSource = {
|
||||
getRepository: vi.fn().mockReturnValue(ormRepo),
|
||||
};
|
||||
|
||||
const mapper = {
|
||||
toDomain: vi.fn().mockReturnValue({ id: 'domain-notification-1' }),
|
||||
toOrmEntity: vi.fn().mockReturnValue({ id: 'orm-notification-1' }),
|
||||
};
|
||||
|
||||
const repo = new TypeOrmNotificationRepository(dataSource as any, mapper as any);
|
||||
|
||||
// Test findById
|
||||
const notification = await repo.findById('notification-1');
|
||||
expect(dataSource.getRepository).toHaveBeenCalledTimes(1);
|
||||
expect(ormRepo.findOne).toHaveBeenCalledWith({ where: { id: 'notification-1' } });
|
||||
expect(mapper.toDomain).toHaveBeenCalledTimes(1);
|
||||
expect(notification).toEqual({ id: 'domain-notification-1' });
|
||||
|
||||
// Test findByRecipientId
|
||||
const notifications = await repo.findByRecipientId('driver-123');
|
||||
expect(ormRepo.find).toHaveBeenCalledWith({ where: { recipientId: 'driver-123' }, order: { createdAt: 'DESC' } });
|
||||
expect(mapper.toDomain).toHaveBeenCalledTimes(3); // 1 from findById + 2 from findByRecipientId
|
||||
expect(notifications).toHaveLength(2);
|
||||
|
||||
// Test findUnreadByRecipientId
|
||||
await repo.findUnreadByRecipientId('driver-123');
|
||||
expect(ormRepo.find).toHaveBeenCalledWith({ where: { recipientId: 'driver-123', status: 'unread' }, order: { createdAt: 'DESC' } });
|
||||
|
||||
// Test countUnreadByRecipientId
|
||||
const count = await repo.countUnreadByRecipientId('driver-123');
|
||||
expect(ormRepo.count).toHaveBeenCalledWith({ where: { recipientId: 'driver-123', status: 'unread' } });
|
||||
expect(count).toBe(1);
|
||||
|
||||
// Test create
|
||||
const domainNotification = { id: 'new-notification', toJSON: () => ({ id: 'new-notification' }) };
|
||||
await repo.create(domainNotification as any);
|
||||
expect(mapper.toOrmEntity).toHaveBeenCalledWith(domainNotification);
|
||||
expect(ormRepo.save).toHaveBeenCalledWith({ id: 'orm-notification-1' });
|
||||
|
||||
// Test update
|
||||
await repo.update(domainNotification as any);
|
||||
expect(mapper.toOrmEntity).toHaveBeenCalledWith(domainNotification);
|
||||
expect(ormRepo.save).toHaveBeenCalledWith({ id: 'orm-notification-1' });
|
||||
|
||||
// Test delete
|
||||
await repo.delete('notification-1');
|
||||
expect(ormRepo.delete).toHaveBeenCalledWith({ id: 'notification-1' });
|
||||
|
||||
// Test deleteAllByRecipientId
|
||||
await repo.deleteAllByRecipientId('driver-123');
|
||||
expect(ormRepo.delete).toHaveBeenCalledWith({ recipientId: 'driver-123' });
|
||||
|
||||
// Test markAllAsReadByRecipientId
|
||||
await repo.markAllAsReadByRecipientId('driver-123');
|
||||
expect(ormRepo.update).toHaveBeenCalledWith(
|
||||
{ recipientId: 'driver-123', status: 'unread' },
|
||||
{ status: 'read', readAt: expect.any(Date) },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { DataSource } from 'typeorm';
|
||||
import type { INotificationRepository } from '@core/notifications/domain/repositories/INotificationRepository';
|
||||
import type { NotificationType } from '@core/notifications/domain/types/NotificationTypes';
|
||||
import { Notification } from '@core/notifications/domain/entities/Notification';
|
||||
import { NotificationOrmEntity } from '../entities/NotificationOrmEntity';
|
||||
import { NotificationOrmMapper } from '../mappers/NotificationOrmMapper';
|
||||
|
||||
export class TypeOrmNotificationRepository implements INotificationRepository {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly mapper: NotificationOrmMapper,
|
||||
) {}
|
||||
|
||||
async findById(id: string): Promise<Notification | null> {
|
||||
const repo = this.dataSource.getRepository(NotificationOrmEntity);
|
||||
const entity = await repo.findOne({ where: { id } });
|
||||
return entity ? this.mapper.toDomain(entity) : null;
|
||||
}
|
||||
|
||||
async findByRecipientId(recipientId: string): Promise<Notification[]> {
|
||||
const repo = this.dataSource.getRepository(NotificationOrmEntity);
|
||||
const entities = await repo.find({
|
||||
where: { recipientId },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
return entities.map(entity => this.mapper.toDomain(entity));
|
||||
}
|
||||
|
||||
async findUnreadByRecipientId(recipientId: string): Promise<Notification[]> {
|
||||
const repo = this.dataSource.getRepository(NotificationOrmEntity);
|
||||
const entities = await repo.find({
|
||||
where: { recipientId, status: 'unread' },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
return entities.map(entity => this.mapper.toDomain(entity));
|
||||
}
|
||||
|
||||
async findByRecipientIdAndType(recipientId: string, type: NotificationType): Promise<Notification[]> {
|
||||
const repo = this.dataSource.getRepository(NotificationOrmEntity);
|
||||
const entities = await repo.find({
|
||||
where: { recipientId, type },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
return entities.map(entity => this.mapper.toDomain(entity));
|
||||
}
|
||||
|
||||
async countUnreadByRecipientId(recipientId: string): Promise<number> {
|
||||
const repo = this.dataSource.getRepository(NotificationOrmEntity);
|
||||
return await repo.count({
|
||||
where: { recipientId, status: 'unread' },
|
||||
});
|
||||
}
|
||||
|
||||
async create(notification: Notification): Promise<void> {
|
||||
const repo = this.dataSource.getRepository(NotificationOrmEntity);
|
||||
const entity = this.mapper.toOrmEntity(notification);
|
||||
await repo.save(entity);
|
||||
}
|
||||
|
||||
async update(notification: Notification): Promise<void> {
|
||||
const repo = this.dataSource.getRepository(NotificationOrmEntity);
|
||||
const entity = this.mapper.toOrmEntity(notification);
|
||||
await repo.save(entity);
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const repo = this.dataSource.getRepository(NotificationOrmEntity);
|
||||
await repo.delete({ id });
|
||||
}
|
||||
|
||||
async deleteAllByRecipientId(recipientId: string): Promise<void> {
|
||||
const repo = this.dataSource.getRepository(NotificationOrmEntity);
|
||||
await repo.delete({ recipientId });
|
||||
}
|
||||
|
||||
async markAllAsReadByRecipientId(recipientId: string): Promise<void> {
|
||||
const repo = this.dataSource.getRepository(NotificationOrmEntity);
|
||||
await repo.update(
|
||||
{ recipientId, status: 'unread' },
|
||||
{ status: 'read', readAt: new Date() },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import { TypeOrmPersistenceSchemaError } from '../errors/TypeOrmPersistenceSchemaError';
|
||||
|
||||
export function assertNonEmptyString(entityName: string, fieldName: string, value: unknown): void {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
throw new TypeOrmPersistenceSchemaError({
|
||||
entityName,
|
||||
fieldName,
|
||||
reason: 'invalid_string',
|
||||
message: `${fieldName} must be a non-empty string`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function assertOptionalStringOrNull(entityName: string, fieldName: string, value: unknown): void {
|
||||
if (value !== null && value !== undefined && typeof value !== 'string') {
|
||||
throw new TypeOrmPersistenceSchemaError({
|
||||
entityName,
|
||||
fieldName,
|
||||
reason: 'invalid_optional_string',
|
||||
message: `${fieldName} must be a string or null`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function assertDate(entityName: string, fieldName: string, value: unknown): void {
|
||||
if (!(value instanceof Date) || isNaN(value.getTime())) {
|
||||
throw new TypeOrmPersistenceSchemaError({
|
||||
entityName,
|
||||
fieldName,
|
||||
reason: 'invalid_date',
|
||||
message: `${fieldName} must be a valid Date`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function assertOptionalDate(entityName: string, fieldName: string, value: unknown): void {
|
||||
if (value !== null && value !== undefined) {
|
||||
assertDate(entityName, fieldName, value);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertBoolean(entityName: string, fieldName: string, value: unknown): void {
|
||||
if (typeof value !== 'boolean') {
|
||||
throw new TypeOrmPersistenceSchemaError({
|
||||
entityName,
|
||||
fieldName,
|
||||
reason: 'invalid_boolean',
|
||||
message: `${fieldName} must be a boolean`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function assertInteger(entityName: string, fieldName: string, value: unknown): void {
|
||||
if (typeof value !== 'number' || !Number.isInteger(value)) {
|
||||
throw new TypeOrmPersistenceSchemaError({
|
||||
entityName,
|
||||
fieldName,
|
||||
reason: 'invalid_integer',
|
||||
message: `${fieldName} must be an integer`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function assertOptionalInteger(entityName: string, fieldName: string, value: unknown): void {
|
||||
if (value !== null && value !== undefined) {
|
||||
assertInteger(entityName, fieldName, value);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertStringArray(entityName: string, fieldName: string, value: unknown): void {
|
||||
if (!Array.isArray(value) || !value.every(item => typeof item === 'string')) {
|
||||
throw new TypeOrmPersistenceSchemaError({
|
||||
entityName,
|
||||
fieldName,
|
||||
reason: 'invalid_string_array',
|
||||
message: `${fieldName} must be an array of strings`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function assertOptionalStringArray(entityName: string, fieldName: string, value: unknown): void {
|
||||
if (value !== null && value !== undefined) {
|
||||
assertStringArray(entityName, fieldName, value);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertObject(entityName: string, fieldName: string, value: unknown): void {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
throw new TypeOrmPersistenceSchemaError({
|
||||
entityName,
|
||||
fieldName,
|
||||
reason: 'invalid_object',
|
||||
message: `${fieldName} must be an object`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function assertOptionalObject(entityName: string, fieldName: string, value: unknown): void {
|
||||
if (value !== null && value !== undefined) {
|
||||
assertObject(entityName, fieldName, value);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertNotificationType(entityName: string, fieldName: string, value: unknown): void {
|
||||
const validTypes = [
|
||||
'system_announcement',
|
||||
'race_reminder',
|
||||
'protest_filed',
|
||||
'protest_resolved',
|
||||
'penalty_applied',
|
||||
'performance_summary',
|
||||
'final_results',
|
||||
'sponsorship_approved',
|
||||
'friend_request',
|
||||
'message_received',
|
||||
'achievement_unlocked',
|
||||
];
|
||||
|
||||
if (typeof value !== 'string' || !validTypes.includes(value)) {
|
||||
throw new TypeOrmPersistenceSchemaError({
|
||||
entityName,
|
||||
fieldName,
|
||||
reason: 'invalid_notification_type',
|
||||
message: `${fieldName} must be one of: ${validTypes.join(', ')}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function assertNotificationChannel(entityName: string, fieldName: string, value: unknown): void {
|
||||
const validChannels = ['in_app', 'email', 'discord', 'push'];
|
||||
|
||||
if (typeof value !== 'string' || !validChannels.includes(value)) {
|
||||
throw new TypeOrmPersistenceSchemaError({
|
||||
entityName,
|
||||
fieldName,
|
||||
reason: 'invalid_notification_channel',
|
||||
message: `${fieldName} must be one of: ${validChannels.join(', ')}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function assertNotificationStatus(entityName: string, fieldName: string, value: unknown): void {
|
||||
const validStatuses = ['unread', 'read', 'dismissed', 'action_required'];
|
||||
|
||||
if (typeof value !== 'string' || !validStatuses.includes(value)) {
|
||||
throw new TypeOrmPersistenceSchemaError({
|
||||
entityName,
|
||||
fieldName,
|
||||
reason: 'invalid_notification_status',
|
||||
message: `${fieldName} must be one of: ${validStatuses.join(', ')}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function assertNotificationUrgency(entityName: string, fieldName: string, value: unknown): void {
|
||||
const validUrgencies = ['silent', 'toast', 'modal'];
|
||||
|
||||
if (typeof value !== 'string' || !validUrgencies.includes(value)) {
|
||||
throw new TypeOrmPersistenceSchemaError({
|
||||
entityName,
|
||||
fieldName,
|
||||
reason: 'invalid_notification_urgency',
|
||||
message: `${fieldName} must be one of: ${validUrgencies.join(', ')}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function assertNotificationActions(entityName: string, fieldName: string, value: unknown): void {
|
||||
if (value === null || value === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(value)) {
|
||||
throw new TypeOrmPersistenceSchemaError({
|
||||
entityName,
|
||||
fieldName,
|
||||
reason: 'invalid_actions_array',
|
||||
message: `${fieldName} must be an array of action objects`,
|
||||
});
|
||||
}
|
||||
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const action = value[i];
|
||||
if (typeof action !== 'object' || action === null) {
|
||||
throw new TypeOrmPersistenceSchemaError({
|
||||
entityName,
|
||||
fieldName: `${fieldName}[${i}]`,
|
||||
reason: 'invalid_action_object',
|
||||
message: `Action at index ${i} must be an object`,
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof action.label !== 'string' || action.label.trim().length === 0) {
|
||||
throw new TypeOrmPersistenceSchemaError({
|
||||
entityName,
|
||||
fieldName: `${fieldName}[${i}].label`,
|
||||
reason: 'invalid_action_label',
|
||||
message: `Action at index ${i} must have a non-empty label`,
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof action.type !== 'string' || !['primary', 'secondary', 'danger'].includes(action.type)) {
|
||||
throw new TypeOrmPersistenceSchemaError({
|
||||
entityName,
|
||||
fieldName: `${fieldName}[${i}].type`,
|
||||
reason: 'invalid_action_type',
|
||||
message: `Action at index ${i} must have type 'primary', 'secondary', or 'danger'`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function assertChannelPreferences(entityName: string, fieldName: string, value: unknown): void {
|
||||
assertObject(entityName, fieldName, value);
|
||||
|
||||
const channels = ['in_app', 'email', 'discord', 'push'];
|
||||
const obj = value as Record<string, unknown>;
|
||||
|
||||
for (const channel of channels) {
|
||||
if (!(channel in obj)) {
|
||||
throw new TypeOrmPersistenceSchemaError({
|
||||
entityName,
|
||||
fieldName: `${fieldName}.${channel}`,
|
||||
reason: 'missing_channel',
|
||||
message: `Channel preferences must include ${channel}`,
|
||||
});
|
||||
}
|
||||
|
||||
const pref = obj[channel];
|
||||
if (typeof pref !== 'object' || pref === null) {
|
||||
throw new TypeOrmPersistenceSchemaError({
|
||||
entityName,
|
||||
fieldName: `${fieldName}.${channel}`,
|
||||
reason: 'invalid_channel_preference',
|
||||
message: `Channel preference for ${channel} must be an object`,
|
||||
});
|
||||
}
|
||||
|
||||
const prefObj = pref as Record<string, unknown>;
|
||||
if (typeof prefObj.enabled !== 'boolean') {
|
||||
throw new TypeOrmPersistenceSchemaError({
|
||||
entityName,
|
||||
fieldName: `${fieldName}.${channel}.enabled`,
|
||||
reason: 'invalid_enabled_flag',
|
||||
message: `Channel preference for ${channel} must have an enabled boolean`,
|
||||
});
|
||||
}
|
||||
|
||||
if (prefObj.settings !== undefined && prefObj.settings !== null && typeof prefObj.settings !== 'object') {
|
||||
throw new TypeOrmPersistenceSchemaError({
|
||||
entityName,
|
||||
fieldName: `${fieldName}.${channel}.settings`,
|
||||
reason: 'invalid_settings',
|
||||
message: `Channel preference for ${channel} settings must be an object or null`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Notification } from '@core/notifications/domain/entities/Notification';
|
||||
import type { NotificationChannel } from '@core/notifications/domain/types/NotificationTypes';
|
||||
import type { NotificationGateway, NotificationGatewayRegistry, NotificationDeliveryResult } from '@core/notifications/application/ports/NotificationGateway';
|
||||
|
||||
export class InMemoryNotificationGatewayRegistry implements NotificationGatewayRegistry {
|
||||
private gateways: Map<NotificationChannel, NotificationGateway> = new Map();
|
||||
|
||||
register(gateway: NotificationGateway): void {
|
||||
this.gateways.set(gateway.getChannel(), gateway);
|
||||
}
|
||||
|
||||
getGateway(channel: NotificationChannel): NotificationGateway | null {
|
||||
return this.gateways.get(channel) || null;
|
||||
}
|
||||
|
||||
getAllGateways(): NotificationGateway[] {
|
||||
return Array.from(this.gateways.values());
|
||||
}
|
||||
|
||||
async send(notification: Notification): Promise<NotificationDeliveryResult> {
|
||||
const gateway = this.gateways.get(notification.channel);
|
||||
|
||||
if (!gateway) {
|
||||
return {
|
||||
success: false,
|
||||
channel: notification.channel,
|
||||
error: `No gateway registered for channel ${notification.channel}`,
|
||||
attemptedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
if (!gateway.isConfigured()) {
|
||||
return {
|
||||
success: false,
|
||||
channel: notification.channel,
|
||||
error: `Gateway for ${notification.channel} is not configured`,
|
||||
attemptedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
return await gateway.send(notification);
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
channel: notification.channel,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
attemptedAt: new Date(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
48
adapters/notifications/ports/NotificationServiceAdapter.ts
Normal file
48
adapters/notifications/ports/NotificationServiceAdapter.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { NotificationService, SendNotificationCommand } from '@core/notifications/application/ports/NotificationService';
|
||||
import type { INotificationRepository } from '@core/notifications/domain/repositories/INotificationRepository';
|
||||
import type { INotificationPreferenceRepository } from '@core/notifications/domain/repositories/INotificationPreferenceRepository';
|
||||
import type { NotificationGatewayRegistry } from '@core/notifications/application/ports/NotificationGateway';
|
||||
import { SendNotificationUseCase } from '@core/notifications/application/use-cases/SendNotificationUseCase';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
|
||||
class NoOpOutputPort implements UseCaseOutputPort<any> {
|
||||
present(_result: any): void {
|
||||
// No-op for adapter
|
||||
}
|
||||
}
|
||||
|
||||
export class NotificationServiceAdapter implements NotificationService {
|
||||
private readonly useCase: SendNotificationUseCase;
|
||||
private readonly logger: Logger;
|
||||
|
||||
constructor(
|
||||
notificationRepository: INotificationRepository,
|
||||
preferenceRepository: INotificationPreferenceRepository,
|
||||
gatewayRegistry: NotificationGatewayRegistry,
|
||||
logger: Logger,
|
||||
) {
|
||||
this.logger = logger;
|
||||
this.useCase = new SendNotificationUseCase(
|
||||
notificationRepository,
|
||||
preferenceRepository,
|
||||
gatewayRegistry,
|
||||
new NoOpOutputPort(),
|
||||
logger,
|
||||
);
|
||||
}
|
||||
|
||||
async sendNotification(command: SendNotificationCommand): Promise<void> {
|
||||
const result = await this.useCase.execute(command);
|
||||
|
||||
if (result.isErr()) {
|
||||
const error = result.error;
|
||||
if (error) {
|
||||
this.logger.error('Failed to send notification', new Error(error.details.message));
|
||||
throw new Error(error.details.message);
|
||||
} else {
|
||||
throw new Error('Unknown error sending notification');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user