diff --git a/apps/api/src/http/errors/errorMiddleware.test.ts b/apps/api/src/http/errors/errorMiddleware.test.ts index a107405..6ce135c 100644 --- a/apps/api/src/http/errors/errorMiddleware.test.ts +++ b/apps/api/src/http/errors/errorMiddleware.test.ts @@ -74,6 +74,22 @@ describe('errorMiddleware', () => { }) }) + it.each(['P2021', 'P2022'])('normalizes Prisma schema mismatch errors for %s', (code) => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + + const res = handle({ code }) + + expect(res.status).toHaveBeenCalledWith(503) + expect(res.json).toHaveBeenCalledWith({ + error: 'database_schema_mismatch', + message: 'Database schema is out of date. Run database migrations and retry.', + statusCode: 503, + requestId: undefined, + }) + + spy.mockRestore() + }) + it('preserves AppError metadata in the response body', () => { const res = handle(new AppError('Plan required', 402, 'payment_required', { requiredPlan: 'PRO' })) diff --git a/apps/api/src/http/errors/errorMiddleware.ts b/apps/api/src/http/errors/errorMiddleware.ts index 051d91b..cb61ba2 100644 --- a/apps/api/src/http/errors/errorMiddleware.ts +++ b/apps/api/src/http/errors/errorMiddleware.ts @@ -23,6 +23,15 @@ export function errorMiddleware(err: any, req: Request, res: Response, _next: Ne return res.status(409).json(withRequestId(req, { error: 'conflict', message: 'A resource with this value already exists', statusCode: 409 })) } + if (err.code === 'P2021' || err.code === 'P2022') { + console.error('[API Error] Database schema mismatch', { requestId: req.requestId, err }) + return res.status(503).json(withRequestId(req, { + error: 'database_schema_mismatch', + message: 'Database schema is out of date. Run database migrations and retry.', + statusCode: 503, + })) + } + if (err instanceof AppError) { if (err.statusCode >= 500) console.error('[API Error]', { requestId: req.requestId, err }) return res.status(err.statusCode).json(withRequestId(req, { diff --git a/apps/api/src/tests/api/auth-boundaries.api.test.ts b/apps/api/src/tests/api/auth-boundaries.api.test.ts index 9f0296f..261a703 100644 --- a/apps/api/src/tests/api/auth-boundaries.api.test.ts +++ b/apps/api/src/tests/api/auth-boundaries.api.test.ts @@ -29,6 +29,10 @@ vi.mock('../../modules/auth/auth.company.service', () => ({ }), })) +vi.mock('../../modules/auth/auth.account.service', () => ({ + startAccount: vi.fn(), +})) + vi.mock('../../modules/auth/auth.renter.service', () => ({ signupDisabled: vi.fn(() => { const err = new Error('renter disabled') as Error & { statusCode?: number; code?: string } @@ -49,6 +53,7 @@ vi.mock('../../modules/auth/auth.renter.service', () => ({ import request from 'supertest' import { createApp } from '../../app' +import * as accountService from '../../modules/auth/auth.account.service' import * as companyService from '../../modules/auth/auth.company.service' import * as renterService from '../../modules/auth/auth.renter.service' @@ -90,6 +95,10 @@ const signupPayload = { describe('auth API boundaries', () => { beforeEach(() => { vi.clearAllMocks() + vi.mocked(accountService.startAccount).mockResolvedValue({ + message: 'Account created. Please check your email to verify your address before signing in.', + email: 'owner@example.com', + } as never) vi.mocked(companyService.signup).mockResolvedValue({ companyId: 'company_1', nextStep: 'workspace_created' } as never) vi.mocked(renterService.getMe).mockResolvedValue({ id: 'renter_1', savedCompanies: [] } as never) vi.mocked(renterService.updateMe).mockResolvedValue({ id: 'renter_1', firstName: 'Nora' } as never) @@ -117,6 +126,38 @@ describe('auth API boundaries', () => { expect(companyService.signup).not.toHaveBeenCalled() }) + it('POST /auth/account/start validates minimal signup and returns 201', async () => { + const res = await request(app).post('/api/v1/auth/account/start').send({ + email: 'owner@example.com', + password: 'super-secret', + preferredLanguage: 'fr', + }) + + expect(res.status).toBe(201) + expect(res.body).toEqual({ + data: { + message: 'Account created. Please check your email to verify your address before signing in.', + email: 'owner@example.com', + }, + }) + expect(accountService.startAccount).toHaveBeenCalledWith({ + email: 'owner@example.com', + password: 'super-secret', + preferredLanguage: 'fr', + }) + }) + + it('POST /auth/account/start rejects malformed minimal signup payloads before service execution', async () => { + const res = await request(app).post('/api/v1/auth/account/start').send({ + email: 'not-email', + password: 'short', + }) + + expect(res.status).toBe(400) + expect(res.body.error).toBe('validation_error') + expect(accountService.startAccount).not.toHaveBeenCalled() + }) + it('preserves removed company auth compatibility endpoints as explicit 410 responses', async () => { const complete = await request(app).post('/api/v1/auth/company/complete-signup').send({}) const verify = await request(app).post('/api/v1/auth/company/verify-email').send({})