inmemory to postgres

This commit is contained in:
2025-12-29 18:34:12 +01:00
parent 9e17d0752a
commit f5639a367f
176 changed files with 10175 additions and 468 deletions

View File

@@ -0,0 +1,49 @@
import type { DataSource } from 'typeorm';
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';
export class TypeOrmAuthRepository implements IAuthRepository {
constructor(
private readonly dataSource: DataSource,
private readonly mapper: UserOrmMapper,
) {}
async findByEmail(email: EmailAddress): Promise<User | null> {
const repo = this.dataSource.getRepository(UserOrmEntity);
const entity = await repo.findOne({ where: { email: email.value.toLowerCase() } });
return entity ? this.mapper.toDomain(entity) : null;
}
async save(user: User): Promise<void> {
const repo = this.dataSource.getRepository(UserOrmEntity);
const id = user.getId().value;
const email = user.getEmail();
const passwordHash = user.getPasswordHash()?.value;
if (!email) {
throw new Error('Cannot persist user without email');
}
if (!passwordHash) {
throw new Error('Cannot persist user without password hash');
}
const existing = await repo.findOne({ where: { id } });
const entity = new UserOrmEntity();
entity.id = id;
entity.email = email.toLowerCase();
entity.displayName = user.getDisplayName();
entity.passwordHash = passwordHash;
entity.salt = '';
entity.primaryDriverId = user.getPrimaryDriverId() ?? null;
entity.createdAt = existing?.createdAt ?? new Date();
await repo.save(entity);
}
}

View File

@@ -0,0 +1,43 @@
import { describe, expect, it, vi } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { TypeOrmUserRepository } from './TypeOrmUserRepository';
describe('TypeOrmUserRepository', () => {
it('does not construct its own mapper dependencies', () => {
const sourcePath = path.resolve(__dirname, 'TypeOrmUserRepository.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(TypeOrmUserRepository.length).toBe(2);
});
it('uses the injected mapper at runtime (DB-free)', async () => {
const ormRepo = {
findOne: vi.fn().mockResolvedValue({ id: 'u1' }),
};
const dataSource = {
getRepository: vi.fn().mockReturnValue(ormRepo),
};
const mapper = {
toStored: vi.fn().mockReturnValue({ id: 'stored-u1' }),
toOrmEntity: vi.fn(),
};
const repo = new TypeOrmUserRepository(dataSource as any, mapper as any);
const user = await repo.findByEmail('ALICE@EXAMPLE.COM');
expect(dataSource.getRepository).toHaveBeenCalledTimes(1);
expect(ormRepo.findOne).toHaveBeenCalledTimes(1);
expect(mapper.toStored).toHaveBeenCalledTimes(1);
expect(user).toEqual({ id: 'stored-u1' });
});
});

View File

@@ -0,0 +1,51 @@
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';
export class TypeOrmUserRepository implements IUserRepository {
constructor(
private readonly dataSource: DataSource,
private readonly mapper: UserOrmMapper,
) {}
async findByEmail(email: string): Promise<StoredUser | null> {
const repo = this.dataSource.getRepository(UserOrmEntity);
const entity = await repo.findOne({ where: { email: email.toLowerCase() } });
return entity ? this.mapper.toStored(entity) : null;
}
async findById(id: string): Promise<StoredUser | null> {
const repo = this.dataSource.getRepository(UserOrmEntity);
const entity = await repo.findOne({ where: { id } });
return entity ? this.mapper.toStored(entity) : null;
}
async create(user: StoredUser): Promise<StoredUser> {
const repo = this.dataSource.getRepository(UserOrmEntity);
const entity = this.mapper.toOrmEntity({
...user,
email: user.email.toLowerCase(),
});
await repo.save(entity);
return user;
}
async update(user: StoredUser): Promise<StoredUser> {
const repo = this.dataSource.getRepository(UserOrmEntity);
const entity = this.mapper.toOrmEntity({
...user,
email: user.email.toLowerCase(),
});
await repo.save(entity);
return user;
}
async emailExists(email: string): Promise<boolean> {
const repo = this.dataSource.getRepository(UserOrmEntity);
const count = await repo.count({ where: { email: email.toLowerCase() } });
return count > 0;
}
}

View File

@@ -0,0 +1,26 @@
import { Column, CreateDateColumn, Entity, Index, PrimaryColumn } from 'typeorm';
@Entity({ name: 'identity_users' })
export class UserOrmEntity {
@PrimaryColumn({ type: 'uuid' })
id!: string;
@Index({ unique: true })
@Column({ type: 'text' })
email!: string;
@Column({ type: 'text' })
displayName!: string;
@Column({ type: 'text' })
passwordHash!: string;
@Column({ type: 'text' })
salt!: string;
@Column({ type: 'text', nullable: true })
primaryDriverId!: string | null;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
}

View File

@@ -0,0 +1,34 @@
export type TypeOrmIdentitySchemaErrorReason =
| '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 TypeOrmIdentitySchemaError extends Error {
readonly entityName: string;
readonly fieldName: string;
readonly reason: TypeOrmIdentitySchemaErrorReason | (string & {});
constructor(params: {
entityName: string;
fieldName: string;
reason: TypeOrmIdentitySchemaError['reason'];
message?: string;
}) {
const message = params.message ?? `Invalid persisted ${params.entityName}.${params.fieldName}: ${params.reason}`;
super(message);
this.name = 'TypeOrmIdentitySchemaError';
this.entityName = params.entityName;
this.fieldName = params.fieldName;
this.reason = params.reason;
}
}

View File

@@ -0,0 +1,64 @@
import { describe, expect, it, vi } from 'vitest';
import { User } from '@core/identity/domain/entities/User';
import { UserOrmEntity } from '../entities/UserOrmEntity';
import { TypeOrmIdentitySchemaError } from '../errors/TypeOrmIdentitySchemaError';
import { UserOrmMapper } from './UserOrmMapper';
describe('UserOrmMapper', () => {
it('toDomain preserves persisted identity and uses rehydrate semantics (does not call create)', () => {
const mapper = new UserOrmMapper();
const entity = new UserOrmEntity();
entity.id = '00000000-0000-4000-8000-000000000001';
entity.email = 'alice@example.com';
entity.displayName = 'Alice';
entity.passwordHash = 'bcrypt-hash';
entity.salt = '';
entity.primaryDriverId = null;
entity.createdAt = new Date('2025-01-01T00:00:00.000Z');
if (typeof (User as unknown as { rehydrate?: unknown }).rehydrate !== 'function') {
throw new Error('rehydrate-missing');
}
const rehydrateSpy = vi.spyOn(User as unknown as { rehydrate: (...args: unknown[]) => unknown }, 'rehydrate');
const createSpy = vi.spyOn(User, 'create').mockImplementation(() => {
throw new Error('create-called');
});
const domain = mapper.toDomain(entity);
expect(domain.getId().value).toBe(entity.id);
expect(domain.getEmail()).toBe(entity.email);
expect(createSpy).not.toHaveBeenCalled();
expect(rehydrateSpy).toHaveBeenCalled();
});
it('toDomain validates persisted shape and throws adapter-scoped base schema error type', () => {
const mapper = new UserOrmMapper();
const entity = new UserOrmEntity();
entity.id = '00000000-0000-4000-8000-000000000001';
entity.email = 123 as unknown as string;
entity.displayName = 'Alice';
entity.passwordHash = 'bcrypt-hash';
entity.salt = '';
entity.primaryDriverId = null;
entity.createdAt = new Date('2025-01-01T00:00:00.000Z');
try {
mapper.toDomain(entity);
throw new Error('expected-to-throw');
} catch (error) {
expect(error).toBeInstanceOf(TypeOrmIdentitySchemaError);
expect(error).toMatchObject({
entityName: 'User',
fieldName: 'email',
reason: 'not_string',
});
}
});
});

View File

@@ -0,0 +1,68 @@
import { User } from '@core/identity/domain/entities/User';
import type { StoredUser } from '@core/identity/domain/repositories/IUserRepository';
import { PasswordHash } from '@core/identity/domain/value-objects/PasswordHash';
import { UserOrmEntity } from '../entities/UserOrmEntity';
import { TypeOrmIdentitySchemaError } from '../errors/TypeOrmIdentitySchemaError';
import { assertDate, assertNonEmptyString, assertOptionalStringOrNull } from '../schema/TypeOrmIdentitySchemaGuards';
export class UserOrmMapper {
toDomain(entity: UserOrmEntity): User {
const entityName = 'User';
try {
assertNonEmptyString(entityName, 'id', entity.id);
assertNonEmptyString(entityName, 'email', entity.email);
assertNonEmptyString(entityName, 'displayName', entity.displayName);
assertNonEmptyString(entityName, 'passwordHash', entity.passwordHash);
assertNonEmptyString(entityName, 'salt', entity.salt);
assertOptionalStringOrNull(entityName, 'primaryDriverId', entity.primaryDriverId);
assertDate(entityName, 'createdAt', entity.createdAt);
} catch (error) {
if (error instanceof TypeOrmIdentitySchemaError) {
throw error;
}
const message = error instanceof Error ? error.message : 'Invalid persisted User';
throw new TypeOrmIdentitySchemaError({ entityName, fieldName: 'unknown', reason: 'invalid_shape', message });
}
try {
const passwordHash = entity.passwordHash ? PasswordHash.fromHash(entity.passwordHash) : undefined;
return User.rehydrate({
id: entity.id,
email: entity.email,
displayName: entity.displayName,
...(passwordHash ? { passwordHash } : {}),
...(entity.primaryDriverId ? { primaryDriverId: entity.primaryDriverId } : {}),
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Invalid persisted User';
throw new TypeOrmIdentitySchemaError({ entityName, fieldName: 'unknown', reason: 'invalid_shape', message });
}
}
toOrmEntity(stored: StoredUser): UserOrmEntity {
const entity = new UserOrmEntity();
entity.id = stored.id;
entity.email = stored.email;
entity.displayName = stored.displayName;
entity.passwordHash = stored.passwordHash;
entity.salt = stored.salt;
entity.primaryDriverId = stored.primaryDriverId ?? null;
entity.createdAt = stored.createdAt;
return entity;
}
toStored(entity: UserOrmEntity): StoredUser {
return {
id: entity.id,
email: entity.email,
displayName: entity.displayName,
passwordHash: entity.passwordHash,
salt: entity.salt,
primaryDriverId: entity.primaryDriverId ?? undefined,
createdAt: entity.createdAt,
};
}
}

View File

@@ -0,0 +1,34 @@
import { TypeOrmIdentitySchemaError } from '../errors/TypeOrmIdentitySchemaError';
export function assertNonEmptyString(entityName: string, fieldName: string, value: unknown): asserts value is string {
if (typeof value !== 'string') {
throw new TypeOrmIdentitySchemaError({ entityName, fieldName, reason: 'not_string' });
}
if (value.trim().length === 0) {
throw new TypeOrmIdentitySchemaError({ entityName, fieldName, reason: 'empty_string' });
}
}
export function assertDate(entityName: string, fieldName: string, value: unknown): asserts value is Date {
if (!(value instanceof Date)) {
throw new TypeOrmIdentitySchemaError({ entityName, fieldName, reason: 'not_date' });
}
if (Number.isNaN(value.getTime())) {
throw new TypeOrmIdentitySchemaError({ entityName, fieldName, reason: 'invalid_date' });
}
}
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 TypeOrmIdentitySchemaError({ entityName, fieldName, reason: 'not_string' });
}
}