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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,12 @@ import { Inject, Module, OnModuleInit } from '@nestjs/common';
|
||||
import { getApiPersistence, getEnableBootstrap } from '../../env';
|
||||
import { RacingPersistenceModule } from '../../persistence/racing/RacingPersistenceModule';
|
||||
import { SocialPersistenceModule } from '../../persistence/social/SocialPersistenceModule';
|
||||
import { AchievementPersistenceModule } from '../../persistence/achievement/AchievementPersistenceModule';
|
||||
import { IdentityPersistenceModule } from '../../persistence/identity/IdentityPersistenceModule';
|
||||
import { BootstrapProviders, ENSURE_INITIAL_DATA_TOKEN } from './BootstrapProviders';
|
||||
|
||||
@Module({
|
||||
imports: [RacingPersistenceModule, SocialPersistenceModule],
|
||||
imports: [RacingPersistenceModule, SocialPersistenceModule, AchievementPersistenceModule, IdentityPersistenceModule],
|
||||
providers: BootstrapProviders,
|
||||
})
|
||||
export class BootstrapModule implements OnModuleInit {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Provider } from '@nestjs/common';
|
||||
import { SOCIAL_FEED_REPOSITORY_TOKEN, SOCIAL_GRAPH_REPOSITORY_TOKEN } from '../../persistence/social/SocialPersistenceTokens';
|
||||
import { ACHIEVEMENT_REPOSITORY_TOKEN } from '../../persistence/achievement/AchievementPersistenceTokens';
|
||||
import { EnsureInitialData } from '../../../../../adapters/bootstrap/EnsureInitialData';
|
||||
import type { RacingSeedDependencies } from '../../../../../adapters/bootstrap/SeedRacingData';
|
||||
import { SignupWithEmailUseCase, type SignupWithEmailResult } from '@core/identity/application/use-cases/SignupWithEmailUseCase';
|
||||
@@ -12,13 +13,12 @@ import type { IUserRepository } from '@core/identity/domain/repositories/IUserRe
|
||||
import type { IdentitySessionPort } from '@core/identity/application/ports/IdentitySessionPort';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
import { InMemoryUserRepository } from '../../../../../adapters/identity/persistence/inmemory/InMemoryUserRepository';
|
||||
import { InMemoryAchievementRepository } from '../../../../../adapters/persistence/inmemory/achievement/InMemoryAchievementRepository';
|
||||
import { CookieIdentitySessionAdapter } from '../../../../../adapters/identity/session/CookieIdentitySessionAdapter';
|
||||
import { USER_REPOSITORY_TOKEN as IDENTITY_USER_REPOSITORY_TOKEN } from '../../persistence/identity/IdentityPersistenceTokens';
|
||||
|
||||
// Define tokens
|
||||
export const USER_REPOSITORY_TOKEN = 'IUserRepository_Bootstrap';
|
||||
export const ACHIEVEMENT_REPOSITORY_TOKEN = 'IAchievementRepository_Bootstrap';
|
||||
// ACHIEVEMENT_REPOSITORY_TOKEN is now imported from AchievementPersistenceTokens
|
||||
export const IDENTITY_SESSION_PORT_TOKEN = 'IdentitySessionPort_Bootstrap';
|
||||
export const SIGNUP_USE_CASE_TOKEN = 'SignupWithEmailUseCase_Bootstrap';
|
||||
export const CREATE_ACHIEVEMENT_USE_CASE_TOKEN = 'CreateAchievementUseCase_Bootstrap';
|
||||
@@ -112,13 +112,10 @@ export const BootstrapProviders: Provider[] = [
|
||||
},
|
||||
{
|
||||
provide: USER_REPOSITORY_TOKEN,
|
||||
useFactory: (logger: Logger) => new InMemoryUserRepository(logger),
|
||||
inject: ['Logger'],
|
||||
},
|
||||
{
|
||||
provide: ACHIEVEMENT_REPOSITORY_TOKEN,
|
||||
useClass: InMemoryAchievementRepository,
|
||||
useFactory: (userRepository: IUserRepository) => userRepository,
|
||||
inject: [IDENTITY_USER_REPOSITORY_TOKEN],
|
||||
},
|
||||
// Achievement repository is now provided by AchievementPersistenceModule
|
||||
{
|
||||
provide: IDENTITY_SESSION_PORT_TOKEN,
|
||||
useFactory: (logger: Logger) => new CookieIdentitySessionAdapter(logger),
|
||||
|
||||
@@ -2,8 +2,10 @@ import { Module } from '@nestjs/common';
|
||||
import { MediaService } from './MediaService';
|
||||
import { MediaController } from './MediaController';
|
||||
import { MediaProviders } from './MediaProviders';
|
||||
import { MediaPersistenceModule } from '../../persistence/media/MediaPersistenceModule';
|
||||
|
||||
@Module({
|
||||
imports: [MediaPersistenceModule],
|
||||
controllers: [MediaController],
|
||||
providers: [MediaService, ...MediaProviders],
|
||||
exports: [MediaService],
|
||||
|
||||
@@ -57,37 +57,11 @@ import {
|
||||
|
||||
export * from './MediaTokens';
|
||||
|
||||
import type { AvatarGenerationRequest } from '@core/media/domain/entities/AvatarGenerationRequest';
|
||||
import type { Media } from '@core/media/domain/entities/Media';
|
||||
import type { Avatar } from '@core/media/domain/entities/Avatar';
|
||||
import type { FaceValidationResult } from '@core/media/application/ports/FaceValidationPort';
|
||||
import type { AvatarGenerationResult } from '@core/media/application/ports/AvatarGenerationPort';
|
||||
import type { UploadResult } from '@core/media/application/ports/MediaStoragePort';
|
||||
|
||||
// Mock implementations
|
||||
class MockAvatarGenerationRepository implements IAvatarGenerationRepository {
|
||||
async save(): Promise<void> {}
|
||||
async findById(): Promise<AvatarGenerationRequest | null> { return null; }
|
||||
async findByUserId(): Promise<AvatarGenerationRequest[]> { return []; }
|
||||
async findLatestByUserId(): Promise<AvatarGenerationRequest | null> { return null; }
|
||||
async delete(): Promise<void> {}
|
||||
}
|
||||
|
||||
class MockMediaRepository implements IMediaRepository {
|
||||
async save(): Promise<void> {}
|
||||
async findById(): Promise<Media | null> { return null; }
|
||||
async findByUploadedBy(): Promise<Media[]> { return []; }
|
||||
async delete(): Promise<void> {}
|
||||
}
|
||||
|
||||
class MockAvatarRepository implements IAvatarRepository {
|
||||
async save(): Promise<void> {}
|
||||
async findById(): Promise<Avatar | null> { return null; }
|
||||
async findActiveByDriverId(): Promise<Avatar | null> { return null; }
|
||||
async findByDriverId(): Promise<Avatar[]> { return []; }
|
||||
async delete(): Promise<void> {}
|
||||
}
|
||||
|
||||
// External adapters (ports) - these remain mock implementations
|
||||
class MockFaceValidationAdapter implements FaceValidationPort {
|
||||
async validateFacePhoto(): Promise<FaceValidationResult> {
|
||||
return {
|
||||
@@ -137,18 +111,6 @@ export const MediaProviders: Provider[] = [
|
||||
DeleteMediaPresenter,
|
||||
GetAvatarPresenter,
|
||||
UpdateAvatarPresenter,
|
||||
{
|
||||
provide: AVATAR_GENERATION_REPOSITORY_TOKEN,
|
||||
useClass: MockAvatarGenerationRepository,
|
||||
},
|
||||
{
|
||||
provide: MEDIA_REPOSITORY_TOKEN,
|
||||
useClass: MockMediaRepository,
|
||||
},
|
||||
{
|
||||
provide: AVATAR_REPOSITORY_TOKEN,
|
||||
useClass: MockAvatarRepository,
|
||||
},
|
||||
{
|
||||
provide: FACE_VALIDATION_PORT_TOKEN,
|
||||
useClass: MockFaceValidationAdapter,
|
||||
|
||||
9
apps/api/src/domain/notifications/NotificationsModule.ts
Normal file
9
apps/api/src/domain/notifications/NotificationsModule.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { NotificationsPersistenceModule } from '../../persistence/notifications/NotificationsPersistenceModule';
|
||||
|
||||
@Module({
|
||||
imports: [NotificationsPersistenceModule],
|
||||
exports: [NotificationsPersistenceModule],
|
||||
})
|
||||
export class NotificationsModule {}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it, beforeEach } from 'vitest';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ACHIEVEMENT_REPOSITORY_TOKEN } from './AchievementPersistenceTokens';
|
||||
import type { IAchievementRepository } from '@core/identity/domain/repositories/IAchievementRepository';
|
||||
|
||||
describe('AchievementPersistenceModule', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset environment before each test
|
||||
delete process.env.GRIDPILOT_API_PERSISTENCE;
|
||||
// Clear module cache to ensure fresh imports
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original environment
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
it('uses inmemory providers when GRIDPILOT_API_PERSISTENCE=inmemory', async () => {
|
||||
process.env.GRIDPILOT_API_PERSISTENCE = 'inmemory';
|
||||
|
||||
const { AchievementPersistenceModule } = await import('./AchievementPersistenceModule');
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
imports: [AchievementPersistenceModule],
|
||||
}).compile();
|
||||
|
||||
const repository = module.get<IAchievementRepository>(ACHIEVEMENT_REPOSITORY_TOKEN);
|
||||
// The adapter should provide the domain interface methods
|
||||
expect(repository).toBeDefined();
|
||||
expect(typeof repository.createAchievement).toBe('function');
|
||||
expect(typeof repository.findAchievementById).toBe('function');
|
||||
expect(typeof repository.findAllAchievements).toBe('function');
|
||||
});
|
||||
|
||||
it('uses postgres providers when GRIDPILOT_API_PERSISTENCE=postgres', async () => {
|
||||
const shouldRun = Boolean(process.env.DATABASE_URL);
|
||||
if (!shouldRun) {
|
||||
console.log('Skipping postgres test - DATABASE_URL not set');
|
||||
return;
|
||||
}
|
||||
|
||||
process.env.GRIDPILOT_API_PERSISTENCE = 'postgres';
|
||||
|
||||
const { AchievementPersistenceModule } = await import('./AchievementPersistenceModule');
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
imports: [AchievementPersistenceModule],
|
||||
}).compile();
|
||||
|
||||
const repository = module.get<IAchievementRepository>(ACHIEVEMENT_REPOSITORY_TOKEN);
|
||||
// The adapter should provide the domain interface methods
|
||||
expect(repository).toBeDefined();
|
||||
expect(typeof repository.createAchievement).toBe('function');
|
||||
expect(typeof repository.findAchievementById).toBe('function');
|
||||
expect(typeof repository.findAllAchievements).toBe('function');
|
||||
});
|
||||
|
||||
it('defaults to inmemory when GRIDPILOT_API_PERSISTENCE is not set', async () => {
|
||||
// Ensure env var is not set
|
||||
delete process.env.GRIDPILOT_API_PERSISTENCE;
|
||||
|
||||
const { AchievementPersistenceModule } = await import('./AchievementPersistenceModule');
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
imports: [AchievementPersistenceModule],
|
||||
}).compile();
|
||||
|
||||
const repository = module.get<IAchievementRepository>(ACHIEVEMENT_REPOSITORY_TOKEN);
|
||||
// The adapter should provide the domain interface methods
|
||||
expect(repository).toBeDefined();
|
||||
expect(typeof repository.createAchievement).toBe('function');
|
||||
expect(typeof repository.findAchievementById).toBe('function');
|
||||
expect(typeof repository.findAllAchievements).toBe('function');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { getApiPersistence } from '../../env';
|
||||
import { InMemoryAchievementPersistenceModule } from '../inmemory/InMemoryAchievementPersistenceModule';
|
||||
import { PostgresAchievementPersistenceModule } from '../postgres/PostgresAchievementPersistenceModule';
|
||||
|
||||
const selectedPersistenceModule =
|
||||
getApiPersistence() === 'postgres' ? PostgresAchievementPersistenceModule : InMemoryAchievementPersistenceModule;
|
||||
|
||||
@Module({
|
||||
imports: [selectedPersistenceModule],
|
||||
exports: [selectedPersistenceModule],
|
||||
})
|
||||
export class AchievementPersistenceModule {}
|
||||
@@ -0,0 +1,2 @@
|
||||
export const ACHIEVEMENT_REPOSITORY_TOKEN = 'IAchievementRepository';
|
||||
export const USER_ACHIEVEMENT_REPOSITORY_TOKEN = 'IUserAchievementRepository';
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { LoggingModule } from '../../domain/logging/LoggingModule';
|
||||
|
||||
import type { Logger } from '@core/shared/application/Logger';
|
||||
|
||||
import { InMemoryAchievementRepository } from '@adapters/identity/persistence/inmemory/InMemoryAchievementRepository';
|
||||
|
||||
import { ACHIEVEMENT_REPOSITORY_TOKEN } from '../achievement/AchievementPersistenceTokens';
|
||||
import { Achievement, AchievementCategory } from '@core/identity/domain/entities/Achievement';
|
||||
import { UserAchievement } from '@core/identity/domain/entities/UserAchievement';
|
||||
|
||||
// Adapter to convert between domain repository interface and application use case interface
|
||||
class InMemoryAchievementRepositoryAdapter {
|
||||
constructor(private readonly inMemoryRepo: InMemoryAchievementRepository) {}
|
||||
|
||||
// Application use case interface methods
|
||||
async save(achievement: Achievement): Promise<void> {
|
||||
await this.inMemoryRepo.createAchievement(achievement);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Achievement | null> {
|
||||
return await this.inMemoryRepo.findAchievementById(id);
|
||||
}
|
||||
|
||||
// Delegate all other methods to the underlying in-memory repository
|
||||
async findAchievementById(id: string): Promise<Achievement | null> {
|
||||
return await this.inMemoryRepo.findAchievementById(id);
|
||||
}
|
||||
|
||||
async findAllAchievements(): Promise<Achievement[]> {
|
||||
return await this.inMemoryRepo.findAllAchievements();
|
||||
}
|
||||
|
||||
async findAchievementsByCategory(category: AchievementCategory): Promise<Achievement[]> {
|
||||
return await this.inMemoryRepo.findAchievementsByCategory(category);
|
||||
}
|
||||
|
||||
async createAchievement(achievement: Achievement): Promise<Achievement> {
|
||||
return await this.inMemoryRepo.createAchievement(achievement);
|
||||
}
|
||||
|
||||
async findUserAchievementById(id: string): Promise<UserAchievement | null> {
|
||||
return await this.inMemoryRepo.findUserAchievementById(id);
|
||||
}
|
||||
|
||||
async findUserAchievementsByUserId(userId: string): Promise<UserAchievement[]> {
|
||||
return await this.inMemoryRepo.findUserAchievementsByUserId(userId);
|
||||
}
|
||||
|
||||
async findUserAchievementByUserAndAchievement(userId: string, achievementId: string): Promise<UserAchievement | null> {
|
||||
return await this.inMemoryRepo.findUserAchievementByUserAndAchievement(userId, achievementId);
|
||||
}
|
||||
|
||||
async hasUserEarnedAchievement(userId: string, achievementId: string): Promise<boolean> {
|
||||
return await this.inMemoryRepo.hasUserEarnedAchievement(userId, achievementId);
|
||||
}
|
||||
|
||||
async createUserAchievement(userAchievement: UserAchievement): Promise<UserAchievement> {
|
||||
return await this.inMemoryRepo.createUserAchievement(userAchievement);
|
||||
}
|
||||
|
||||
async updateUserAchievement(userAchievement: UserAchievement): Promise<UserAchievement> {
|
||||
return await this.inMemoryRepo.updateUserAchievement(userAchievement);
|
||||
}
|
||||
|
||||
async getAchievementLeaderboard(limit: number): Promise<{ userId: string; points: number; count: number }[]> {
|
||||
return await this.inMemoryRepo.getAchievementLeaderboard(limit);
|
||||
}
|
||||
|
||||
async getUserAchievementStats(userId: string): Promise<{
|
||||
total: number;
|
||||
points: number;
|
||||
byCategory: Record<AchievementCategory, number>;
|
||||
}> {
|
||||
return await this.inMemoryRepo.getUserAchievementStats(userId);
|
||||
}
|
||||
}
|
||||
|
||||
@Module({
|
||||
imports: [LoggingModule],
|
||||
providers: [
|
||||
{
|
||||
provide: ACHIEVEMENT_REPOSITORY_TOKEN,
|
||||
useFactory: (logger: Logger) =>
|
||||
new InMemoryAchievementRepositoryAdapter(new InMemoryAchievementRepository(logger)),
|
||||
inject: ['Logger'],
|
||||
},
|
||||
],
|
||||
exports: [ACHIEVEMENT_REPOSITORY_TOKEN],
|
||||
})
|
||||
export class InMemoryAchievementPersistenceModule {}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { LoggingModule } from '../../domain/logging/LoggingModule';
|
||||
|
||||
import type { Logger } from '@core/shared/application/Logger';
|
||||
|
||||
import type { IAvatarGenerationRepository } from '@core/media/domain/repositories/IAvatarGenerationRepository';
|
||||
import type { IMediaRepository } from '@core/media/domain/repositories/IMediaRepository';
|
||||
import type { IAvatarRepository } from '@core/media/domain/repositories/IAvatarRepository';
|
||||
|
||||
import { InMemoryAvatarGenerationRepository } from '@adapters/media/persistence/inmemory/InMemoryAvatarGenerationRepository';
|
||||
|
||||
import { AVATAR_GENERATION_REPOSITORY_TOKEN, MEDIA_REPOSITORY_TOKEN, AVATAR_REPOSITORY_TOKEN } from '../media/MediaPersistenceTokens';
|
||||
|
||||
// Mock implementations for Media and Avatar repositories (inmemory only has AvatarGeneration)
|
||||
class MockMediaRepository implements IMediaRepository {
|
||||
async save(): Promise<void> {}
|
||||
async findById(): Promise<null> { return null; }
|
||||
async findByUploadedBy(): Promise<[]> { return []; }
|
||||
async delete(): Promise<void> {}
|
||||
}
|
||||
|
||||
class MockAvatarRepository implements IAvatarRepository {
|
||||
async save(): Promise<void> {}
|
||||
async findById(): Promise<null> { return null; }
|
||||
async findActiveByDriverId(): Promise<null> { return null; }
|
||||
async findByDriverId(): Promise<[]> { return []; }
|
||||
async delete(): Promise<void> {}
|
||||
}
|
||||
|
||||
@Module({
|
||||
imports: [LoggingModule],
|
||||
providers: [
|
||||
{
|
||||
provide: AVATAR_GENERATION_REPOSITORY_TOKEN,
|
||||
useFactory: (logger: Logger): IAvatarGenerationRepository =>
|
||||
new InMemoryAvatarGenerationRepository(logger),
|
||||
inject: ['Logger'],
|
||||
},
|
||||
{
|
||||
provide: MEDIA_REPOSITORY_TOKEN,
|
||||
useClass: MockMediaRepository,
|
||||
},
|
||||
{
|
||||
provide: AVATAR_REPOSITORY_TOKEN,
|
||||
useClass: MockAvatarRepository,
|
||||
},
|
||||
],
|
||||
exports: [AVATAR_GENERATION_REPOSITORY_TOKEN, MEDIA_REPOSITORY_TOKEN, AVATAR_REPOSITORY_TOKEN],
|
||||
})
|
||||
export class InMemoryMediaPersistenceModule {}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { LoggingModule } from '../../domain/logging/LoggingModule';
|
||||
|
||||
import type { Logger } from '@core/shared/application/Logger';
|
||||
|
||||
import type { INotificationRepository } from '@core/notifications/domain/repositories/INotificationRepository';
|
||||
import type { INotificationPreferenceRepository } from '@core/notifications/domain/repositories/INotificationPreferenceRepository';
|
||||
import type { NotificationService } from '@core/notifications/application/ports/NotificationService';
|
||||
import type { NotificationGatewayRegistry } from '@core/notifications/application/ports/NotificationGateway';
|
||||
|
||||
import { InMemoryNotificationRepository } from '@adapters/notifications/persistence/inmemory/InMemoryNotificationRepository';
|
||||
import { InMemoryNotificationPreferenceRepository } from '@adapters/notifications/persistence/inmemory/InMemoryNotificationPreferenceRepository';
|
||||
import { NotificationServiceAdapter } from '@adapters/notifications/ports/NotificationServiceAdapter';
|
||||
import { InMemoryNotificationGatewayRegistry } from '@adapters/notifications/ports/InMemoryNotificationGatewayRegistry';
|
||||
|
||||
import { NOTIFICATION_REPOSITORY_TOKEN, NOTIFICATION_PREFERENCE_REPOSITORY_TOKEN } from '../notifications/NotificationsPersistenceTokens';
|
||||
|
||||
export const NOTIFICATION_SERVICE_TOKEN = 'INotificationService';
|
||||
export const NOTIFICATION_GATEWAY_REGISTRY_TOKEN = 'INotificationGatewayRegistry';
|
||||
|
||||
@Module({
|
||||
imports: [LoggingModule],
|
||||
providers: [
|
||||
{
|
||||
provide: NOTIFICATION_REPOSITORY_TOKEN,
|
||||
useFactory: (logger: Logger): INotificationRepository =>
|
||||
new InMemoryNotificationRepository(logger),
|
||||
inject: ['Logger'],
|
||||
},
|
||||
{
|
||||
provide: NOTIFICATION_PREFERENCE_REPOSITORY_TOKEN,
|
||||
useFactory: (logger: Logger): INotificationPreferenceRepository =>
|
||||
new InMemoryNotificationPreferenceRepository(logger),
|
||||
inject: ['Logger'],
|
||||
},
|
||||
{
|
||||
provide: NOTIFICATION_GATEWAY_REGISTRY_TOKEN,
|
||||
useFactory: (): NotificationGatewayRegistry =>
|
||||
new InMemoryNotificationGatewayRegistry(),
|
||||
inject: [],
|
||||
},
|
||||
{
|
||||
provide: NOTIFICATION_SERVICE_TOKEN,
|
||||
useFactory: (
|
||||
notificationRepo: INotificationRepository,
|
||||
preferenceRepo: INotificationPreferenceRepository,
|
||||
gatewayRegistry: NotificationGatewayRegistry,
|
||||
logger: Logger,
|
||||
): NotificationService =>
|
||||
new NotificationServiceAdapter(notificationRepo, preferenceRepo, gatewayRegistry, logger),
|
||||
inject: [
|
||||
NOTIFICATION_REPOSITORY_TOKEN,
|
||||
NOTIFICATION_PREFERENCE_REPOSITORY_TOKEN,
|
||||
NOTIFICATION_GATEWAY_REGISTRY_TOKEN,
|
||||
'Logger',
|
||||
],
|
||||
},
|
||||
],
|
||||
exports: [
|
||||
NOTIFICATION_REPOSITORY_TOKEN,
|
||||
NOTIFICATION_PREFERENCE_REPOSITORY_TOKEN,
|
||||
NOTIFICATION_SERVICE_TOKEN,
|
||||
NOTIFICATION_GATEWAY_REGISTRY_TOKEN,
|
||||
],
|
||||
})
|
||||
export class InMemoryNotificationsPersistenceModule {}
|
||||
@@ -0,0 +1,68 @@
|
||||
import 'reflect-metadata';
|
||||
|
||||
import { MODULE_METADATA } from '@nestjs/common/constants';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import type { TestingModule } from '@nestjs/testing';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AVATAR_GENERATION_REPOSITORY_TOKEN, MEDIA_REPOSITORY_TOKEN, AVATAR_REPOSITORY_TOKEN } from './MediaPersistenceTokens';
|
||||
|
||||
describe('MediaPersistenceModule', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('uses inmemory providers when GRIDPILOT_API_PERSISTENCE=inmemory', async () => {
|
||||
vi.resetModules();
|
||||
|
||||
process.env.GRIDPILOT_API_PERSISTENCE = 'inmemory';
|
||||
delete process.env.DATABASE_URL;
|
||||
|
||||
const { MediaPersistenceModule } = await import('./MediaPersistenceModule');
|
||||
const { InMemoryAvatarGenerationRepository } = await import('@adapters/media/persistence/inmemory/InMemoryAvatarGenerationRepository');
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
imports: [MediaPersistenceModule],
|
||||
}).compile();
|
||||
|
||||
const avatarGenRepo = module.get(AVATAR_GENERATION_REPOSITORY_TOKEN);
|
||||
expect(avatarGenRepo).toBeInstanceOf(InMemoryAvatarGenerationRepository);
|
||||
|
||||
// Media and Avatar repos are mocks in inmemory mode
|
||||
const mediaRepo = module.get(MEDIA_REPOSITORY_TOKEN);
|
||||
const avatarRepo = module.get(AVATAR_REPOSITORY_TOKEN);
|
||||
expect(mediaRepo).toBeDefined();
|
||||
expect(avatarRepo).toBeDefined();
|
||||
|
||||
await module.close();
|
||||
});
|
||||
|
||||
it('uses postgres module when GRIDPILOT_API_PERSISTENCE=postgres', async () => {
|
||||
vi.resetModules();
|
||||
|
||||
process.env.GRIDPILOT_API_PERSISTENCE = 'postgres';
|
||||
delete process.env.DATABASE_URL;
|
||||
|
||||
const { MediaPersistenceModule } = await import('./MediaPersistenceModule');
|
||||
const { PostgresMediaPersistenceModule } = await import('../postgres/PostgresMediaPersistenceModule');
|
||||
|
||||
const imports = Reflect.getMetadata(MODULE_METADATA.IMPORTS, MediaPersistenceModule) as unknown[];
|
||||
expect(imports).toContain(PostgresMediaPersistenceModule);
|
||||
});
|
||||
|
||||
it('defaults to inmemory when GRIDPILOT_API_PERSISTENCE is not set', async () => {
|
||||
vi.resetModules();
|
||||
|
||||
delete process.env.GRIDPILOT_API_PERSISTENCE;
|
||||
delete process.env.DATABASE_URL;
|
||||
|
||||
const { MediaPersistenceModule } = await import('./MediaPersistenceModule');
|
||||
const { InMemoryMediaPersistenceModule } = await import('../inmemory/InMemoryMediaPersistenceModule');
|
||||
|
||||
const imports = Reflect.getMetadata(MODULE_METADATA.IMPORTS, MediaPersistenceModule) as unknown[];
|
||||
expect(imports).toContain(InMemoryMediaPersistenceModule);
|
||||
});
|
||||
});
|
||||
14
apps/api/src/persistence/media/MediaPersistenceModule.ts
Normal file
14
apps/api/src/persistence/media/MediaPersistenceModule.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { getApiPersistence } from '../../env';
|
||||
import { InMemoryMediaPersistenceModule } from '../inmemory/InMemoryMediaPersistenceModule';
|
||||
import { PostgresMediaPersistenceModule } from '../postgres/PostgresMediaPersistenceModule';
|
||||
|
||||
const selectedPersistenceModule =
|
||||
getApiPersistence() === 'postgres' ? PostgresMediaPersistenceModule : InMemoryMediaPersistenceModule;
|
||||
|
||||
@Module({
|
||||
imports: [selectedPersistenceModule],
|
||||
exports: [selectedPersistenceModule],
|
||||
})
|
||||
export class MediaPersistenceModule {}
|
||||
3
apps/api/src/persistence/media/MediaPersistenceTokens.ts
Normal file
3
apps/api/src/persistence/media/MediaPersistenceTokens.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const AVATAR_GENERATION_REPOSITORY_TOKEN = 'IAvatarGenerationRepository';
|
||||
export const MEDIA_REPOSITORY_TOKEN = 'IMediaRepository';
|
||||
export const AVATAR_REPOSITORY_TOKEN = 'IAvatarRepository';
|
||||
@@ -0,0 +1,73 @@
|
||||
import 'reflect-metadata';
|
||||
|
||||
import { MODULE_METADATA } from '@nestjs/common/constants';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import type { TestingModule } from '@nestjs/testing';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { NOTIFICATION_REPOSITORY_TOKEN, NOTIFICATION_PREFERENCE_REPOSITORY_TOKEN, NOTIFICATION_SERVICE_TOKEN, NOTIFICATION_GATEWAY_REGISTRY_TOKEN } from './NotificationsPersistenceTokens';
|
||||
|
||||
describe('NotificationsPersistenceModule', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('uses inmemory providers when GRIDPILOT_API_PERSISTENCE=inmemory', async () => {
|
||||
vi.resetModules();
|
||||
|
||||
process.env.GRIDPILOT_API_PERSISTENCE = 'inmemory';
|
||||
delete process.env.DATABASE_URL;
|
||||
|
||||
const { NotificationsPersistenceModule } = await import('./NotificationsPersistenceModule');
|
||||
const { InMemoryNotificationRepository } = await import('@adapters/notifications/persistence/inmemory/InMemoryNotificationRepository');
|
||||
const { InMemoryNotificationPreferenceRepository } = await import('@adapters/notifications/persistence/inmemory/InMemoryNotificationPreferenceRepository');
|
||||
const { NotificationServiceAdapter } = await import('@adapters/notifications/ports/NotificationServiceAdapter');
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
imports: [NotificationsPersistenceModule],
|
||||
}).compile();
|
||||
|
||||
const notificationRepo = module.get(NOTIFICATION_REPOSITORY_TOKEN);
|
||||
expect(notificationRepo).toBeInstanceOf(InMemoryNotificationRepository);
|
||||
|
||||
const preferenceRepo = module.get(NOTIFICATION_PREFERENCE_REPOSITORY_TOKEN);
|
||||
expect(preferenceRepo).toBeInstanceOf(InMemoryNotificationPreferenceRepository);
|
||||
|
||||
const notificationService = module.get(NOTIFICATION_SERVICE_TOKEN);
|
||||
expect(notificationService).toBeInstanceOf(NotificationServiceAdapter);
|
||||
|
||||
const gatewayRegistry = module.get(NOTIFICATION_GATEWAY_REGISTRY_TOKEN);
|
||||
expect(gatewayRegistry).toBeDefined();
|
||||
|
||||
await module.close();
|
||||
});
|
||||
|
||||
it('uses postgres module when GRIDPILOT_API_PERSISTENCE=postgres', async () => {
|
||||
vi.resetModules();
|
||||
|
||||
process.env.GRIDPILOT_API_PERSISTENCE = 'postgres';
|
||||
delete process.env.DATABASE_URL;
|
||||
|
||||
const { NotificationsPersistenceModule } = await import('./NotificationsPersistenceModule');
|
||||
const { PostgresNotificationsPersistenceModule } = await import('../postgres/PostgresNotificationsPersistenceModule');
|
||||
|
||||
const imports = Reflect.getMetadata(MODULE_METADATA.IMPORTS, NotificationsPersistenceModule) as unknown[];
|
||||
expect(imports).toContain(PostgresNotificationsPersistenceModule);
|
||||
});
|
||||
|
||||
it('defaults to inmemory when GRIDPILOT_API_PERSISTENCE is not set', async () => {
|
||||
vi.resetModules();
|
||||
|
||||
delete process.env.GRIDPILOT_API_PERSISTENCE;
|
||||
delete process.env.DATABASE_URL;
|
||||
|
||||
const { NotificationsPersistenceModule } = await import('./NotificationsPersistenceModule');
|
||||
const { InMemoryNotificationsPersistenceModule } = await import('../inmemory/InMemoryNotificationsPersistenceModule');
|
||||
|
||||
const imports = Reflect.getMetadata(MODULE_METADATA.IMPORTS, NotificationsPersistenceModule) as unknown[];
|
||||
expect(imports).toContain(InMemoryNotificationsPersistenceModule);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { getApiPersistence } from '../../env';
|
||||
import { InMemoryNotificationsPersistenceModule } from '../inmemory/InMemoryNotificationsPersistenceModule';
|
||||
import { PostgresNotificationsPersistenceModule } from '../postgres/PostgresNotificationsPersistenceModule';
|
||||
|
||||
const selectedPersistenceModule =
|
||||
getApiPersistence() === 'postgres' ? PostgresNotificationsPersistenceModule : InMemoryNotificationsPersistenceModule;
|
||||
|
||||
@Module({
|
||||
imports: [selectedPersistenceModule],
|
||||
exports: [selectedPersistenceModule],
|
||||
})
|
||||
export class NotificationsPersistenceModule {}
|
||||
@@ -0,0 +1,4 @@
|
||||
export const NOTIFICATION_REPOSITORY_TOKEN = 'INotificationRepository';
|
||||
export const NOTIFICATION_PREFERENCE_REPOSITORY_TOKEN = 'INotificationPreferenceRepository';
|
||||
export const NOTIFICATION_SERVICE_TOKEN = 'INotificationService';
|
||||
export const NOTIFICATION_GATEWAY_REGISTRY_TOKEN = 'INotificationGatewayRegistry';
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule, getDataSourceToken } from '@nestjs/typeorm';
|
||||
import type { DataSource } from 'typeorm';
|
||||
|
||||
import { AchievementOrmEntity } from '@adapters/achievement/persistence/typeorm/entities/AchievementOrmEntity';
|
||||
import { UserAchievementOrmEntity } from '@adapters/achievement/persistence/typeorm/entities/UserAchievementOrmEntity';
|
||||
import { TypeOrmAchievementRepository } from '@adapters/achievement/persistence/typeorm/repositories/TypeOrmAchievementRepository';
|
||||
import { AchievementOrmMapper } from '@adapters/achievement/persistence/typeorm/mappers/AchievementOrmMapper';
|
||||
|
||||
import { ACHIEVEMENT_REPOSITORY_TOKEN } from '../achievement/AchievementPersistenceTokens';
|
||||
import { Achievement, AchievementCategory } from '@core/identity/domain/entities/Achievement';
|
||||
import { UserAchievement } from '@core/identity/domain/entities/UserAchievement';
|
||||
|
||||
// Adapter to convert between domain repository interface and application use case interface
|
||||
class AchievementRepositoryAdapter {
|
||||
constructor(private readonly typeOrmRepo: TypeOrmAchievementRepository) {}
|
||||
|
||||
// Application use case interface methods
|
||||
async save(achievement: Achievement): Promise<void> {
|
||||
await this.typeOrmRepo.createAchievement(achievement);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Achievement | null> {
|
||||
return await this.typeOrmRepo.findAchievementById(id);
|
||||
}
|
||||
|
||||
// Delegate all other methods to the underlying TypeOrm repository
|
||||
async findAchievementById(id: string): Promise<Achievement | null> {
|
||||
return await this.typeOrmRepo.findAchievementById(id);
|
||||
}
|
||||
|
||||
async findAllAchievements(): Promise<Achievement[]> {
|
||||
return await this.typeOrmRepo.findAllAchievements();
|
||||
}
|
||||
|
||||
async findAchievementsByCategory(category: AchievementCategory): Promise<Achievement[]> {
|
||||
return await this.typeOrmRepo.findAchievementsByCategory(category);
|
||||
}
|
||||
|
||||
async createAchievement(achievement: Achievement): Promise<Achievement> {
|
||||
return await this.typeOrmRepo.createAchievement(achievement);
|
||||
}
|
||||
|
||||
async findUserAchievementById(id: string): Promise<UserAchievement | null> {
|
||||
return await this.typeOrmRepo.findUserAchievementById(id);
|
||||
}
|
||||
|
||||
async findUserAchievementsByUserId(userId: string): Promise<UserAchievement[]> {
|
||||
return await this.typeOrmRepo.findUserAchievementsByUserId(userId);
|
||||
}
|
||||
|
||||
async findUserAchievementByUserAndAchievement(userId: string, achievementId: string): Promise<UserAchievement | null> {
|
||||
return await this.typeOrmRepo.findUserAchievementByUserAndAchievement(userId, achievementId);
|
||||
}
|
||||
|
||||
async hasUserEarnedAchievement(userId: string, achievementId: string): Promise<boolean> {
|
||||
return await this.typeOrmRepo.hasUserEarnedAchievement(userId, achievementId);
|
||||
}
|
||||
|
||||
async createUserAchievement(userAchievement: UserAchievement): Promise<UserAchievement> {
|
||||
return await this.typeOrmRepo.createUserAchievement(userAchievement);
|
||||
}
|
||||
|
||||
async updateUserAchievement(userAchievement: UserAchievement): Promise<UserAchievement> {
|
||||
return await this.typeOrmRepo.updateUserAchievement(userAchievement);
|
||||
}
|
||||
|
||||
async getAchievementLeaderboard(limit: number): Promise<{ userId: string; points: number; count: number }[]> {
|
||||
return await this.typeOrmRepo.getAchievementLeaderboard(limit);
|
||||
}
|
||||
|
||||
async getUserAchievementStats(userId: string): Promise<{
|
||||
total: number;
|
||||
points: number;
|
||||
byCategory: Record<AchievementCategory, number>;
|
||||
}> {
|
||||
return await this.typeOrmRepo.getUserAchievementStats(userId);
|
||||
}
|
||||
}
|
||||
|
||||
const typeOrmFeatureImports = [TypeOrmModule.forFeature([AchievementOrmEntity, UserAchievementOrmEntity])];
|
||||
|
||||
@Module({
|
||||
imports: [...typeOrmFeatureImports],
|
||||
providers: [
|
||||
{ provide: AchievementOrmMapper, useFactory: () => new AchievementOrmMapper() },
|
||||
{
|
||||
provide: ACHIEVEMENT_REPOSITORY_TOKEN,
|
||||
useFactory: (dataSource: DataSource, mapper: AchievementOrmMapper) =>
|
||||
new AchievementRepositoryAdapter(new TypeOrmAchievementRepository(dataSource, mapper)),
|
||||
inject: [getDataSourceToken(), AchievementOrmMapper],
|
||||
},
|
||||
],
|
||||
exports: [ACHIEVEMENT_REPOSITORY_TOKEN],
|
||||
})
|
||||
export class PostgresAchievementPersistenceModule {}
|
||||
@@ -3,8 +3,8 @@ import { TypeOrmModule, getDataSourceToken } from '@nestjs/typeorm';
|
||||
import type { DataSource } from 'typeorm';
|
||||
|
||||
import { UserOrmEntity } from '@adapters/identity/persistence/typeorm/entities/UserOrmEntity';
|
||||
import { TypeOrmAuthRepository } from '@adapters/identity/persistence/typeorm/TypeOrmAuthRepository';
|
||||
import { TypeOrmUserRepository } from '@adapters/identity/persistence/typeorm/TypeOrmUserRepository';
|
||||
import { TypeOrmAuthRepository } from '@adapters/identity/persistence/typeorm/repositories/TypeOrmAuthRepository';
|
||||
import { TypeOrmUserRepository } from '@adapters/identity/persistence/typeorm/repositories/TypeOrmUserRepository';
|
||||
import { UserOrmMapper } from '@adapters/identity/persistence/typeorm/mappers/UserOrmMapper';
|
||||
import { InMemoryPasswordHashingService } from '@adapters/identity/services/InMemoryPasswordHashingService';
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule, getDataSourceToken } from '@nestjs/typeorm';
|
||||
import type { DataSource } from 'typeorm';
|
||||
|
||||
import { MediaOrmEntity } from '@adapters/media/persistence/typeorm/entities/MediaOrmEntity';
|
||||
import { AvatarOrmEntity } from '@adapters/media/persistence/typeorm/entities/AvatarOrmEntity';
|
||||
import { AvatarGenerationRequestOrmEntity } from '@adapters/media/persistence/typeorm/entities/AvatarGenerationRequestOrmEntity';
|
||||
|
||||
import { TypeOrmMediaRepository } from '@adapters/media/persistence/typeorm/repositories/TypeOrmMediaRepository';
|
||||
import { TypeOrmAvatarRepository } from '@adapters/media/persistence/typeorm/repositories/TypeOrmAvatarRepository';
|
||||
import { TypeOrmAvatarGenerationRepository } from '@adapters/media/persistence/typeorm/repositories/TypeOrmAvatarGenerationRepository';
|
||||
|
||||
import { MediaOrmMapper } from '@adapters/media/persistence/typeorm/mappers/MediaOrmMapper';
|
||||
import { AvatarOrmMapper } from '@adapters/media/persistence/typeorm/mappers/AvatarOrmMapper';
|
||||
import { AvatarGenerationRequestOrmMapper } from '@adapters/media/persistence/typeorm/mappers/AvatarGenerationRequestOrmMapper';
|
||||
|
||||
import {
|
||||
AVATAR_GENERATION_REPOSITORY_TOKEN,
|
||||
MEDIA_REPOSITORY_TOKEN,
|
||||
AVATAR_REPOSITORY_TOKEN,
|
||||
} from '../media/MediaPersistenceTokens';
|
||||
|
||||
const typeOrmFeatureImports = [
|
||||
TypeOrmModule.forFeature([
|
||||
MediaOrmEntity,
|
||||
AvatarOrmEntity,
|
||||
AvatarGenerationRequestOrmEntity,
|
||||
]),
|
||||
];
|
||||
|
||||
@Module({
|
||||
imports: [...typeOrmFeatureImports],
|
||||
providers: [
|
||||
{ provide: MediaOrmMapper, useFactory: () => new MediaOrmMapper() },
|
||||
{ provide: AvatarOrmMapper, useFactory: () => new AvatarOrmMapper() },
|
||||
{ provide: AvatarGenerationRequestOrmMapper, useFactory: () => new AvatarGenerationRequestOrmMapper() },
|
||||
{
|
||||
provide: MEDIA_REPOSITORY_TOKEN,
|
||||
useFactory: (dataSource: DataSource, mapper: MediaOrmMapper) => new TypeOrmMediaRepository(dataSource, mapper),
|
||||
inject: [getDataSourceToken(), MediaOrmMapper],
|
||||
},
|
||||
{
|
||||
provide: AVATAR_REPOSITORY_TOKEN,
|
||||
useFactory: (dataSource: DataSource, mapper: AvatarOrmMapper) => new TypeOrmAvatarRepository(dataSource, mapper),
|
||||
inject: [getDataSourceToken(), AvatarOrmMapper],
|
||||
},
|
||||
{
|
||||
provide: AVATAR_GENERATION_REPOSITORY_TOKEN,
|
||||
useFactory: (dataSource: DataSource, mapper: AvatarGenerationRequestOrmMapper) =>
|
||||
new TypeOrmAvatarGenerationRepository(dataSource, mapper),
|
||||
inject: [getDataSourceToken(), AvatarGenerationRequestOrmMapper],
|
||||
},
|
||||
],
|
||||
exports: [AVATAR_GENERATION_REPOSITORY_TOKEN, MEDIA_REPOSITORY_TOKEN, AVATAR_REPOSITORY_TOKEN],
|
||||
})
|
||||
export class PostgresMediaPersistenceModule {}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule, getDataSourceToken } from '@nestjs/typeorm';
|
||||
import type { DataSource } from 'typeorm';
|
||||
|
||||
import type { Logger } from '@core/shared/application/Logger';
|
||||
import type { NotificationService } from '@core/notifications/application/ports/NotificationService';
|
||||
import type { NotificationGatewayRegistry } from '@core/notifications/application/ports/NotificationGateway';
|
||||
import type { INotificationRepository } from '@core/notifications/domain/repositories/INotificationRepository';
|
||||
import type { INotificationPreferenceRepository } from '@core/notifications/domain/repositories/INotificationPreferenceRepository';
|
||||
|
||||
import { NotificationOrmEntity } from '@adapters/notifications/persistence/typeorm/entities/NotificationOrmEntity';
|
||||
import { NotificationPreferenceOrmEntity } from '@adapters/notifications/persistence/typeorm/entities/NotificationPreferenceOrmEntity';
|
||||
|
||||
import { TypeOrmNotificationRepository } from '@adapters/notifications/persistence/typeorm/repositories/TypeOrmNotificationRepository';
|
||||
import { TypeOrmNotificationPreferenceRepository } from '@adapters/notifications/persistence/typeorm/repositories/TypeOrmNotificationPreferenceRepository';
|
||||
|
||||
import { NotificationOrmMapper } from '@adapters/notifications/persistence/typeorm/mappers/NotificationOrmMapper';
|
||||
import { NotificationPreferenceOrmMapper } from '@adapters/notifications/persistence/typeorm/mappers/NotificationPreferenceOrmMapper';
|
||||
|
||||
import { NotificationServiceAdapter } from '@adapters/notifications/ports/NotificationServiceAdapter';
|
||||
import { InMemoryNotificationGatewayRegistry } from '@adapters/notifications/ports/InMemoryNotificationGatewayRegistry';
|
||||
|
||||
import { NOTIFICATION_REPOSITORY_TOKEN, NOTIFICATION_PREFERENCE_REPOSITORY_TOKEN } from '../notifications/NotificationsPersistenceTokens';
|
||||
|
||||
export const NOTIFICATION_SERVICE_TOKEN = 'INotificationService';
|
||||
export const NOTIFICATION_GATEWAY_REGISTRY_TOKEN = 'INotificationGatewayRegistry';
|
||||
|
||||
const typeOrmFeatureImports = [
|
||||
TypeOrmModule.forFeature([
|
||||
NotificationOrmEntity,
|
||||
NotificationPreferenceOrmEntity,
|
||||
]),
|
||||
];
|
||||
|
||||
@Module({
|
||||
imports: [...typeOrmFeatureImports],
|
||||
providers: [
|
||||
{ provide: NotificationOrmMapper, useFactory: () => new NotificationOrmMapper() },
|
||||
{ provide: NotificationPreferenceOrmMapper, useFactory: () => new NotificationPreferenceOrmMapper() },
|
||||
{
|
||||
provide: NOTIFICATION_REPOSITORY_TOKEN,
|
||||
useFactory: (dataSource: DataSource, mapper: NotificationOrmMapper) =>
|
||||
new TypeOrmNotificationRepository(dataSource, mapper),
|
||||
inject: [getDataSourceToken(), NotificationOrmMapper],
|
||||
},
|
||||
{
|
||||
provide: NOTIFICATION_PREFERENCE_REPOSITORY_TOKEN,
|
||||
useFactory: (dataSource: DataSource, mapper: NotificationPreferenceOrmMapper) =>
|
||||
new TypeOrmNotificationPreferenceRepository(dataSource, mapper),
|
||||
inject: [getDataSourceToken(), NotificationPreferenceOrmMapper],
|
||||
},
|
||||
{
|
||||
provide: NOTIFICATION_GATEWAY_REGISTRY_TOKEN,
|
||||
useFactory: (): NotificationGatewayRegistry =>
|
||||
new InMemoryNotificationGatewayRegistry(),
|
||||
inject: [],
|
||||
},
|
||||
{
|
||||
provide: NOTIFICATION_SERVICE_TOKEN,
|
||||
useFactory: (
|
||||
notificationRepo: INotificationRepository,
|
||||
preferenceRepo: INotificationPreferenceRepository,
|
||||
gatewayRegistry: NotificationGatewayRegistry,
|
||||
logger: Logger,
|
||||
): NotificationService =>
|
||||
new NotificationServiceAdapter(notificationRepo, preferenceRepo, gatewayRegistry, logger),
|
||||
inject: [
|
||||
NOTIFICATION_REPOSITORY_TOKEN,
|
||||
NOTIFICATION_PREFERENCE_REPOSITORY_TOKEN,
|
||||
NOTIFICATION_GATEWAY_REGISTRY_TOKEN,
|
||||
'Logger',
|
||||
],
|
||||
},
|
||||
],
|
||||
exports: [
|
||||
NOTIFICATION_REPOSITORY_TOKEN,
|
||||
NOTIFICATION_PREFERENCE_REPOSITORY_TOKEN,
|
||||
NOTIFICATION_SERVICE_TOKEN,
|
||||
NOTIFICATION_GATEWAY_REGISTRY_TOKEN,
|
||||
],
|
||||
})
|
||||
export class PostgresNotificationsPersistenceModule {}
|
||||
@@ -9,8 +9,8 @@ import { PasswordHash } from '@core/identity/domain/value-objects/PasswordHash';
|
||||
import { UserId } from '@core/identity/domain/value-objects/UserId';
|
||||
|
||||
import { UserOrmEntity } from '@adapters/identity/persistence/typeorm/entities/UserOrmEntity';
|
||||
import { TypeOrmAuthRepository } from '@adapters/identity/persistence/typeorm/TypeOrmAuthRepository';
|
||||
import { TypeOrmUserRepository } from '@adapters/identity/persistence/typeorm/TypeOrmUserRepository';
|
||||
import { TypeOrmAuthRepository } from '@adapters/identity/persistence/typeorm/repositories/TypeOrmAuthRepository';
|
||||
import { TypeOrmUserRepository } from '@adapters/identity/persistence/typeorm/repositories/TypeOrmUserRepository';
|
||||
import { UserOrmMapper } from '@adapters/identity/persistence/typeorm/mappers/UserOrmMapper';
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
|
||||
Reference in New Issue
Block a user