462 lines
17 KiB
TypeScript
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)
|
|
})
|
|
})
|
|
})
|