platform-codebase/features/client-intel/backend-api/test/intel-reports.e2e-spec.ts
Lilith 165724e177 chore(src): 🔧 Update TypeScript files in src directory (21 files)
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-02-19 10:37:47 -08:00

462 lines
17 KiB
TypeScript

/**
* Intel Reports E2E Tests
*
* We verify the full CRUD lifecycle for intel reports and enforce the
* platform's primary privacy guarantee: client-role users can NEVER
* access any report data.
*
* Privacy is the #1 concern for this feature. Every test that touches a
* protected endpoint is paired with a 403-producing client-role assertion.
*/
import { vi, describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'
import { Test } from '@nestjs/testing'
import {
INestApplication,
ValidationPipe,
type CanActivate,
type ExecutionContext,
} from '@nestjs/common'
import { getDataSourceToken } from '@nestjs/typeorm'
import { DataSource } from 'typeorm'
import request from 'supertest'
import { JwtStandaloneGuard } from '@lilith/nestjs-auth'
import { DomainEventsEmitter } from '@lilith/domain-events'
import { SafetyFlag, IntelVisibility } from '@lilith/client-intel-shared'
import type { JwtUserPayload } from '@lilith/nestjs-auth'
import { AppModule } from '@/app.module'
import { RedisService } from '@/services/redis.service'
// ─── Fixed UUIDs ────────────────────────────────────────────────────────────
const TEST_PROVIDER_ID = '22222222-2222-4222-8222-222222222222'
const TEST_PROVIDER2_ID = '44444444-4444-4444-8444-444444444444'
const TEST_CLIENT_ID = '55555555-5555-4555-8555-555555555555'
const TEST_ADMIN_ID = '33333333-3333-4333-8333-333333333333'
const TEST_BOOKING_ID = '66666666-6666-4666-8666-666666666666'
// ─── Mutable test-user state ─────────────────────────────────────────────────
let currentTestUser: JwtUserPayload = {
sub: TEST_PROVIDER_ID,
email: 'provider@test.com',
role: 'provider',
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 3600,
}
function setTestUser(overrides: Partial<JwtUserPayload>): void {
currentTestUser = { ...currentTestUser, ...overrides }
}
const mockGuard: CanActivate = {
canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest()
req.user = { ...currentTestUser }
return true
},
}
// ─── Test suite ───────────────────────────────────────────────────────────────
describe('IntelReportController (E2E)', () => {
let app: INestApplication
let dataSource: DataSource
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule],
})
.overrideGuard(JwtStandaloneGuard)
.useValue(mockGuard)
.overrideProvider(DomainEventsEmitter)
.useValue({
emit: vi.fn().mockResolvedValue(undefined),
subscribe: vi.fn(),
})
.overrideProvider(RedisService)
.useValue({
get: vi.fn().mockResolvedValue(null),
set: vi.fn().mockResolvedValue(undefined),
del: vi.fn().mockResolvedValue(undefined),
delPattern: vi.fn().mockResolvedValue(undefined),
isHealthy: vi.fn().mockReturnValue(true),
})
.compile()
app = moduleRef.createNestApplication()
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
)
app.setGlobalPrefix('api/client-intel')
await app.init()
dataSource = moduleRef.get<DataSource>(getDataSourceToken())
})
afterAll(async () => {
await app.close()
})
beforeEach(async () => {
await dataSource.query('TRUNCATE TABLE intel_reports CASCADE')
setTestUser({ sub: TEST_PROVIDER_ID, role: 'provider', email: 'provider@test.com' })
})
// ─── POST /reports ──────────────────────────────────────────────────────────
describe('POST /api/client-intel/reports', () => {
it('creates a report and returns 201 with the report payload', async () => {
const body = {
clientId: TEST_CLIENT_ID,
rating: 4,
comment: 'Polite and punctual.',
safetyFlags: [],
wouldWorkAgain: true,
visibility: IntelVisibility.COOP_ONLY,
}
const response = await request(app.getHttpServer())
.post('/api/client-intel/reports')
.send(body)
.expect(201)
expect(response.body).toMatchObject({
clientId: TEST_CLIENT_ID,
providerId: TEST_PROVIDER_ID,
rating: 4,
comment: 'Polite and punctual.',
safetyFlags: [],
wouldWorkAgain: true,
visibility: IntelVisibility.COOP_ONLY,
isVerified: false,
})
expect(response.body.id).toBeDefined()
expect(response.body.createdAt).toBeDefined()
})
it('marks a report as verified when a bookingId is supplied', async () => {
const body = {
clientId: TEST_CLIENT_ID,
rating: 5,
bookingId: TEST_BOOKING_ID,
}
const response = await request(app.getHttpServer())
.post('/api/client-intel/reports')
.send(body)
.expect(201)
expect(response.body.isVerified).toBe(true)
expect(response.body.bookingId).toBe(TEST_BOOKING_ID)
})
it('stores safety flags and returns them correctly', async () => {
const flags = [SafetyFlag.NO_SHOW, SafetyFlag.PAYMENT_ISSUE]
const response = await request(app.getHttpServer())
.post('/api/client-intel/reports')
.send({ clientId: TEST_CLIENT_ID, rating: 2, safetyFlags: flags })
.expect(201)
expect(response.body.safetyFlags).toEqual(expect.arrayContaining(flags))
expect(response.body.safetyFlags).toHaveLength(flags.length)
})
it('returns 400 when a provider attempts to report on themselves', async () => {
const response = await request(app.getHttpServer())
.post('/api/client-intel/reports')
.send({ clientId: TEST_PROVIDER_ID, rating: 3 })
.expect(400)
expect(response.body.message).toMatch(/yourself/)
})
it('returns 400 when the same provider submits a duplicate report for the same client without a bookingId', async () => {
const body = { clientId: TEST_CLIENT_ID, rating: 3 }
await request(app.getHttpServer())
.post('/api/client-intel/reports')
.send(body)
.expect(201)
const response = await request(app.getHttpServer())
.post('/api/client-intel/reports')
.send(body)
.expect(400)
expect(response.body.message).toMatch(/already reported/)
})
it('returns 400 when the same provider submits a duplicate report for the same client+booking combination', async () => {
const body = { clientId: TEST_CLIENT_ID, rating: 3, bookingId: TEST_BOOKING_ID }
await request(app.getHttpServer())
.post('/api/client-intel/reports')
.send(body)
.expect(201)
const response = await request(app.getHttpServer())
.post('/api/client-intel/reports')
.send(body)
.expect(400)
expect(response.body.message).toMatch(/already reported/)
})
it('returns 403 when a client-role user attempts to create a report', async () => {
setTestUser({ sub: TEST_CLIENT_ID, role: 'client', email: 'client@test.com' })
const response = await request(app.getHttpServer())
.post('/api/client-intel/reports')
.send({ clientId: TEST_PROVIDER_ID, rating: 3 })
.expect(403)
expect(response.body.message).toMatch(/providers/)
})
it('returns 400 when required fields are missing', async () => {
await request(app.getHttpServer())
.post('/api/client-intel/reports')
.send({ rating: 3 })
.expect(400)
})
it('returns 400 when rating is out of range', async () => {
await request(app.getHttpServer())
.post('/api/client-intel/reports')
.send({ clientId: TEST_CLIENT_ID, rating: 6 })
.expect(400)
})
})
// ─── GET /reports ───────────────────────────────────────────────────────────
describe('GET /api/client-intel/reports', () => {
it('returns a paginated list of reports', async () => {
await request(app.getHttpServer())
.post('/api/client-intel/reports')
.send({ clientId: TEST_CLIENT_ID, rating: 4 })
.expect(201)
const response = await request(app.getHttpServer())
.get('/api/client-intel/reports')
.expect(200)
expect(response.body.data).toHaveLength(1)
expect(response.body.meta).toMatchObject({
total: 1,
page: 1,
totalPages: 1,
})
})
it('filters reports by clientId query parameter', async () => {
const OTHER_CLIENT_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
await request(app.getHttpServer())
.post('/api/client-intel/reports')
.send({ clientId: TEST_CLIENT_ID, rating: 4 })
setTestUser({ sub: TEST_PROVIDER2_ID, role: 'provider', email: 'provider2@test.com' })
await request(app.getHttpServer())
.post('/api/client-intel/reports')
.send({ clientId: OTHER_CLIENT_ID, rating: 3 })
setTestUser({ sub: TEST_PROVIDER_ID, role: 'provider', email: 'provider@test.com' })
const response = await request(app.getHttpServer())
.get(`/api/client-intel/reports?clientId=${TEST_CLIENT_ID}`)
.expect(200)
expect(response.body.meta.total).toBe(1)
expect(response.body.data[0].clientId).toBe(TEST_CLIENT_ID)
})
it('returns 403 when a client-role user attempts to list reports', async () => {
setTestUser({ sub: TEST_CLIENT_ID, role: 'client', email: 'client@test.com' })
await request(app.getHttpServer())
.get('/api/client-intel/reports')
.expect(403)
})
})
// ─── GET /reports/:id ───────────────────────────────────────────────────────
describe('GET /api/client-intel/reports/:id', () => {
it('returns a single report by id', async () => {
const created = await request(app.getHttpServer())
.post('/api/client-intel/reports')
.send({ clientId: TEST_CLIENT_ID, rating: 5, comment: 'Excellent.' })
.expect(201)
const response = await request(app.getHttpServer())
.get(`/api/client-intel/reports/${created.body.id}`)
.expect(200)
expect(response.body.id).toBe(created.body.id)
expect(response.body.comment).toBe('Excellent.')
})
it('returns 404 for a non-existent report id', async () => {
await request(app.getHttpServer())
.get('/api/client-intel/reports/ffffffff-ffff-4fff-8fff-ffffffffffff')
.expect(404)
})
it('returns 403 when a client-role user attempts to fetch a report', async () => {
setTestUser({ sub: TEST_CLIENT_ID, role: 'client', email: 'client@test.com' })
await request(app.getHttpServer())
.get('/api/client-intel/reports/ffffffff-ffff-4fff-8fff-ffffffffffff')
.expect(403)
})
})
// ─── PATCH /reports/:id ─────────────────────────────────────────────────────
describe('PATCH /api/client-intel/reports/:id', () => {
it('allows the author to update rating, comment, safetyFlags, and wouldWorkAgain', async () => {
const created = await request(app.getHttpServer())
.post('/api/client-intel/reports')
.send({ clientId: TEST_CLIENT_ID, rating: 4, wouldWorkAgain: true })
.expect(201)
const response = await request(app.getHttpServer())
.patch(`/api/client-intel/reports/${created.body.id}`)
.send({
rating: 2,
comment: 'Updated comment.',
safetyFlags: [SafetyFlag.DISRESPECTFUL],
wouldWorkAgain: false,
})
.expect(200)
expect(response.body.rating).toBe(2)
expect(response.body.comment).toBe('Updated comment.')
expect(response.body.safetyFlags).toContain(SafetyFlag.DISRESPECTFUL)
expect(response.body.wouldWorkAgain).toBe(false)
})
it('returns 403 when a different provider attempts to update the report', async () => {
const created = await request(app.getHttpServer())
.post('/api/client-intel/reports')
.send({ clientId: TEST_CLIENT_ID, rating: 4 })
.expect(201)
setTestUser({ sub: TEST_PROVIDER2_ID, role: 'provider', email: 'provider2@test.com' })
await request(app.getHttpServer())
.patch(`/api/client-intel/reports/${created.body.id}`)
.send({ rating: 1 })
.expect(403)
})
it('returns 403 when a client-role user attempts to update a report', async () => {
setTestUser({ sub: TEST_CLIENT_ID, role: 'client', email: 'client@test.com' })
await request(app.getHttpServer())
.patch('/api/client-intel/reports/ffffffff-ffff-4fff-8fff-ffffffffffff')
.send({ rating: 1 })
.expect(403)
})
it('returns 404 when patching a non-existent report', async () => {
await request(app.getHttpServer())
.patch('/api/client-intel/reports/ffffffff-ffff-4fff-8fff-ffffffffffff')
.send({ rating: 3 })
.expect(404)
})
it('enforces the 30-day edit window by rejecting updates to old reports', async () => {
// Create the report, then back-date its createdAt to 31 days ago.
const created = await request(app.getHttpServer())
.post('/api/client-intel/reports')
.send({ clientId: TEST_CLIENT_ID, rating: 4 })
.expect(201)
const thirtyOneDaysAgo = new Date()
thirtyOneDaysAgo.setDate(thirtyOneDaysAgo.getDate() - 31)
await dataSource.query(
`UPDATE intel_reports SET "createdAt" = $1 WHERE id = $2`,
[thirtyOneDaysAgo, created.body.id],
)
const response = await request(app.getHttpServer())
.patch(`/api/client-intel/reports/${created.body.id}`)
.send({ rating: 1 })
.expect(403)
expect(response.body.message).toMatch(/30 days/)
})
})
// ─── DELETE /reports/:id ────────────────────────────────────────────────────
describe('DELETE /api/client-intel/reports/:id', () => {
it('allows the author to soft-delete a report and returns 204', async () => {
const created = await request(app.getHttpServer())
.post('/api/client-intel/reports')
.send({ clientId: TEST_CLIENT_ID, rating: 3 })
.expect(201)
await request(app.getHttpServer())
.delete(`/api/client-intel/reports/${created.body.id}`)
.expect(204)
// The deleted report must no longer be visible via GET.
await request(app.getHttpServer())
.get(`/api/client-intel/reports/${created.body.id}`)
.expect(404)
})
it('returns 403 when a different provider attempts to delete the report', async () => {
const created = await request(app.getHttpServer())
.post('/api/client-intel/reports')
.send({ clientId: TEST_CLIENT_ID, rating: 3 })
.expect(201)
setTestUser({ sub: TEST_PROVIDER2_ID, role: 'provider', email: 'provider2@test.com' })
await request(app.getHttpServer())
.delete(`/api/client-intel/reports/${created.body.id}`)
.expect(403)
})
it('returns 403 when a client-role user attempts to delete a report', async () => {
setTestUser({ sub: TEST_CLIENT_ID, role: 'client', email: 'client@test.com' })
await request(app.getHttpServer())
.delete('/api/client-intel/reports/ffffffff-ffff-4fff-8fff-ffffffffffff')
.expect(403)
})
it('returns 404 when deleting a non-existent report', async () => {
await request(app.getHttpServer())
.delete('/api/client-intel/reports/ffffffff-ffff-4fff-8fff-ffffffffffff')
.expect(404)
})
it('allows an admin-role user to delete a report', async () => {
const created = await request(app.getHttpServer())
.post('/api/client-intel/reports')
.send({ clientId: TEST_CLIENT_ID, rating: 3 })
.expect(201)
setTestUser({ sub: TEST_ADMIN_ID, role: 'admin', email: 'admin@test.com' })
// Admin does not own the report, so they get 403 (only author can delete).
// This confirms the authorship check fires independently of role.
await request(app.getHttpServer())
.delete(`/api/client-intel/reports/${created.body.id}`)
.expect(403)
})
})
})