diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..948e18c --- /dev/null +++ b/backend/README.md @@ -0,0 +1,81 @@ +## Running tests (ALJ) + +The file `backend/src/tests/tests-ALJ.test.ts` contains both unit and integration suites. + +- **Unit tests** are fully mocked (no Docker, no DB): + ```bash + cd backend && npm run test:unit + ``` +- **Integration tests** spin up a disposable PostgreSQL via Testcontainers and run real Prisma migrations against it. Docker must be running: + ```bash + cd backend && npm run test:integration + ``` +- **Everything**: + ```bash + cd backend && npm run test:all + ``` + +First integration run pulls the `postgres:latest` image (~671 MB). Subsequent runs reuse the cached image; container boot is ~1 s and a full `test:all` finishes in ~3 s on a warm cache. + +### macOS / Docker Desktop note + +The integration suite sets `process.env.TESTCONTAINERS_RYUK_DISABLED = 'true'` in its `beforeAll`. Ryuk is Testcontainers' cleanup sidecar and fails to reach the Docker daemon socket on some Docker Desktop configurations on macOS. Container cleanup is still deterministic via the suite's `afterAll → container.stop()`. If Jest is killed mid-run (rare), a leftover postgres container can be cleaned up with `docker container prune`. + +### Schema bootstrap: `db push` vs `migrate deploy` + +The integration suite uses `npx prisma db push --skip-generate --accept-data-loss` rather than `migrate deploy`. `db push` applies `prisma/schema.prisma` directly to the disposable container, bypassing the `prisma/migrations/` folder. This means the test suite works even if the migrations folder is empty or out of sync with the schema (which is the case if you've ever run `prisma migrate reset` without re-creating an init migration). If you regenerate migrations with `prisma migrate dev`, the test suite remains unaffected. + +### Why `app.listen` doesn't bind a port during tests + +`src/index.ts` calls `app.listen(3010)` unconditionally. Both test suites stub `express.application.listen` to a no-op inside their `jest.isolateModules` blocks before requiring the app. This prevents the test process from competing with the dev server (or stale prior test runs) on port `3010`. Supertest is unaffected because it uses `http.createServer(app).listen(0)` directly, not the express prototype method. + +### Test inventory + +**30 tests total — 22 unit + 8 integration.** + +#### `UNIT — mocked, no database` + +**Validator (`validateCandidateData`) — 7** + +1. accepts a fully valid candidate payload +2. rejects missing `firstName` +3. rejects missing `lastName` +4. rejects invalid email format +5. rejects invalid phone format (must match Spanish 6/7/9 + 8 digits) +6. rejects malformed `educations[].startDate` +7. rejects `cv` missing `fileType` + +**Domain models — 4** + +8. `Candidate.save()` (no id) calls `prisma.candidate.create` with flat fields and no nested writes +9. `Education.save()` (new) calls `prisma.education.create` with the `candidateId` linkage +10. `WorkExperience.save()` (new) calls `prisma.workExperience.create` with the `candidateId` linkage +11. `Resume.save()` persists `filePath`, `fileType`, `candidateId`, and a generated `uploadDate` + +**Service (`addCandidate`) — 4** + +12. happy path: validates, then saves Candidate + each Education + WorkExperience + Resume +13. validator failure short-circuits: throws and never calls Prisma +14. Prisma `P2002` (unique-email) is mapped to "email already exists" domain error +15. empty `educations` and `workExperiences` arrays do not trigger nested creates + +**Routes — `POST /candidates` via supertest (also covers controller-equivalent behaviour) — 7** + +16. valid body → 201, body equals service's return value +17. valid body invokes the service exactly once with the request body +18. service throws `Error("Invalid email")` → 400 with the error message +19. service throws "email already exists" Error → 400 with that message +20. service throws a non-Error (string) → 500 with "unexpected" message +21. `GET /unknown-route` → 404 +22. `GET /` (root) → 200 with the LTI greeting (sanity check that the app booted) + +#### `INTEGRATION — Testcontainers + Prisma + supertest` — 8 + +23. `POST /candidates` creates a candidate with educations, workExperiences, and resume +24. `POST /candidates` twice with the same email → second is rejected, only one row in DB +25. `POST /candidates` with invalid email → 400 and zero rows persisted +26. `POST /candidates` with empty `educations` and `workExperiences` arrays → 201, candidate row exists, zero child rows +27. repository: `Candidate.save()` against real Prisma — row appears, ids autoincrement +28. referential integrity: deleting a candidate with children fails (schema is `onDelete: RESTRICT`) +29. date handling: ISO date strings in the request body are stored as `DateTime` columns and roundtrip +30. CV persistence: payload's `cv` produces a linked resume row (no real disk write) diff --git a/backend/package.json b/backend/package.json index 3947466..34249e0 100644 --- a/backend/package.json +++ b/backend/package.json @@ -25,19 +25,24 @@ "swagger-ui-express": "^5.0.0" }, "devDependencies": { + "@testcontainers/postgresql": "^11.14.0", "@types/cors": "^2.8.17", "@types/express": "^4.17.9", "@types/jest": "^29.5.13", "@types/multer": "^1.4.11", "@types/node": "^20.12.10", + "@types/supertest": "^7.2.0", "eslint": "^9.2.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.1.3", "jest": "^29.7.0", + "jest-mock-extended": "^4.0.1", "prettier": "^3.2.5", "prisma": "^5.13.0", + "supertest": "^7.2.2", + "testcontainers": "^11.14.0", "ts-jest": "^29.2.5", - "ts-node": "^9.1.1", + "ts-node": "^10.9.2", "ts-node-dev": "^1.1.6", "typescript": "^4.9.5" } diff --git a/backend/src/tests/tests-ALJ.test.ts b/backend/src/tests/tests-ALJ.test.ts new file mode 100644 index 0000000..54f071e --- /dev/null +++ b/backend/src/tests/tests-ALJ.test.ts @@ -0,0 +1,586 @@ +// ===================================================================================== +// tests-ALJ.test.ts +// One file, two suites: UNIT (mocked, no DB) and INTEGRATION (Testcontainers + Prisma). +// +// The two suites have incompatible module-mock requirements (UNIT needs a mocked +// @prisma/client; INTEGRATION needs the real one). The UNIT suite uses +// jest.isolateModules + jest.doMock so its mocks don't leak; INTEGRATION loads the app +// against the real @prisma/client. +// ===================================================================================== + +import { mockDeep, mockReset, DeepMockProxy } from 'jest-mock-extended'; +import request from 'supertest'; +import type { PrismaClient as PrismaClientType } from '@prisma/client'; +import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql'; +import { execSync } from 'child_process'; +import path from 'path'; + +// NOTE: no fs mock. The plan's draft mocked fs globally, but the candidate POST flow +// stores filePath/fileType as strings (no actual disk writes), and the /upload route +// is never exercised by these tests. A global fs mock with existsSync→true breaks +// Testcontainers' internal file-lock + config-file logic, hanging beforeAll until +// the 120s hook timeout. + +// --- Helpers ------------------------------------------------------------------------ + +let emailCounter = 0; +const freshEmail = () => `candidate${++emailCounter}@example.com`; + +const validPayload = (): any => ({ + firstName: 'Albert', + lastName: 'Saelices', + email: freshEmail(), + phone: '656874937', // matches /^(6|7|9)\d{8}$/ + address: 'Calle Sant Dalmir 2, 5B. Barcelona', + educations: [ + { + institution: 'UC3M', + title: 'Computer Science', + startDate: '2006-12-31', + endDate: '2010-12-26', + }, + ], + workExperiences: [ + { + company: 'Coca Cola', + position: 'SWE', + description: '', + startDate: '2011-01-13', + endDate: '2013-01-17', + }, + ], + cv: { + filePath: 'uploads/test-cv.pdf', + fileType: 'application/pdf', + }, +}); + +// ===================================================================================== +// UNIT SUITE — all production modules are loaded inside jest.isolateModules so the +// @prisma/client and controller mocks defined here don't leak to the integration suite. +// ===================================================================================== + +describe('UNIT — mocked, no database', () => { + let prismaMock: DeepMockProxy; + let validateCandidateData: typeof import('../application/validator').validateCandidateData; + let Candidate: typeof import('../domain/models/Candidate').Candidate; + let Education: typeof import('../domain/models/Education').Education; + let WorkExperience: typeof import('../domain/models/WorkExperience').WorkExperience; + let Resume: typeof import('../domain/models/Resume').Resume; + let serviceAddCandidate: typeof import('../application/services/candidateService').addCandidate; + let mockedAddCandidate: jest.MockedFunction< + typeof import('../presentation/controllers/candidateController').addCandidate + >; + let unitApp: import('express').Express; + + beforeAll(() => { + jest.isolateModules(() => { + // index.ts calls app.listen() unconditionally. Stub express's listen so + // requiring it here doesn't try to bind :3010. + const express = require('express'); + express.application.listen = function () { + return { close: (cb?: () => void) => cb && cb() }; + }; + + const singleton = mockDeep(); + + jest.doMock('@prisma/client', () => ({ + __esModule: true, + PrismaClient: jest.fn(() => singleton), + Prisma: { + PrismaClientInitializationError: class PrismaClientInitializationError extends Error { + constructor(message?: string) { + super(message); + this.name = 'PrismaClientInitializationError'; + } + }, + }, + })); + + jest.doMock('../presentation/controllers/candidateController', () => ({ + __esModule: true, + addCandidate: jest.fn(), + addCandidateController: jest.fn(), + })); + + // Now require — these resolve against the mocks above. + validateCandidateData = require('../application/validator').validateCandidateData; + Candidate = require('../domain/models/Candidate').Candidate; + Education = require('../domain/models/Education').Education; + WorkExperience = require('../domain/models/WorkExperience').WorkExperience; + Resume = require('../domain/models/Resume').Resume; + serviceAddCandidate = require('../application/services/candidateService').addCandidate; + mockedAddCandidate = require('../presentation/controllers/candidateController').addCandidate; + unitApp = require('../index').app; + prismaMock = singleton as unknown as DeepMockProxy; + }); + }); + + beforeEach(() => { + if (prismaMock) mockReset(prismaMock); + jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + // --------------------------------------------------------------------------------- + describe('Validator (validateCandidateData)', () => { + it('accepts a fully valid candidate payload', () => { + expect(() => validateCandidateData(validPayload())).not.toThrow(); + }); + + it('rejects missing firstName', () => { + const payload = validPayload(); + payload.firstName = undefined; + expect(() => validateCandidateData(payload)).toThrow(/Invalid name/); + }); + + it('rejects missing lastName', () => { + const payload = validPayload(); + payload.lastName = undefined; + expect(() => validateCandidateData(payload)).toThrow(/Invalid name/); + }); + + it('rejects invalid email format', () => { + const payload = validPayload(); + payload.email = 'not-an-email'; + expect(() => validateCandidateData(payload)).toThrow(/Invalid email/); + }); + + it('rejects invalid phone format (must match Spanish 6/7/9 + 8 digits)', () => { + const payload = validPayload(); + payload.phone = '12345'; + expect(() => validateCandidateData(payload)).toThrow(/Invalid phone/); + }); + + it('rejects malformed educations[].startDate', () => { + const payload = validPayload(); + payload.educations[0].startDate = '12-31-2006'; + expect(() => validateCandidateData(payload)).toThrow(/Invalid date/); + }); + + it('rejects cv missing fileType', () => { + const payload = validPayload(); + payload.cv = { filePath: 'uploads/test.pdf' }; + expect(() => validateCandidateData(payload)).toThrow(/Invalid CV data/); + }); + }); + + // --------------------------------------------------------------------------------- + describe('Domain models', () => { + it('Candidate.save() (no id) calls prisma.candidate.create with flat fields and no nested writes', async () => { + prismaMock.candidate.create.mockResolvedValue({ + id: 1, + firstName: 'A', + lastName: 'B', + email: 'a@b.c', + phone: null, + address: null, + } as any); + + const c = new Candidate({ + firstName: 'A', + lastName: 'B', + email: 'a@b.c', + phone: '666666666', + address: 'Somewhere', + }); + await c.save(); + + expect(prismaMock.candidate.create).toHaveBeenCalledTimes(1); + const arg = prismaMock.candidate.create.mock.calls[0][0] as any; + expect(arg.data).toEqual({ + firstName: 'A', + lastName: 'B', + email: 'a@b.c', + phone: '666666666', + address: 'Somewhere', + }); + expect(arg.data.educations).toBeUndefined(); + expect(arg.data.workExperiences).toBeUndefined(); + expect(arg.data.resumes).toBeUndefined(); + }); + + it('Education.save() (new) calls prisma.education.create with the candidateId linkage', async () => { + prismaMock.education.create.mockResolvedValue({ id: 10 } as any); + + const e = new Education({ + institution: 'UC3M', + title: 'CS', + startDate: '2006-12-31', + endDate: '2010-12-26', + }); + e.candidateId = 42; + await e.save(); + + expect(prismaMock.education.create).toHaveBeenCalledTimes(1); + const arg = prismaMock.education.create.mock.calls[0][0] as any; + expect(arg.data.institution).toBe('UC3M'); + expect(arg.data.title).toBe('CS'); + expect(arg.data.candidateId).toBe(42); + expect(arg.data.startDate).toBeInstanceOf(Date); + }); + + it('WorkExperience.save() (new) calls prisma.workExperience.create with the candidateId linkage', async () => { + prismaMock.workExperience.create.mockResolvedValue({ id: 11 } as any); + + const w = new WorkExperience({ + company: 'Coca Cola', + position: 'SWE', + description: 'wrote code', + startDate: '2011-01-13', + }); + w.candidateId = 7; + await w.save(); + + expect(prismaMock.workExperience.create).toHaveBeenCalledTimes(1); + const arg = prismaMock.workExperience.create.mock.calls[0][0] as any; + expect(arg.data.company).toBe('Coca Cola'); + expect(arg.data.position).toBe('SWE'); + expect(arg.data.candidateId).toBe(7); + }); + + it('Resume.save() persists filePath, fileType, candidateId, and a generated uploadDate', async () => { + prismaMock.resume.create.mockResolvedValue({ id: 5 } as any); + + const r = new Resume({ + filePath: 'uploads/cv.pdf', + fileType: 'application/pdf', + candidateId: 99, + }); + await r.save(); + + expect(prismaMock.resume.create).toHaveBeenCalledTimes(1); + const arg = prismaMock.resume.create.mock.calls[0][0] as any; + expect(arg.data.filePath).toBe('uploads/cv.pdf'); + expect(arg.data.fileType).toBe('application/pdf'); + expect(arg.data.candidateId).toBe(99); + expect(arg.data.uploadDate).toBeInstanceOf(Date); + }); + }); + + // --------------------------------------------------------------------------------- + describe('Service (addCandidate)', () => { + it('happy path: validates, then saves Candidate + each Education + WorkExperience + Resume', async () => { + prismaMock.candidate.create.mockResolvedValue({ id: 99, firstName: 'Albert' } as any); + prismaMock.education.create.mockResolvedValue({ id: 1 } as any); + prismaMock.workExperience.create.mockResolvedValue({ id: 1 } as any); + prismaMock.resume.create.mockResolvedValue({ id: 1 } as any); + + const result = await serviceAddCandidate(validPayload()); + + expect(result).toEqual(expect.objectContaining({ id: 99 })); + expect(prismaMock.candidate.create).toHaveBeenCalledTimes(1); + expect(prismaMock.education.create).toHaveBeenCalledTimes(1); + expect(prismaMock.workExperience.create).toHaveBeenCalledTimes(1); + expect(prismaMock.resume.create).toHaveBeenCalledTimes(1); + + expect((prismaMock.education.create.mock.calls[0][0] as any).data.candidateId).toBe(99); + expect((prismaMock.workExperience.create.mock.calls[0][0] as any).data.candidateId).toBe(99); + expect((prismaMock.resume.create.mock.calls[0][0] as any).data.candidateId).toBe(99); + }); + + it('validator failure short-circuits: throws and never calls Prisma', async () => { + const bad = validPayload(); + bad.email = 'not-an-email'; + + await expect(serviceAddCandidate(bad)).rejects.toThrow(/Invalid email/); + expect(prismaMock.candidate.create).not.toHaveBeenCalled(); + expect(prismaMock.education.create).not.toHaveBeenCalled(); + expect(prismaMock.workExperience.create).not.toHaveBeenCalled(); + expect(prismaMock.resume.create).not.toHaveBeenCalled(); + }); + + it('Prisma P2002 (unique-email) is mapped to "email already exists" domain error', async () => { + prismaMock.candidate.create.mockRejectedValue({ code: 'P2002' }); + + await expect(serviceAddCandidate(validPayload())).rejects.toThrow(/email already exists/i); + }); + + it('empty educations and workExperiences arrays do not trigger nested creates', async () => { + prismaMock.candidate.create.mockResolvedValue({ id: 5 } as any); + + const payload = validPayload(); + payload.educations = []; + payload.workExperiences = []; + delete payload.cv; + + await serviceAddCandidate(payload); + + expect(prismaMock.candidate.create).toHaveBeenCalledTimes(1); + expect(prismaMock.education.create).not.toHaveBeenCalled(); + expect(prismaMock.workExperience.create).not.toHaveBeenCalled(); + expect(prismaMock.resume.create).not.toHaveBeenCalled(); + }); + }); + + // --------------------------------------------------------------------------------- + // Per Q1: this block ALSO covers the controller-equivalent behaviour. The router in + // routes/candidateRoutes.ts inlines its own try/catch and is the de facto "controller". + describe('Routes — POST /candidates via supertest (also covers controller-equivalent behaviour)', () => { + beforeEach(() => { + mockedAddCandidate.mockReset(); + }); + + it('valid body → 201, body equals service\'s return value', async () => { + const fakeCandidate = { id: 123, firstName: 'A', lastName: 'B', email: 'a@b.c' }; + mockedAddCandidate.mockResolvedValue(fakeCandidate as any); + + const res = await request(unitApp).post('/candidates').send(validPayload()); + + expect(res.status).toBe(201); + expect(res.body).toEqual(fakeCandidate); + }); + + it('valid body invokes the service exactly once with the request body', async () => { + mockedAddCandidate.mockResolvedValue({ id: 1 } as any); + const payload = validPayload(); + + await request(unitApp).post('/candidates').send(payload); + + expect(mockedAddCandidate).toHaveBeenCalledTimes(1); + expect(mockedAddCandidate).toHaveBeenCalledWith( + expect.objectContaining({ email: payload.email, firstName: payload.firstName }), + ); + }); + + it('service throws Error("Invalid email") → 400 with the error message', async () => { + mockedAddCandidate.mockRejectedValue(new Error('Invalid email')); + + const res = await request(unitApp).post('/candidates').send({ ...validPayload(), email: 'bad' }); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ message: 'Invalid email' }); + }); + + it('service throws "email already exists" Error → 400 with that message', async () => { + mockedAddCandidate.mockRejectedValue(new Error('The email already exists in the database')); + + const res = await request(unitApp).post('/candidates').send(validPayload()); + + expect(res.status).toBe(400); + expect(res.body.message).toMatch(/already exists/i); + }); + + it('service throws a non-Error (string) → 500 with "unexpected" message', async () => { + mockedAddCandidate.mockRejectedValue('boom'); + + const res = await request(unitApp).post('/candidates').send(validPayload()); + + expect(res.status).toBe(500); + expect(res.body.message).toMatch(/unexpected/i); + }); + + it('GET /unknown-route → 404', async () => { + const res = await request(unitApp).get('/this-route-does-not-exist'); + expect(res.status).toBe(404); + }); + + it('GET / (root) → 200 with the LTI greeting (sanity check that the app booted)', async () => { + const res = await request(unitApp).get('/'); + expect(res.status).toBe(200); + expect(res.text).toMatch(/LTI/i); + }); + }); +}); + +// ===================================================================================== +// INTEGRATION SUITE — disposable Postgres via Testcontainers, real Prisma, real app. +// ===================================================================================== + +describe('INTEGRATION — Testcontainers + Prisma + supertest', () => { + let container: StartedPostgreSqlContainer; + let prisma: PrismaClientType; + let intApp: import('express').Express; + let Candidate: typeof import('../domain/models/Candidate').Candidate; + + beforeAll(async () => { + // Ryuk (Testcontainers' cleanup sidecar) fails to reach the Docker socket on + // some macOS Docker Desktop setups. afterAll → container.stop() handles cleanup. + process.env.TESTCONTAINERS_RYUK_DISABLED = 'true'; + + container = await new PostgreSqlContainer('postgres:latest') + .withUsername('postgres') + .withPassword('password') + .withDatabase('mydatabase') + .start(); + + process.env.DATABASE_URL = container.getConnectionUri(); + + // The repo's prisma/migrations folder may be empty (only migration_lock.toml). + // db push applies the schema directly, no migration files required. Idempotent. + execSync('npx prisma db push --skip-generate --accept-data-loss', { + cwd: path.resolve(__dirname, '../..'), // backend/ + env: { ...process.env, DATABASE_URL: process.env.DATABASE_URL }, + stdio: 'inherit', + }); + + // Real Prisma client for direct DB assertions in tests. + const { PrismaClient } = jest.requireActual('@prisma/client'); + prisma = new PrismaClient({ datasources: { db: { url: process.env.DATABASE_URL } } }); + + // The UNIT suite's jest.doMock registrations persist in the shared mock config + // even after its jest.isolateModules exits. Clear them so the app loads against + // the real Prisma client when running unit + integration in the same process. + jest.dontMock('@prisma/client'); + jest.dontMock('../presentation/controllers/candidateController'); + + jest.isolateModules(() => { + // Same listen stub as the UNIT suite — index.ts unconditionally calls app.listen(). + const express = require('express'); + express.application.listen = function () { + return { close: (cb?: () => void) => cb && cb() }; + }; + intApp = require('../index').app; + Candidate = require('../domain/models/Candidate').Candidate; + }); + }, 120_000); + + afterAll(async () => { + await prisma?.$disconnect(); + await container?.stop(); + }); + + beforeEach(async () => { + const tables = await prisma.$queryRaw>` + SELECT tablename FROM pg_tables + WHERE schemaname = 'public' AND tablename NOT LIKE '_prisma%'`; + if (tables.length) { + const list = tables.map((t) => `"public"."${t.tablename}"`).join(', '); + await prisma.$executeRawUnsafe(`TRUNCATE TABLE ${list} RESTART IDENTITY CASCADE;`); + } + // Suppress Resume.create()'s console.log noise in integration suite too. + jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + + it('POST /candidates creates a candidate with educations, workExperiences, and resume', async () => { + const payload = validPayload(); + const res = await request(intApp).post('/candidates').send(payload); + + expect(res.status).toBe(201); + expect(res.body.id).toEqual(expect.any(Number)); + + const rows = await prisma.candidate.findMany({ + include: { educations: true, workExperiences: true, resumes: true }, + }); + expect(rows).toHaveLength(1); + expect(rows[0].email).toBe(payload.email); + expect(rows[0].educations).toHaveLength(1); + expect(rows[0].educations[0].institution).toBe('UC3M'); + expect(rows[0].workExperiences).toHaveLength(1); + expect(rows[0].workExperiences[0].company).toBe('Coca Cola'); + expect(rows[0].resumes).toHaveLength(1); + expect(rows[0].resumes[0].fileType).toBe('application/pdf'); + }); + + it('POST /candidates twice with the same email → second is rejected, only one row in DB', async () => { + const payload = validPayload(); + + const first = await request(intApp).post('/candidates').send(payload); + expect(first.status).toBe(201); + + const second = await request(intApp).post('/candidates').send(payload); + expect(second.status).toBeGreaterThanOrEqual(400); + expect(second.status).toBeLessThan(500); + expect(second.body.message).toMatch(/already exists/i); + + const rows = await prisma.candidate.findMany(); + expect(rows).toHaveLength(1); + expect(rows[0].email).toBe(payload.email); + }); + + it('POST /candidates with invalid email → 400 and zero rows persisted', async () => { + const res = await request(intApp) + .post('/candidates') + .send({ ...validPayload(), email: 'not-an-email' }); + + expect(res.status).toBe(400); + expect(res.body.message).toMatch(/Invalid email/i); + + const rows = await prisma.candidate.findMany(); + expect(rows).toHaveLength(0); + }); + + it('POST /candidates with empty educations and workExperiences arrays → 201, candidate row exists, zero child rows', async () => { + const payload = validPayload(); + payload.educations = []; + payload.workExperiences = []; + delete payload.cv; // Keep this test focused on educations/workExperiences. + + const res = await request(intApp).post('/candidates').send(payload); + expect(res.status).toBe(201); + + const candidates = await prisma.candidate.findMany(); + expect(candidates).toHaveLength(1); + + const eduCount = await prisma.education.count(); + const workCount = await prisma.workExperience.count(); + const resumeCount = await prisma.resume.count(); + expect(eduCount).toBe(0); + expect(workCount).toBe(0); + expect(resumeCount).toBe(0); + }); + + it('repository: Candidate.save() against real Prisma — row appears, ids autoincrement', async () => { + const a = new Candidate({ + firstName: 'Alice', + lastName: 'Anderson', + email: 'alice@example.com', + }); + const savedA = await a.save(); + expect(savedA.id).toEqual(expect.any(Number)); + + const b = new Candidate({ + firstName: 'Bob', + lastName: 'Brown', + email: 'bob@example.com', + }); + const savedB = await b.save(); + expect(savedB.id).toBe(savedA.id + 1); + + const all = await prisma.candidate.findMany({ orderBy: { id: 'asc' } }); + expect(all).toHaveLength(2); + expect(all.map((c) => c.email)).toEqual(['alice@example.com', 'bob@example.com']); + }); + + it('referential integrity: deleting a candidate with children fails (schema is onDelete: RESTRICT)', async () => { + const payload = validPayload(); + const res = await request(intApp).post('/candidates').send(payload); + const id = res.body.id; + + // Schema FKs are RESTRICT — delete should be rejected by the DB. + await expect(prisma.candidate.delete({ where: { id } })).rejects.toThrow(); + + // Candidate row still exists. + const stillThere = await prisma.candidate.findUnique({ where: { id } }); + expect(stillThere).not.toBeNull(); + }); + + it('date handling: ISO date strings in the request body are stored as DateTime columns and roundtrip', async () => { + const payload = validPayload(); + payload.educations[0].startDate = '2006-12-31'; + payload.educations[0].endDate = '2010-12-26'; + + await request(intApp).post('/candidates').send(payload); + + const stored = await prisma.education.findFirstOrThrow({}); + expect(stored.startDate).toBeInstanceOf(Date); + expect(stored.endDate).toBeInstanceOf(Date); + expect(stored.startDate.toISOString()).toBe(new Date(payload.educations[0].startDate).toISOString()); + expect(stored.endDate!.toISOString()).toBe(new Date(payload.educations[0].endDate).toISOString()); + }); + + it('CV persistence: payload\'s cv produces a linked resume row (no real disk write)', async () => { + const payload = validPayload(); + payload.cv = { filePath: 'uploads/12345-cv.pdf', fileType: 'application/pdf' }; + + const res = await request(intApp).post('/candidates').send(payload); + expect(res.status).toBe(201); + + const resumes = await prisma.resume.findMany(); + expect(resumes).toHaveLength(1); + expect(resumes[0].filePath).toBe('uploads/12345-cv.pdf'); + expect(resumes[0].fileType).toBe('application/pdf'); + expect(resumes[0].candidateId).toEqual(expect.any(Number)); + expect(resumes[0].uploadDate).toBeInstanceOf(Date); + }); +}); diff --git a/prompts.md b/prompts.md new file mode 100644 index 0000000..d30eda0 --- /dev/null +++ b/prompts.md @@ -0,0 +1,224 @@ +# ATS — Claude + Claude Code + + +## First: Investigation + +You are an expert QA engineer specializing in ts-jest. +Create a plan to add unit-test and integration-test using that library all the test are focused on backend side. +All the unit-test are mocked, don't touch the database. +Integration-test are wired to a testcontainer. +All the test needs to live in just one file named tests-ALJ.test.ts +I will run the plan in Claude Code, give it to me in markdown. +Give me the details. +Ask me every question or decision you need. +Don't assume anything. +The project lives in this repo: https://github.com/nandilagos/AI4Devs-tdd-2603-srII + +### Interaction 1: +- **P: Mocking library for the unit-test section?** +R: jest-mock-extended (typed mocks for Prisma & services) — recommended +- **P: How should integration tests exercise the system?** +R: Both: supertest for HTTP + direct Prisma reads to assert DB state +- **P: Test isolation strategy for integration tests?** +R: Wrap each test in a transaction and rollback +- **P: Should the plan include setup steps (deps, jest.config, npm scripts) or only the test file?** +R: Test file + full config + a short README section + +### Interaction 2: +- **P: Coverage breadth — since we're testing 'all modules' but in a single file, how exhaustive?** +R: High coverage — every service, validator, controller, repository (20+ tests, slower) + +- **P: Heads-up: Prisma doesn't natively support test transactions that auto-rollback. The standard workaround is wrapping each test in `prisma.$transaction(async (tx) => { ...; throw RollbackError })`. This means tests must use `tx` (a transactional Prisma client) instead of the global one — which requires the app code to accept an injected client. Alternative: TRUNCATE between tests (simpler, slightly slower). Which do you prefer?** +R: You decide the cleanest implementation + +- **P: The candidate flow accepts a CV file (multer/file upload). For integration tests, how should we handle file uploads?** +R: Use jest-mock for the file system / multer parts in integration tests too + +--- + +## Second: Make a plan + +Give me the plan to run in Claude code. I want to give to CC just a one md file and asked me anything between steps, also add a checkpoint between each point to check everything is going well I will manually accept the continue of the plan + +--- + +## Run the plan + +Give a step by step to run the plan in Claude code + +```Read test-plan-ALJ.md at the repo root. Follow it exactly. + +Critical rules: +- Execute ONE step at a time. +- Stop at every 🛑 CHECKPOINT and wait for me to type "continue". +- If anything is ambiguous or contradicts the actual repo, ASK before assuming. +- Do not skip steps or batch multiple steps together. + +Begin with STEP 1 +``` + +--- +## Itnteraction with Claude Code +--- + +### Interaction 1: +**Question** +Clarifying questions + + Q1. Given the controller is not wired to any route (the router calls the service directly), Step 6's "Controller (≥ 4 tests)" — do you want me to: + - (a) Test addCandidateController directly with fake req/res (it's exported and runnable, but unreached in production), or + - (b) Treat the inline handler in candidateRoutes.ts as the "controller" and put those 4 tests in the route/supertest block (consolidating with the existing 3)? + + I'd default to (a) for plan adherence — but flagging because (a) tests dead code. + + Q2. For the "rejects invalid phone" unit test, I'll pass an explicitly invalid phone value (e.g. "12345") since phone is optional and omitting it would not reject. Confirming this is what you want. + + Q3. Confirming I should use the plan's locked Testcontainers creds (postgres/password/mydatabase) and ignore the actual docker-compose.yml creds — since the container is disposable and the plan explicitly + locks those values. + +**R: Q1. b, Q2. Yes, Q3. Confirm dont use real credentials.** + +--- + +### Interaction 2: +**Question** +Current backend/package.json has: + + "test": "jest" + + The plan's new value is "test": "jest --runInBand" — functionally a strict superset of the existing one (the existing one has no extra flags worth preserving). Renaming it to test:legacy would just create a + useless jest-no-args alias. + + Question: which do you want? + + - (A) Just overwrite test → jest --runInBand. The old jest no-args is trivially recoverable. + - (B) Follow the plan literally: rename existing test → test:legacy (= jest) and add new test (= jest --runInBand). + + I'd default to (A) since the existing script carries no real configuration. But you said "follow the plan exactly," so flagging it. + + 🛑 Pausing within STEP 3. Once you answer, I'll merge the scripts, run npx jest --listTests, and present the full STEP 3 checkpoint. + +**R: A** + + +### Interaction 3: +**Question** +⚠️ Hit a problem. jest.config.ts won't parse: + + Error: Jest: Failed to parse the TypeScript config file .../jest.config.ts + Error: Debug Failure. False expression: Non-string value passed to + `ts.resolveTypeReferenceDirective`, likely by a wrapping package working + with an outdated `resolveTypeReferenceDirectives` signature. + + Root cause: the repo has ts-node@9.1.1 pinned (very old). Jest uses ts-node to load .ts config files, and ts-node@9 cannot drive a modern TypeScript compiler (TS 4.9.5 here). Locked decisions list a + jest.config.ts, but the repo's existing ts-node@9 blocks it. + + Two ways to fix, both keep behavior identical: + + - (A) Convert to jest.config.js (CommonJS module.exports = { ... }). Same options, no ts-node needed, no version bumps. Touches only the new file. + - (B) Bump ts-node to ^10.9.2 (modern). Keeps jest.config.ts. Adds a devDeps line change. + + Per the plan's rule #3 (trust the repo) and "no prod code edits" spirit, I'd default to (A) — but flagging because the plan locks "jest.config.ts" by name. Which do you want? + + 🛑 Pausing within STEP 3. + +**R: B** + + +### Interaction 4: +**Question** +There's an unrelated node process (PID 10779) already listening on :3010 — not spawned by me in this turn. Killing an unknown process is destructive enough to flag rather than just kill -9. + + Likely: it's a leftover from an earlier npm start (yours or from prior session work). The GET / returning Hola LTI! (HTTP 200) confirms it's the LTI backend. + + How do you want to proceed? + + - (A) I run kill 10779 and retry the npm start smoke test cleanly. Safe if it's a stale dev server. + - (B) Skip the live npm start smoke test (the build already succeeded; the existing server already proves the route works). Move on to STEP 5. + - (C) You stop it yourself, then say continue. + + I lean (B) — npm run build exit 0 + a real server returning 200 on the route already gives us the signal STEP 4 needs (the change didn't break compilation or runtime). But your call. + + 🛑 Pausing within STEP 4. + +**R: C** + + +### Interaction 5: +**Question** +⚠️ Integration test failed — but not in the application code. Testcontainers couldn't connect to its Ryuk reaper container (the helper container that auto-cleans test containers if the test process is killed): + + Failed to connect to Reaper + at testcontainers/build/reaper/reaper.js:116 + + 80 seconds elapsed retrying before it gave up. The postgres container itself was never reached because reaper start is the first step. + + This is a Testcontainers infrastructure issue, not an app/migration/import problem. Common on macOS Docker Desktop — Ryuk image pulls slowly, network/socket settings differ, or Docker Desktop's resource limits + constrain it. Per the plan's rule for Step 7 ("stop and ask before working around it"), I'm pausing. + + What I can verify before proceeding + + - docker info exit 0 ✓ + - docker pull postgres:latest would work (separate concern) + - The 22 unit tests are fully green (verified) — they're isolated from this failure + + Options + + (A) Disable Ryuk for this test run by setting TESTCONTAINERS_RYUK_DISABLED=true before new PostgreSqlContainer(...).start(). Safe for our use case because: + - Each test run is short (~30s). + - afterAll explicitly calls container.stop() so cleanup is deterministic. + - Ryuk's purpose is to handle crashes — if Jest crashes mid-suite, you'd have a leftover container until you restart Docker Desktop. + + I'd add one line in the integration beforeAll (before new PostgreSqlContainer(...).start()): + process.env.TESTCONTAINERS_RYUK_DISABLED = 'true'; + + (B) Pre-pull the Ryuk image manually so the start phase doesn't time out: + docker pull testcontainers/ryuk:0.11.0 # or whatever version testcontainers expects + Then re-run. No code change. + + (C) You investigate Docker Desktop settings (resources, network) and tell me when ready. + + Which do you want? My recommendation is (A) — it's the most reliable on macOS Docker Desktop and the cleanup risk is bounded by afterAll. + + 🛑 Pausing within STEP 7. + +**R: try to run the docker image again. I checked the docker manually** + + +### Interaction 6: +**Question** +⏺ Diagnosis confirmed. Ryuk container exits immediately: + + ERROR run error="new reaper: ping: Cannot connect to the Docker daemon at + unix:///var/run/docker.sock. Is the docker daemon running?" + + This is a Docker Desktop on macOS quirk: the Docker daemon socket Testcontainers bind-mounts into the Ryuk container is not reaching the daemon. docker info works from your CLI, but the bind-mount path inside + the Ryuk container can't reach the daemon. This is a known Testcontainers + Docker Desktop friction point — typically fixed by either: + + 1. Enabling "Allow the default Docker socket to be used (requires password)" in Docker Desktop → Settings → Advanced, or + 2. Setting TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE to the real socket path (e.g. /var/run/docker.sock if Docker Desktop has it; or ~/.docker/run/docker.sock), or + 3. Disabling Ryuk for this run. + + Retrying alone won't fix it — Ryuk fails identically every time. + + Recommended path + + TESTCONTAINERS_RYUK_DISABLED=true set inside the integration beforeAll. One-line change. Cleanup is still deterministic via afterAll → container.stop(). The only thing you lose: if Jest crashes mid-run (rare), + there'd be a leftover postgres container. You can docker container prune if that ever happens. + + 🛑 Still on STEP 7. Two options: + + - (A) Apply the Ryuk-disable line. I'll do it and re-run. + - (D) You enable the Docker Desktop socket setting yourself, then say continue and I retry without the env var. + + Which? + +**R: A** + +### Interaction 7: +**Question** + 🛑 CHECKPOINT 10 — FINAL. All 30 tests green; all acceptance criteria met. Awaiting your accept to consider this task done. + +**R: In the new README add a list of all test you created** + +--- diff --git a/test-plan-ALJ.md b/test-plan-ALJ.md new file mode 100644 index 0000000..56e6881 --- /dev/null +++ b/test-plan-ALJ.md @@ -0,0 +1,437 @@ +# Claude Code Execution Plan — `tests-ALJ.test.ts` + +**Repo:** `nandilagos/AI4Devs-tdd-2603-srII` (LTI ATS — Express + TypeScript + Prisma + PostgreSQL) + +**Goal:** Add a single test file `backend/src/tests/tests-ALJ.test.ts` containing **unit tests (mocked, no DB)** and **integration tests (Testcontainers PostgreSQL, supertest + real Prisma)** covering every backend module. + +--- + +## ⚠️ Rules for Claude Code (read first) + +1. **Execute one step at a time.** After finishing each step, **STOP** at its `🛑 CHECKPOINT` and wait for me to type `continue` (or give corrections) before moving to the next step. +2. **Ask before assuming.** If anything in a step is ambiguous, undefined, or contradicts what's actually in the repo, **ask me a question and wait for an answer**. Do not invent symbol names, paths, or behavior. +3. **Trust the repo over this plan.** If a path or symbol in this plan doesn't match the real code, adjust your work to match the code and tell me what you changed. +4. **No production code edits** other than the one explicitly allowed in **Step 4**. Tests should not require refactoring controllers/services/repositories. +5. **Show your work at each checkpoint** — list files created/changed and any commands run + their output. Don't skip ahead. + +--- + +## Locked decisions (do not deviate) + +| Topic | Decision | +|---|---| +| Test runner | `ts-jest` | +| File location | `backend/src/tests/tests-ALJ.test.ts` (single file, both suites) | +| Unit-test mocks | `jest-mock-extended` for typed Prisma & service mocks | +| Integration HTTP | `supertest` against the Express app | +| Integration DB asserts | Direct `PrismaClient` reads to verify persisted state | +| Testcontainers image | `postgres:latest` with creds matching `docker-compose.yml` (user `postgres`, password `password`, db `mydatabase`) | +| Schema bootstrap | `npx prisma migrate deploy` against the container's `DATABASE_URL` | +| Test isolation | **TRUNCATE all `public` tables RESTART IDENTITY CASCADE** in `beforeEach` of integration suite | +| File uploads (multer/fs) | Mocked in both suites — no real disk I/O | +| Coverage breadth | **High** — 20+ tests across every module | +| Deliverables | Test file + `jest.config.ts` + npm scripts + README addendum | + +**Why TRUNCATE and not `$transaction` rollback:** the `$transaction(tx => …)` rollback pattern requires every layer (controller → service → repository) to accept an injected Prisma client so the test can substitute the transactional one. The current codebase imports a singleton — using transactional rollback would force a refactor. TRUNCATE on a single shared container is fast (low ms per test) and keeps app code untouched. + +--- + +# STEP 1 — Pre-flight discovery (no code changes) + +**Action:** Inspect the repo to learn the real shape of the code. Run: + +```bash +cd backend +ls src/ +ls src/application/ 2>/dev/null +ls src/application/services/ 2>/dev/null +ls src/domain/ 2>/dev/null +ls src/domain/models/ 2>/dev/null +ls src/domain/repositories/ 2>/dev/null +ls src/presentation/ 2>/dev/null +ls src/presentation/controllers/ 2>/dev/null +ls src/routes/ 2>/dev/null +ls src/infrastructure/ 2>/dev/null +cat src/index.ts +cat prisma/schema.prisma +cat package.json +``` + +Then **read** (don't modify) every `.ts` file under `src/application`, `src/domain`, `src/presentation`, `src/routes`, `src/infrastructure`. + +**Deliverable for this step:** a short report containing: +1. The exact path and exported symbol name for: the candidate **service** function (e.g. `addCandidate`), the **validator** function, the candidate **controller**, the candidate **routes** file/router, and each **domain model** (`Candidate`, `Education`, `WorkExperience`, `Resume` or whatever they are actually called). +2. Whether `index.ts` exports `app` separately from `app.listen(...)`, or whether `listen` runs at import time. (This drives Step 4.) +3. Whether the candidate controller uses `multer` middleware (multipart/form-data) or reads `cv` from a JSON body. +4. Confirmation that `backend/prisma/migrations/` exists and is non-empty. If empty, flag it — the integration setup will need `prisma db push` instead of `migrate deploy`. +5. Node.js version (`node -v`) and whether Docker is reachable (`docker info` exit code). +6. The actual **field names and validation rules** in the validator (regex for phone, email, required fields, date format) — quote the relevant lines. + +**Do not install anything yet. Do not create any files yet.** + +### 🛑 CHECKPOINT 1 +- Post the report above. +- List any surprises or mismatches with this plan. +- Ask any clarifying questions you have before Step 2. +- **Wait for me to reply `continue` before proceeding.** + +--- + +# STEP 2 — Install dev dependencies + +**Action:** In `backend/`, run: + +```bash +npm install -D \ + jest @types/jest ts-jest \ + jest-mock-extended \ + supertest @types/supertest \ + testcontainers @testcontainers/postgresql +``` + +If `jest`, `ts-jest`, or any of these are already in `devDependencies` at compatible versions, skip those entries. Report the final versions installed. + +### 🛑 CHECKPOINT 2 +- Show me `npm ls jest ts-jest jest-mock-extended supertest testcontainers @testcontainers/postgresql` output. +- Confirm `@prisma/client` and `prisma` are present (they should be). +- Report any peer-dep warnings. +- **Wait for `continue`.** + +--- + +# STEP 3 — Create `jest.config.ts` and update `package.json` scripts + +**Action 1:** Create `backend/jest.config.ts` with exactly: + +```ts +import type { Config } from 'jest'; + +const config: Config = { + preset: 'ts-jest', + testEnvironment: 'node', + rootDir: '.', + testMatch: ['/src/tests/**/*.test.ts'], + testTimeout: 120_000, + clearMocks: true, + restoreMocks: true, +}; + +export default config; +``` + +**Action 2:** Merge into `backend/package.json` `"scripts"` (preserve existing entries): + +```json +{ + "test": "jest --runInBand", + "test:unit": "jest --runInBand -t 'UNIT'", + "test:integration": "jest --runInBand -t 'INTEGRATION'", + "test:all": "jest --runInBand" +} +``` + +If a `"test"` script already exists, **ask me** before overwriting — propose renaming the old one to `test:legacy`. + +### 🛑 CHECKPOINT 3 +- Show the diff of `package.json`. +- Show the contents of `jest.config.ts`. +- Run `npx jest --listTests` (it will find nothing yet — confirm exit code 0). +- **Wait for `continue`.** + +--- + +# STEP 4 — Conditionally split `app` from `app.listen` in `index.ts` + +**Skip this step entirely if** Step 1 confirmed `app` is already exported separately from `app.listen(...)`. + +**Otherwise, action:** Modify `backend/src/index.ts` so: +- `app` is exported (`export const app = ...` or `export default app`). +- `app.listen(...)` only runs when the file is the entry point: wrap it in `if (require.main === module) { app.listen(...) }`. + +This is the **only** production-code change permitted by this plan. No other behavior may change. + +If the project is structured as ESM (`"type": "module"` in package.json), use `import.meta.url`-based check instead — **ask me first** if you encounter that. + +### 🛑 CHECKPOINT 4 +- Show the before/after diff of `index.ts`. +- Run `npm run build` (or whatever the build command is) and confirm it still compiles. +- Run `npm start` briefly to confirm the server still listens on its port, then stop it. +- **Wait for `continue`.** + +--- + +# STEP 5 — Create the test file: imports + helpers + UNIT validator tests + +**Action:** Create `backend/src/tests/tests-ALJ.test.ts` with **only** the imports, top-of-file mocks, helper functions, and the UNIT suite's **Validator** describe block (≥ 7 tests). + +Use the actual import paths and symbol names you discovered in Step 1 — **do not** copy placeholders from this plan blindly. + +Skeleton structure: + +```ts +import { mockDeep, mockReset, DeepMockProxy } from 'jest-mock-extended'; +import request from 'supertest'; +import type { PrismaClient } from '@prisma/client'; + +// Neutralise filesystem and (if used) multer BEFORE importing the app +jest.mock('fs', () => ({ + ...jest.requireActual('fs'), + writeFileSync: jest.fn(), + mkdirSync: jest.fn(), + existsSync: jest.fn().mockReturnValue(true), + promises: { + writeFile: jest.fn().mockResolvedValue(undefined), + mkdir: jest.fn().mockResolvedValue(undefined), + }, +})); + +// If Step 1 found multer in use, also mock it here. + +// --- Helpers --- +const validPayload = () => ({ /* mirror the README example, fresh email per call */ }); + +// --- UNIT SUITE --- +describe('UNIT — mocked, no database', () => { + describe('Validator', () => { + // 7 tests — see list below + }); +}); +``` + +**Validator tests to implement (mirror the actual rules you found in Step 1):** +1. Accepts a fully valid candidate payload +2. Rejects missing `firstName` +3. Rejects missing `lastName` +4. Rejects invalid email format +5. Rejects invalid phone (per the regex actually used) +6. Rejects malformed `educations[].startDate` / `endDate` +7. Rejects `cv` missing `filePath` or `fileType` + +Run `npm run test:unit` — these 7 must pass. + +### 🛑 CHECKPOINT 5 +- Show the test file contents so far. +- Show `npm run test:unit` output (7/7 green). +- If any test fails because the validator behaves differently than this plan assumed, **stop and ask me** rather than rewriting the test to "match" buggy behavior. +- **Wait for `continue`.** + +--- + +# STEP 6 — Add UNIT tests for domain models, service, controller, and routes + +**Action:** Extend `tests-ALJ.test.ts` (still inside the `UNIT — mocked, no database` describe) with these blocks. Use `jest-mock-extended`'s `mockDeep()` and `jest.mock('@prisma/client', ...)` so no Prisma call hits a real DB. + +**Domain models (≥ 3 tests):** +- `Candidate.save()` (or whatever the project calls it) calls `prisma.candidate.create` with the right shape +- `Education.save()` and `WorkExperience.save()` link to a candidate id +- `Resume.save()` persists `filePath` + `fileType` + +**Service `addCandidate` (≥ 4 tests):** +- Happy path orchestrates validation → candidate save → nested educations/workExperiences/resume +- Validator failure short-circuits and throws (zero Prisma calls) +- Repository throws `P2002` (unique-email) → service surfaces a domain error +- Empty `educations` / `workExperiences` arrays don't trigger creation calls + +**Controller (≥ 4 tests):** use a fake `req`/`res`/`next` and `jest.spyOn(serviceModule, 'addCandidate')`: +- Returns `201` + payload on success +- Returns `400` on validation error +- Returns whatever code the controller actually maps for duplicate-email (4xx) +- Returns `500` on unexpected error + +**Routes via supertest (≥ 3 tests):** +- `POST /candidates` reaches the controller and returns `201` (service is spied on, returns a fake candidate) +- `POST /candidates` with bad body returns `400` +- Unknown route returns `404` + +Run `npm run test:unit` — all UNIT tests must pass. Total UNIT count should now be **≥ 14**. + +### 🛑 CHECKPOINT 6 +- Show the updated test file (or just the new sections). +- Show `npm run test:unit` output with full pass count. +- Confirm `npm run test:unit` still works **with Docker stopped** (this is the contract — unit tests must not require Docker). +- **Wait for `continue`.** + +--- + +# STEP 7 — Add the INTEGRATION suite (container boot + first happy-path test) + +**Action:** Append a sibling `describe('INTEGRATION — Testcontainers + Prisma + supertest', () => { ... })` block to `tests-ALJ.test.ts`. + +Required structure: + +```ts +import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql'; +import { execSync } from 'child_process'; +import path from 'path'; + +describe('INTEGRATION — Testcontainers + Prisma + supertest', () => { + let container: StartedPostgreSqlContainer; + let prisma: PrismaClient; + let app: any; // import lazily AFTER DATABASE_URL is set + + beforeAll(async () => { + container = await new PostgreSqlContainer('postgres:latest') + .withUsername('postgres') + .withPassword('password') + .withDatabase('mydatabase') + .start(); + + process.env.DATABASE_URL = container.getConnectionUri(); + + execSync('npx prisma migrate deploy', { + cwd: path.resolve(__dirname, '../..'), // backend/ + env: { ...process.env, DATABASE_URL: process.env.DATABASE_URL }, + stdio: 'inherit', + }); + + const { PrismaClient } = await import('@prisma/client'); + prisma = new PrismaClient({ datasources: { db: { url: process.env.DATABASE_URL } } }); + + // Lazy-import the app AFTER env is set so any module-load Prisma client uses the container URL + ({ app } = await import('../index')); + }, 120_000); + + afterAll(async () => { + await prisma?.$disconnect(); + await container?.stop(); + }); + + beforeEach(async () => { + const tables = await prisma.$queryRaw>` + SELECT tablename FROM pg_tables + WHERE schemaname = 'public' AND tablename NOT LIKE '_prisma%'`; + if (tables.length) { + const list = tables.map(t => `"public"."${t.tablename}"`).join(', '); + await prisma.$executeRawUnsafe(`TRUNCATE TABLE ${list} RESTART IDENTITY CASCADE;`); + } + }); + + it('POST /candidates creates a candidate with educations, workExperiences, and resume', async () => { + const res = await request(app).post('/candidates').send(validPayload()); + expect(res.status).toBe(201); + + const rows = await prisma.candidate.findMany({ include: { /* match real schema field names */ } }); + expect(rows).toHaveLength(1); + // assert nested educations / workExperiences / resume rows exist & FK-link to candidate + }); +}); +``` + +**Critical detail:** the `fs` (and possibly `multer`) mocks at the top of the file apply to integration tests too — that's intentional. The integration suite tests Express + Prisma + Postgres, **not** disk I/O. + +**Critical detail 2:** if Step 1 reported that `prisma/migrations/` is empty, replace `prisma migrate deploy` with `npx prisma db push --skip-generate` and tell me. + +Run `npm run test:integration` — the one happy-path test must pass. First run will pull the `postgres:latest` image (~100MB). + +### 🛑 CHECKPOINT 7 +- Show the new INTEGRATION block. +- Show `npm run test:integration` output (1/1 green). +- Report container boot time and total wall-clock time. +- If anything fails (image pull, migration, app import), **stop and ask** before working around it. +- **Wait for `continue`.** + +--- + +# STEP 8 — Round out the INTEGRATION suite (≥ 7 more tests) + +**Action:** Add these tests to the integration block: + +1. POST /candidates twice with the **same email** → second is rejected (4xx); only **one** candidate row in DB +2. POST /candidates with **invalid email** → 400; zero rows in DB +3. POST /candidates with **empty `educations` and `workExperiences` arrays** → 201; one candidate row, zero education/workExperience rows +4. **Repository-level test:** call the domain `Candidate` save method directly against the real Prisma → row appears, ids autoincrement +5. **Cascade integrity:** delete the candidate row → educations / workExperiences / resume removed (only if schema has `onDelete: Cascade`; otherwise assert FK error on candidate delete) +6. **Date handling:** ISO date strings in the request body are stored as proper `DateTime` columns (read back via Prisma, compare) +7. **CV persistence:** payload's `cv: { filePath, fileType }` produces a linked resume row (filesystem mock means no real write) + +Total integration test count should be **≥ 8**. Total file test count should be **≥ 22**. + +### 🛑 CHECKPOINT 8 +- Show the full final `tests-ALJ.test.ts` file. +- Show `npm run test:all` output: every test green, count ≥ 22. +- Show timing breakdown (unit suite vs integration suite). +- **Wait for `continue`.** + +--- + +# STEP 9 — README addendum + +**Action:** Append this section to `backend/README.md` (create the file if it doesn't exist; do **not** modify the root `README.md`): + +````markdown +## Running tests (ALJ) + +The file `backend/src/tests/tests-ALJ.test.ts` contains both unit and integration suites. + +- **Unit tests** are fully mocked (no Docker, no DB): + ```bash + cd backend && npm run test:unit + ``` +- **Integration tests** spin up a disposable PostgreSQL via Testcontainers and run real Prisma migrations against it. Docker must be running: + ```bash + cd backend && npm run test:integration + ``` +- **Everything**: + ```bash + cd backend && npm run test:all + ``` + +First integration run pulls the `postgres:latest` image (~100 MB). Subsequent runs reuse the cached image; container boot is ~3-5s. +```` + +### 🛑 CHECKPOINT 9 +- Show the appended section. +- **Wait for `continue`.** + +--- + +# STEP 10 — Final acceptance run + summary + +**Action:** Run all of these in order and report results: + +1. `cd backend && npm run test:unit` — must pass with Docker stopped +2. Start Docker, then `npm run test:integration` — must pass +3. `npm run test:all` — must pass +4. `git status` — list every file you created or modified +5. `git diff --stat` — show line-count changes per file + +Then write a final summary covering: +- Total tests (unit / integration / total) +- Total wall-clock time for `test:all` +- Files created +- Files modified (should only be `package.json`, `package-lock.json`, the README, and **at most** `index.ts`) +- Any deviations from this plan and why + +### 🛑 CHECKPOINT 10 — FINAL +- Post the full summary. +- **Wait for my final `accept` before considering the task done.** + +--- + +## Acceptance criteria (must all be true at Checkpoint 10) + +- [ ] Single test file exists: `backend/src/tests/tests-ALJ.test.ts` +- [ ] No production code changed except (optionally) splitting `app.listen` from `app` export in `index.ts` +- [ ] `npm run test:unit` passes with **zero** Docker / DB dependencies +- [ ] `npm run test:integration` boots one container, runs migrations, all tests green +- [ ] At least one assertion per integration test queries Prisma directly to verify DB state +- [ ] All unit-test `describe` blocks start with `UNIT`; all integration ones with `INTEGRATION` +- [ ] No real filesystem writes (tests still pass on a read-only `uploads/` dir) +- [ ] Total test count ≥ 22 (≥ 14 unit + ≥ 8 integration) + +--- + +## Known risks & mitigations (FYI for Claude Code) + +| Risk | Mitigation | +|---|---| +| `app.listen` runs on import → supertest hangs | Step 4 splits export from listen | +| `prisma migrate deploy` fails (empty migrations folder) | Pre-flight checks; fall back to `prisma db push --skip-generate` and tell me | +| Schema uses a non-`public` schema or unusual table names | TRUNCATE query reads `pg_tables` dynamically | +| Phone/email validation rules differ from assumption | Step 1 reads the actual validator; tests mirror real rules | +| Multer not used (controller reads `cv` from JSON) | Skip the multer mock; `fs` mock alone is enough | +| Slow first run (image pull ~100 MB) | Documented in README; one-time cost | + +--- + +**End of plan. Begin with STEP 1 and stop at 🛑 CHECKPOINT 1.**