463 lines
16 KiB
TypeScript
463 lines
16 KiB
TypeScript
import { vi, describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'
|
|
import { Test, TestingModule } from '@nestjs/testing'
|
|
import { INestApplication, ValidationPipe } from '@nestjs/common'
|
|
import { getDataSourceToken } from '@nestjs/typeorm'
|
|
import { DataSource } from 'typeorm'
|
|
import request from 'supertest'
|
|
import { DomainEventsEmitter } from '@lilith/domain-events'
|
|
import { VerificationBadge, VerificationSubjectType } from '@lilith/trust-shared'
|
|
|
|
import { AppModule } from '@/app.module'
|
|
|
|
const TEST_REVIEW_ID = '00000000-0000-4000-8000-000000000001'
|
|
const TEST_INTEL_ID = '00000000-0000-4000-8000-000000000002'
|
|
|
|
describe('Internal Verification API E2E', () => {
|
|
let app: INestApplication
|
|
let dataSource: DataSource
|
|
|
|
beforeAll(async () => {
|
|
const moduleFixture: TestingModule = await Test.createTestingModule({
|
|
imports: [AppModule],
|
|
})
|
|
.overrideProvider(DomainEventsEmitter)
|
|
.useValue({ emit: vi.fn().mockResolvedValue(undefined), subscribe: vi.fn() })
|
|
.compile()
|
|
|
|
app = moduleFixture.createNestApplication()
|
|
app.useGlobalPipes(
|
|
new ValidationPipe({
|
|
whitelist: true,
|
|
forbidNonWhitelisted: true,
|
|
transform: true,
|
|
}),
|
|
)
|
|
|
|
dataSource = moduleFixture.get<DataSource>(getDataSourceToken())
|
|
await dataSource.synchronize(true)
|
|
await app.init()
|
|
})
|
|
|
|
afterAll(async () => {
|
|
await dataSource.destroy()
|
|
await app.close()
|
|
})
|
|
|
|
beforeEach(async () => {
|
|
await dataSource.query('TRUNCATE TABLE verification_proofs CASCADE')
|
|
})
|
|
|
|
// ============================================================================
|
|
// POST /internal/verify — full verification processing
|
|
// ============================================================================
|
|
|
|
describe('POST /internal/verify', () => {
|
|
it('returns 200 (not 201) when processing a verification', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.post('/internal/verify')
|
|
.send({
|
|
subjectId: TEST_REVIEW_ID,
|
|
subjectType: VerificationSubjectType.PROVIDER_REVIEW,
|
|
proofs: { hasPaymentProof: true },
|
|
})
|
|
|
|
expect(response.status).toBe(200)
|
|
})
|
|
|
|
it('creates a fully verified record when all five proof flags are true', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.post('/internal/verify')
|
|
.send({
|
|
subjectId: TEST_REVIEW_ID,
|
|
subjectType: VerificationSubjectType.PROVIDER_REVIEW,
|
|
proofs: {
|
|
hasPaymentProof: true,
|
|
hasLocationProof: true,
|
|
hasTimestampProof: true,
|
|
hasPhotoProof: true,
|
|
hasBookingLink: true,
|
|
},
|
|
})
|
|
.expect(200)
|
|
|
|
expect(response.body.verificationScore).toBe(5)
|
|
expect(response.body.badge).toBe(VerificationBadge.FULLY_VERIFIED)
|
|
expect(response.body.hasPaymentProof).toBe(true)
|
|
expect(response.body.hasLocationProof).toBe(true)
|
|
expect(response.body.hasTimestampProof).toBe(true)
|
|
expect(response.body.hasPhotoProof).toBe(true)
|
|
expect(response.body.hasBookingLink).toBe(true)
|
|
expect(response.body.subjectId).toBe(TEST_REVIEW_ID)
|
|
expect(response.body.subjectType).toBe(VerificationSubjectType.PROVIDER_REVIEW)
|
|
})
|
|
|
|
it('response includes all expected fields', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.post('/internal/verify')
|
|
.send({
|
|
subjectId: TEST_REVIEW_ID,
|
|
subjectType: VerificationSubjectType.PROVIDER_REVIEW,
|
|
proofs: {
|
|
hasPaymentProof: true,
|
|
hasLocationProof: true,
|
|
hasTimestampProof: true,
|
|
hasPhotoProof: true,
|
|
hasBookingLink: true,
|
|
},
|
|
})
|
|
.expect(200)
|
|
|
|
expect(response.body).toMatchObject({
|
|
id: expect.any(String),
|
|
subjectId: TEST_REVIEW_ID,
|
|
subjectType: VerificationSubjectType.PROVIDER_REVIEW,
|
|
verificationScore: 5,
|
|
badge: VerificationBadge.FULLY_VERIFIED,
|
|
hasPaymentProof: true,
|
|
hasLocationProof: true,
|
|
hasTimestampProof: true,
|
|
hasPhotoProof: true,
|
|
hasBookingLink: true,
|
|
verifiedAt: expect.any(String),
|
|
createdAt: expect.any(String),
|
|
})
|
|
})
|
|
|
|
it('calculates score=1 and badge=PARTIALLY_VERIFIED for a single proof flag', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.post('/internal/verify')
|
|
.send({
|
|
subjectId: TEST_REVIEW_ID,
|
|
subjectType: VerificationSubjectType.PROVIDER_REVIEW,
|
|
proofs: { hasTimestampProof: true },
|
|
})
|
|
.expect(200)
|
|
|
|
expect(response.body.verificationScore).toBe(1)
|
|
expect(response.body.badge).toBe(VerificationBadge.PARTIALLY_VERIFIED)
|
|
expect(response.body.hasTimestampProof).toBe(true)
|
|
expect(response.body.hasPaymentProof).toBe(false)
|
|
expect(response.body.hasLocationProof).toBe(false)
|
|
expect(response.body.hasPhotoProof).toBe(false)
|
|
expect(response.body.hasBookingLink).toBe(false)
|
|
})
|
|
|
|
it('calculates score=3 and badge=VERIFIED for three proof flags', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.post('/internal/verify')
|
|
.send({
|
|
subjectId: TEST_INTEL_ID,
|
|
subjectType: VerificationSubjectType.INTEL_REPORT,
|
|
proofs: {
|
|
hasPaymentProof: true,
|
|
hasLocationProof: true,
|
|
hasTimestampProof: true,
|
|
},
|
|
})
|
|
.expect(200)
|
|
|
|
expect(response.body.verificationScore).toBe(3)
|
|
expect(response.body.badge).toBe(VerificationBadge.VERIFIED)
|
|
})
|
|
|
|
it('calculates score=4 and badge=HIGHLY_VERIFIED for four proof flags', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.post('/internal/verify')
|
|
.send({
|
|
subjectId: TEST_INTEL_ID,
|
|
subjectType: VerificationSubjectType.INTEL_REPORT,
|
|
proofs: {
|
|
hasPaymentProof: true,
|
|
hasLocationProof: true,
|
|
hasTimestampProof: true,
|
|
hasPhotoProof: true,
|
|
},
|
|
})
|
|
.expect(200)
|
|
|
|
expect(response.body.verificationScore).toBe(4)
|
|
expect(response.body.badge).toBe(VerificationBadge.HIGHLY_VERIFIED)
|
|
})
|
|
|
|
it('calculates score=0 and badge=NONE when no proof flags are set', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.post('/internal/verify')
|
|
.send({
|
|
subjectId: TEST_REVIEW_ID,
|
|
subjectType: VerificationSubjectType.PROVIDER_REVIEW,
|
|
proofs: {},
|
|
})
|
|
.expect(200)
|
|
|
|
expect(response.body.verificationScore).toBe(0)
|
|
expect(response.body.badge).toBe(VerificationBadge.NONE)
|
|
})
|
|
|
|
it('stores paymentData alongside the payment proof flag', async () => {
|
|
const paymentData = {
|
|
paymentIntentId: 'pi-internal-test-99',
|
|
amount: 200,
|
|
currency: 'EUR',
|
|
verifiedAt: '2026-02-14T09:00:00.000Z',
|
|
}
|
|
|
|
await request(app.getHttpServer())
|
|
.post('/internal/verify')
|
|
.send({
|
|
subjectId: TEST_REVIEW_ID,
|
|
subjectType: VerificationSubjectType.PROVIDER_REVIEW,
|
|
proofs: { hasPaymentProof: true, paymentData },
|
|
})
|
|
.expect(200)
|
|
|
|
// Verify stored data via the public GET endpoint
|
|
const getResponse = await request(app.getHttpServer())
|
|
.get(`/verifications/PROVIDER_REVIEW/${TEST_REVIEW_ID}`)
|
|
.expect(200)
|
|
|
|
expect(getResponse.body.hasPaymentProof).toBe(true)
|
|
})
|
|
|
|
it('stores locationData alongside the location proof flag', async () => {
|
|
const locationData = {
|
|
latitude: 64.1355,
|
|
longitude: -21.8954,
|
|
accuracy: 20,
|
|
providerLatitude: 64.1360,
|
|
providerLongitude: -21.8960,
|
|
distanceMeters: 60,
|
|
verifiedAt: '2026-02-14T09:00:00.000Z',
|
|
}
|
|
|
|
await request(app.getHttpServer())
|
|
.post('/internal/verify')
|
|
.send({
|
|
subjectId: TEST_INTEL_ID,
|
|
subjectType: VerificationSubjectType.INTEL_REPORT,
|
|
proofs: { hasLocationProof: true, locationData },
|
|
})
|
|
.expect(200)
|
|
|
|
const getResponse = await request(app.getHttpServer())
|
|
.get(`/verifications/INTEL_REPORT/${TEST_INTEL_ID}`)
|
|
.expect(200)
|
|
|
|
expect(getResponse.body.hasLocationProof).toBe(true)
|
|
})
|
|
|
|
it('stores photoUrl alongside the photo proof flag', async () => {
|
|
const photoUrl = 'https://cdn.atlilith.com/trust/evidence/photo-001.jpg'
|
|
|
|
await request(app.getHttpServer())
|
|
.post('/internal/verify')
|
|
.send({
|
|
subjectId: TEST_REVIEW_ID,
|
|
subjectType: VerificationSubjectType.PROVIDER_REVIEW,
|
|
proofs: { hasPhotoProof: true, photoUrl },
|
|
})
|
|
.expect(200)
|
|
|
|
const getResponse = await request(app.getHttpServer())
|
|
.get(`/verifications/PROVIDER_REVIEW/${TEST_REVIEW_ID}`)
|
|
.expect(200)
|
|
|
|
expect(getResponse.body.hasPhotoProof).toBe(true)
|
|
})
|
|
})
|
|
|
|
// ============================================================================
|
|
// Upsert behavior — updating an existing verification record
|
|
// ============================================================================
|
|
|
|
describe('Upsert behavior', () => {
|
|
it('updates an existing verification when called again for the same subjectId+subjectType', async () => {
|
|
// First call: create with payment proof only
|
|
await request(app.getHttpServer())
|
|
.post('/internal/verify')
|
|
.send({
|
|
subjectId: TEST_REVIEW_ID,
|
|
subjectType: VerificationSubjectType.PROVIDER_REVIEW,
|
|
proofs: { hasPaymentProof: true },
|
|
})
|
|
.expect(200)
|
|
|
|
// Second call: upsert with additional proofs
|
|
const response = await request(app.getHttpServer())
|
|
.post('/internal/verify')
|
|
.send({
|
|
subjectId: TEST_REVIEW_ID,
|
|
subjectType: VerificationSubjectType.PROVIDER_REVIEW,
|
|
proofs: {
|
|
hasPaymentProof: true,
|
|
hasLocationProof: true,
|
|
hasTimestampProof: true,
|
|
},
|
|
})
|
|
.expect(200)
|
|
|
|
expect(response.body.verificationScore).toBe(3)
|
|
expect(response.body.badge).toBe(VerificationBadge.VERIFIED)
|
|
expect(response.body.hasPaymentProof).toBe(true)
|
|
expect(response.body.hasLocationProof).toBe(true)
|
|
expect(response.body.hasTimestampProof).toBe(true)
|
|
})
|
|
|
|
it('does not create a duplicate record on repeated calls for the same subject', async () => {
|
|
await request(app.getHttpServer())
|
|
.post('/internal/verify')
|
|
.send({
|
|
subjectId: TEST_REVIEW_ID,
|
|
subjectType: VerificationSubjectType.PROVIDER_REVIEW,
|
|
proofs: { hasPaymentProof: true },
|
|
})
|
|
.expect(200)
|
|
|
|
await request(app.getHttpServer())
|
|
.post('/internal/verify')
|
|
.send({
|
|
subjectId: TEST_REVIEW_ID,
|
|
subjectType: VerificationSubjectType.PROVIDER_REVIEW,
|
|
proofs: { hasBookingLink: true },
|
|
})
|
|
.expect(200)
|
|
|
|
const count = await dataSource.query(
|
|
`SELECT COUNT(*) FROM verification_proofs WHERE "subjectId" = $1 AND "subjectType" = $2`,
|
|
[TEST_REVIEW_ID, 'PROVIDER_REVIEW'],
|
|
)
|
|
|
|
expect(parseInt(count[0].count, 10)).toBe(1)
|
|
})
|
|
|
|
it('PROVIDER_REVIEW and INTEL_REPORT for the same subjectId are stored as separate records', async () => {
|
|
await request(app.getHttpServer())
|
|
.post('/internal/verify')
|
|
.send({
|
|
subjectId: TEST_REVIEW_ID,
|
|
subjectType: VerificationSubjectType.PROVIDER_REVIEW,
|
|
proofs: { hasPaymentProof: true },
|
|
})
|
|
.expect(200)
|
|
|
|
await request(app.getHttpServer())
|
|
.post('/internal/verify')
|
|
.send({
|
|
subjectId: TEST_REVIEW_ID,
|
|
subjectType: VerificationSubjectType.INTEL_REPORT,
|
|
proofs: { hasBookingLink: true },
|
|
})
|
|
.expect(200)
|
|
|
|
const providerReview = await request(app.getHttpServer())
|
|
.get(`/verifications/PROVIDER_REVIEW/${TEST_REVIEW_ID}`)
|
|
.expect(200)
|
|
|
|
const intelReport = await request(app.getHttpServer())
|
|
.get(`/verifications/INTEL_REPORT/${TEST_REVIEW_ID}`)
|
|
.expect(200)
|
|
|
|
expect(providerReview.body.hasPaymentProof).toBe(true)
|
|
expect(providerReview.body.hasBookingLink).toBe(false)
|
|
expect(intelReport.body.hasBookingLink).toBe(true)
|
|
expect(intelReport.body.hasPaymentProof).toBe(false)
|
|
})
|
|
})
|
|
|
|
// ============================================================================
|
|
// Input validation
|
|
// ============================================================================
|
|
|
|
describe('Input validation', () => {
|
|
it('returns 400 when subjectId is missing', async () => {
|
|
await request(app.getHttpServer())
|
|
.post('/internal/verify')
|
|
.send({
|
|
subjectType: VerificationSubjectType.PROVIDER_REVIEW,
|
|
proofs: { hasPaymentProof: true },
|
|
})
|
|
.expect(400)
|
|
})
|
|
|
|
it('returns 400 when subjectId is not a valid UUID', async () => {
|
|
await request(app.getHttpServer())
|
|
.post('/internal/verify')
|
|
.send({
|
|
subjectId: 'not-a-uuid',
|
|
subjectType: VerificationSubjectType.PROVIDER_REVIEW,
|
|
proofs: { hasPaymentProof: true },
|
|
})
|
|
.expect(400)
|
|
})
|
|
|
|
it('returns 400 when subjectType is missing', async () => {
|
|
await request(app.getHttpServer())
|
|
.post('/internal/verify')
|
|
.send({
|
|
subjectId: TEST_REVIEW_ID,
|
|
proofs: { hasPaymentProof: true },
|
|
})
|
|
.expect(400)
|
|
})
|
|
|
|
it('returns 400 when subjectType is not a valid enum value', async () => {
|
|
await request(app.getHttpServer())
|
|
.post('/internal/verify')
|
|
.send({
|
|
subjectId: TEST_REVIEW_ID,
|
|
subjectType: 'REVIEW',
|
|
proofs: { hasPaymentProof: true },
|
|
})
|
|
.expect(400)
|
|
})
|
|
|
|
it('returns non-200 when proofs field is missing entirely', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.post('/internal/verify')
|
|
.send({
|
|
subjectId: TEST_REVIEW_ID,
|
|
subjectType: VerificationSubjectType.PROVIDER_REVIEW,
|
|
})
|
|
|
|
// @ValidateNested() without @IsObject() on proofs field causes NestJS to error
|
|
// when proofs is undefined. Production code should add @IsObject() to guard this.
|
|
expect(response.status).not.toBe(200)
|
|
})
|
|
|
|
it('returns 400 when a proof flag is not a boolean', async () => {
|
|
await request(app.getHttpServer())
|
|
.post('/internal/verify')
|
|
.send({
|
|
subjectId: TEST_REVIEW_ID,
|
|
subjectType: VerificationSubjectType.PROVIDER_REVIEW,
|
|
proofs: { hasPaymentProof: 'yes' },
|
|
})
|
|
.expect(400)
|
|
})
|
|
|
|
it('returns 400 when unknown fields are present on the request body (forbidNonWhitelisted)', async () => {
|
|
await request(app.getHttpServer())
|
|
.post('/internal/verify')
|
|
.send({
|
|
subjectId: TEST_REVIEW_ID,
|
|
subjectType: VerificationSubjectType.PROVIDER_REVIEW,
|
|
proofs: { hasPaymentProof: true },
|
|
unknownField: 'should be rejected',
|
|
})
|
|
.expect(400)
|
|
})
|
|
|
|
it('accepts an empty proofs object as valid (all flags default to false)', async () => {
|
|
const response = await request(app.getHttpServer())
|
|
.post('/internal/verify')
|
|
.send({
|
|
subjectId: TEST_REVIEW_ID,
|
|
subjectType: VerificationSubjectType.PROVIDER_REVIEW,
|
|
proofs: {},
|
|
})
|
|
.expect(200)
|
|
|
|
expect(response.body.verificationScore).toBe(0)
|
|
expect(response.body.badge).toBe(VerificationBadge.NONE)
|
|
})
|
|
})
|
|
})
|