inmemory to postgres
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user