From 4e9e2c711bda5c7b90d0440c10bcce02fdc8aa5d Mon Sep 17 00:00:00 2001 From: Heitor Rosa Date: Fri, 13 Feb 2026 16:42:55 -0300 Subject: [PATCH 1/6] test: setup PostgreSQL integration test infrastructure and Jest config chore: remove comment in Jest config chore: fix sourceType in ESLint config chore: remove path aliases from Jest bootstrap in infrastructure and test layers chore: add test alias to tsconfig chore: add test path alias to Jest config chore: mark test target as phony in Makefile fix: remove test migration step in favor of Makefile migrate target chore: add test-reset and test-migrate Makefile targets to ci-test pipeline chore: add test database truncation script --- Makefile | 5 +++- eslint.config.mjs | 2 +- jest.config.ts | 3 +- .../errors/database-url-not-defined.error.ts | 2 +- .../unique-constraint-violation.error.ts | 2 +- test/setup/global-setup.ts | 5 ---- test/utils/infra/test-database.ts | 3 ++ test/utils/infra/truncate-test-db.ts | 28 +++++++++++++++++++ test/utils/migrate-test-db.ts | 11 -------- tsconfig.json | 3 +- 10 files changed, 42 insertions(+), 22 deletions(-) create mode 100644 test/utils/infra/test-database.ts create mode 100644 test/utils/infra/truncate-test-db.ts delete mode 100644 test/utils/migrate-test-db.ts diff --git a/Makefile b/Makefile index b827698..2e57823 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,7 @@ TREE_IGNORE = node_modules|.git|dist|*.env* .PHONY: \ dev-up dev-down dev-reset dev-migrate \ prod-up prod-migrate \ - test-up test-down test-reset test-migrate test-run \ + test test-up test-down test-reset test-migrate test-run \ lint typecheck build \ check-dev-env check-prod-env check-test-env \ tree @@ -55,9 +55,12 @@ typecheck: pnpm tsc --noEmit ci-test: + make test-reset make test-up + make test-migrate make test make test-down + # ───────────────────────────────────────────────────────────── # DEV # ───────────────────────────────────────────────────────────── diff --git a/eslint.config.mjs b/eslint.config.mjs index ded6898..f420626 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -20,7 +20,7 @@ export default tseslint.config( ...globals.node, ...globals.jest, }, - sourceType: 'commonjs', + sourceType: 'module', parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname, diff --git a/jest.config.ts b/jest.config.ts index 188568a..af6633f 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -1,4 +1,3 @@ -// jest.config.ts import type { Config } from 'jest'; const config: Config = { @@ -9,6 +8,8 @@ const config: Config = { '^@domain/(.*)$': '/src/domain/$1', '^@application/(.*)$': '/src/application/$1', '^@infrastructure/(.*)$': '/src/infrastructure/$1', + '^@http/(.*)$': '/src/http/$1', + '^@test/(.*)$': '/test/$1', }, }; diff --git a/src/infrastructure/database/errors/database-url-not-defined.error.ts b/src/infrastructure/database/errors/database-url-not-defined.error.ts index 86afcc8..ec267cd 100644 --- a/src/infrastructure/database/errors/database-url-not-defined.error.ts +++ b/src/infrastructure/database/errors/database-url-not-defined.error.ts @@ -1,4 +1,4 @@ -import { InfrastructureError } from '@infrastructure/errors/infrastructure'; +import { InfrastructureError } from '../../errors/infrastructure'; export class DatabaseUrlNotDefinedError extends InfrastructureError { constructor() { diff --git a/src/infrastructure/database/errors/unique-constraint-violation.error.ts b/src/infrastructure/database/errors/unique-constraint-violation.error.ts index 4e33fea..b429f6b 100644 --- a/src/infrastructure/database/errors/unique-constraint-violation.error.ts +++ b/src/infrastructure/database/errors/unique-constraint-violation.error.ts @@ -1,4 +1,4 @@ -import { InfrastructureError } from '@infrastructure/errors/infrastructure'; +import { InfrastructureError } from '../../errors/infrastructure'; export class UniqueConstraintViolationError extends InfrastructureError { constructor(readonly constraint: string) { diff --git a/test/setup/global-setup.ts b/test/setup/global-setup.ts index 331bf6c..b6ec8a0 100644 --- a/test/setup/global-setup.ts +++ b/test/setup/global-setup.ts @@ -1,10 +1,5 @@ import { config } from 'dotenv'; -import { migrateTestDatabase } from '../utils/migrate-test-db'; - export default () => { config({ path: '.env.test' }); - - console.log('🚀 Migrating test database...'); - migrateTestDatabase(); }; diff --git a/test/utils/infra/test-database.ts b/test/utils/infra/test-database.ts new file mode 100644 index 0000000..e55d333 --- /dev/null +++ b/test/utils/infra/test-database.ts @@ -0,0 +1,3 @@ +import { createDatabase } from '../../../src/infrastructure/database/database.provider'; + +export const testDb = createDatabase(); diff --git a/test/utils/infra/truncate-test-db.ts b/test/utils/infra/truncate-test-db.ts new file mode 100644 index 0000000..2e8dc96 --- /dev/null +++ b/test/utils/infra/truncate-test-db.ts @@ -0,0 +1,28 @@ +import { sql } from 'drizzle-orm'; + +import { testDb } from './test-database'; + +type PgTableRow = { tablename: string }; + +export async function truncateTestDatabase() { + const tables = (await testDb.execute( + sql.raw(` + SELECT tablename + FROM pg_tables + WHERE schemaname = 'public' + AND tablename NOT LIKE 'drizzle_%' + AND tablename NOT LIKE 'migrations%'; + `), + )) as { rows: PgTableRow[] }; + + const tableNames = tables.rows.map((r) => `"public"."${r.tablename}"`); + + if (!tableNames.length) return; + + await testDb.execute( + sql.raw(` + TRUNCATE TABLE ${tableNames.join(', ')} + RESTART IDENTITY CASCADE; + `), + ); +} diff --git a/test/utils/migrate-test-db.ts b/test/utils/migrate-test-db.ts deleted file mode 100644 index 68ded7e..0000000 --- a/test/utils/migrate-test-db.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { execSync } from 'node:child_process'; - -export function migrateTestDatabase() { - execSync('make test-migrate', { - stdio: 'inherit', - env: { - ...process.env, - NODE_ENV: 'test', - }, - }); -} diff --git a/tsconfig.json b/tsconfig.json index 1a5e6fc..8d0a28b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,7 +18,8 @@ "@domain/*": ["src/domain/*"], "@application/*": ["src/application/*"], "@infrastructure/*": ["src/infrastructure/*"], - "@http/*": ["src/http/*"] + "@http/*": ["src/http/*"], + "@test/*": ["test/*"] }, "removeComments": true, "skipLibCheck": true, From cfe3e784f0239936011a171ed5cf9c05737356e2 Mon Sep 17 00:00:00 2001 From: Heitor Rosa Date: Fri, 20 Feb 2026 20:47:59 -0300 Subject: [PATCH 2/6] test: add user and barber factories for test database seeding --- test/factories/barber.factory.ts | 30 ++++++++++++++++++++++++++++++ test/factories/user.factory.ts | 24 ++++++++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 test/factories/barber.factory.ts create mode 100644 test/factories/user.factory.ts diff --git a/test/factories/barber.factory.ts b/test/factories/barber.factory.ts new file mode 100644 index 0000000..84e5dfb --- /dev/null +++ b/test/factories/barber.factory.ts @@ -0,0 +1,30 @@ +import { testDb } from '@test/utils/infra/test-database'; +import { randomUUID } from 'crypto'; + +import { barbers } from '@infrastructure/database/schema/barber'; + +import { createUserFactory } from './user.factory'; + +type CreateBarberInput = { + id?: string; + name?: string; + bio?: string | null; + active?: boolean; +}; + +export async function createBarberFactory(input: CreateBarberInput = {}) { + const id = input.id ?? randomUUID(); + + // Ensure user exists first + await createUserFactory({ id }); + + await testDb.insert(barbers).values({ + id, + name: input.name ?? 'John Barber', + bio: input.bio ?? null, + active: input.active ?? true, + createdAt: new Date(), + }); + + return { id }; +} diff --git a/test/factories/user.factory.ts b/test/factories/user.factory.ts new file mode 100644 index 0000000..7cb1098 --- /dev/null +++ b/test/factories/user.factory.ts @@ -0,0 +1,24 @@ +import { testDb } from '@test/utils/infra/test-database'; +import { randomUUID } from 'crypto'; + +import { users } from '@infrastructure/database/schema/user'; + +type CreateUserInput = { + id?: string; + email?: string; + passwordHash?: string; + createdAt?: Date; +}; + +export async function createUserFactory(input: CreateUserInput = {}) { + const id = input.id ?? randomUUID(); + + await testDb.insert(users).values({ + id, + email: input.email ?? `${id}@test.com`, + passwordHash: input.passwordHash ?? 'hash', + createdAt: input.createdAt ?? new Date(), + }); + + return { id }; +} From b2d4b9bbf4f91bc18a3f95255f041d96216f66b6 Mon Sep 17 00:00:00 2001 From: Heitor Rosa Date: Fri, 20 Feb 2026 14:36:43 -0300 Subject: [PATCH 3/6] refactor: inject Drizzle schemas into database provider for typed DB access chore: export ReservationRow type in reservation mapper chore: use Database type instead of DrizzleDatabase in Drizzle repositories docs: add ADR documenting Drizzle schema injection in database provider chore: wire Drizzle schemas into database provider --- ...008-drizzle-schema-in-database-provider.md | 70 +++++++++++++++++++ docs/adr/README.md | 2 + .../database/database.provider.ts | 9 ++- src/infrastructure/database/schema/drizzle.ts | 11 --- src/infrastructure/database/schema/index.ts | 3 + .../reservation.drizzle-repository.ts | 4 +- .../reservation/reservation.mapper.ts | 2 +- .../user/user.drizzle-repository.ts | 4 +- 8 files changed, 84 insertions(+), 21 deletions(-) create mode 100644 docs/adr/0008-drizzle-schema-in-database-provider.md delete mode 100644 src/infrastructure/database/schema/drizzle.ts create mode 100644 src/infrastructure/database/schema/index.ts diff --git a/docs/adr/0008-drizzle-schema-in-database-provider.md b/docs/adr/0008-drizzle-schema-in-database-provider.md new file mode 100644 index 0000000..31c3b77 --- /dev/null +++ b/docs/adr/0008-drizzle-schema-in-database-provider.md @@ -0,0 +1,70 @@ +# ADR 0008 – Injecting Drizzle Schemas into Database Provider for Type Safety + +## Context + +The project uses Drizzle ORM for database access with SQL-first migrations. +Drizzle allows injecting schema definitions directly into the database instance, enabling full TypeScript type inference and IDE autocomplete. + +A strict Clean Architecture approach would separate: + +* Database connection provider (infrastructure bootstrap) +* ORM schema mapping layer +* Repository implementations + +In such architecture, the database provider would expose only a raw connection, and repositories would import schemas explicitly. +However, this approach increases boilerplate and reduces developer experience (DX), especially in small single-service codebases. + +## Decision + +1. **Drizzle schemas will be injected directly into the database provider** + + * The database provider will return a typed Drizzle instance with schema metadata attached. + * Repositories will consume the typed database instance without manually importing schema definitions. + +2. **Schema definitions will remain in the infrastructure layer** + + * Domain entities and value objects will not depend on Drizzle or database schema types. + * Mapping between domain and persistence will continue to be handled via mappers. + +3. **This coupling is accepted as a conscious trade-off for improved DX** + + * The project is a single-service portfolio and study system, where productivity and type safety outweigh future modularization concerns. + +## Consequences + +### Positive + +* Full TypeScript type inference for queries and relations +* Improved IDE autocomplete and developer productivity +* Reduced boilerplate in repository implementations +* Lower risk of runtime type mismatches + +### Negative / Trade-offs + +* Database provider becomes coupled to all schema definitions +* Harder to modularize or split into multiple services in the future +* Less flexibility for dynamic schema loading or multi-database architectures + +These trade-offs are considered acceptable for a portfolio and learning-oriented project. + +## Resulting Structure + +### infrastructure/database/database.provider.ts + +* Responsible for creating the PostgreSQL connection pool +* Initializes Drizzle with schema injection for typed database access + +### infrastructure/database/schema/* + +* Contains Drizzle schema definitions for all tables +* Does not import domain entities or value objects + +### infrastructure/*/repositories + +* Consume the typed database instance +* Map persistence models to domain entities using explicit mappers + +## Final Observations + +This decision prioritizes **developer experience, type safety, and clarity of repository code** over strict architectural purity. +If the system evolves into a multi-module or distributed architecture, the schema injection strategy should be revisited and moved into a dedicated ORM adapter layer. diff --git a/docs/adr/README.md b/docs/adr/README.md index a2af67c..fd4ac85 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -34,6 +34,8 @@ The decisions described here **reflect the actual state of the code at the time | 0005 | Drizzle over Prisma | | 0006 | SQL-First Migration Runner | | 0007 | Barber as Specialized User | +| 0008 | Drizzle schema in database provider | + --- diff --git a/src/infrastructure/database/database.provider.ts b/src/infrastructure/database/database.provider.ts index 721be08..6b718f2 100644 --- a/src/infrastructure/database/database.provider.ts +++ b/src/infrastructure/database/database.provider.ts @@ -2,16 +2,15 @@ import { drizzle } from 'drizzle-orm/node-postgres'; import { Pool } from 'pg'; import { DatabaseUrlNotDefinedError } from './errors/database-url-not-defined.error'; +import * as schema from './schema'; export function createDatabase() { const url = process.env.DATABASE_URL; - - if (!url) { - throw new DatabaseUrlNotDefinedError(); - } + if (!url) throw new DatabaseUrlNotDefinedError(); const pool = new Pool({ connectionString: url }); - return drizzle(pool); + + return drizzle(pool, { schema }); } export type Database = ReturnType; diff --git a/src/infrastructure/database/schema/drizzle.ts b/src/infrastructure/database/schema/drizzle.ts deleted file mode 100644 index 3a8389d..0000000 --- a/src/infrastructure/database/schema/drizzle.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; - -import * as barbersSchema from './barber'; -import * as reservationSchema from './reservation'; -import * as userSchema from './user'; - -export type DrizzleDatabase = NodePgDatabase<{ - barber: typeof barbersSchema; - reservation: typeof reservationSchema; - user: typeof userSchema; -}>; diff --git a/src/infrastructure/database/schema/index.ts b/src/infrastructure/database/schema/index.ts new file mode 100644 index 0000000..691d480 --- /dev/null +++ b/src/infrastructure/database/schema/index.ts @@ -0,0 +1,3 @@ +export * from './barber'; +export * from './reservation'; +export * from './user'; diff --git a/src/infrastructure/reservation/reservation.drizzle-repository.ts b/src/infrastructure/reservation/reservation.drizzle-repository.ts index 86c25a2..0f6f241 100644 --- a/src/infrastructure/reservation/reservation.drizzle-repository.ts +++ b/src/infrastructure/reservation/reservation.drizzle-repository.ts @@ -4,14 +4,14 @@ import { ReservationConflictError } from '@domain/reservation/errors/reservation import { Reservation } from '@domain/reservation/reservation.entity'; import { ReservationRepository } from '@domain/reservation/reservation.repository'; +import { Database } from '@infrastructure/database/database.provider'; import { DATABASE } from '@infrastructure/database/database.token'; -import { DrizzleDatabase } from '@infrastructure/database/schema/drizzle'; import { reservations } from '@infrastructure/database/schema/reservation'; import { ReservationMapper } from './reservation.mapper'; export class ReservationDrizzleRepository implements ReservationRepository { - constructor(@Inject(DATABASE) private readonly db: DrizzleDatabase) {} + constructor(@Inject(DATABASE) private readonly db: Database) {} async save(reservation: Reservation): Promise { try { diff --git a/src/infrastructure/reservation/reservation.mapper.ts b/src/infrastructure/reservation/reservation.mapper.ts index 2681308..d7f5236 100644 --- a/src/infrastructure/reservation/reservation.mapper.ts +++ b/src/infrastructure/reservation/reservation.mapper.ts @@ -1,6 +1,6 @@ import { Reservation } from '@domain/reservation/reservation.entity'; -type ReservationRow = { +export type ReservationRow = { id: string; userId: string; barberId: string; diff --git a/src/infrastructure/user/user.drizzle-repository.ts b/src/infrastructure/user/user.drizzle-repository.ts index 11def3e..be6f366 100644 --- a/src/infrastructure/user/user.drizzle-repository.ts +++ b/src/infrastructure/user/user.drizzle-repository.ts @@ -4,16 +4,16 @@ import { eq } from 'drizzle-orm'; import { User } from '@domain/user/user.entity'; import { UserRepository } from '@domain/user/user.repository'; +import { Database } from '@infrastructure/database/database.provider'; import { DATABASE } from '@infrastructure/database/database.token'; import { UniqueConstraintViolationError } from '@infrastructure/database/errors/unique-constraint-violation.error'; -import { DrizzleDatabase } from '@infrastructure/database/schema/drizzle'; import { users } from '@infrastructure/database/schema/user'; import { UserMapper } from './user.mapper'; @Injectable() export class UserDrizzleRepository implements UserRepository { - constructor(@Inject(DATABASE) private readonly db: DrizzleDatabase) {} + constructor(@Inject(DATABASE) private readonly db: Database) {} async findByEmail(email: string): Promise { const result = await this.db .select() From 3bdef038608ef0be13c80b96cfe5b43bfcba03a6 Mon Sep 17 00:00:00 2001 From: Heitor Rosa Date: Fri, 20 Feb 2026 20:46:24 -0300 Subject: [PATCH 4/6] feat: add PostgresErrorMapper to translate DB errors to domain errors fix: correct PostgreSQL unique violation error code mapping docs: add ADR for PostgreSQL error mapper chore: fix capitalization in ADR table in README feat: add PostgreSQL error mapper chore: fix ADR name in README table --- docs/adr/0009-postgres-error-mapper.md | 67 +++++++++++++++++++ docs/adr/README.md | 5 +- .../database/postgres-error.mapper.ts | 47 +++++++++++++ .../reservation.drizzle-repository.ts | 24 +++---- 4 files changed, 128 insertions(+), 15 deletions(-) create mode 100644 docs/adr/0009-postgres-error-mapper.md create mode 100644 src/infrastructure/database/postgres-error.mapper.ts diff --git a/docs/adr/0009-postgres-error-mapper.md b/docs/adr/0009-postgres-error-mapper.md new file mode 100644 index 0000000..0368f35 --- /dev/null +++ b/docs/adr/0009-postgres-error-mapper.md @@ -0,0 +1,67 @@ +# ADR 0009 – Centralized PostgreSQL Error Mapping Adapter + +## Context + +Repositories currently inspect database errors directly to detect PostgreSQL constraint violations (e.g., unique constraint `23505`). +This logic was implemented inline in repository classes, resulting in: + +* Duplication of error parsing logic across repositories +* Tight coupling between repositories and PostgreSQL error codes +* Reduced readability and maintainability +* Violation of Clean Architecture boundaries (infrastructure concerns leaking into repositories) + +Drizzle ORM wraps PostgreSQL errors, requiring custom logic to extract the underlying error code, which further complicates repository implementations. + +## Decision + +1. **Introduce a dedicated PostgresErrorMapper in the infrastructure layer** + + * Centralizes PostgreSQL error code extraction and classification. + * Abstracts Drizzle and pg error structures from repositories. + +2. **Repositories will depend only on semantic error checks** + + * Example: `PostgresErrorMapper.isUniqueViolation(error)` + * Repositories will no longer parse error objects manually. + +3. **The mapper remains an infrastructure concern** + + * Domain and application layers remain unaware of PostgreSQL-specific details. + * Only infrastructure repositories may reference the mapper. + +## Consequences + +### Positive + +* Eliminates duplicated error parsing logic +* Improves repository readability and maintainability +* Reduces PostgreSQL coupling leakage into repository code +* Centralizes future database-specific error handling changes +* Easier to extend for additional PostgreSQL error codes + +### Negative / Trade-offs + +* Introduces an additional infrastructure abstraction layer +* Still PostgreSQL-specific (not a generic SQL error adapter) +* If multi-database support is required, a higher-level error abstraction layer will be needed + +These trade-offs are acceptable given the PostgreSQL-first architecture strategy of this project. + +## Resulting Structure + +### infrastructure/database/postgres-error.mapper.ts + +* Responsible for: + * Extracting PostgreSQL error codes from pg and Drizzle error objects + * Providing semantic helpers (e.g., unique constraint violation detection) + +### infrastructure/*/*.drizzle-repository.ts + +* Uses PostgresErrorMapper for database error classification +* Maps database errors to domain-specific errors (e.g., ReservationConflictError) + +## Final Observations + +This decision reinforces Clean Architecture boundaries by isolating database-specific error handling logic. +Repositories now focus strictly on persistence behavior and domain mapping, while infrastructure-specific error parsing remains centralized and reusable. +If the system evolves into a multi-database architecture, this mapper should be replaced with a generic database error abstraction layer. diff --git a/docs/adr/README.md b/docs/adr/README.md index fd4ac85..a038f80 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -31,10 +31,11 @@ The decisions described here **reflect the actual state of the code at the time | 0002 | Architecture Enforcement via Tooling | | 0003 | PostgreSQL-First Database Strategy | | 0004 | SQL-First Migrations | -| 0005 | Drizzle over Prisma | +| 0005 | Drizzle Over Prisma | | 0006 | SQL-First Migration Runner | | 0007 | Barber as Specialized User | -| 0008 | Drizzle schema in database provider | +| 0008 | Drizzle Schema in Database Provider | +| 0009 | Postgres Error Mapper | --- diff --git a/src/infrastructure/database/postgres-error.mapper.ts b/src/infrastructure/database/postgres-error.mapper.ts new file mode 100644 index 0000000..7851812 --- /dev/null +++ b/src/infrastructure/database/postgres-error.mapper.ts @@ -0,0 +1,47 @@ +export class PostgresErrorMapper { + static isPostgresError(error: unknown): error is { code: string } { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + typeof error.code === 'string' + ); + } + + static extractCode(error: unknown): string | null { + if (!error || typeof error !== 'object') return null; + + // Direct pg error + if (this.isPostgresError(error)) { + return error.code; + } + + // Drizzle wraps pg errors inside `cause` + if ('cause' in error && error.cause && typeof error.cause === 'object') { + if ( + 'code' in error.cause && + typeof (error.cause as { code: unknown }).code === 'string' + ) { + return (error.cause as { code: string }).code; + } + } + + return null; + } + + static isUniqueViolation(error: unknown): boolean { + return this.extractCode(error) === '23P01'; + } + + static isForeignKeyViolation(error: unknown): boolean { + return this.extractCode(error) === '23503'; + } + + static isNotNullViolation(error: unknown): boolean { + return this.extractCode(error) === '23502'; + } + + static isCheckViolation(error: unknown): boolean { + return this.extractCode(error) === '23514'; + } +} diff --git a/src/infrastructure/reservation/reservation.drizzle-repository.ts b/src/infrastructure/reservation/reservation.drizzle-repository.ts index 0f6f241..7528a33 100644 --- a/src/infrastructure/reservation/reservation.drizzle-repository.ts +++ b/src/infrastructure/reservation/reservation.drizzle-repository.ts @@ -1,4 +1,5 @@ import { Inject } from '@nestjs/common'; +import { eq } from 'drizzle-orm'; import { ReservationConflictError } from '@domain/reservation/errors/reservation-conflict.error'; import { Reservation } from '@domain/reservation/reservation.entity'; @@ -6,9 +7,10 @@ import { ReservationRepository } from '@domain/reservation/reservation.repositor import { Database } from '@infrastructure/database/database.provider'; import { DATABASE } from '@infrastructure/database/database.token'; +import { PostgresErrorMapper } from '@infrastructure/database/postgres-error.mapper'; import { reservations } from '@infrastructure/database/schema/reservation'; -import { ReservationMapper } from './reservation.mapper'; +import { ReservationMapper, ReservationRow } from './reservation.mapper'; export class ReservationDrizzleRepository implements ReservationRepository { constructor(@Inject(DATABASE) private readonly db: Database) {} @@ -16,10 +18,9 @@ export class ReservationDrizzleRepository implements ReservationRepository { async save(reservation: Reservation): Promise { try { const data = ReservationMapper.toPersistence(reservation); - await this.db.insert(reservations).values(data); } catch (error: unknown) { - if (this.errorHasCode(error, '23P01')) { + if (PostgresErrorMapper.isUniqueViolation(error)) { throw new ReservationConflictError(); } @@ -27,15 +28,12 @@ export class ReservationDrizzleRepository implements ReservationRepository { } } - private errorHasCode(error: unknown, code: string): boolean { - if ( - error && - typeof error === 'object' && - 'code' in error && - error.code === code - ) { - return true; - } - return false; + async findById(id: string): Promise { + const [row]: ReservationRow[] = await this.db + .select() + .from(reservations) + .where(eq(reservations.id, id)); + + return row ? ReservationMapper.toDomain(row) : null; } } From 5a2b8a1a7850ec1d370a4ca245ba9d79742247f6 Mon Sep 17 00:00:00 2001 From: Heitor Rosa Date: Fri, 13 Feb 2026 18:32:40 -0300 Subject: [PATCH 5/6] chore: add findById method to ReservationDrizzleRepository --- src/domain/reservation/reservation.repository.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/domain/reservation/reservation.repository.ts b/src/domain/reservation/reservation.repository.ts index 24fb7e3..0ec1c0d 100644 --- a/src/domain/reservation/reservation.repository.ts +++ b/src/domain/reservation/reservation.repository.ts @@ -2,4 +2,5 @@ import { Reservation } from './reservation.entity'; export interface ReservationRepository { save(reservation: Reservation): Promise; + findById(id: string): Promise; } From 8ae902cc6d92c6c45dc4f15058f80d311cca5ec3 Mon Sep 17 00:00:00 2001 From: Heitor Rosa Date: Fri, 20 Feb 2026 21:02:19 -0300 Subject: [PATCH 6/6] test: add reservation invariant integration tests --- .../reservation-invariants.int.spec.ts | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 test/reservation/reservation-invariants.int.spec.ts diff --git a/test/reservation/reservation-invariants.int.spec.ts b/test/reservation/reservation-invariants.int.spec.ts new file mode 100644 index 0000000..410b7f0 --- /dev/null +++ b/test/reservation/reservation-invariants.int.spec.ts @@ -0,0 +1,75 @@ +import { createBarberFactory } from '@test/factories/barber.factory'; +import { createUserFactory } from '@test/factories/user.factory'; +import { testDb } from '@test/utils/infra/test-database'; +import { truncateTestDatabase } from '@test/utils/infra/truncate-test-db'; +import { randomUUID } from 'crypto'; + +import { ReservationConflictError } from '@domain/reservation/errors/reservation-conflict.error'; +import { Reservation } from '@domain/reservation/reservation.entity'; + +import { ReservationDrizzleRepository } from '@infrastructure/reservation/reservation.drizzle-repository'; + +describe('Reservation invariants (integration)', () => { + let repository: ReservationDrizzleRepository; + + beforeAll(() => { + repository = new ReservationDrizzleRepository(testDb); + }); + + beforeEach(async () => { + await truncateTestDatabase(); + }); + + it('should persist a valid reservation', async () => { + const user = await createUserFactory(); + const barber = await createBarberFactory(); + + const reservation = Reservation.create({ + id: randomUUID(), + barberId: barber.id, + userId: user.id, + startTime: new Date('2030-01-01T10:00:00Z'), + endTime: new Date('2030-01-01T11:00:00Z'), + createdAt: new Date(), + }); + + await repository.save(reservation); + + const found = await repository.findById(reservation.id); + expect(found).toBeDefined(); + expect(found?.id).toBe(reservation.id); + }); + + it('should throw ReservationConflictError when slot is already taken', async () => { + const barber = await createBarberFactory(); + const userA = await createUserFactory(); + const userB = await createUserFactory(); + + const start = new Date('2030-01-01T10:00:00Z'); + const end = new Date('2030-01-01T11:00:00Z'); + + const first = Reservation.create({ + id: randomUUID(), + barberId: barber.id, + userId: userA.id, + startTime: start, + endTime: end, + createdAt: new Date(), + }); + + await repository.save(first); + + const conflicting = Reservation.create({ + id: randomUUID(), + barberId: barber.id, + userId: userB.id, + startTime: start, + endTime: end, + createdAt: new Date(), + }); + + await expect(repository.save(conflicting)).rejects.toBeInstanceOf( + ReservationConflictError, + ); + }); +});