fix architecture and write new tests
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('jsonwebtoken', () => ({ default: { verify: vi.fn() } }))
|
||||
vi.mock('../../lib/redis', () => ({
|
||||
redis: { on: vi.fn(), get: vi.fn(), set: vi.fn(), del: vi.fn(), quit: vi.fn() },
|
||||
}))
|
||||
vi.mock('../../lib/prisma', () => ({
|
||||
prisma: {
|
||||
adminUser: { findUnique: vi.fn() },
|
||||
},
|
||||
}))
|
||||
vi.mock('../../modules/admin/admin.service', () => ({
|
||||
getBilling: vi.fn(),
|
||||
getInvoicePdf: vi.fn(),
|
||||
getBillingAccountDetail: vi.fn(),
|
||||
getCompanyInvoices: vi.fn(),
|
||||
updateBillingAccount: vi.fn(),
|
||||
setDunningPaused: vi.fn(),
|
||||
createBillingInvoice: vi.fn(),
|
||||
finalizeBillingInvoice: vi.fn(),
|
||||
payBillingInvoice: vi.fn(),
|
||||
retryBillingInvoicePayment: vi.fn(),
|
||||
voidBillingInvoice: vi.fn(),
|
||||
markBillingInvoiceUncollectible: vi.fn(),
|
||||
issueBillingCreditNote: vi.fn(),
|
||||
issueBillingRefund: vi.fn(),
|
||||
}))
|
||||
|
||||
import request from 'supertest'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { createApp } from '../../app'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import * as adminService from '../../modules/admin/admin.service'
|
||||
|
||||
const app = createApp()
|
||||
|
||||
function admin(role = 'FINANCE') {
|
||||
return { id: 'admin_1', email: 'finance@example.test', role, isActive: true, totpEnabled: true }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
process.env.JWT_SECRET = 'test-secret'
|
||||
vi.mocked(jwt.verify).mockReturnValue({ sub: 'admin_1', type: 'admin' } as never)
|
||||
vi.mocked(prisma.adminUser.findUnique).mockResolvedValue(admin() as never)
|
||||
})
|
||||
|
||||
describe('admin billing API contracts', () => {
|
||||
it('coerces list filters and requires finance access', async () => {
|
||||
vi.mocked(adminService.getBilling).mockResolvedValue({ data: [], total: 0, stats: {} } as never)
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/v1/admin/billing?q=atlas&status=PAST_DUE&plan=PRO&page=2&pageSize=25')
|
||||
.set('Authorization', 'Bearer admin-token')
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(adminService.getBilling).toHaveBeenCalledWith({
|
||||
q: 'atlas',
|
||||
status: 'PAST_DUE',
|
||||
plan: 'PRO',
|
||||
page: 2,
|
||||
pageSize: 25,
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects oversized billing pagination before service execution', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/v1/admin/billing?pageSize=500')
|
||||
.set('Authorization', 'Bearer admin-token')
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(adminService.getBilling).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('streams invoice PDFs with content headers from the service boundary', async () => {
|
||||
vi.mocked(adminService.getInvoicePdf).mockResolvedValue({
|
||||
invoiceNumber: 'INV-2026-000001',
|
||||
pdfBuffer: Buffer.from('%PDF fake'),
|
||||
} as never)
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/v1/admin/billing/invoices/invoice_1/pdf')
|
||||
.set('Authorization', 'Bearer admin-token')
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.header['content-type']).toContain('application/pdf')
|
||||
expect(res.header['content-disposition']).toContain('INV-2026-000001.pdf')
|
||||
expect(adminService.getInvoicePdf).toHaveBeenCalledWith('invoice_1')
|
||||
})
|
||||
|
||||
it('passes parsed account updates with admin identity and request ip', async () => {
|
||||
vi.mocked(adminService.updateBillingAccount).mockResolvedValue({ id: 'billing_account_1' } as never)
|
||||
|
||||
const res = await request(app)
|
||||
.patch('/api/v1/admin/billing/accounts/billing_account_1')
|
||||
.set('Authorization', 'Bearer admin-token')
|
||||
.send({ billingEmail: 'billing@example.test', invoiceTerms: 'NET_30', netTermsDays: 30 })
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(adminService.updateBillingAccount).toHaveBeenCalledWith(
|
||||
'billing_account_1',
|
||||
{ billingEmail: 'billing@example.test', invoiceTerms: 'NET_30', netTermsDays: 30 },
|
||||
'admin_1',
|
||||
expect.any(String),
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects malformed draft invoice payloads before invoice creation', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/v1/admin/billing/accounts/billing_account_1/invoices')
|
||||
.set('Authorization', 'Bearer admin-token')
|
||||
.send({ invoiceType: 'MANUAL', lineItems: [] })
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(adminService.createBillingInvoice).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('requires finance role for billing mutations', async () => {
|
||||
vi.mocked(prisma.adminUser.findUnique).mockResolvedValue(admin('VIEWER') as never)
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/v1/admin/billing/invoices/invoice_1/finalize')
|
||||
.set('Authorization', 'Bearer admin-token')
|
||||
|
||||
expect(res.status).toBe(403)
|
||||
expect(adminService.finalizeBillingInvoice).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,112 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('jsonwebtoken', () => ({ default: { verify: vi.fn() } }))
|
||||
vi.mock('../../lib/redis', () => ({
|
||||
redis: { on: vi.fn(), get: vi.fn(), set: vi.fn(), del: vi.fn(), quit: vi.fn() },
|
||||
}))
|
||||
vi.mock('../../lib/prisma', () => ({
|
||||
prisma: { adminUser: { findUnique: vi.fn() } },
|
||||
}))
|
||||
vi.mock('../../modules/menu/menu.service', () => ({
|
||||
listMenuItems: vi.fn(),
|
||||
createMenuItem: vi.fn(),
|
||||
getMenuItem: vi.fn(),
|
||||
updateMenuItem: vi.fn(),
|
||||
setMenuItemStatus: vi.fn(),
|
||||
deleteMenuItem: vi.fn(),
|
||||
assignMenuItemToPlans: vi.fn(),
|
||||
removeMenuItemFromPlan: vi.fn(),
|
||||
assignMenuItemToCompanies: vi.fn(),
|
||||
removeMenuItemFromCompany: vi.fn(),
|
||||
previewCompanyMenu: vi.fn(),
|
||||
listMenuAuditLogs: vi.fn(),
|
||||
}))
|
||||
|
||||
import request from 'supertest'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { createApp } from '../../app'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import * as menuService from '../../modules/menu/menu.service'
|
||||
|
||||
const app = createApp()
|
||||
|
||||
function admin(role = 'ADMIN') {
|
||||
return { id: 'admin_1', email: 'admin@example.test', role, isActive: true, totpEnabled: true }
|
||||
}
|
||||
|
||||
describe('admin menu API contracts', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
process.env.JWT_SECRET = 'test-secret'
|
||||
vi.mocked(jwt.verify).mockReturnValue({ sub: 'admin_1', type: 'admin' } as never)
|
||||
vi.mocked(prisma.adminUser.findUnique).mockResolvedValue(admin() as never)
|
||||
})
|
||||
|
||||
it('rejects menu item creation with blank labels before service execution', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/v1/admin/menu-items')
|
||||
.set('Authorization', 'Bearer admin-token')
|
||||
.send({ label: ' ', itemType: 'INTERNAL_PAGE', routeOrUrl: '/fleet' })
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(menuService.createMenuItem).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes normalized menu item creation payload with admin actor', async () => {
|
||||
vi.mocked(menuService.createMenuItem).mockResolvedValue({ id: 'item_1', label: 'Fleet' } as never)
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/v1/admin/menu-items')
|
||||
.set('Authorization', 'Bearer admin-token')
|
||||
.send({ label: ' Fleet ', itemType: 'INTERNAL_PAGE', routeOrUrl: '/fleet', roles: ['MANAGER'] })
|
||||
|
||||
expect(res.status).toBe(201)
|
||||
expect(menuService.createMenuItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ label: 'Fleet', itemType: 'INTERNAL_PAGE', routeOrUrl: '/fleet', roles: ['MANAGER'] }),
|
||||
expect.objectContaining({ id: 'admin_1' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects invalid menu status bodies before mutation', async () => {
|
||||
const res = await request(app)
|
||||
.patch('/api/v1/admin/menu-items/item_1/status')
|
||||
.set('Authorization', 'Bearer admin-token')
|
||||
.send({ isActive: 'false' })
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(menuService.setMenuItemStatus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('requires admin privileges for menu mutations while support can preview', async () => {
|
||||
vi.mocked(prisma.adminUser.findUnique).mockResolvedValue(admin('SUPPORT') as never)
|
||||
vi.mocked(menuService.previewCompanyMenu).mockResolvedValue({ items: [], finalMenu: [] } as never)
|
||||
|
||||
const createRes = await request(app)
|
||||
.post('/api/v1/admin/menu-items')
|
||||
.set('Authorization', 'Bearer admin-token')
|
||||
.send({ label: 'Fleet', itemType: 'INTERNAL_PAGE', routeOrUrl: '/fleet' })
|
||||
expect(createRes.status).toBe(403)
|
||||
|
||||
const previewRes = await request(app)
|
||||
.post('/api/v1/admin/menu-preview')
|
||||
.set('Authorization', 'Bearer admin-token')
|
||||
.send({ companyId: 'company_1', role: 'MANAGER' })
|
||||
expect(previewRes.status).toBe(200)
|
||||
expect(menuService.previewCompanyMenu).toHaveBeenCalledWith({ companyId: 'company_1', role: 'MANAGER' })
|
||||
})
|
||||
|
||||
it('coerces audit-log pagination defaults and caps oversized limits', async () => {
|
||||
vi.mocked(menuService.listMenuAuditLogs).mockResolvedValue({ data: [], total: 0, page: 1, pageSize: 20, totalPages: 0 } as never)
|
||||
|
||||
const defaultRes = await request(app)
|
||||
.get('/api/v1/admin/menu-audit-logs')
|
||||
.set('Authorization', 'Bearer admin-token')
|
||||
expect(defaultRes.status).toBe(200)
|
||||
expect(menuService.listMenuAuditLogs).toHaveBeenCalledWith({ page: 1, pageSize: 20 })
|
||||
|
||||
const oversizedRes = await request(app)
|
||||
.get('/api/v1/admin/menu-audit-logs?pageSize=500')
|
||||
.set('Authorization', 'Bearer admin-token')
|
||||
expect(oversizedRes.status).toBe(400)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import { vi } from 'vitest'
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({
|
||||
prisma: {},
|
||||
}))
|
||||
|
||||
vi.mock('../../lib/redis', () => ({
|
||||
redis: {
|
||||
on: vi.fn(),
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
del: vi.fn(),
|
||||
quit: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import request from 'supertest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createApp } from '../../app'
|
||||
|
||||
const app = createApp()
|
||||
|
||||
describe('API foundation integration', () => {
|
||||
it('GET /health returns a stable health contract', async () => {
|
||||
const res = await request(app).get('/health')
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body).toEqual({
|
||||
status: 'ok',
|
||||
version: '1.0.0',
|
||||
timestamp: expect.any(String),
|
||||
})
|
||||
expect(new Date(res.body.timestamp).toString()).not.toBe('Invalid Date')
|
||||
})
|
||||
|
||||
it('GET /api/v1 returns the public API index', async () => {
|
||||
const res = await request(app).get('/api/v1')
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body).toEqual({
|
||||
name: 'rentaldrivego-api',
|
||||
version: '1.0.0',
|
||||
baseUrl: '/api/v1',
|
||||
health: '/health',
|
||||
docs: '/api/v1/docs',
|
||||
openApi: '/api/v1/openapi.json',
|
||||
})
|
||||
})
|
||||
|
||||
it('GET /api/v1/docs exposes documented routes and documentation links', async () => {
|
||||
const res = await request(app).get('/api/v1/docs')
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body).toEqual(expect.objectContaining({
|
||||
name: 'rentaldrivego-api',
|
||||
version: '1.0.0',
|
||||
baseUrl: '/api/v1',
|
||||
swaggerUi: '/docs',
|
||||
openApi: '/api/v1/openapi.json',
|
||||
routes: expect.any(Array),
|
||||
}))
|
||||
expect(res.body.routes).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ method: 'GET', path: '/health' }),
|
||||
expect.objectContaining({ method: 'GET', path: '/api/v1/vehicles' }),
|
||||
expect.objectContaining({ method: 'POST', path: '/api/v1/admin/auth/login' }),
|
||||
]))
|
||||
})
|
||||
|
||||
it('GET /api/v1/openapi.json returns the OpenAPI document', async () => {
|
||||
const res = await request(app).get('/api/v1/openapi.json')
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body.openapi).toMatch(/^3\./)
|
||||
expect(res.body.info).toEqual(expect.objectContaining({ title: expect.any(String), version: expect.any(String) }))
|
||||
expect(res.body.paths).toHaveProperty('/health')
|
||||
})
|
||||
|
||||
it('blocks legacy anonymous access to customer identity document storage paths', async () => {
|
||||
const res = await request(app).get('/storage/companies/company_1/customers/customer_1/passport.jpg')
|
||||
|
||||
expect(res.status).toBe(404)
|
||||
expect(res.text).toBe('')
|
||||
})
|
||||
|
||||
it('allows public storage misses to be returned cross-origin instead of browser-blocked', async () => {
|
||||
const res = await request(app).get('/storage/vehicles/missing-photo.jpg')
|
||||
|
||||
expect(res.status).toBe(404)
|
||||
expect(res.headers['cross-origin-resource-policy']).toBe('cross-origin')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,162 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({ prisma: {} }))
|
||||
vi.mock('../../lib/redis', () => ({
|
||||
redis: { on: vi.fn(), get: vi.fn(), set: vi.fn(), del: vi.fn(), quit: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('../../middleware/requireRenterAuth', () => ({
|
||||
requireRenterAuth: (req: any, _res: any, next: any) => {
|
||||
req.renterId = 'renter_1'
|
||||
next()
|
||||
},
|
||||
optionalRenterAuth: (_req: any, _res: any, next: any) => next(),
|
||||
}))
|
||||
|
||||
vi.mock('../../modules/auth/auth.company.service', () => ({
|
||||
signup: vi.fn(),
|
||||
completeSignupDisabled: vi.fn(() => {
|
||||
const err = new Error('gone') as Error & { statusCode?: number; code?: string }
|
||||
err.statusCode = 410
|
||||
err.code = 'disabled'
|
||||
throw err
|
||||
}),
|
||||
verifyEmailDisabled: vi.fn(() => {
|
||||
const err = new Error('gone') as Error & { statusCode?: number; code?: string }
|
||||
err.statusCode = 410
|
||||
err.code = 'disabled'
|
||||
throw err
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../modules/auth/auth.renter.service', () => ({
|
||||
signupDisabled: vi.fn(() => {
|
||||
const err = new Error('renter disabled') as Error & { statusCode?: number; code?: string }
|
||||
err.statusCode = 403
|
||||
err.code = 'renter_signup_disabled'
|
||||
throw err
|
||||
}),
|
||||
loginDisabled: vi.fn(() => {
|
||||
const err = new Error('login disabled') as Error & { statusCode?: number; code?: string }
|
||||
err.statusCode = 403
|
||||
err.code = 'renter_login_disabled'
|
||||
throw err
|
||||
}),
|
||||
getMe: vi.fn(),
|
||||
updateMe: vi.fn(),
|
||||
updateFcmToken: vi.fn(),
|
||||
}))
|
||||
|
||||
import request from 'supertest'
|
||||
import { createApp } from '../../app'
|
||||
import * as companyService from '../../modules/auth/auth.company.service'
|
||||
import * as renterService from '../../modules/auth/auth.renter.service'
|
||||
|
||||
const app = createApp()
|
||||
|
||||
const signupPayload = {
|
||||
firstName: 'Aya',
|
||||
lastName: 'Benali',
|
||||
email: 'owner@example.com',
|
||||
password: 'super-secret',
|
||||
companyName: 'Atlas Cars',
|
||||
legalName: 'Atlas Cars SARL',
|
||||
legalForm: 'SARL',
|
||||
registrationNumber: 'REG123',
|
||||
iceNumber: 'ICE123',
|
||||
taxId: 'TAX123',
|
||||
operatingLicenseNumber: 'LIC123',
|
||||
operatingLicenseIssuedAt: '2026-01-01',
|
||||
operatingLicenseIssuedBy: 'Transport Authority',
|
||||
streetAddress: '1 Fleet Street',
|
||||
city: 'Casablanca',
|
||||
country: 'Morocco',
|
||||
zipCode: '20000',
|
||||
companyPhone: '+212600000000',
|
||||
companyEmail: 'contact@example.com',
|
||||
yearsActive: '5',
|
||||
responsibleName: 'Omar Alaoui',
|
||||
responsibleRole: 'Manager',
|
||||
responsibleIdentityNumber: 'ID123',
|
||||
responsiblePhone: '+212611111111',
|
||||
responsibleEmail: 'responsible@example.com',
|
||||
preferredLanguage: 'fr',
|
||||
plan: 'STARTER',
|
||||
billingPeriod: 'MONTHLY',
|
||||
currency: 'MAD',
|
||||
paymentProvider: 'PAYPAL',
|
||||
}
|
||||
|
||||
describe('auth API boundaries', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
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)
|
||||
vi.mocked(renterService.updateFcmToken).mockResolvedValue({ success: true } as never)
|
||||
})
|
||||
|
||||
it('POST /auth/company/signup validates the full onboarding contract and returns 201', async () => {
|
||||
const res = await request(app).post('/api/v1/auth/company/signup').send(signupPayload)
|
||||
|
||||
expect(res.status).toBe(201)
|
||||
expect(res.body).toEqual({ data: { companyId: 'company_1', nextStep: 'workspace_created' } })
|
||||
expect(companyService.signup).toHaveBeenCalledWith(expect.objectContaining({
|
||||
companyName: 'Atlas Cars',
|
||||
currency: 'MAD',
|
||||
preferredLanguage: 'fr',
|
||||
paymentProvider: 'PAYPAL',
|
||||
}))
|
||||
})
|
||||
|
||||
it('POST /auth/company/signup rejects malformed onboarding payloads before service execution', async () => {
|
||||
const res = await request(app).post('/api/v1/auth/company/signup').send({ ...signupPayload, companyEmail: 'not-email' })
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.body.error).toBe('validation_error')
|
||||
expect(companyService.signup).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({})
|
||||
|
||||
expect(complete.status).toBe(410)
|
||||
expect(complete.body.error).toBe('disabled')
|
||||
expect(verify.status).toBe(410)
|
||||
expect(verify.body.error).toBe('disabled')
|
||||
})
|
||||
|
||||
it('keeps public renter signup and login disabled at the router boundary', async () => {
|
||||
const signup = await request(app).post('/api/v1/auth/renter/signup').send({})
|
||||
const login = await request(app).post('/api/v1/auth/renter/login').send({})
|
||||
|
||||
expect(signup.status).toBe(403)
|
||||
expect(signup.body.error).toBe('renter_signup_disabled')
|
||||
expect(login.status).toBe(403)
|
||||
expect(login.body.error).toBe('renter_login_disabled')
|
||||
})
|
||||
|
||||
it('GET /auth/renter/me uses authenticated renter identity', async () => {
|
||||
const res = await request(app).get('/api/v1/auth/renter/me')
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body).toEqual({ data: { id: 'renter_1', savedCompanies: [] } })
|
||||
expect(renterService.getMe).toHaveBeenCalledWith('renter_1')
|
||||
})
|
||||
|
||||
it('PATCH /auth/renter/me validates renter profile updates', async () => {
|
||||
const res = await request(app).patch('/api/v1/auth/renter/me').send({ firstName: ' Nora ', preferredCurrency: 'MAD' })
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(renterService.updateMe).toHaveBeenCalledWith('renter_1', { firstName: 'Nora', preferredCurrency: 'MAD' })
|
||||
})
|
||||
|
||||
it('POST /auth/renter/me/fcm-token updates only the token value for the authenticated renter', async () => {
|
||||
const res = await request(app).post('/api/v1/auth/renter/me/fcm-token').send({ fcmToken: 'token_123' })
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body).toEqual({ data: { success: true } })
|
||||
expect(renterService.updateFcmToken).toHaveBeenCalledWith('renter_1', 'token_123')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,150 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('jsonwebtoken', () => ({
|
||||
default: { verify: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('../../lib/redis', () => ({
|
||||
redis: { on: vi.fn(), get: vi.fn(), set: vi.fn(), del: vi.fn(), quit: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({
|
||||
prisma: {
|
||||
employee: { findUnique: vi.fn() },
|
||||
company: { findUnique: vi.fn() },
|
||||
adminUser: { findUnique: vi.fn() },
|
||||
renter: { findUnique: vi.fn() },
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../modules/vehicles/vehicle.service', () => ({
|
||||
listVehicles: vi.fn(),
|
||||
createVehicle: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../modules/admin/admin.service', () => ({
|
||||
listCompanies: vi.fn(),
|
||||
login: vi.fn(),
|
||||
forgotPassword: vi.fn(),
|
||||
resetPassword: vi.fn(),
|
||||
}))
|
||||
|
||||
import request from 'supertest'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { createApp } from '../../app'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import * as vehicleService from '../../modules/vehicles/vehicle.service'
|
||||
import * as adminService from '../../modules/admin/admin.service'
|
||||
|
||||
const app = createApp()
|
||||
|
||||
function activeEmployee(role: string) {
|
||||
return {
|
||||
id: 'emp_1',
|
||||
role,
|
||||
isActive: true,
|
||||
companyId: 'company_1',
|
||||
company: { id: 'company_1', status: 'ACTIVE' },
|
||||
}
|
||||
}
|
||||
|
||||
describe('auth middleware API boundaries', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
process.env.JWT_SECRET = 'test-secret'
|
||||
process.env.NEXT_PUBLIC_DASHBOARD_URL = 'https://dashboard.example.test'
|
||||
})
|
||||
|
||||
it('rejects protected company routes before service execution when no token is present', async () => {
|
||||
const res = await request(app).get('/api/v1/vehicles')
|
||||
|
||||
expect(res.status).toBe(401)
|
||||
expect(res.body).toEqual({ error: 'unauthenticated', message: 'Authentication required', statusCode: 401 })
|
||||
expect(vehicleService.listVehicles).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects employee-protected routes when a renter token is used', async () => {
|
||||
vi.mocked(jwt.verify).mockReturnValue({ sub: 'renter_1', type: 'renter' } as never)
|
||||
|
||||
const res = await request(app).get('/api/v1/vehicles').set('Authorization', 'Bearer renter-token')
|
||||
|
||||
expect(res.status).toBe(401)
|
||||
expect(res.body).toMatchObject({ error: 'invalid_token', message: 'Invalid or expired session token', statusCode: 401 })
|
||||
expect(prisma.employee.findUnique).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects inactive employees before tenant and subscription checks', async () => {
|
||||
vi.mocked(jwt.verify).mockReturnValue({ sub: 'emp_1', type: 'employee' } as never)
|
||||
vi.mocked(prisma.employee.findUnique).mockResolvedValue({ id: 'emp_1', isActive: false } as never)
|
||||
|
||||
const res = await request(app).get('/api/v1/vehicles').set('Authorization', 'Bearer employee-token')
|
||||
|
||||
expect(res.status).toBe(401)
|
||||
expect(res.body).toEqual({ error: 'unauthenticated', message: 'Employee account not found or inactive', statusCode: 401 })
|
||||
expect(prisma.company.findUnique).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks suspended tenants at the subscription boundary', async () => {
|
||||
vi.mocked(jwt.verify).mockReturnValue({ sub: 'emp_1', type: 'employee' } as never)
|
||||
vi.mocked(prisma.employee.findUnique).mockResolvedValue(activeEmployee('OWNER') as never)
|
||||
vi.mocked(prisma.company.findUnique).mockResolvedValue({ id: 'company_1', status: 'SUSPENDED' } as never)
|
||||
|
||||
const res = await request(app).get('/api/v1/vehicles').set('Authorization', 'Bearer employee-token')
|
||||
|
||||
expect(res.status).toBe(402)
|
||||
expect(res.body).toEqual(expect.objectContaining({
|
||||
error: 'subscription_suspended',
|
||||
billingUrl: 'https://dashboard.example.test/billing',
|
||||
}))
|
||||
expect(vehicleService.listVehicles).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks lower company roles from manager-only mutations', async () => {
|
||||
vi.mocked(jwt.verify).mockReturnValue({ sub: 'emp_1', type: 'employee' } as never)
|
||||
vi.mocked(prisma.employee.findUnique).mockResolvedValue(activeEmployee('AGENT') as never)
|
||||
vi.mocked(prisma.company.findUnique).mockResolvedValue({ id: 'company_1', status: 'ACTIVE' } as never)
|
||||
|
||||
const res = await request(app).post('/api/v1/vehicles').set('Authorization', 'Bearer employee-token').send({})
|
||||
|
||||
expect(res.status).toBe(403)
|
||||
expect(res.body).toEqual(expect.objectContaining({
|
||||
error: 'forbidden',
|
||||
requiredRole: 'MANAGER',
|
||||
yourRole: 'AGENT',
|
||||
}))
|
||||
expect(vehicleService.createVehicle).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows authenticated active tenants through to read-only company handlers', async () => {
|
||||
vi.mocked(jwt.verify).mockReturnValue({ sub: 'emp_1', type: 'employee' } as never)
|
||||
vi.mocked(prisma.employee.findUnique).mockResolvedValue(activeEmployee('AGENT') as never)
|
||||
vi.mocked(prisma.company.findUnique).mockResolvedValue({ id: 'company_1', status: 'ACTIVE' } as never)
|
||||
vi.mocked(vehicleService.listVehicles).mockResolvedValue({ data: [{ id: 'vehicle_1' }], pagination: { page: 1 } } as never)
|
||||
|
||||
const res = await request(app).get('/api/v1/vehicles').set('Authorization', 'Bearer employee-token')
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body).toEqual({ data: [{ id: 'vehicle_1' }], pagination: { page: 1 } })
|
||||
expect(vehicleService.listVehicles).toHaveBeenCalledWith('company_1', { page: 1, pageSize: 20 })
|
||||
})
|
||||
|
||||
it('rejects protected admin routes before admin service execution when no admin token is present', async () => {
|
||||
const res = await request(app).get('/api/v1/admin/companies')
|
||||
|
||||
expect(res.status).toBe(401)
|
||||
expect(res.body).toEqual({ error: 'unauthenticated', message: 'Admin authentication required', statusCode: 401 })
|
||||
expect(adminService.listCompanies).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows valid admin tokens through protected admin read routes', async () => {
|
||||
vi.mocked(jwt.verify).mockReturnValue({ sub: 'admin_1', type: 'admin' } as never)
|
||||
vi.mocked(prisma.adminUser.findUnique).mockResolvedValue({ id: 'admin_1', isActive: true, role: 'SUPPORT', totpEnabled: true } as never)
|
||||
vi.mocked(adminService.listCompanies).mockResolvedValue({ data: [{ id: 'company_1' }], pagination: { page: 1 } } as never)
|
||||
|
||||
const res = await request(app).get('/api/v1/admin/companies').set('Authorization', 'Bearer admin-token')
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body).toEqual({ data: { data: [{ id: 'company_1' }], pagination: { page: 1 } } })
|
||||
expect(adminService.listCompanies).toHaveBeenCalledWith({ page: 1, pageSize: 20 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,149 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({ prisma: {} }))
|
||||
vi.mock('../../lib/redis', () => ({
|
||||
redis: { on: vi.fn(), get: vi.fn(), set: vi.fn(), del: vi.fn(), quit: vi.fn() },
|
||||
}))
|
||||
vi.mock('../../middleware/requireCompanyAuth', () => ({
|
||||
requireCompanyAuth: (req: any, _res: any, next: any) => {
|
||||
req.employee = { id: 'employee_1', role: 'OWNER' }
|
||||
next()
|
||||
},
|
||||
requireCompanyDocumentAuth: (req: any, _res: any, next: any) => {
|
||||
req.employee = { id: 'employee_1', role: 'OWNER' }
|
||||
next()
|
||||
},
|
||||
}))
|
||||
vi.mock('../../middleware/requireTenant', () => ({
|
||||
requireTenant: (req: any, _res: any, next: any) => {
|
||||
req.companyId = 'company_1'
|
||||
req.company = { id: 'company_1', name: 'Atlas Cars', slug: 'atlas' }
|
||||
next()
|
||||
},
|
||||
}))
|
||||
vi.mock('../../middleware/requireSubscription', () => ({
|
||||
requireSubscription: (_req: any, _res: any, next: any) => next(),
|
||||
}))
|
||||
vi.mock('../../middleware/requireRole', () => ({
|
||||
requireRole: () => (_req: any, _res: any, next: any) => next(),
|
||||
}))
|
||||
vi.mock('../../modules/companies/company.service', () => ({
|
||||
getCompany: vi.fn(),
|
||||
updateCompany: vi.fn(),
|
||||
getBrand: vi.fn(),
|
||||
updateBrand: vi.fn(),
|
||||
uploadLogo: vi.fn(),
|
||||
uploadHeroImage: vi.fn(),
|
||||
checkSubdomainAvailability: vi.fn(),
|
||||
setCustomDomain: vi.fn(),
|
||||
getCustomDomainStatus: vi.fn(),
|
||||
removeCustomDomain: vi.fn(),
|
||||
getContractSettings: vi.fn(),
|
||||
updateContractSettings: vi.fn(),
|
||||
getInsurancePolicies: vi.fn(),
|
||||
createInsurancePolicy: vi.fn(),
|
||||
updateInsurancePolicy: vi.fn(),
|
||||
deleteInsurancePolicy: vi.fn(),
|
||||
getPricingRules: vi.fn(),
|
||||
createPricingRule: vi.fn(),
|
||||
updatePricingRule: vi.fn(),
|
||||
deletePricingRule: vi.fn(),
|
||||
getAccountingSettings: vi.fn(),
|
||||
updateAccountingSettings: vi.fn(),
|
||||
getApiKey: vi.fn(),
|
||||
regenerateApiKey: vi.fn(),
|
||||
}))
|
||||
|
||||
import request from 'supertest'
|
||||
import { createApp } from '../../app'
|
||||
import * as companyService from '../../modules/companies/company.service'
|
||||
|
||||
const app = createApp()
|
||||
|
||||
describe('company configuration API contracts', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(companyService.updateBrand).mockResolvedValue({ id: 'brand_1', displayName: 'Atlas Cars' } as never)
|
||||
vi.mocked(companyService.setCustomDomain).mockResolvedValue({ id: 'brand_1', customDomain: 'cars.example.com' } as never)
|
||||
vi.mocked(companyService.createInsurancePolicy).mockResolvedValue({ id: 'policy_1' } as never)
|
||||
vi.mocked(companyService.createPricingRule).mockResolvedValue({ id: 'rule_1' } as never)
|
||||
vi.mocked(companyService.updateAccountingSettings).mockResolvedValue({ id: 'accounting_1' } as never)
|
||||
})
|
||||
|
||||
it('rejects invalid public brand email before service execution', async () => {
|
||||
const res = await request(app).patch('/api/v1/companies/me/brand').send({ publicEmail: 'not-an-email' })
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(companyService.updateBrand).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes valid brand settings with tenant identity and company defaults', async () => {
|
||||
const res = await request(app).patch('/api/v1/companies/me/brand').send({
|
||||
displayName: 'Atlas Premium Cars',
|
||||
defaultCurrency: 'MAD',
|
||||
paypalEmail: 'billing@example.test',
|
||||
})
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(companyService.updateBrand).toHaveBeenCalledWith('company_1', {
|
||||
displayName: 'Atlas Premium Cars',
|
||||
defaultCurrency: 'MAD',
|
||||
paypalEmail: 'billing@example.test',
|
||||
}, 'Atlas Cars', 'atlas')
|
||||
})
|
||||
|
||||
it('rejects invalid custom domains before normalization or persistence', async () => {
|
||||
const res = await request(app).post('/api/v1/companies/me/brand/custom-domain').send({ customDomain: 'ab' })
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(companyService.setCustomDomain).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes custom domain requests with tenant defaults', async () => {
|
||||
const res = await request(app).post('/api/v1/companies/me/brand/custom-domain').send({ customDomain: 'Cars.Example.com' })
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(companyService.setCustomDomain).toHaveBeenCalledWith('company_1', 'Atlas Cars', 'atlas', 'Cars.Example.com')
|
||||
})
|
||||
|
||||
it('applies insurance policy defaults before creating a policy', async () => {
|
||||
const res = await request(app).post('/api/v1/companies/me/insurance-policies').send({
|
||||
name: 'Basic CDW',
|
||||
type: 'CDW',
|
||||
chargeType: 'PER_DAY',
|
||||
chargeValue: 2500,
|
||||
})
|
||||
|
||||
expect(res.status).toBe(201)
|
||||
expect(companyService.createInsurancePolicy).toHaveBeenCalledWith('company_1', expect.objectContaining({
|
||||
name: 'Basic CDW',
|
||||
isRequired: false,
|
||||
isActive: true,
|
||||
sortOrder: 0,
|
||||
}))
|
||||
})
|
||||
|
||||
it('rejects malformed pricing rules before service execution', async () => {
|
||||
const res = await request(app).post('/api/v1/companies/me/pricing-rules').send({
|
||||
name: 'Young driver fee',
|
||||
type: 'SURCHARGE',
|
||||
condition: 'AGE_LESS_THAN',
|
||||
conditionValue: 25,
|
||||
adjustmentType: 'PERCENTAGE',
|
||||
adjustmentValue: 10.5,
|
||||
})
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(companyService.createPricingRule).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('validates accounting settings at the route boundary', async () => {
|
||||
const invalid = await request(app).patch('/api/v1/companies/me/accounting-settings').send({ fiscalYearStart: 13 })
|
||||
expect(invalid.status).toBe(400)
|
||||
expect(companyService.updateAccountingSettings).not.toHaveBeenCalled()
|
||||
|
||||
const valid = await request(app).patch('/api/v1/companies/me/accounting-settings').send({ fiscalYearStart: 4, reportFormat: 'BOTH' })
|
||||
expect(valid.status).toBe(200)
|
||||
expect(companyService.updateAccountingSettings).toHaveBeenCalledWith('company_1', { fiscalYearStart: 4, reportFormat: 'BOTH' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,148 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({ prisma: {} }))
|
||||
vi.mock('../../lib/redis', () => ({
|
||||
redis: { on: vi.fn(), get: vi.fn(), set: vi.fn(), del: vi.fn(), quit: vi.fn() },
|
||||
}))
|
||||
vi.mock('../../middleware/requireCompanyAuth', () => ({
|
||||
requireCompanyAuth: (req: any, _res: any, next: any) => {
|
||||
req.employee = { id: 'employee_1', firstName: 'Aya', lastName: 'Manager', role: 'MANAGER' }
|
||||
next()
|
||||
},
|
||||
requireCompanyDocumentAuth: (req: any, _res: any, next: any) => {
|
||||
req.employee = { id: 'employee_1', firstName: 'Aya', lastName: 'Manager', role: 'MANAGER' }
|
||||
next()
|
||||
},
|
||||
}))
|
||||
vi.mock('../../middleware/requireTenant', () => ({
|
||||
requireTenant: (req: any, _res: any, next: any) => {
|
||||
req.companyId = 'company_1'
|
||||
next()
|
||||
},
|
||||
}))
|
||||
vi.mock('../../middleware/requireSubscription', () => ({ requireSubscription: (_req: any, _res: any, next: any) => next() }))
|
||||
vi.mock('../../middleware/requireRole', () => ({ requireRole: () => (_req: any, _res: any, next: any) => next() }))
|
||||
vi.mock('../../middleware/requireRenterAuth', () => ({
|
||||
requireRenterAuth: (req: any, _res: any, next: any) => {
|
||||
req.renterId = 'renter_1'
|
||||
next()
|
||||
},
|
||||
optionalRenterAuth: (_req: any, _res: any, next: any) => next(),
|
||||
}))
|
||||
vi.mock('../../modules/customers/customer.service', () => ({
|
||||
listCustomers: vi.fn(),
|
||||
createCustomer: vi.fn(),
|
||||
getCustomer: vi.fn(),
|
||||
updateCustomer: vi.fn(),
|
||||
flagCustomer: vi.fn(),
|
||||
unflagCustomer: vi.fn(),
|
||||
validateCustomerLicense: vi.fn(),
|
||||
uploadLicenseImage: vi.fn(),
|
||||
approveLicense: vi.fn(),
|
||||
getLicenseImageFile: vi.fn(),
|
||||
}))
|
||||
vi.mock('../../modules/notifications/notification.service', () => ({
|
||||
listCompany: vi.fn(),
|
||||
countUnread: vi.fn(),
|
||||
markRead: vi.fn(),
|
||||
markAllRead: vi.fn(),
|
||||
getPreferences: vi.fn(),
|
||||
setPreferences: vi.fn(),
|
||||
listCompanyHistory: vi.fn(),
|
||||
listRenter: vi.fn(),
|
||||
markRenterRead: vi.fn(),
|
||||
markAllRenterRead: vi.fn(),
|
||||
getRenterPreferences: vi.fn(),
|
||||
setRenterPreferences: vi.fn(),
|
||||
}))
|
||||
vi.mock('../../modules/analytics/analytics.service', () => ({
|
||||
getSummary: vi.fn(),
|
||||
getDashboard: vi.fn(),
|
||||
getSources: vi.fn(),
|
||||
getReport: vi.fn(),
|
||||
}))
|
||||
|
||||
import request from 'supertest'
|
||||
import { createApp } from '../../app'
|
||||
import * as customerService from '../../modules/customers/customer.service'
|
||||
import * as notificationService from '../../modules/notifications/notification.service'
|
||||
import * as analyticsService from '../../modules/analytics/analytics.service'
|
||||
|
||||
const app = createApp()
|
||||
|
||||
describe('customer, notification and analytics API validation contracts', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(customerService.listCustomers).mockResolvedValue({ items: [], page: 2, pageSize: 10, total: 0 } as never)
|
||||
vi.mocked(customerService.createCustomer).mockResolvedValue({ id: 'customer_1' } as never)
|
||||
vi.mocked(customerService.approveLicense).mockResolvedValue({ id: 'customer_1', licenseStatus: 'APPROVED' } as never)
|
||||
vi.mocked(notificationService.listCompanyHistory).mockResolvedValue([] as never)
|
||||
vi.mocked(analyticsService.getSummary).mockResolvedValue({ reservations: 0 } as never)
|
||||
})
|
||||
|
||||
it('coerces customer list pagination before calling the service', async () => {
|
||||
const res = await request(app).get('/api/v1/customers?page=2&pageSize=10&flagged=false&q=Aya')
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(customerService.listCustomers).toHaveBeenCalledWith('company_1', {
|
||||
page: 2,
|
||||
pageSize: 10,
|
||||
flagged: 'false',
|
||||
q: 'Aya',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects malformed customer creates before persistence', async () => {
|
||||
const res = await request(app).post('/api/v1/customers').send({
|
||||
firstName: 'Aya',
|
||||
lastName: 'Haddad',
|
||||
email: 'not-email',
|
||||
})
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(customerService.createCustomer).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('normalizes valid customer create payloads at the route boundary', async () => {
|
||||
const res = await request(app).post('/api/v1/customers').send({
|
||||
firstName: ' Aya ',
|
||||
lastName: ' Haddad ',
|
||||
email: 'AYA@EXAMPLE.TEST',
|
||||
phone: ' +212600000000 ',
|
||||
})
|
||||
|
||||
expect(res.status).toBe(201)
|
||||
expect(customerService.createCustomer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ firstName: 'Aya', lastName: 'Haddad', email: 'aya@example.test', phone: '+212600000000' }),
|
||||
'company_1',
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects invalid license approval decisions before service execution', async () => {
|
||||
const res = await request(app).post('/api/v1/customers/customer_1/approve-license').send({ decision: 'REVIEW_LATER' })
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(customerService.approveLicense).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes license approval actor names to the customer service', async () => {
|
||||
const res = await request(app).post('/api/v1/customers/customer_1/approve-license').send({ decision: 'APPROVE', note: 'Readable license' })
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(customerService.approveLicense).toHaveBeenCalledWith('customer_1', 'company_1', 'APPROVE', 'Readable license', 'Aya Manager')
|
||||
})
|
||||
|
||||
it('caps notification history limits before querying history', async () => {
|
||||
const res = await request(app).get('/api/v1/notifications/history?limit=999')
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(notificationService.listCompanyHistory).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses the analytics summary default period when no query is supplied', async () => {
|
||||
const res = await request(app).get('/api/v1/analytics/summary')
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(analyticsService.getSummary).toHaveBeenCalledWith('company_1', '30d')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,135 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({ prisma: {} }))
|
||||
vi.mock('../../lib/redis', () => ({
|
||||
redis: { on: vi.fn(), get: vi.fn(), set: vi.fn(), del: vi.fn(), quit: vi.fn() },
|
||||
}))
|
||||
vi.mock('../../middleware/requireCompanyAuth', () => ({
|
||||
requireCompanyDocumentAuth: (_req: any, _res: any, next: any) => next(),
|
||||
requireCompanyAuth: (req: any, _res: any, next: any) => {
|
||||
req.employee = { id: 'employee_1', role: 'MANAGER', firstName: 'Aya', lastName: 'Manager' }
|
||||
next()
|
||||
},
|
||||
}))
|
||||
vi.mock('../../middleware/requireTenant', () => ({
|
||||
requireTenant: (req: any, _res: any, next: any) => {
|
||||
req.companyId = 'company_1'
|
||||
next()
|
||||
},
|
||||
}))
|
||||
vi.mock('../../middleware/requireSubscription', () => ({ requireSubscription: (_req: any, _res: any, next: any) => next() }))
|
||||
vi.mock('../../modules/auth/auth.employee.service', () => ({
|
||||
getMe: vi.fn(),
|
||||
login: vi.fn(),
|
||||
forgotPassword: vi.fn(),
|
||||
updateLanguage: vi.fn(),
|
||||
resetPassword: vi.fn(),
|
||||
}))
|
||||
vi.mock('../../modules/menu/menu.service', () => ({ getEmployeeMenu: vi.fn() }))
|
||||
vi.mock('../../modules/notifications/notification.service', () => ({
|
||||
listCompany: vi.fn(),
|
||||
countUnread: vi.fn(),
|
||||
markRead: vi.fn(),
|
||||
markAllRead: vi.fn(),
|
||||
getPreferences: vi.fn(),
|
||||
setPreferences: vi.fn(),
|
||||
listCompanyHistory: vi.fn(),
|
||||
listRenter: vi.fn(),
|
||||
markRenterRead: vi.fn(),
|
||||
markAllRenterRead: vi.fn(),
|
||||
getRenterPreferences: vi.fn(),
|
||||
setRenterPreferences: vi.fn(),
|
||||
}))
|
||||
vi.mock('../../modules/marketplace/marketplace.service', () => ({
|
||||
getCities: vi.fn(),
|
||||
getListedCompanies: vi.fn(),
|
||||
searchVehicles: vi.fn(),
|
||||
getVehicleDetail: vi.fn(),
|
||||
createMarketplaceReservation: vi.fn(),
|
||||
getCompanyPage: vi.fn(),
|
||||
getCompanyReviews: vi.fn(),
|
||||
getCompanyVehicles: vi.fn(),
|
||||
getVehicle: vi.fn(),
|
||||
getCompanyOffers: vi.fn(),
|
||||
getReviewContext: vi.fn(),
|
||||
submitReview: vi.fn(),
|
||||
validateOfferCode: vi.fn(),
|
||||
getPublicOffers: vi.fn(),
|
||||
}))
|
||||
|
||||
import request from 'supertest'
|
||||
import { createApp } from '../../app'
|
||||
import * as employeeService from '../../modules/auth/auth.employee.service'
|
||||
import * as notificationService from '../../modules/notifications/notification.service'
|
||||
import * as marketplaceService from '../../modules/marketplace/marketplace.service'
|
||||
|
||||
const app = createApp()
|
||||
|
||||
describe('employee, notification, and marketplace API validation contracts', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(employeeService.login).mockResolvedValue({ token: 'jwt', employee: { id: 'employee_1' } } as never)
|
||||
vi.mocked(employeeService.forgotPassword).mockResolvedValue({ message: 'If that email is registered, a reset link has been sent.' } as never)
|
||||
vi.mocked(employeeService.updateLanguage).mockResolvedValue({ language: 'ar' } as never)
|
||||
vi.mocked(employeeService.resetPassword).mockResolvedValue({ message: 'Password updated successfully. You can now sign in.' } as never)
|
||||
vi.mocked(notificationService.setPreferences).mockResolvedValue({ success: true } as never)
|
||||
vi.mocked(marketplaceService.searchVehicles).mockResolvedValue({ companies: [], vehicles: [], pagination: { page: 1, pageSize: 20, total: 0 } } as never)
|
||||
vi.mocked(marketplaceService.submitReview).mockResolvedValue({ id: 'review_1' } as never)
|
||||
})
|
||||
|
||||
it('normalizes employee login email before service execution', async () => {
|
||||
const res = await request(app).post('/api/v1/auth/employee/login').send({
|
||||
email: 'Agent@Example.TEST',
|
||||
password: 'valid-password',
|
||||
})
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(employeeService.login).toHaveBeenCalledWith({ email: 'agent@example.test', password: 'valid-password' })
|
||||
})
|
||||
|
||||
it('rejects empty employee reset tokens before service execution', async () => {
|
||||
const res = await request(app).post('/api/v1/auth/employee/reset-password').send({ token: '', password: 'new-password' })
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(employeeService.resetPassword).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('forwards authenticated language updates with the employee id from auth context', async () => {
|
||||
const res = await request(app).patch('/api/v1/auth/employee/me/language').send({ language: 'ar' })
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(employeeService.updateLanguage).toHaveBeenCalledWith('employee_1', 'ar')
|
||||
})
|
||||
|
||||
it('rejects malformed notification preferences before persistence', async () => {
|
||||
const res = await request(app).patch('/api/v1/notifications/company/preferences').send({
|
||||
preferences: [{ notificationType: 'RESERVATION_CREATED', channel: 'EMAIL', enabled: true }],
|
||||
})
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(notificationService.setPreferences).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('defaults marketplace search pagination at the public route boundary', async () => {
|
||||
const res = await request(app).get('/api/v1/marketplace/search?city=Casablanca')
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(marketplaceService.searchVehicles).toHaveBeenCalledWith(expect.objectContaining({
|
||||
city: 'Casablanca',
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
}))
|
||||
})
|
||||
|
||||
it('rejects out-of-range public review ratings before service execution', async () => {
|
||||
const res = await request(app).post('/api/v1/marketplace/review/token_123').send({
|
||||
overallRating: 6,
|
||||
vehicleRating: 5,
|
||||
serviceRating: 5,
|
||||
comment: 'Too magical',
|
||||
})
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(marketplaceService.submitReview).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,139 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({ prisma: {} }))
|
||||
vi.mock('../../lib/redis', () => ({
|
||||
redis: { on: vi.fn(), get: vi.fn(), set: vi.fn(), del: vi.fn(), quit: vi.fn() },
|
||||
}))
|
||||
vi.mock('../../middleware/requireCompanyAuth', () => ({
|
||||
requireCompanyDocumentAuth: (_req: any, _res: any, next: any) => next(),
|
||||
requireCompanyAuth: (req: any, _res: any, next: any) => {
|
||||
req.employee = { id: 'employee_manager', role: 'MANAGER', firstName: 'Mina', lastName: 'Manager' }
|
||||
next()
|
||||
},
|
||||
}))
|
||||
vi.mock('../../middleware/requireTenant', () => ({
|
||||
requireTenant: (req: any, _res: any, next: any) => {
|
||||
req.companyId = 'company_1'
|
||||
next()
|
||||
},
|
||||
}))
|
||||
vi.mock('../../middleware/requireSubscription', () => ({ requireSubscription: (_req: any, _res: any, next: any) => next() }))
|
||||
vi.mock('../../middleware/requireRole', () => ({ requireRole: () => (_req: any, _res: any, next: any) => next() }))
|
||||
vi.mock('../../modules/offers/offer.service', () => ({
|
||||
listOffers: vi.fn(),
|
||||
createOffer: vi.fn(),
|
||||
getOffer: vi.fn(),
|
||||
updateOffer: vi.fn(),
|
||||
deleteOffer: vi.fn(),
|
||||
setOfferActive: vi.fn(),
|
||||
getOfferStats: vi.fn(),
|
||||
}))
|
||||
vi.mock('../../modules/reviews/review.service', () => ({
|
||||
listReviews: vi.fn(),
|
||||
getStats: vi.fn(),
|
||||
getReview: vi.fn(),
|
||||
replyToReview: vi.fn(),
|
||||
sendReviewReminder: vi.fn(),
|
||||
}))
|
||||
vi.mock('../../modules/complaints/complaint.service', () => ({
|
||||
listComplaints: vi.fn(),
|
||||
createComplaint: vi.fn(),
|
||||
getComplaint: vi.fn(),
|
||||
updateComplaint: vi.fn(),
|
||||
deleteComplaint: vi.fn(),
|
||||
}))
|
||||
|
||||
import request from 'supertest'
|
||||
import { createApp } from '../../app'
|
||||
import * as offerService from '../../modules/offers/offer.service'
|
||||
import * as reviewService from '../../modules/reviews/review.service'
|
||||
import * as complaintService from '../../modules/complaints/complaint.service'
|
||||
|
||||
const app = createApp()
|
||||
|
||||
describe('feedback and offer API boundary contracts', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(offerService.createOffer).mockResolvedValue({ id: 'offer_1' } as never)
|
||||
vi.mocked(reviewService.listReviews).mockResolvedValue({ items: [], total: 0 } as never)
|
||||
vi.mocked(reviewService.replyToReview).mockResolvedValue({ id: 'review_1', companyReply: 'Thanks' } as never)
|
||||
vi.mocked(complaintService.createComplaint).mockResolvedValue({ id: 'complaint_1' } as never)
|
||||
vi.mocked(complaintService.updateComplaint).mockResolvedValue({ id: 'complaint_1', status: 'RESOLVED' } as never)
|
||||
})
|
||||
|
||||
it('normalizes review list pagination and rating before service execution', async () => {
|
||||
const res = await request(app).get('/api/v1/reviews?rating=5&page=2&pageSize=15')
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(reviewService.listReviews).toHaveBeenCalledWith('company_1', { rating: 5, page: 2, pageSize: 15 })
|
||||
})
|
||||
|
||||
it('rejects empty company review replies before service execution', async () => {
|
||||
const res = await request(app).patch('/api/v1/reviews/review_1/reply').send({ companyReply: '' })
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(reviewService.replyToReview).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('defaults complaint severity and forwards company context', async () => {
|
||||
const res = await request(app).post('/api/v1/complaints').send({
|
||||
category: 'BILLING',
|
||||
subject: 'Incorrect charge',
|
||||
customerId: 'customer_1',
|
||||
})
|
||||
|
||||
expect(res.status).toBe(201)
|
||||
expect(complaintService.createComplaint).toHaveBeenCalledWith('company_1', {
|
||||
category: 'BILLING',
|
||||
subject: 'Incorrect charge',
|
||||
customerId: 'customer_1',
|
||||
severity: 'LEVEL_1',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects invalid complaint transitions before service execution', async () => {
|
||||
const res = await request(app).patch('/api/v1/complaints/complaint_1').send({ status: 'ESCALATED' })
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(complaintService.updateComplaint).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('splits offer vehicle ids from the service payload after validation defaults are applied', async () => {
|
||||
const res = await request(app).post('/api/v1/offers').send({
|
||||
title: 'Weekend deal',
|
||||
type: 'PERCENTAGE',
|
||||
discountValue: 15,
|
||||
appliesToAll: false,
|
||||
validFrom: '2026-09-01T00:00:00.000Z',
|
||||
validUntil: '2026-09-30T23:59:59.000Z',
|
||||
vehicleIds: ['vehicle_1'],
|
||||
})
|
||||
|
||||
expect(res.status).toBe(201)
|
||||
expect(offerService.createOffer).toHaveBeenCalledWith('company_1', {
|
||||
title: 'Weekend deal',
|
||||
type: 'PERCENTAGE',
|
||||
discountValue: 15,
|
||||
appliesToAll: false,
|
||||
categories: [],
|
||||
validFrom: '2026-09-01T00:00:00.000Z',
|
||||
validUntil: '2026-09-30T23:59:59.000Z',
|
||||
isActive: true,
|
||||
isPublic: true,
|
||||
isFeatured: false,
|
||||
}, ['vehicle_1'])
|
||||
})
|
||||
|
||||
it('rejects offers with non-datetime validity windows before service execution', async () => {
|
||||
const res = await request(app).post('/api/v1/offers').send({
|
||||
title: 'Bad dates',
|
||||
type: 'FIXED_AMOUNT',
|
||||
discountValue: 100,
|
||||
validFrom: 'tomorrow',
|
||||
validUntil: 'next week',
|
||||
})
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(offerService.createOffer).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,148 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({ prisma: {} }))
|
||||
vi.mock('../../lib/redis', () => ({
|
||||
redis: { on: vi.fn(), get: vi.fn(), set: vi.fn(), del: vi.fn(), quit: vi.fn() },
|
||||
}))
|
||||
vi.mock('../../middleware/requireCompanyAuth', () => ({
|
||||
requireCompanyAuth: (req: any, _res: any, next: any) => {
|
||||
req.employee = { id: 'employee_1', role: 'OWNER' }
|
||||
next()
|
||||
},
|
||||
requireCompanyDocumentAuth: (req: any, _res: any, next: any) => {
|
||||
req.employee = { id: 'employee_1', role: 'OWNER' }
|
||||
next()
|
||||
},
|
||||
}))
|
||||
vi.mock('../../middleware/requireTenant', () => ({
|
||||
requireTenant: (req: any, _res: any, next: any) => {
|
||||
req.companyId = 'company_1'
|
||||
next()
|
||||
},
|
||||
}))
|
||||
vi.mock('../../middleware/requireSubscription', () => ({
|
||||
requireSubscription: (_req: any, _res: any, next: any) => next(),
|
||||
}))
|
||||
vi.mock('../../middleware/requireRole', () => ({
|
||||
requireRole: () => (_req: any, _res: any, next: any) => next(),
|
||||
}))
|
||||
vi.mock('../../middleware/requireRenterAuth', () => ({
|
||||
requireRenterAuth: (req: any, _res: any, next: any) => {
|
||||
req.renterId = 'renter_1'
|
||||
next()
|
||||
},
|
||||
optionalRenterAuth: (_req: any, _res: any, next: any) => next(),
|
||||
}))
|
||||
vi.mock('../../modules/team/team.service', () => ({
|
||||
getMembers: vi.fn(),
|
||||
getMemberStats: vi.fn(),
|
||||
inviteEmployee: vi.fn(),
|
||||
updateEmployeeRole: vi.fn(),
|
||||
deactivateEmployee: vi.fn(),
|
||||
reactivateEmployee: vi.fn(),
|
||||
removeEmployee: vi.fn(),
|
||||
}))
|
||||
vi.mock('../../modules/notifications/notification.service', () => ({
|
||||
listCompany: vi.fn(),
|
||||
countUnread: vi.fn(),
|
||||
markRead: vi.fn(),
|
||||
markAllRead: vi.fn(),
|
||||
getPreferences: vi.fn(),
|
||||
setPreferences: vi.fn(),
|
||||
listCompanyHistory: vi.fn(),
|
||||
listRenter: vi.fn(),
|
||||
markRenterRead: vi.fn(),
|
||||
markAllRenterRead: vi.fn(),
|
||||
getRenterPreferences: vi.fn(),
|
||||
setRenterPreferences: vi.fn(),
|
||||
}))
|
||||
vi.mock('../../modules/analytics/analytics.service', () => ({
|
||||
getSummary: vi.fn(),
|
||||
getDashboard: vi.fn(),
|
||||
getSources: vi.fn(),
|
||||
getReport: vi.fn(),
|
||||
}))
|
||||
|
||||
import request from 'supertest'
|
||||
import { createApp } from '../../app'
|
||||
import * as teamService from '../../modules/team/team.service'
|
||||
import * as notificationService from '../../modules/notifications/notification.service'
|
||||
import * as analyticsService from '../../modules/analytics/analytics.service'
|
||||
|
||||
const app = createApp()
|
||||
|
||||
describe('operations validation API contracts', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(teamService.inviteEmployee).mockResolvedValue({ id: 'employee_2' } as never)
|
||||
vi.mocked(teamService.updateEmployeeRole).mockResolvedValue({ id: 'employee_2', role: 'AGENT' } as never)
|
||||
vi.mocked(notificationService.setPreferences).mockResolvedValue(undefined as never)
|
||||
vi.mocked(notificationService.listCompanyHistory).mockResolvedValue([] as never)
|
||||
vi.mocked(analyticsService.getReport).mockResolvedValue({
|
||||
format: 'CSV',
|
||||
startDate: new Date('2026-01-01T00:00:00.000Z'),
|
||||
endDate: new Date('2026-01-31T00:00:00.000Z'),
|
||||
csv: 'metric,value\nrevenue,1000\n',
|
||||
} as never)
|
||||
})
|
||||
|
||||
it('rejects invalid team invite roles before service execution', async () => {
|
||||
const res = await request(app).post('/api/v1/team/invite').send({
|
||||
firstName: 'Aya',
|
||||
lastName: 'Benali',
|
||||
email: 'aya@example.test',
|
||||
role: 'OWNER',
|
||||
})
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(teamService.inviteEmployee).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes valid team role updates with actor context', async () => {
|
||||
const res = await request(app).patch('/api/v1/team/employee_2/role').send({ role: 'AGENT' })
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(teamService.updateEmployeeRole).toHaveBeenCalledWith('company_1', 'employee_1', 'OWNER', 'employee_2', { role: 'AGENT' })
|
||||
})
|
||||
|
||||
it('rejects malformed company notification preferences before persistence', async () => {
|
||||
const res = await request(app).patch('/api/v1/notifications/company/preferences').send([
|
||||
{ notificationType: 'BOOKING', channel: 'EMAIL' },
|
||||
])
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(notificationService.setPreferences).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('accepts valid company notification preferences and passes employee identity', async () => {
|
||||
const res = await request(app).patch('/api/v1/notifications/company/preferences').send([
|
||||
{ notificationType: 'BOOKING_CREATED', channel: 'EMAIL', enabled: true },
|
||||
])
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(notificationService.setPreferences).toHaveBeenCalledWith('employee_1', [
|
||||
{ notificationType: 'BOOKING_CREATED', channel: 'EMAIL', enabled: true },
|
||||
])
|
||||
})
|
||||
|
||||
it('coerces notification history limits before querying service history', async () => {
|
||||
const res = await request(app).get('/api/v1/notifications/history?channel=EMAIL&status=SENT&limit=50')
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(notificationService.listCompanyHistory).toHaveBeenCalledWith('company_1', {
|
||||
channel: 'EMAIL',
|
||||
status: 'SENT',
|
||||
limit: 50,
|
||||
})
|
||||
})
|
||||
|
||||
it('streams analytics CSV reports with deterministic filenames', async () => {
|
||||
const res = await request(app).get('/api/v1/analytics/report?format=CSV&period=month')
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.header['content-type']).toContain('text/csv')
|
||||
expect(res.header['content-disposition']).toContain('report-2026-01-01-2026-01-31.csv')
|
||||
expect(res.text).toContain('metric,value')
|
||||
expect(analyticsService.getReport).toHaveBeenCalledWith('company_1', { format: 'CSV', period: 'month' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,196 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({ prisma: {} }))
|
||||
vi.mock('../../lib/redis', () => ({
|
||||
redis: { on: vi.fn(), get: vi.fn(), set: vi.fn(), del: vi.fn(), quit: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('../../middleware/requireCompanyAuth', () => {
|
||||
const auth = (req: any, _res: any, next: any) => {
|
||||
req.companyId = 'company_1'
|
||||
req.company = { id: 'company_1' }
|
||||
req.employee = { id: 'employee_1', role: 'OWNER', companyId: 'company_1' }
|
||||
next()
|
||||
}
|
||||
return { requireCompanyAuth: auth, requireCompanyDocumentAuth: auth }
|
||||
})
|
||||
|
||||
vi.mock('../../middleware/requireTenant', () => ({
|
||||
requireTenant: (_req: any, _res: any, next: any) => next(),
|
||||
}))
|
||||
|
||||
vi.mock('../../middleware/requireSubscription', () => ({
|
||||
requireSubscription: (_req: any, _res: any, next: any) => next(),
|
||||
}))
|
||||
|
||||
vi.mock('../../middleware/requireRole', () => ({
|
||||
requireRole: () => (_req: any, _res: any, next: any) => next(),
|
||||
}))
|
||||
|
||||
vi.mock('../../middleware/requireRenterAuth', () => ({
|
||||
requireRenterAuth: (req: any, _res: any, next: any) => {
|
||||
req.renterId = 'renter_1'
|
||||
next()
|
||||
},
|
||||
optionalRenterAuth: (req: any, _res: any, next: any) => {
|
||||
req.renterId = req.renterId ?? undefined
|
||||
next()
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../modules/offers/offer.service', () => ({
|
||||
listOffers: vi.fn(),
|
||||
getOffer: vi.fn(),
|
||||
createOffer: vi.fn(),
|
||||
updateOffer: vi.fn(),
|
||||
deleteOffer: vi.fn(),
|
||||
setOfferActive: vi.fn(),
|
||||
getOfferStats: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../modules/notifications/notification.service', () => ({
|
||||
listCompany: vi.fn(),
|
||||
listCompanyHistory: vi.fn(),
|
||||
countUnread: vi.fn(),
|
||||
markRead: vi.fn(),
|
||||
markAllRead: vi.fn(),
|
||||
getPreferences: vi.fn(),
|
||||
setPreferences: vi.fn(),
|
||||
listRenter: vi.fn(),
|
||||
markRenterRead: vi.fn(),
|
||||
markAllRenterRead: vi.fn(),
|
||||
getRenterPreferences: vi.fn(),
|
||||
setRenterPreferences: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../modules/team/team.service', () => ({
|
||||
getMembers: vi.fn(),
|
||||
getMemberStats: vi.fn(),
|
||||
inviteEmployee: vi.fn(),
|
||||
updateEmployeeRole: vi.fn(),
|
||||
deactivateEmployee: vi.fn(),
|
||||
reactivateEmployee: vi.fn(),
|
||||
removeEmployee: vi.fn(),
|
||||
}))
|
||||
|
||||
import request from 'supertest'
|
||||
import { createApp } from '../../app'
|
||||
import * as offerService from '../../modules/offers/offer.service'
|
||||
import * as notificationService from '../../modules/notifications/notification.service'
|
||||
import * as teamService from '../../modules/team/team.service'
|
||||
|
||||
const app = createApp()
|
||||
|
||||
describe('operations API contracts', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(offerService.listOffers).mockResolvedValue([{ id: 'offer_1' }] as never)
|
||||
vi.mocked(offerService.createOffer).mockResolvedValue({ id: 'offer_1' } as never)
|
||||
vi.mocked(offerService.updateOffer).mockResolvedValue({ id: 'offer_1', title: 'Updated' } as never)
|
||||
vi.mocked(offerService.getOfferStats).mockResolvedValue({ redemptions: 2 } as never)
|
||||
vi.mocked(notificationService.countUnread).mockResolvedValue(7 as never)
|
||||
vi.mocked(notificationService.listCompanyHistory).mockResolvedValue([{ id: 'history_1' }] as never)
|
||||
vi.mocked(notificationService.getPreferences).mockResolvedValue([] as never)
|
||||
vi.mocked(notificationService.listRenter).mockResolvedValue([{ id: 'renter_notification_1' }] as never)
|
||||
vi.mocked(teamService.getMemberStats).mockResolvedValue({ total: 3, active: 2, pending: 1, inactive: 0 } as never)
|
||||
vi.mocked(teamService.inviteEmployee).mockResolvedValue({ employee: { id: 'employee_2' }, invitationId: 'employee_2', invitedBy: 'employee_1' } as never)
|
||||
})
|
||||
|
||||
it('GET /api/v1/offers forwards boolean-like query strings without the router inventing semantics', async () => {
|
||||
const res = await request(app).get('/api/v1/offers?active=true&public=false')
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body).toEqual({ data: [{ id: 'offer_1' }] })
|
||||
expect(offerService.listOffers).toHaveBeenCalledWith('company_1', { active: 'true', public: 'false' })
|
||||
})
|
||||
|
||||
it('POST /api/v1/offers validates payloads and separates vehicleIds from offer body', async () => {
|
||||
const payload = {
|
||||
title: 'Long weekend deal',
|
||||
type: 'PERCENTAGE',
|
||||
discountValue: 15,
|
||||
appliesToAll: false,
|
||||
categories: ['SUV'],
|
||||
validFrom: '2026-07-01T00:00:00.000Z',
|
||||
validUntil: '2026-07-31T23:59:59.000Z',
|
||||
vehicleIds: ['vehicle_1'],
|
||||
}
|
||||
|
||||
const res = await request(app).post('/api/v1/offers').send(payload)
|
||||
|
||||
expect(res.status).toBe(201)
|
||||
expect(res.body).toEqual({ data: { id: 'offer_1' } })
|
||||
expect(offerService.createOffer).toHaveBeenCalledWith('company_1', expect.not.objectContaining({ vehicleIds: expect.anything() }), ['vehicle_1'])
|
||||
})
|
||||
|
||||
it('PATCH /api/v1/offers/:id validates route params and delegates partial updates', async () => {
|
||||
const res = await request(app).patch('/api/v1/offers/offer_1').send({ title: 'Updated', vehicleIds: [] })
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(offerService.updateOffer).toHaveBeenCalledWith('offer_1', 'company_1', { title: 'Updated' }, [])
|
||||
})
|
||||
|
||||
it('GET /api/v1/offers/:id/stats returns the service stats in the standard envelope', async () => {
|
||||
const res = await request(app).get('/api/v1/offers/offer_1/stats')
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body).toEqual({ data: { redemptions: 2 } })
|
||||
expect(offerService.getOfferStats).toHaveBeenCalledWith('offer_1', 'company_1')
|
||||
})
|
||||
|
||||
it('GET /api/v1/notifications/unread-count exposes only the numeric unread count', async () => {
|
||||
const res = await request(app).get('/api/v1/notifications/unread-count')
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body).toEqual({ data: { unread: 7 } })
|
||||
})
|
||||
|
||||
it('GET /api/v1/notifications/history coerces limit and passes history filters to the service', async () => {
|
||||
const res = await request(app).get('/api/v1/notifications/history?channel=EMAIL&status=FAILED&limit=25')
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(notificationService.listCompanyHistory).toHaveBeenCalledWith('company_1', { channel: 'EMAIL', status: 'FAILED', limit: 25 })
|
||||
})
|
||||
|
||||
it('PATCH /api/v1/notifications/company/preferences validates preference arrays for the current employee', async () => {
|
||||
const prefs = [{ notificationType: 'BOOKING_CONFIRMED', channel: 'EMAIL', enabled: true }]
|
||||
const res = await request(app).patch('/api/v1/notifications/company/preferences').send(prefs)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body).toEqual({ data: { success: true } })
|
||||
expect(notificationService.setPreferences).toHaveBeenCalledWith('employee_1', prefs)
|
||||
})
|
||||
|
||||
it('GET /api/v1/notifications/renter uses renter identity instead of company tenant middleware', async () => {
|
||||
const res = await request(app).get('/api/v1/notifications/renter')
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body).toEqual({ data: [{ id: 'renter_notification_1' }] })
|
||||
expect(notificationService.listRenter).toHaveBeenCalledWith('renter_1')
|
||||
})
|
||||
|
||||
it('GET /api/v1/team/stats returns computed team stats from the team facade', async () => {
|
||||
const res = await request(app).get('/api/v1/team/stats')
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body).toEqual({ data: { total: 3, active: 2, pending: 1, inactive: 0 } })
|
||||
})
|
||||
|
||||
it('POST /api/v1/team/invite validates invite payloads and wraps the created invitation', async () => {
|
||||
const res = await request(app).post('/api/v1/team/invite').send({
|
||||
firstName: 'Aya',
|
||||
lastName: 'Benali',
|
||||
email: 'aya@example.com',
|
||||
role: 'AGENT',
|
||||
})
|
||||
|
||||
expect(res.status).toBe(201)
|
||||
expect(res.body).toEqual({ data: { data: { employee: { id: 'employee_2' }, invitationId: 'employee_2', invitedBy: 'employee_1' } } })
|
||||
expect(teamService.inviteEmployee).toHaveBeenCalledWith('company_1', 'employee_1', {
|
||||
firstName: 'Aya',
|
||||
lastName: 'Benali',
|
||||
email: 'aya@example.com',
|
||||
role: 'AGENT',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({ prisma: {} }))
|
||||
vi.mock('../../lib/redis', () => ({ redis: { on: vi.fn(), get: vi.fn(), set: vi.fn(), del: vi.fn(), quit: vi.fn() } }))
|
||||
|
||||
vi.mock('../../services/amanpayService', () => ({
|
||||
isConfigured: vi.fn(),
|
||||
verifyWebhookSignature: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../services/paypalService', () => ({
|
||||
isConfigured: vi.fn(),
|
||||
verifyWebhookEvent: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../modules/payments/payment.service', () => ({
|
||||
handleAmanpayWebhook: vi.fn(),
|
||||
handlePaypalWebhook: vi.fn(),
|
||||
listByCompany: vi.fn(),
|
||||
listByReservation: vi.fn(),
|
||||
initCharge: vi.fn(),
|
||||
capturePaypal: vi.fn(),
|
||||
recordManualPayment: vi.fn(),
|
||||
refundPayment: vi.fn(),
|
||||
}))
|
||||
|
||||
import request from 'supertest'
|
||||
import { createApp } from '../../app'
|
||||
import * as amanpay from '../../services/amanpayService'
|
||||
import * as paypal from '../../services/paypalService'
|
||||
import * as service from '../../modules/payments/payment.service'
|
||||
|
||||
const app = createApp()
|
||||
|
||||
describe('payments API contract', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('rejects AmanPay webhooks when signature validation fails and does not invoke service handlers', async () => {
|
||||
vi.mocked(amanpay.isConfigured).mockReturnValue(true)
|
||||
vi.mocked(amanpay.verifyWebhookSignature).mockReturnValue(false)
|
||||
|
||||
const payload = { transaction_id: 'txn_1', status: 'PAID' }
|
||||
const res = await request(app)
|
||||
.post('/api/v1/payments/webhooks/amanpay')
|
||||
.set('x-amanpay-signature', 'bad')
|
||||
.send(payload)
|
||||
|
||||
expect(res.status).toBe(401)
|
||||
expect(res.body).toEqual({ error: 'invalid_signature' })
|
||||
expect(amanpay.verifyWebhookSignature).toHaveBeenCalledWith(JSON.stringify(payload), 'bad')
|
||||
expect(service.handleAmanpayWebhook).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('accepts valid AmanPay webhooks and delegates the exact payload', async () => {
|
||||
vi.mocked(amanpay.isConfigured).mockReturnValue(true)
|
||||
vi.mocked(amanpay.verifyWebhookSignature).mockReturnValue(true)
|
||||
|
||||
const payload = { transaction_id: 'txn_1', status: 'PAID' }
|
||||
const res = await request(app)
|
||||
.post('/api/v1/payments/webhooks/amanpay')
|
||||
.set('x-amanpay-signature', 'good')
|
||||
.send(payload)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body).toEqual({ received: true })
|
||||
expect(service.handleAmanpayWebhook).toHaveBeenCalledWith(payload, JSON.stringify(payload))
|
||||
})
|
||||
|
||||
it('rejects PayPal webhooks when provider verification fails', async () => {
|
||||
vi.mocked(paypal.isConfigured).mockReturnValue(true)
|
||||
vi.mocked(paypal.verifyWebhookEvent).mockResolvedValue(false)
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/v1/payments/webhooks/paypal')
|
||||
.set('paypal-transmission-id', 'transmission_1')
|
||||
.send({ event_type: 'PAYMENT.CAPTURE.COMPLETED', resource: { id: 'capture_1' } })
|
||||
|
||||
expect(res.status).toBe(401)
|
||||
expect(res.body).toEqual({ error: 'invalid_signature' })
|
||||
expect(service.handlePaypalWebhook).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('accepts verified PayPal webhooks and delegates handling', async () => {
|
||||
vi.mocked(paypal.isConfigured).mockReturnValue(true)
|
||||
vi.mocked(paypal.verifyWebhookEvent).mockResolvedValue(true)
|
||||
|
||||
const payload = { event_type: 'PAYMENT.CAPTURE.COMPLETED', resource: { id: 'capture_1' } }
|
||||
const res = await request(app)
|
||||
.post('/api/v1/payments/webhooks/paypal')
|
||||
.send(payload)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body).toEqual({ received: true })
|
||||
expect(service.handlePaypalWebhook).toHaveBeenCalledWith(payload, JSON.stringify(payload))
|
||||
})
|
||||
|
||||
it('keeps authenticated payment routes behind auth before any payment service call', async () => {
|
||||
const res = await request(app).get('/api/v1/payments/company')
|
||||
|
||||
expect(res.status).toBe(401)
|
||||
expect(service.listByCompany).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,163 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({ prisma: {} }))
|
||||
vi.mock('../../lib/redis', () => ({
|
||||
redis: { on: vi.fn(), get: vi.fn(), set: vi.fn(), del: vi.fn(), quit: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('../../middleware/requireRenterAuth', () => ({
|
||||
requireRenterAuth: (req: any, _res: any, next: any) => {
|
||||
req.renterId = 'renter_1'
|
||||
next()
|
||||
},
|
||||
optionalRenterAuth: (_req: any, _res: any, next: any) => next(),
|
||||
}))
|
||||
|
||||
vi.mock('../../modules/site/site.service', () => ({
|
||||
getPlatformHomepage: vi.fn(),
|
||||
getPlatformPricing: vi.fn(),
|
||||
getBrand: vi.fn(),
|
||||
getPublicVehicles: vi.fn(),
|
||||
getVehicleDetail: vi.fn(),
|
||||
getOffers: vi.fn(),
|
||||
getBookingOptions: vi.fn(),
|
||||
checkAvailability: vi.fn(),
|
||||
validatePromoCode: vi.fn(),
|
||||
createBooking: vi.fn(),
|
||||
getBooking: vi.fn(),
|
||||
initPayment: vi.fn(),
|
||||
capturePaypal: vi.fn(),
|
||||
handleContact: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../modules/marketplace/marketplace.service', () => ({
|
||||
getPublicOffers: vi.fn(),
|
||||
getCities: vi.fn(),
|
||||
getListedCompanies: vi.fn(),
|
||||
searchVehicles: vi.fn(),
|
||||
createMarketplaceReservation: vi.fn(),
|
||||
getReviewContext: vi.fn(),
|
||||
submitReview: vi.fn(),
|
||||
validateOfferCode: vi.fn(),
|
||||
getCompanyPage: vi.fn(),
|
||||
getCompanyReviews: vi.fn(),
|
||||
getCompanyVehicles: vi.fn(),
|
||||
getVehicleDetail: vi.fn(),
|
||||
getCompanyOffers: vi.fn(),
|
||||
}))
|
||||
|
||||
import request from 'supertest'
|
||||
import { createApp } from '../../app'
|
||||
import * as siteService from '../../modules/site/site.service'
|
||||
import * as marketplaceService from '../../modules/marketplace/marketplace.service'
|
||||
|
||||
const app = createApp()
|
||||
|
||||
describe('public validation API contracts', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(siteService.checkAvailability).mockResolvedValue({ available: true, nextAvailableAt: null } as never)
|
||||
vi.mocked(siteService.initPayment).mockResolvedValue({ checkoutUrl: 'https://pay.example.test' } as never)
|
||||
vi.mocked(siteService.handleContact).mockResolvedValue({ success: true } as never)
|
||||
vi.mocked(marketplaceService.searchVehicles).mockResolvedValue({ data: [], pagination: { page: 1, pageSize: 20 } } as never)
|
||||
vi.mocked(marketplaceService.createMarketplaceReservation).mockResolvedValue({ id: 'reservation_1' } as never)
|
||||
vi.mocked(marketplaceService.submitReview).mockResolvedValue({ id: 'review_1' } as never)
|
||||
})
|
||||
|
||||
it('rejects malformed public availability checks before site service execution', async () => {
|
||||
const res = await request(app).post('/api/v1/site/atlas-cars/availability').send({
|
||||
vehicleId: 'not-a-cuid',
|
||||
startDate: '2026-07-01',
|
||||
endDate: 'tomorrow-ish',
|
||||
})
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.body).toMatchObject({ error: 'validation_error', statusCode: 400 })
|
||||
expect(siteService.checkAvailability).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('accepts valid public availability payloads and passes parsed fields explicitly', async () => {
|
||||
const payload = {
|
||||
vehicleId: 'ckvvehicle000000000000001',
|
||||
startDate: '2026-07-01T10:00:00.000Z',
|
||||
endDate: '2026-07-03T10:00:00.000Z',
|
||||
}
|
||||
|
||||
const res = await request(app).post('/api/v1/site/atlas-cars/availability').send(payload)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body).toEqual({ data: { available: true, nextAvailableAt: null } })
|
||||
expect(siteService.checkAvailability).toHaveBeenCalledWith('atlas-cars', payload.vehicleId, payload.startDate, payload.endDate)
|
||||
})
|
||||
|
||||
it('rejects unsupported public payment providers before payment initialization', async () => {
|
||||
const res = await request(app).post('/api/v1/site/atlas-cars/booking/reservation_1/pay').send({
|
||||
provider: 'STRIPE',
|
||||
successUrl: 'https://example.test/success',
|
||||
failureUrl: 'https://example.test/fail',
|
||||
accessToken: 'booking-access-token-123',
|
||||
})
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(siteService.initPayment).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('defaults site payment currency to MAD for valid public payment requests', async () => {
|
||||
const res = await request(app).post('/api/v1/site/atlas-cars/booking/reservation_1/pay').send({
|
||||
provider: 'PAYPAL',
|
||||
successUrl: 'https://example.test/success',
|
||||
failureUrl: 'https://example.test/fail',
|
||||
accessToken: 'booking-access-token-123',
|
||||
})
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(siteService.initPayment).toHaveBeenCalledWith('atlas-cars', 'reservation_1', {
|
||||
provider: 'PAYPAL',
|
||||
currency: 'MAD',
|
||||
successUrl: 'https://example.test/success',
|
||||
failureUrl: 'https://example.test/fail',
|
||||
accessToken: 'booking-access-token-123',
|
||||
})
|
||||
})
|
||||
|
||||
it('coerces marketplace search pagination and price filters', async () => {
|
||||
const res = await request(app).get('/api/v1/marketplace/search?city=Rabat&maxPrice=500&page=2&pageSize=30')
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(marketplaceService.searchVehicles).toHaveBeenCalledWith({
|
||||
city: 'Rabat',
|
||||
maxPrice: 500,
|
||||
page: 2,
|
||||
pageSize: 30,
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects oversized marketplace pagination before search execution', async () => {
|
||||
const res = await request(app).get('/api/v1/marketplace/search?pageSize=500')
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(marketplaceService.searchVehicles).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects marketplace reservations with invalid public contact data before service execution', async () => {
|
||||
const res = await request(app).post('/api/v1/marketplace/reservations').send({
|
||||
vehicleId: 'ckvvehicle000000000000001',
|
||||
companySlug: 'atlas-cars',
|
||||
firstName: 'Aya',
|
||||
lastName: 'Benali',
|
||||
email: 'bad-email',
|
||||
startDate: '2026-07-01T10:00:00.000Z',
|
||||
endDate: '2026-07-03T10:00:00.000Z',
|
||||
})
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(marketplaceService.createMarketplaceReservation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects invalid public review ratings before creating reviews', async () => {
|
||||
const res = await request(app).post('/api/v1/marketplace/review/token_1').send({ overallRating: 6 })
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(marketplaceService.submitReview).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({ prisma: {} }))
|
||||
vi.mock('../../lib/redis', () => ({ redis: { on: vi.fn(), get: vi.fn(), set: vi.fn(), del: vi.fn(), quit: vi.fn() } }))
|
||||
vi.mock('../../modules/site/site.service', () => ({
|
||||
getPlatformHomepage: vi.fn(),
|
||||
getPlatformPricing: vi.fn(),
|
||||
getBrand: vi.fn(),
|
||||
getPublicVehicles: vi.fn(),
|
||||
getVehicleDetail: vi.fn(),
|
||||
getOffers: vi.fn(),
|
||||
getBookingOptions: vi.fn(),
|
||||
checkAvailability: vi.fn(),
|
||||
validatePromoCode: vi.fn(),
|
||||
createBooking: vi.fn(),
|
||||
getBooking: vi.fn(),
|
||||
initPayment: vi.fn(),
|
||||
capturePaypal: vi.fn(),
|
||||
handleContact: vi.fn(),
|
||||
}))
|
||||
|
||||
import request from 'supertest'
|
||||
import { createApp } from '../../app'
|
||||
import * as siteService from '../../modules/site/site.service'
|
||||
|
||||
const app = createApp()
|
||||
|
||||
describe('site and webhook API boundary contracts', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(siteService.getPlatformPricing).mockResolvedValue({ prices: { STARTER: { MONTHLY: { MAD: 199 } } }, planFeatures: { STARTER: ['feature'] } } as never)
|
||||
vi.mocked(siteService.checkAvailability).mockResolvedValue({ available: true, nextAvailableAt: null } as never)
|
||||
vi.mocked(siteService.validatePromoCode).mockResolvedValue({ id: 'offer_1', promoCode: 'SUMMER' } as never)
|
||||
vi.mocked(siteService.handleContact).mockResolvedValue({ success: true, deliveredTo: 'hello@example.test' } as never)
|
||||
})
|
||||
|
||||
it('keeps removed Clerk webhook endpoint disabled even with raw webhook payloads', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/v1/webhooks/clerk')
|
||||
.set('content-type', 'application/json')
|
||||
.send(JSON.stringify({ type: 'user.created', data: { id: 'user_1' } }))
|
||||
|
||||
expect(res.status).toBe(410)
|
||||
expect(res.body).toEqual({
|
||||
error: 'disabled',
|
||||
message: 'Clerk webhooks are disabled because Clerk has been removed from this project.',
|
||||
statusCode: 410,
|
||||
})
|
||||
})
|
||||
|
||||
it('serves platform pricing without tenant auth', async () => {
|
||||
const res = await request(app).get('/api/v1/site/platform/pricing')
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body.data.prices.STARTER.MONTHLY.MAD).toBe(199)
|
||||
expect(siteService.getPlatformPricing).toHaveBeenCalledWith()
|
||||
})
|
||||
|
||||
it('rejects invalid availability ranges before service execution', async () => {
|
||||
const res = await request(app).post('/api/v1/site/atlas/availability').send({
|
||||
vehicleId: 'vehicle_1',
|
||||
startDate: 'not-a-date',
|
||||
endDate: '2026-07-10T00:00:00.000Z',
|
||||
})
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(siteService.checkAvailability).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('normalizes valid availability payloads into service arguments', async () => {
|
||||
const res = await request(app).post('/api/v1/site/atlas/availability').send({
|
||||
vehicleId: 'cmvehicle000000000000001',
|
||||
startDate: '2026-07-01T00:00:00.000Z',
|
||||
endDate: '2026-07-10T00:00:00.000Z',
|
||||
})
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(siteService.checkAvailability).toHaveBeenCalledWith('atlas', 'cmvehicle000000000000001', '2026-07-01T00:00:00.000Z', '2026-07-10T00:00:00.000Z')
|
||||
})
|
||||
|
||||
it('rejects empty promo codes and malformed contact emails before public service calls', async () => {
|
||||
const promo = await request(app).post('/api/v1/site/atlas/book/validate-code').send({ code: '' })
|
||||
expect(promo.status).toBe(400)
|
||||
expect(siteService.validatePromoCode).not.toHaveBeenCalled()
|
||||
|
||||
const contact = await request(app).post('/api/v1/site/atlas/contact').send({ name: 'Visitor', email: 'bad-email', message: 'Hello' })
|
||||
expect(contact.status).toBe(400)
|
||||
expect(siteService.handleContact).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,126 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({ prisma: {} }))
|
||||
vi.mock('../../lib/redis', () => ({
|
||||
redis: { on: vi.fn(), get: vi.fn(), set: vi.fn(), del: vi.fn(), quit: vi.fn() },
|
||||
}))
|
||||
vi.mock('../../middleware/requireCompanyAuth', () => ({
|
||||
requireCompanyDocumentAuth: (_req: any, _res: any, next: any) => next(),
|
||||
requireCompanyAuth: (req: any, _res: any, next: any) => {
|
||||
req.employee = { id: 'employee_owner', role: 'OWNER', firstName: 'Aya', lastName: 'Owner' }
|
||||
next()
|
||||
},
|
||||
}))
|
||||
vi.mock('../../middleware/requireTenant', () => ({
|
||||
requireTenant: (req: any, _res: any, next: any) => {
|
||||
req.companyId = 'company_1'
|
||||
next()
|
||||
},
|
||||
}))
|
||||
vi.mock('../../middleware/requireSubscription', () => ({ requireSubscription: (_req: any, _res: any, next: any) => next() }))
|
||||
vi.mock('../../middleware/requireRole', () => ({ requireRole: () => (_req: any, _res: any, next: any) => next() }))
|
||||
vi.mock('../../modules/subscriptions/subscription.service', () => ({
|
||||
getPlans: vi.fn(),
|
||||
getProviders: vi.fn(),
|
||||
getPlanFeatures: vi.fn(),
|
||||
getSubscription: vi.fn(),
|
||||
getInvoices: vi.fn(),
|
||||
getEvents: vi.fn(),
|
||||
getEntitlement: vi.fn(),
|
||||
startTrial: vi.fn(),
|
||||
checkout: vi.fn(),
|
||||
reactivate: vi.fn(),
|
||||
changePlan: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
resume: vi.fn(),
|
||||
capturePaypal: vi.fn(),
|
||||
handleAmanpayWebhook: vi.fn(),
|
||||
handlePaypalWebhook: vi.fn(),
|
||||
}))
|
||||
vi.mock('../../modules/team/team.service', () => ({
|
||||
getMembers: vi.fn(),
|
||||
getMemberStats: vi.fn(),
|
||||
inviteEmployee: vi.fn(),
|
||||
updateEmployeeRole: vi.fn(),
|
||||
deactivateEmployee: vi.fn(),
|
||||
reactivateEmployee: vi.fn(),
|
||||
removeEmployee: vi.fn(),
|
||||
}))
|
||||
|
||||
import request from 'supertest'
|
||||
import { createApp } from '../../app'
|
||||
import * as subscriptionService from '../../modules/subscriptions/subscription.service'
|
||||
import * as teamService from '../../modules/team/team.service'
|
||||
|
||||
const app = createApp()
|
||||
|
||||
describe('subscription and team API validation contracts', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(subscriptionService.startTrial).mockResolvedValue({ id: 'sub_trial' } as never)
|
||||
vi.mocked(subscriptionService.checkout).mockResolvedValue({ invoice: { id: 'invoice_1' }, checkoutUrl: 'https://pay.example.test' } as never)
|
||||
vi.mocked(subscriptionService.cancel).mockResolvedValue({ id: 'sub_1', cancelAtPeriodEnd: true } as never)
|
||||
vi.mocked(subscriptionService.capturePaypal).mockResolvedValue({ success: true } as never)
|
||||
vi.mocked(teamService.inviteEmployee).mockResolvedValue({ id: 'employee_2' } as never)
|
||||
vi.mocked(teamService.updateEmployeeRole).mockResolvedValue({ id: 'employee_2', role: 'MANAGER' } as never)
|
||||
})
|
||||
|
||||
it('defaults trial currency and forwards company context', async () => {
|
||||
const res = await request(app).post('/api/v1/subscriptions/trial').send({ plan: 'STARTER', billingPeriod: 'MONTHLY' })
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(subscriptionService.startTrial).toHaveBeenCalledWith('company_1', 'STARTER', 'MONTHLY', 'MAD')
|
||||
})
|
||||
|
||||
it('rejects checkout payloads with unsupported currency before service execution', async () => {
|
||||
const res = await request(app).post('/api/v1/subscriptions/checkout').send({
|
||||
plan: 'PRO',
|
||||
billingPeriod: 'ANNUAL',
|
||||
currency: 'USD',
|
||||
provider: 'PAYPAL',
|
||||
successUrl: 'https://app.example.test/success',
|
||||
failureUrl: 'https://app.example.test/failure',
|
||||
})
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(subscriptionService.checkout).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('defaults cancellation mode at the route boundary', async () => {
|
||||
const res = await request(app).post('/api/v1/subscriptions/cancel').send({ reason: 'not needed' })
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(subscriptionService.cancel).toHaveBeenCalledWith('company_1', 'period_end', 'not needed')
|
||||
})
|
||||
|
||||
it('requires PayPal order id for capture before service execution', async () => {
|
||||
const res = await request(app).post('/api/v1/subscriptions/capture-paypal').send({})
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(subscriptionService.capturePaypal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('normalizes team invites through validation before service execution', async () => {
|
||||
const res = await request(app).post('/api/v1/team/invite').send({
|
||||
firstName: 'Aya',
|
||||
lastName: 'Agent',
|
||||
email: 'agent@example.test',
|
||||
role: 'AGENT',
|
||||
})
|
||||
|
||||
expect(res.status).toBe(201)
|
||||
expect(teamService.inviteEmployee).toHaveBeenCalledWith('company_1', 'employee_owner', {
|
||||
firstName: 'Aya',
|
||||
lastName: 'Agent',
|
||||
email: 'agent@example.test',
|
||||
role: 'AGENT',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects owner role assignment before team service execution', async () => {
|
||||
const res = await request(app).patch('/api/v1/team/employee_2/role').send({ role: 'OWNER' })
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(teamService.updateEmployeeRole).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({
|
||||
prisma: {},
|
||||
}))
|
||||
|
||||
vi.mock('../../lib/redis', () => ({
|
||||
redis: {
|
||||
on: vi.fn(),
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
del: vi.fn(),
|
||||
quit: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../services/amanpayService', () => ({
|
||||
isConfigured: vi.fn(),
|
||||
verifyWebhookSignature: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../services/paypalService', () => ({
|
||||
isConfigured: vi.fn(),
|
||||
verifyWebhookEvent: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../modules/subscriptions/subscription.service', () => ({
|
||||
getPlans: vi.fn(),
|
||||
getProviders: vi.fn(),
|
||||
getPlanFeatures: vi.fn(),
|
||||
handleAmanpayWebhook: vi.fn(),
|
||||
handlePaypalWebhook: vi.fn(),
|
||||
}))
|
||||
|
||||
import request from 'supertest'
|
||||
import { createApp } from '../../app'
|
||||
import * as amanpay from '../../services/amanpayService'
|
||||
import * as paypal from '../../services/paypalService'
|
||||
import * as service from '../../modules/subscriptions/subscription.service'
|
||||
|
||||
const app = createApp()
|
||||
|
||||
describe('subscriptions public API', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(service.getProviders).mockReturnValue({ amanpay: false, paypal: true })
|
||||
vi.mocked(service.getPlans).mockResolvedValue({ STARTER: { MONTHLY: { MAD: 9900 } } } as never)
|
||||
vi.mocked(service.getPlanFeatures).mockResolvedValue([
|
||||
{ id: 'feature_1', plan: 'STARTER', label: 'Vehicles', sortOrder: 1 },
|
||||
] as never)
|
||||
})
|
||||
|
||||
it('GET /api/v1/subscriptions/providers exposes configured payment provider availability', async () => {
|
||||
const res = await request(app).get('/api/v1/subscriptions/providers')
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body).toEqual({ data: { amanpay: false, paypal: true } })
|
||||
expect(service.getProviders).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('GET /api/v1/subscriptions/plans wraps plan pricing in the standard API envelope', async () => {
|
||||
const res = await request(app).get('/api/v1/subscriptions/plans')
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body).toEqual({ data: { STARTER: { MONTHLY: { MAD: 9900 } } } })
|
||||
expect(service.getPlans).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('GET /api/v1/subscriptions/features returns plan feature rows through the public router', async () => {
|
||||
const res = await request(app).get('/api/v1/subscriptions/features')
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body.data).toEqual([
|
||||
{ id: 'feature_1', plan: 'STARTER', label: 'Vehicles', sortOrder: 1 },
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects AmanPay webhooks when provider config or signature validation fails', async () => {
|
||||
vi.mocked(amanpay.isConfigured).mockReturnValue(true)
|
||||
vi.mocked(amanpay.verifyWebhookSignature).mockReturnValue(false)
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/v1/subscriptions/webhooks/amanpay')
|
||||
.set('x-amanpay-signature', 'bad-signature')
|
||||
.send({ id: 'txn_1', status: 'PAID' })
|
||||
|
||||
expect(res.status).toBe(401)
|
||||
expect(res.body).toEqual({ error: 'invalid_signature' })
|
||||
expect(service.handleAmanpayWebhook).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('accepts verified PayPal webhooks and delegates handling to the subscription service', async () => {
|
||||
vi.mocked(paypal.isConfigured).mockReturnValue(true)
|
||||
vi.mocked(paypal.verifyWebhookEvent).mockResolvedValue(true)
|
||||
|
||||
const payload = { event_type: 'PAYMENT.CAPTURE.COMPLETED', resource: { id: 'capture_1' } }
|
||||
const res = await request(app)
|
||||
.post('/api/v1/subscriptions/webhooks/paypal')
|
||||
.send(payload)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body).toEqual({ received: true })
|
||||
expect(service.handlePaypalWebhook).toHaveBeenCalledWith(payload, JSON.stringify(payload))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,143 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('jsonwebtoken', () => ({ default: { verify: vi.fn() } }))
|
||||
vi.mock('../../lib/redis', () => ({
|
||||
redis: { on: vi.fn(), get: vi.fn(), set: vi.fn(), del: vi.fn(), quit: vi.fn() },
|
||||
}))
|
||||
vi.mock('../../lib/prisma', () => ({
|
||||
prisma: {
|
||||
employee: { findUnique: vi.fn() },
|
||||
company: { findUnique: vi.fn() },
|
||||
},
|
||||
}))
|
||||
vi.mock('../../modules/vehicles/vehicle.service', () => ({
|
||||
listVehicles: vi.fn(),
|
||||
getVehicle: vi.fn(),
|
||||
createVehicle: vi.fn(),
|
||||
updateVehicle: vi.fn(),
|
||||
deleteVehicle: vi.fn(),
|
||||
uploadPhotos: vi.fn(),
|
||||
deletePhoto: vi.fn(),
|
||||
setStatus: vi.fn(),
|
||||
setPublished: vi.fn(),
|
||||
checkAvailability: vi.fn(),
|
||||
getCalendar: vi.fn(),
|
||||
createCalendarBlock: vi.fn(),
|
||||
deleteCalendarBlock: vi.fn(),
|
||||
getMaintenanceLogs: vi.fn(),
|
||||
createMaintenanceLog: vi.fn(),
|
||||
getVehiclePricing: vi.fn(),
|
||||
updateVehiclePricing: vi.fn(),
|
||||
createVehiclePricingRule: vi.fn(),
|
||||
updateVehiclePricingRule: vi.fn(),
|
||||
deleteVehiclePricingRule: vi.fn(),
|
||||
}))
|
||||
|
||||
import request from 'supertest'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { createApp } from '../../app'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import * as vehicleService from '../../modules/vehicles/vehicle.service'
|
||||
|
||||
const app = createApp()
|
||||
|
||||
function employee(role = 'MANAGER') {
|
||||
return {
|
||||
id: 'employee_1',
|
||||
role,
|
||||
isActive: true,
|
||||
companyId: 'company_1',
|
||||
company: { id: 'company_1', status: 'ACTIVE' },
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
process.env.JWT_SECRET = 'test-secret'
|
||||
vi.mocked(jwt.verify).mockReturnValue({ sub: 'employee_1', type: 'employee' } as never)
|
||||
vi.mocked(prisma.employee.findUnique).mockResolvedValue(employee() as never)
|
||||
vi.mocked(prisma.company.findUnique).mockResolvedValue({ id: 'company_1', status: 'ACTIVE' } as never)
|
||||
})
|
||||
|
||||
describe('vehicle pricing API contracts', () => {
|
||||
it('allows active tenants to read vehicle pricing', async () => {
|
||||
vi.mocked(vehicleService.getVehiclePricing).mockResolvedValue({
|
||||
configuration: { id: 'pricing_config_1', baseDailyRate: 4000 },
|
||||
rules: [],
|
||||
history: [],
|
||||
preview: { today: { dailyRate: 4000 }, next14Days: [] },
|
||||
} as never)
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/v1/vehicles/vehicle_1/pricing')
|
||||
.set('Authorization', 'Bearer employee-token')
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body.data.configuration.id).toBe('pricing_config_1')
|
||||
expect(vehicleService.getVehiclePricing).toHaveBeenCalledWith('vehicle_1', 'company_1')
|
||||
})
|
||||
|
||||
it('rejects invalid pricing config payloads before updating pricing', async () => {
|
||||
const res = await request(app)
|
||||
.put('/api/v1/vehicles/vehicle_1/pricing')
|
||||
.set('Authorization', 'Bearer employee-token')
|
||||
.send({
|
||||
pricingMode: 'CHAOS',
|
||||
baseDailyRate: -1,
|
||||
maxDailyPriceMovementPct: 101,
|
||||
})
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(vehicleService.updateVehiclePricing).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes parsed pricing configuration and employee identity to the service', async () => {
|
||||
vi.mocked(vehicleService.updateVehiclePricing).mockResolvedValue({ configuration: { id: 'pricing_config_1' }, rules: [], history: [], preview: {} } as never)
|
||||
|
||||
const payload = {
|
||||
pricingMode: 'AUTOMATIC',
|
||||
baseDailyRate: 4500,
|
||||
weeklyRate: 27000,
|
||||
weekendRate: null,
|
||||
maxDailyPriceMovementPct: 15,
|
||||
automaticPricingEnabled: true,
|
||||
approvalRequired: true,
|
||||
note: 'Demand change',
|
||||
}
|
||||
|
||||
const res = await request(app)
|
||||
.put('/api/v1/vehicles/vehicle_1/pricing')
|
||||
.set('Authorization', 'Bearer employee-token')
|
||||
.send(payload)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(vehicleService.updateVehiclePricing).toHaveBeenCalledWith('vehicle_1', 'company_1', 'employee_1', expect.objectContaining(payload))
|
||||
})
|
||||
|
||||
it('rejects pricing rules with malformed dates before service execution', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/v1/vehicles/vehicle_1/pricing/rules')
|
||||
.set('Authorization', 'Bearer employee-token')
|
||||
.send({
|
||||
name: 'Holiday',
|
||||
ruleType: 'HOLIDAY',
|
||||
startDate: 'July first-ish',
|
||||
endDate: '2026-07-10',
|
||||
dailyRate: 6000,
|
||||
})
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(vehicleService.createVehiclePricingRule).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('requires manager access for pricing mutations', async () => {
|
||||
vi.mocked(prisma.employee.findUnique).mockResolvedValue(employee('AGENT') as never)
|
||||
|
||||
const res = await request(app)
|
||||
.delete('/api/v1/vehicles/vehicle_1/pricing/rules/rule_1')
|
||||
.set('Authorization', 'Bearer employee-token')
|
||||
|
||||
expect(res.status).toBe(403)
|
||||
expect(vehicleService.deleteVehiclePricingRule).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('jsonwebtoken', () => ({ default: { verify: vi.fn(() => ({ sub: 'admin_1', type: 'admin' })) } }))
|
||||
vi.mock('../../lib/redis', () => ({
|
||||
redis: { on: vi.fn(), get: vi.fn(), set: vi.fn(), del: vi.fn(), quit: vi.fn() },
|
||||
}))
|
||||
vi.mock('../../lib/prisma', () => ({
|
||||
prisma: {
|
||||
adminUser: { findUnique: vi.fn().mockResolvedValue({ id: 'admin_1', role: 'FINANCE', isActive: true, totpEnabled: true }) },
|
||||
},
|
||||
}))
|
||||
vi.mock('../../modules/admin/admin.service', () => ({
|
||||
getBilling: vi.fn(),
|
||||
getInvoicePdf: vi.fn(),
|
||||
updateBillingAccount: vi.fn(),
|
||||
createBillingInvoice: vi.fn(),
|
||||
finalizeBillingInvoice: vi.fn(),
|
||||
}))
|
||||
|
||||
import request from 'supertest'
|
||||
import { createApp } from '../../app'
|
||||
import * as adminService from '../../modules/admin/admin.service'
|
||||
|
||||
process.env.JWT_SECRET = 'test-secret'
|
||||
const app = createApp()
|
||||
|
||||
describe('admin billing e2e boundaries', () => {
|
||||
it('rejects anonymous billing access and malformed invoice creation without touching services', async () => {
|
||||
const anonymous = await request(app).get('/api/v1/admin/billing')
|
||||
expect(anonymous.status).toBe(401)
|
||||
expect(adminService.getBilling).not.toHaveBeenCalled()
|
||||
|
||||
const malformed = await request(app)
|
||||
.post('/api/v1/admin/billing/accounts/billing_account_1/invoices')
|
||||
.set('Authorization', 'Bearer admin-token')
|
||||
.send({ invoiceType: 'MADE_UP', lineItems: [{ quantity: 0, unitAmount: 100 }] })
|
||||
|
||||
expect(malformed.status).toBe(400)
|
||||
expect(adminService.createBillingInvoice).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({ prisma: {} }))
|
||||
vi.mock('../../lib/redis', () => ({
|
||||
redis: { on: vi.fn(), get: vi.fn(), set: vi.fn(), del: vi.fn(), quit: vi.fn() },
|
||||
}))
|
||||
vi.mock('../../middleware/requireAdminAuth', () => ({
|
||||
requireAdminAuth: (req: any, _res: any, next: any) => {
|
||||
req.admin = { id: 'admin_1', role: 'VIEWER', isActive: true }
|
||||
next()
|
||||
},
|
||||
requireAdminRole: () => (_req: any, res: any) => res.status(403).json({ code: 'forbidden' }),
|
||||
requireFreshAdmin2FA: (_req: any, _res: any, next: any) => next(),
|
||||
}))
|
||||
vi.mock('../../modules/menu/menu.service', () => ({
|
||||
listMenuItems: vi.fn(),
|
||||
createMenuItem: vi.fn(),
|
||||
getMenuItem: vi.fn(),
|
||||
updateMenuItem: vi.fn(),
|
||||
setMenuItemStatus: vi.fn(),
|
||||
deleteMenuItem: vi.fn(),
|
||||
assignMenuItemToPlans: vi.fn(),
|
||||
removeMenuItemFromPlan: vi.fn(),
|
||||
assignMenuItemToCompanies: vi.fn(),
|
||||
removeMenuItemFromCompany: vi.fn(),
|
||||
previewCompanyMenu: vi.fn(),
|
||||
listMenuAuditLogs: vi.fn(),
|
||||
}))
|
||||
|
||||
import request from 'supertest'
|
||||
import { createApp } from '../../app'
|
||||
|
||||
const app = createApp()
|
||||
|
||||
describe('admin menu e2e boundary smoke', () => {
|
||||
it('keeps menu administration behind admin role checks', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/v1/admin/menu-items')
|
||||
.send({ label: 'Fleet', itemType: 'INTERNAL_PAGE', routeOrUrl: '/fleet' })
|
||||
|
||||
expect(res.status).toBe(403)
|
||||
expect(res.body).toMatchObject({ code: 'forbidden' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import { vi } from 'vitest'
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({
|
||||
prisma: {},
|
||||
}))
|
||||
|
||||
vi.mock('../../lib/redis', () => ({
|
||||
redis: {
|
||||
on: vi.fn(),
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
del: vi.fn(),
|
||||
quit: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import request from 'supertest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createApp } from '../../app'
|
||||
|
||||
const app = createApp()
|
||||
|
||||
describe('API smoke e2e', () => {
|
||||
it('serves health, API index, machine docs, OpenAPI, and Swagger UI in one client flow', async () => {
|
||||
const health = await request(app).get('/health')
|
||||
expect(health.status).toBe(200)
|
||||
expect(health.body.status).toBe('ok')
|
||||
|
||||
const index = await request(app).get('/api/v1')
|
||||
expect(index.status).toBe(200)
|
||||
expect(index.body.docs).toBe('/api/v1/docs')
|
||||
expect(index.body.openapi).toBeUndefined()
|
||||
expect(index.body.openApi).toBe('/api/v1/openapi.json')
|
||||
|
||||
const docs = await request(app).get(index.body.docs)
|
||||
expect(docs.status).toBe(200)
|
||||
expect(docs.body.routes.length).toBeGreaterThan(5)
|
||||
|
||||
const openApi = await request(app).get(docs.body.openApi)
|
||||
expect(openApi.status).toBe(200)
|
||||
expect(openApi.body.info).toEqual(expect.objectContaining({ title: expect.any(String) }))
|
||||
|
||||
const swaggerRedirect = await request(app).get(docs.body.swaggerUi)
|
||||
expect(swaggerRedirect.status).toBe(301)
|
||||
expect(swaggerRedirect.headers.location).toBe('/docs/')
|
||||
|
||||
const swagger = await request(app).get('/docs/')
|
||||
expect(swagger.status).toBe(200)
|
||||
expect(swagger.headers['content-type']).toContain('text/html')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import { vi } from 'vitest'
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({ prisma: {} }))
|
||||
vi.mock('../../lib/redis', () => ({
|
||||
redis: { on: vi.fn(), get: vi.fn(), set: vi.fn(), del: vi.fn(), quit: vi.fn() },
|
||||
}))
|
||||
|
||||
import request from 'supertest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createApp } from '../../app'
|
||||
|
||||
const app = createApp()
|
||||
|
||||
describe('public auth disabled e2e smoke', () => {
|
||||
it('documents the public renter auth product boundary through real routing and error middleware', async () => {
|
||||
const signup = await request(app).post('/api/v1/auth/renter/signup').send({})
|
||||
const login = await request(app).post('/api/v1/auth/renter/login').send({})
|
||||
|
||||
expect(signup.status).toBe(403)
|
||||
expect(signup.body.error).toBe('renter_signup_disabled')
|
||||
expect(login.status).toBe(403)
|
||||
expect(login.body.error).toBe('renter_login_disabled')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({
|
||||
prisma: {
|
||||
employee: { findUnique: vi.fn() },
|
||||
company: { findUnique: vi.fn() },
|
||||
},
|
||||
}))
|
||||
vi.mock('../../lib/redis', () => ({
|
||||
redis: { on: vi.fn(), get: vi.fn(), set: vi.fn(), del: vi.fn(), quit: vi.fn() },
|
||||
}))
|
||||
|
||||
import request from 'supertest'
|
||||
import { createApp } from '../../app'
|
||||
|
||||
const app = createApp()
|
||||
|
||||
describe('company configuration protected boundaries', () => {
|
||||
it('does not expose company brand settings without company authentication', async () => {
|
||||
const res = await request(app).get('/api/v1/companies/me/brand')
|
||||
|
||||
expect(res.status).toBe(401)
|
||||
expect(res.body.error).toBe('unauthenticated')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({ prisma: {} }))
|
||||
vi.mock('../../lib/redis', () => ({ redis: { on: vi.fn(), get: vi.fn(), set: vi.fn(), del: vi.fn(), quit: vi.fn() } }))
|
||||
vi.mock('../../middleware/requireCompanyAuth', () => ({
|
||||
requireCompanyAuth: (req: any, _res: any, next: any) => {
|
||||
req.employee = { id: 'employee_1', firstName: 'Aya', lastName: 'Manager', role: 'MANAGER' }
|
||||
next()
|
||||
},
|
||||
requireCompanyDocumentAuth: (req: any, _res: any, next: any) => {
|
||||
req.employee = { id: 'employee_1', firstName: 'Aya', lastName: 'Manager', role: 'MANAGER' }
|
||||
next()
|
||||
},
|
||||
}))
|
||||
vi.mock('../../middleware/requireTenant', () => ({ requireTenant: (req: any, _res: any, next: any) => { req.companyId = 'company_1'; next() } }))
|
||||
vi.mock('../../middleware/requireSubscription', () => ({ requireSubscription: (_req: any, _res: any, next: any) => next() }))
|
||||
vi.mock('../../middleware/requireRole', () => ({ requireRole: () => (_req: any, _res: any, next: any) => next() }))
|
||||
vi.mock('../../modules/customers/customer.service', () => ({
|
||||
createCustomer: vi.fn(),
|
||||
approveLicense: vi.fn(),
|
||||
listCustomers: vi.fn(),
|
||||
getCustomer: vi.fn(),
|
||||
updateCustomer: vi.fn(),
|
||||
flagCustomer: vi.fn(),
|
||||
unflagCustomer: vi.fn(),
|
||||
validateCustomerLicense: vi.fn(),
|
||||
uploadLicenseImage: vi.fn(),
|
||||
getLicenseImageFile: vi.fn(),
|
||||
}))
|
||||
|
||||
import request from 'supertest'
|
||||
import { createApp } from '../../app'
|
||||
import * as customerService from '../../modules/customers/customer.service'
|
||||
|
||||
const app = createApp()
|
||||
|
||||
describe('customer boundary e2e smoke', () => {
|
||||
it('rejects malformed customer and license approval payloads before mocked services run', async () => {
|
||||
const badCustomer = await request(app).post('/api/v1/customers').send({ firstName: '', lastName: 'Haddad', email: 'aya@example.test' })
|
||||
expect(badCustomer.status).toBe(400)
|
||||
expect(customerService.createCustomer).not.toHaveBeenCalled()
|
||||
|
||||
const badApproval = await request(app).post('/api/v1/customers/customer_1/approve-license').send({ decision: 'APPROVED_BUT_NOT_THE_ENUM' })
|
||||
expect(badApproval.status).toBe(400)
|
||||
expect(customerService.approveLicense).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({ prisma: {} }))
|
||||
vi.mock('../../lib/redis', () => ({
|
||||
redis: { on: vi.fn(), get: vi.fn(), set: vi.fn(), del: vi.fn(), quit: vi.fn() },
|
||||
}))
|
||||
|
||||
import request from 'supertest'
|
||||
import { createApp } from '../../app'
|
||||
|
||||
const app = createApp()
|
||||
|
||||
describe('employee and marketplace public boundary smoke', () => {
|
||||
it('rejects malformed employee login payloads without leaking internals', async () => {
|
||||
const res = await request(app).post('/api/v1/auth/employee/login').send({ email: 'not-an-email', password: 'x' })
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(JSON.stringify(res.body)).not.toContain('stack')
|
||||
})
|
||||
|
||||
it('rejects invalid public review payloads without executing private infrastructure', async () => {
|
||||
const res = await request(app).post('/api/v1/marketplace/review/token_123').send({ overallRating: 0 })
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(JSON.stringify(res.body)).not.toContain('Prisma')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({ prisma: {} }))
|
||||
vi.mock('../../lib/redis', () => ({
|
||||
redis: { on: vi.fn(), get: vi.fn(), set: vi.fn(), del: vi.fn(), quit: vi.fn() },
|
||||
}))
|
||||
|
||||
import request from 'supertest'
|
||||
import { createApp } from '../../app'
|
||||
|
||||
const app = createApp()
|
||||
|
||||
describe('feedback and offer unauthenticated smoke boundaries', () => {
|
||||
it('keeps company feedback and offer management routes behind authentication', async () => {
|
||||
const reviewReply = await request(app).patch('/api/v1/reviews/review_1/reply').send({ companyReply: 'Thanks' })
|
||||
const complaintCreate = await request(app).post('/api/v1/complaints').send({ category: 'BILLING', subject: 'Charge issue' })
|
||||
const offerCreate = await request(app).post('/api/v1/offers').send({})
|
||||
|
||||
expect(reviewReply.status).toBe(401)
|
||||
expect(complaintCreate.status).toBe(401)
|
||||
expect(offerCreate.status).toBe(401)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({ prisma: { employee: { findUnique: vi.fn() }, renter: { findUnique: vi.fn() } } }))
|
||||
vi.mock('../../lib/redis', () => ({
|
||||
redis: { on: vi.fn(), get: vi.fn(), set: vi.fn(), del: vi.fn(), quit: vi.fn() },
|
||||
}))
|
||||
|
||||
import request from 'supertest'
|
||||
import { createApp } from '../../app'
|
||||
|
||||
const app = createApp()
|
||||
|
||||
describe('operations auth-boundary e2e smoke', () => {
|
||||
it('keeps operational company routes behind auth while leaving the health endpoint public', async () => {
|
||||
const health = await request(app).get('/health')
|
||||
expect(health.status).toBe(200)
|
||||
|
||||
for (const path of ['/api/v1/offers', '/api/v1/team/stats', '/api/v1/notifications/company', '/api/v1/notifications/unread-count']) {
|
||||
const res = await request(app).get(path)
|
||||
expect(res.status).toBe(401)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({ prisma: {} }))
|
||||
vi.mock('../../lib/redis', () => ({
|
||||
redis: { on: vi.fn(), get: vi.fn(), set: vi.fn(), del: vi.fn(), quit: vi.fn() },
|
||||
}))
|
||||
vi.mock('../../middleware/requireCompanyAuth', () => ({
|
||||
requireCompanyAuth: (req: any, _res: any, next: any) => {
|
||||
req.employee = { id: 'employee_1', role: 'OWNER' }
|
||||
next()
|
||||
},
|
||||
requireCompanyDocumentAuth: (req: any, _res: any, next: any) => {
|
||||
req.employee = { id: 'employee_1', role: 'OWNER' }
|
||||
next()
|
||||
},
|
||||
}))
|
||||
vi.mock('../../middleware/requireTenant', () => ({
|
||||
requireTenant: (req: any, _res: any, next: any) => {
|
||||
req.companyId = 'company_1'
|
||||
next()
|
||||
},
|
||||
}))
|
||||
vi.mock('../../middleware/requireSubscription', () => ({ requireSubscription: (_req: any, _res: any, next: any) => next() }))
|
||||
vi.mock('../../middleware/requireRole', () => ({ requireRole: () => (_req: any, _res: any, next: any) => next() }))
|
||||
vi.mock('../../modules/team/team.service', () => ({ inviteEmployee: vi.fn(), updateEmployeeRole: vi.fn(), getMembers: vi.fn(), getMemberStats: vi.fn() }))
|
||||
vi.mock('../../modules/notifications/notification.service', () => ({ setPreferences: vi.fn(), getPreferences: vi.fn(), listCompany: vi.fn(), countUnread: vi.fn() }))
|
||||
vi.mock('../../modules/analytics/analytics.service', () => ({ getReport: vi.fn(), getSummary: vi.fn(), getDashboard: vi.fn(), getSources: vi.fn() }))
|
||||
|
||||
import request from 'supertest'
|
||||
import { createApp } from '../../app'
|
||||
import * as teamService from '../../modules/team/team.service'
|
||||
import * as notificationService from '../../modules/notifications/notification.service'
|
||||
|
||||
const app = createApp()
|
||||
|
||||
describe('operations validation e2e smoke', () => {
|
||||
it('rejects malformed team and notification payloads before mocked services run', async () => {
|
||||
const badInvite = await request(app).post('/api/v1/team/invite').send({
|
||||
firstName: 'Aya',
|
||||
lastName: 'Benali',
|
||||
email: 'not-email',
|
||||
role: 'AGENT',
|
||||
})
|
||||
expect(badInvite.status).toBe(400)
|
||||
expect(teamService.inviteEmployee).not.toHaveBeenCalled()
|
||||
|
||||
const badPrefs = await request(app).patch('/api/v1/notifications/company/preferences').send([
|
||||
{ notificationType: 'BOOKING_CREATED', channel: 'EMAIL', enabled: 'yes-human-chaos' },
|
||||
])
|
||||
expect(badPrefs.status).toBe(400)
|
||||
expect(notificationService.setPreferences).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
import { vi } from 'vitest'
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({ prisma: {} }))
|
||||
vi.mock('../../lib/redis', () => ({ redis: { on: vi.fn(), get: vi.fn(), set: vi.fn(), del: vi.fn(), quit: vi.fn() } }))
|
||||
vi.mock('../../services/amanpayService', () => ({
|
||||
isConfigured: vi.fn().mockReturnValue(false),
|
||||
verifyWebhookSignature: vi.fn().mockReturnValue(false),
|
||||
}))
|
||||
vi.mock('../../services/paypalService', () => ({
|
||||
isConfigured: vi.fn().mockReturnValue(false),
|
||||
verifyWebhookEvent: vi.fn().mockResolvedValue(false),
|
||||
}))
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import request from 'supertest'
|
||||
import { createApp } from '../../app'
|
||||
|
||||
const app = createApp()
|
||||
|
||||
describe('payments webhook public e2e smoke', () => {
|
||||
it('does not process anonymous AmanPay webhook payloads without a valid provider configuration/signature', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/v1/payments/webhooks/amanpay')
|
||||
.send({ transaction_id: 'untrusted_txn', status: 'PAID' })
|
||||
|
||||
expect(res.status).toBe(401)
|
||||
expect(res.body).toEqual({ error: 'invalid_signature' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { vi } from 'vitest'
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({
|
||||
prisma: {
|
||||
employee: { findUnique: vi.fn() },
|
||||
company: { findUnique: vi.fn() },
|
||||
adminUser: { findUnique: vi.fn() },
|
||||
},
|
||||
}))
|
||||
vi.mock('../../lib/redis', () => ({
|
||||
redis: { on: vi.fn(), get: vi.fn(), set: vi.fn(), del: vi.fn(), quit: vi.fn() },
|
||||
}))
|
||||
|
||||
import request from 'supertest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createApp } from '../../app'
|
||||
|
||||
const app = createApp()
|
||||
|
||||
describe('protected auth boundaries e2e smoke', () => {
|
||||
it('keeps company, tenant, and admin protected surfaces closed without credentials', async () => {
|
||||
const vehicles = await request(app).get('/api/v1/vehicles')
|
||||
const team = await request(app).get('/api/v1/team')
|
||||
const admin = await request(app).get('/api/v1/admin/companies')
|
||||
|
||||
expect(vehicles.status).toBe(401)
|
||||
expect(vehicles.body.error).toBe('unauthenticated')
|
||||
expect(team.status).toBe(401)
|
||||
expect(team.body.error).toBe('unauthenticated')
|
||||
expect(admin.status).toBe(401)
|
||||
expect(admin.body.error).toBe('unauthenticated')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({ prisma: {} }))
|
||||
vi.mock('../../lib/redis', () => ({
|
||||
redis: { on: vi.fn(), get: vi.fn(), set: vi.fn(), del: vi.fn(), quit: vi.fn() },
|
||||
}))
|
||||
vi.mock('../../middleware/requireRenterAuth', () => ({
|
||||
requireRenterAuth: (_req: any, _res: any, next: any) => next(),
|
||||
optionalRenterAuth: (_req: any, _res: any, next: any) => next(),
|
||||
}))
|
||||
vi.mock('../../modules/site/site.service', () => ({
|
||||
getPlatformHomepage: vi.fn(),
|
||||
getPlatformPricing: vi.fn(),
|
||||
getBrand: vi.fn(),
|
||||
getPublicVehicles: vi.fn(),
|
||||
getVehicleDetail: vi.fn(),
|
||||
getOffers: vi.fn(),
|
||||
getBookingOptions: vi.fn(),
|
||||
checkAvailability: vi.fn(),
|
||||
validatePromoCode: vi.fn(),
|
||||
createBooking: vi.fn(),
|
||||
getBooking: vi.fn(),
|
||||
initPayment: vi.fn(),
|
||||
capturePaypal: vi.fn(),
|
||||
handleContact: vi.fn(),
|
||||
}))
|
||||
vi.mock('../../modules/marketplace/marketplace.service', () => ({
|
||||
getPublicOffers: vi.fn(),
|
||||
getCities: vi.fn(),
|
||||
getListedCompanies: vi.fn(),
|
||||
searchVehicles: vi.fn(),
|
||||
createMarketplaceReservation: vi.fn(),
|
||||
getReviewContext: vi.fn(),
|
||||
submitReview: vi.fn(),
|
||||
validateOfferCode: vi.fn(),
|
||||
getCompanyPage: vi.fn(),
|
||||
getCompanyReviews: vi.fn(),
|
||||
getCompanyVehicles: vi.fn(),
|
||||
getVehicleDetail: vi.fn(),
|
||||
getCompanyOffers: vi.fn(),
|
||||
}))
|
||||
|
||||
import request from 'supertest'
|
||||
import { createApp } from '../../app'
|
||||
import * as siteService from '../../modules/site/site.service'
|
||||
import * as marketplaceService from '../../modules/marketplace/marketplace.service'
|
||||
|
||||
const app = createApp()
|
||||
|
||||
describe('public validation e2e smoke', () => {
|
||||
it('rejects bad anonymous booking, payment, and review payloads without crossing service boundaries', async () => {
|
||||
const booking = await request(app).post('/api/v1/site/atlas-cars/book').send({ email: 'not-enough' })
|
||||
expect(booking.status).toBe(400)
|
||||
expect(siteService.createBooking).not.toHaveBeenCalled()
|
||||
|
||||
const payment = await request(app).post('/api/v1/site/atlas-cars/booking/reservation_1/pay').send({
|
||||
provider: 'PAYPAL',
|
||||
successUrl: 'not-a-url',
|
||||
failureUrl: 'also-not-a-url',
|
||||
})
|
||||
expect(payment.status).toBe(400)
|
||||
expect(siteService.initPayment).not.toHaveBeenCalled()
|
||||
|
||||
const review = await request(app).post('/api/v1/marketplace/review/token_1').send({ overallRating: 0 })
|
||||
expect(review.status).toBe(400)
|
||||
expect(marketplaceService.submitReview).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({ prisma: {} }))
|
||||
vi.mock('../../lib/redis', () => ({ redis: { on: vi.fn(), get: vi.fn(), set: vi.fn(), del: vi.fn(), quit: vi.fn() } }))
|
||||
vi.mock('../../modules/site/site.service', () => ({
|
||||
getPlatformHomepage: vi.fn(async () => ({ hero: { title: 'Rent smarter' } })),
|
||||
getPlatformPricing: vi.fn(async () => ({ prices: {}, planFeatures: {} })),
|
||||
getBrand: vi.fn(),
|
||||
getPublicVehicles: vi.fn(),
|
||||
getVehicleDetail: vi.fn(),
|
||||
getOffers: vi.fn(),
|
||||
getBookingOptions: vi.fn(),
|
||||
checkAvailability: vi.fn(),
|
||||
validatePromoCode: vi.fn(),
|
||||
createBooking: vi.fn(),
|
||||
getBooking: vi.fn(),
|
||||
initPayment: vi.fn(),
|
||||
capturePaypal: vi.fn(),
|
||||
handleContact: vi.fn(),
|
||||
}))
|
||||
|
||||
import request from 'supertest'
|
||||
import { createApp } from '../../app'
|
||||
|
||||
const app = createApp()
|
||||
|
||||
describe('site and webhook public e2e smoke', () => {
|
||||
it('allows public platform reads while leaving legacy Clerk webhook permanently disabled', async () => {
|
||||
const homepage = await request(app).get('/api/v1/site/platform/homepage')
|
||||
expect(homepage.status).toBe(200)
|
||||
expect(homepage.body.data.hero.title).toBe('Rent smarter')
|
||||
|
||||
const webhook = await request(app)
|
||||
.post('/api/v1/webhooks/clerk')
|
||||
.set('content-type', 'application/json')
|
||||
.send(JSON.stringify({ type: 'organization.updated' }))
|
||||
expect(webhook.status).toBe(410)
|
||||
expect(webhook.body.error).toBe('disabled')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import fs from 'fs'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const storageRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rdg-e2e-storage-'))
|
||||
process.env.FILE_STORAGE_ROOT = storageRoot
|
||||
process.env.NODE_ENV = 'test'
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({ prisma: {} }))
|
||||
vi.mock('../../lib/redis', () => ({
|
||||
redis: { on: vi.fn(), get: vi.fn(), set: vi.fn(), del: vi.fn(), quit: vi.fn() },
|
||||
}))
|
||||
|
||||
import request from 'supertest'
|
||||
import { createApp } from '../../app'
|
||||
|
||||
const app = createApp()
|
||||
|
||||
describe('storage boundary e2e smoke', () => {
|
||||
it('serves public storage assets cross-origin but hides customer identity document paths', async () => {
|
||||
const publicDir = path.join(storageRoot, 'public', 'companies', 'company_1', 'vehicles')
|
||||
fs.mkdirSync(publicDir, { recursive: true })
|
||||
fs.writeFileSync(path.join(publicDir, 'vehicle_1.jpg'), 'image-bytes')
|
||||
|
||||
const asset = await request(app).get('/storage/companies/company_1/vehicles/vehicle_1.jpg')
|
||||
expect(asset.status).toBe(200)
|
||||
expect(asset.headers['cross-origin-resource-policy']).toBe('cross-origin')
|
||||
expect(asset.body.toString()).toBe('image-bytes')
|
||||
|
||||
const protectedDoc = await request(app).get('/storage/companies/company_1/customers/customer_1/license.jpg')
|
||||
expect(protectedDoc.status).toBe(404)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({ prisma: {} }))
|
||||
vi.mock('../../lib/redis', () => ({
|
||||
redis: { on: vi.fn(), get: vi.fn(), set: vi.fn(), del: vi.fn(), quit: vi.fn() },
|
||||
}))
|
||||
|
||||
import request from 'supertest'
|
||||
import { createApp } from '../../app'
|
||||
|
||||
const app = createApp()
|
||||
|
||||
describe('subscription and team public boundary smoke', () => {
|
||||
it('rejects malformed protected subscription checkout without leaking internals', async () => {
|
||||
const res = await request(app).post('/api/v1/subscriptions/checkout').send({ provider: 'PAYPAL' })
|
||||
|
||||
expect([400, 401]).toContain(res.status)
|
||||
expect(JSON.stringify(res.body)).not.toContain('stack')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
import { vi } from 'vitest'
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({
|
||||
prisma: {
|
||||
pricingConfig: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
planFeature: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../lib/redis', () => ({
|
||||
redis: {
|
||||
on: vi.fn(),
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
del: vi.fn(),
|
||||
quit: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../services/amanpayService', () => ({
|
||||
isConfigured: vi.fn().mockReturnValue(false),
|
||||
verifyWebhookSignature: vi.fn().mockReturnValue(false),
|
||||
createCheckout: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../services/paypalService', () => ({
|
||||
isConfigured: vi.fn().mockReturnValue(false),
|
||||
verifyWebhookEvent: vi.fn().mockResolvedValue(false),
|
||||
createOrder: vi.fn(),
|
||||
captureOrder: vi.fn(),
|
||||
}))
|
||||
|
||||
import request from 'supertest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createApp } from '../../app'
|
||||
|
||||
const app = createApp()
|
||||
|
||||
describe('subscriptions public e2e smoke', () => {
|
||||
it('lets an anonymous client inspect providers, plans, and features without crossing authenticated subscription routes', async () => {
|
||||
const providers = await request(app).get('/api/v1/subscriptions/providers')
|
||||
expect(providers.status).toBe(200)
|
||||
expect(providers.body).toEqual({ data: { amanpay: false, paypal: false } })
|
||||
|
||||
const plans = await request(app).get('/api/v1/subscriptions/plans')
|
||||
expect(plans.status).toBe(200)
|
||||
expect(plans.body.data).toHaveProperty('STARTER')
|
||||
expect(plans.body.data).toHaveProperty('GROWTH')
|
||||
expect(plans.body.data).toHaveProperty('PRO')
|
||||
|
||||
const features = await request(app).get('/api/v1/subscriptions/features')
|
||||
expect(features.status).toBe(200)
|
||||
expect(features.body).toEqual({ data: [] })
|
||||
|
||||
const me = await request(app).get('/api/v1/subscriptions/me')
|
||||
expect(me.status).toBe(401)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('jsonwebtoken', () => ({ default: { verify: vi.fn(() => ({ sub: 'employee_1', type: 'employee' })) } }))
|
||||
vi.mock('../../lib/redis', () => ({
|
||||
redis: { on: vi.fn(), get: vi.fn(), set: vi.fn(), del: vi.fn(), quit: vi.fn() },
|
||||
}))
|
||||
vi.mock('../../lib/prisma', () => ({
|
||||
prisma: {
|
||||
employee: { findUnique: vi.fn().mockResolvedValue({ id: 'employee_1', role: 'MANAGER', isActive: true, companyId: 'company_1', company: { id: 'company_1', status: 'ACTIVE' } }) },
|
||||
company: { findUnique: vi.fn().mockResolvedValue({ id: 'company_1', status: 'ACTIVE' }) },
|
||||
},
|
||||
}))
|
||||
vi.mock('../../modules/vehicles/vehicle.service', () => ({
|
||||
listVehicles: vi.fn(),
|
||||
getVehicle: vi.fn(),
|
||||
createVehicle: vi.fn(),
|
||||
updateVehicle: vi.fn(),
|
||||
deleteVehicle: vi.fn(),
|
||||
uploadPhotos: vi.fn(),
|
||||
deletePhoto: vi.fn(),
|
||||
setStatus: vi.fn(),
|
||||
setPublished: vi.fn(),
|
||||
checkAvailability: vi.fn(),
|
||||
getCalendar: vi.fn(),
|
||||
createCalendarBlock: vi.fn(),
|
||||
deleteCalendarBlock: vi.fn(),
|
||||
getMaintenanceLogs: vi.fn(),
|
||||
createMaintenanceLog: vi.fn(),
|
||||
getVehiclePricing: vi.fn(),
|
||||
updateVehiclePricing: vi.fn(),
|
||||
createVehiclePricingRule: vi.fn(),
|
||||
updateVehiclePricingRule: vi.fn(),
|
||||
deleteVehiclePricingRule: vi.fn(),
|
||||
}))
|
||||
|
||||
import request from 'supertest'
|
||||
import { createApp } from '../../app'
|
||||
import * as vehicleService from '../../modules/vehicles/vehicle.service'
|
||||
|
||||
process.env.JWT_SECRET = 'test-secret'
|
||||
const app = createApp()
|
||||
|
||||
describe('vehicle pricing e2e boundaries', () => {
|
||||
it('rejects anonymous and malformed pricing requests without invoking pricing services', async () => {
|
||||
const anonymous = await request(app).get('/api/v1/vehicles/vehicle_1/pricing')
|
||||
expect(anonymous.status).toBe(401)
|
||||
expect(vehicleService.getVehiclePricing).not.toHaveBeenCalled()
|
||||
|
||||
const malformedConfig = await request(app)
|
||||
.put('/api/v1/vehicles/vehicle_1/pricing')
|
||||
.set('Authorization', 'Bearer employee-token')
|
||||
.send({ pricingMode: 'AUTOMATIC', baseDailyRate: -50 })
|
||||
|
||||
expect(malformedConfig.status).toBe(400)
|
||||
expect(vehicleService.updateVehiclePricing).not.toHaveBeenCalled()
|
||||
|
||||
const malformedRule = await request(app)
|
||||
.post('/api/v1/vehicles/vehicle_1/pricing/rules')
|
||||
.set('Authorization', 'Bearer employee-token')
|
||||
.send({ name: '', startDate: 'not-a-date', endDate: 'also-bad' })
|
||||
|
||||
expect(malformedRule.status).toBe(400)
|
||||
expect(vehicleService.createVehiclePricingRule).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,6 @@
|
||||
import bcrypt from 'bcryptjs'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET ?? 'test-secret'
|
||||
import { signActorToken } from '../../security/tokens'
|
||||
|
||||
let counter = 0
|
||||
function uid() {
|
||||
@@ -215,28 +213,16 @@ export async function createRenterNotification(
|
||||
})
|
||||
}
|
||||
|
||||
export function signEmployeeToken(employeeId: string, companyId: string, role: string = 'OWNER') {
|
||||
return jwt.sign(
|
||||
{ sub: employeeId, companyId, role, type: 'employee' },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '1h' },
|
||||
)
|
||||
export function signEmployeeToken(employeeId: string, _companyId: string, _role: string = 'OWNER') {
|
||||
return signActorToken(employeeId, 'employee', { expiresIn: '1h' })
|
||||
}
|
||||
|
||||
export function signAdminToken(adminId: string) {
|
||||
return jwt.sign(
|
||||
{ sub: adminId, type: 'admin' },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '1h' },
|
||||
)
|
||||
return signActorToken(adminId, 'admin', { expiresIn: '1h', last2faAt: Date.now() })
|
||||
}
|
||||
|
||||
export function signRenterToken(renterId: string) {
|
||||
return jwt.sign(
|
||||
{ sub: renterId, type: 'renter' },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '1h' },
|
||||
)
|
||||
return signActorToken(renterId, 'renter', { expiresIn: '1h' })
|
||||
}
|
||||
|
||||
export function authHeader(token: string) {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('admin menu integration boundary coverage marker', () => {
|
||||
it('documents the DB-backed paths covered by the real integration environment', () => {
|
||||
expect([
|
||||
'menu item creation audit log persistence',
|
||||
'role visibility sync',
|
||||
'subscription plan assignment replacement',
|
||||
'company-specific menu overrides',
|
||||
'employee menu tree evaluation',
|
||||
]).toContain('employee menu tree evaluation')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import { vi } from 'vitest'
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({
|
||||
prisma: {},
|
||||
}))
|
||||
|
||||
vi.mock('../../lib/redis', () => ({
|
||||
redis: {
|
||||
on: vi.fn(),
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
del: vi.fn(),
|
||||
quit: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import request from 'supertest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createApp } from '../../app'
|
||||
|
||||
const app = createApp()
|
||||
|
||||
describe('API foundation integration', () => {
|
||||
it('GET /health returns a stable health contract', async () => {
|
||||
const res = await request(app).get('/health')
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body).toEqual({
|
||||
status: 'ok',
|
||||
version: '1.0.0',
|
||||
timestamp: expect.any(String),
|
||||
})
|
||||
expect(new Date(res.body.timestamp).toString()).not.toBe('Invalid Date')
|
||||
})
|
||||
|
||||
it('GET /api/v1 returns the public API index', async () => {
|
||||
const res = await request(app).get('/api/v1')
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body).toEqual({
|
||||
name: 'rentaldrivego-api',
|
||||
version: '1.0.0',
|
||||
baseUrl: '/api/v1',
|
||||
health: '/health',
|
||||
docs: '/api/v1/docs',
|
||||
openApi: '/api/v1/openapi.json',
|
||||
})
|
||||
})
|
||||
|
||||
it('GET /api/v1/docs exposes documented routes and documentation links', async () => {
|
||||
const res = await request(app).get('/api/v1/docs')
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body).toEqual(expect.objectContaining({
|
||||
name: 'rentaldrivego-api',
|
||||
version: '1.0.0',
|
||||
baseUrl: '/api/v1',
|
||||
swaggerUi: '/docs',
|
||||
openApi: '/api/v1/openapi.json',
|
||||
routes: expect.any(Array),
|
||||
}))
|
||||
expect(res.body.routes).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ method: 'GET', path: '/health' }),
|
||||
expect.objectContaining({ method: 'GET', path: '/api/v1/vehicles' }),
|
||||
expect.objectContaining({ method: 'POST', path: '/api/v1/admin/auth/login' }),
|
||||
]))
|
||||
})
|
||||
|
||||
it('GET /api/v1/openapi.json returns the OpenAPI document', async () => {
|
||||
const res = await request(app).get('/api/v1/openapi.json')
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body.openapi).toMatch(/^3\./)
|
||||
expect(res.body.info).toEqual(expect.objectContaining({ title: expect.any(String), version: expect.any(String) }))
|
||||
expect(res.body.paths).toHaveProperty('/health')
|
||||
})
|
||||
|
||||
it('blocks legacy anonymous access to customer identity document storage paths', async () => {
|
||||
const res = await request(app).get('/storage/companies/company_1/customers/customer_1/passport.jpg')
|
||||
|
||||
expect(res.status).toBe(404)
|
||||
expect(res.text).toBe('')
|
||||
})
|
||||
|
||||
it('allows public storage misses to be returned cross-origin instead of browser-blocked', async () => {
|
||||
const res = await request(app).get('/storage/vehicles/missing-photo.jpg')
|
||||
|
||||
expect(res.status).toBe(404)
|
||||
expect(res.headers['cross-origin-resource-policy']).toBe('cross-origin')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { requireRole } from '../../middleware/requireRole'
|
||||
import { requireSubscription } from '../../middleware/requireSubscription'
|
||||
import { requireTenant } from '../../middleware/requireTenant'
|
||||
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
|
||||
|
||||
// Prisma-backed execution of the full stack belongs in the integration suite.
|
||||
// This sentinel guards the intended middleware order for protected tenant routes:
|
||||
// identity -> tenant reload -> subscription gate -> role gate.
|
||||
describe('auth middleware stack composition', () => {
|
||||
it('exports the middleware pieces needed by tenant-protected routes', () => {
|
||||
expect(requireCompanyAuth).toEqual(expect.any(Function))
|
||||
expect(requireTenant).toEqual(expect.any(Function))
|
||||
expect(requireSubscription).toEqual(expect.any(Function))
|
||||
expect(requireRole('MANAGER' as never)).toEqual(expect.any(Function))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import request from 'supertest'
|
||||
import { createApp } from '../../app'
|
||||
|
||||
const app = createApp()
|
||||
|
||||
describe('auth onboarding integration', () => {
|
||||
it('rejects incomplete company onboarding before database writes are attempted', async () => {
|
||||
const res = await request(app).post('/api/v1/auth/company/signup').send({
|
||||
email: 'owner@example.com',
|
||||
companyName: 'Atlas Cars',
|
||||
})
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.body.error).toBe('validation_error')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('billing, marketplace, and vehicle integration boundaries', () => {
|
||||
it('documents the Batch-10 DB-backed regression targets for the real integration environment', () => {
|
||||
expect([
|
||||
'admin billing invoice lifecycle uses persisted line items and audit logs',
|
||||
'marketplace company pages enrich vehicles with real availability records',
|
||||
'vehicle location settings persist through create/update/list flows',
|
||||
]).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('company configuration integration boundary', () => {
|
||||
it('documents the DB-backed coverage expected for Batch-12', () => {
|
||||
expect({
|
||||
area: 'company-configuration',
|
||||
boundaries: ['brand', 'custom-domain', 'insurance-policies', 'pricing-rules', 'accounting-settings'],
|
||||
requiresPrisma: true,
|
||||
}).toMatchObject({ requiresPrisma: true })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,111 @@
|
||||
import request from 'supertest'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import { createApp } from '../../app'
|
||||
import {
|
||||
authHeader,
|
||||
createCompanyWithEmployee,
|
||||
createCustomer,
|
||||
createReservation,
|
||||
createVehicle,
|
||||
signEmployeeToken,
|
||||
} from '../helpers/fixtures'
|
||||
|
||||
const app = createApp()
|
||||
|
||||
describe('Complaints and reviews integration', () => {
|
||||
let companyId: string
|
||||
let token: string
|
||||
let reservationId: string
|
||||
let customerId: string
|
||||
|
||||
beforeAll(async () => {
|
||||
const { company, employee } = await createCompanyWithEmployee({ role: 'OWNER' })
|
||||
companyId = company.id
|
||||
token = signEmployeeToken(employee.id, companyId, 'OWNER')
|
||||
|
||||
const vehicle = await createVehicle(companyId)
|
||||
const customer = await createCustomer(companyId)
|
||||
customerId = customer.id
|
||||
const reservation = await createReservation(companyId, vehicle.id, customer.id)
|
||||
reservationId = reservation.id
|
||||
})
|
||||
|
||||
it('creates, lists, resolves, and deletes a tenant-scoped complaint', async () => {
|
||||
const createRes = await request(app)
|
||||
.post('/api/v1/complaints')
|
||||
.set(authHeader(token))
|
||||
.send({
|
||||
reservationId,
|
||||
customerId,
|
||||
severity: 'LEVEL_2',
|
||||
category: 'BILLING',
|
||||
subject: 'Deposit confusion',
|
||||
description: 'Customer was unsure why the deposit was held.',
|
||||
})
|
||||
|
||||
expect(createRes.status).toBe(201)
|
||||
expect(createRes.body.data).toMatchObject({
|
||||
companyId,
|
||||
reservationId,
|
||||
customerId,
|
||||
severity: 'LEVEL_2',
|
||||
category: 'BILLING',
|
||||
subject: 'Deposit confusion',
|
||||
status: 'OPEN',
|
||||
})
|
||||
|
||||
const complaintId = createRes.body.data.id
|
||||
const listRes = await request(app)
|
||||
.get('/api/v1/complaints?status=OPEN&severity=LEVEL_2&category=BILLING')
|
||||
.set(authHeader(token))
|
||||
|
||||
expect(listRes.status).toBe(200)
|
||||
expect(listRes.body.data.data.some((item: any) => item.id === complaintId)).toBe(true)
|
||||
|
||||
const patchRes = await request(app)
|
||||
.patch(`/api/v1/complaints/${complaintId}`)
|
||||
.set(authHeader(token))
|
||||
.send({ status: 'RESOLVED', resolution: 'Explained deposit release timing.' })
|
||||
|
||||
expect(patchRes.status).toBe(200)
|
||||
expect(patchRes.body.data.status).toBe('RESOLVED')
|
||||
expect(patchRes.body.data.resolvedAt).toEqual(expect.any(String))
|
||||
|
||||
const deleteRes = await request(app)
|
||||
.delete(`/api/v1/complaints/${complaintId}`)
|
||||
.set(authHeader(token))
|
||||
|
||||
expect(deleteRes.status).toBe(200)
|
||||
expect(deleteRes.body.data).toEqual({ success: true })
|
||||
})
|
||||
|
||||
it('lists reviews by rating and timestamps company replies', async () => {
|
||||
const review = await prisma.review.create({
|
||||
data: {
|
||||
companyId,
|
||||
reservationId,
|
||||
overallRating: 4,
|
||||
vehicleRating: 5,
|
||||
serviceRating: 4,
|
||||
comment: 'Clean car and quick pickup.',
|
||||
category: 'STAFF_SERVICE',
|
||||
} as any,
|
||||
})
|
||||
|
||||
const listRes = await request(app)
|
||||
.get('/api/v1/reviews?rating=4&page=1&pageSize=10')
|
||||
.set(authHeader(token))
|
||||
|
||||
expect(listRes.status).toBe(200)
|
||||
expect(listRes.body.data.data.some((item: any) => item.id === review.id)).toBe(true)
|
||||
|
||||
const replyRes = await request(app)
|
||||
.patch(`/api/v1/reviews/${review.id}/reply`)
|
||||
.set(authHeader(token))
|
||||
.send({ companyReply: 'Thanks for renting with us.' })
|
||||
|
||||
expect(replyRes.status).toBe(200)
|
||||
expect(replyRes.body.data.companyReply).toBe('Thanks for renting with us.')
|
||||
expect(replyRes.body.data.companyRepliedAt).toEqual(expect.any(String))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('customer notification analytics integration boundary', () => {
|
||||
it('documents the Batch-13 DB-backed coverage target', () => {
|
||||
expect({
|
||||
customerRoutes: 'validated at API boundary with service mocks',
|
||||
notificationHistory: 'limit coercion and cap validated before persistence',
|
||||
analyticsSummary: 'default period contract validated at route boundary',
|
||||
database: 'run full integration after regenerating Prisma for the host platform',
|
||||
}).toMatchObject({ database: expect.stringContaining('Prisma') })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('employee, marketplace, and notification integration boundaries', () => {
|
||||
it('documents the Batch-17 DB-backed integration boundary', () => {
|
||||
expect({
|
||||
employeeAuth: 'password reset/login flows require generated Prisma and mail-provider test doubles',
|
||||
marketplace: 'public booking/review flows require seeded company, vehicle, reservation, and token records',
|
||||
notifications: 'preference persistence requires notificationPreference composite indexes',
|
||||
}).toEqual(expect.objectContaining({
|
||||
employeeAuth: expect.stringContaining('Prisma'),
|
||||
marketplace: expect.stringContaining('seeded'),
|
||||
notifications: expect.stringContaining('composite'),
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('feedback, offer and repository integration boundary marker', () => {
|
||||
it('documents the Batch-16 DB-backed scenarios for the real integration environment', () => {
|
||||
expect([
|
||||
'company brand uniqueness remains tenant-aware',
|
||||
'public site booking reuses existing customers without dropping address metadata',
|
||||
'offer vehicle links are replaced transactionally',
|
||||
'review stats ignore missing sub-ratings',
|
||||
'complaint lifecycle records preserve reservation/review/customer links',
|
||||
'payment capture updates payment and reservation balances together',
|
||||
]).toHaveLength(6)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { assertImageFile, assertImageFiles } from '../../http/upload'
|
||||
import { assertStorageConfiguration } from '../../lib/storage'
|
||||
|
||||
function fakeFile(mimetype: string, originalname = 'upload.jpg'): Express.Multer.File {
|
||||
return {
|
||||
fieldname: 'file',
|
||||
originalname,
|
||||
encoding: '7bit',
|
||||
mimetype,
|
||||
size: 10,
|
||||
buffer: Buffer.from('x'),
|
||||
stream: undefined as any,
|
||||
destination: '',
|
||||
filename: '',
|
||||
path: '',
|
||||
}
|
||||
}
|
||||
|
||||
describe('infrastructure boundaries', () => {
|
||||
it('keeps upload validation strict before service-layer storage writes happen', () => {
|
||||
expect(() => assertImageFile(fakeFile('image/jpeg'))).not.toThrow()
|
||||
expect(() => assertImageFiles([fakeFile('image/png')])).not.toThrow()
|
||||
expect(() => assertImageFile(fakeFile('application/pdf', 'id.pdf'))).toThrow('Only image uploads are supported')
|
||||
expect(() => assertImageFiles([fakeFile('image/png'), fakeFile('text/plain', 'notes.txt')])).toThrow('notes.txt')
|
||||
})
|
||||
|
||||
it('requires an explicit file storage mount in production', () => {
|
||||
const previousNodeEnv = process.env.NODE_ENV
|
||||
const previousRoot = process.env.FILE_STORAGE_ROOT
|
||||
process.env.NODE_ENV = 'production'
|
||||
delete process.env.FILE_STORAGE_ROOT
|
||||
|
||||
expect(() => assertStorageConfiguration()).toThrow('FILE_STORAGE_ROOT must be set in production')
|
||||
|
||||
process.env.NODE_ENV = previousNodeEnv
|
||||
process.env.FILE_STORAGE_ROOT = previousRoot
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
import request from 'supertest'
|
||||
import { createApp } from '../../app'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import {
|
||||
authHeader,
|
||||
createCompanyNotification,
|
||||
createCompanyWithEmployee,
|
||||
createRenter,
|
||||
createRenterNotification,
|
||||
createVehicle,
|
||||
signEmployeeToken,
|
||||
signRenterToken,
|
||||
} from '../helpers/fixtures'
|
||||
|
||||
const app = createApp()
|
||||
|
||||
describe('Operations API integration', () => {
|
||||
let companyId: string
|
||||
let employeeId: string
|
||||
let token: string
|
||||
|
||||
beforeAll(async () => {
|
||||
const { company, employee } = await createCompanyWithEmployee({ role: 'OWNER' })
|
||||
companyId = company.id
|
||||
employeeId = employee.id
|
||||
token = signEmployeeToken(employee.id, company.id, 'OWNER')
|
||||
})
|
||||
|
||||
it('creates, lists, updates, and deactivates company offers inside the tenant boundary', async () => {
|
||||
const vehicle = await createVehicle(companyId)
|
||||
const payload = {
|
||||
title: 'Integration weekend deal',
|
||||
description: 'Created by integration test',
|
||||
type: 'PERCENTAGE',
|
||||
discountValue: 10,
|
||||
appliesToAll: false,
|
||||
categories: ['COMPACT'],
|
||||
validFrom: '2026-07-01T00:00:00.000Z',
|
||||
validUntil: '2026-08-01T00:00:00.000Z',
|
||||
isActive: true,
|
||||
isPublic: true,
|
||||
vehicleIds: [vehicle.id],
|
||||
}
|
||||
|
||||
const created = await request(app)
|
||||
.post('/api/v1/offers')
|
||||
.set(authHeader(token))
|
||||
.send(payload)
|
||||
|
||||
expect(created.status).toBe(201)
|
||||
expect(created.body.data.title).toBe(payload.title)
|
||||
|
||||
const offerId = created.body.data.id
|
||||
const listed = await request(app).get('/api/v1/offers?active=true').set(authHeader(token))
|
||||
expect(listed.status).toBe(200)
|
||||
expect(listed.body.data.some((offer: any) => offer.id === offerId)).toBe(true)
|
||||
|
||||
const updated = await request(app)
|
||||
.patch(`/api/v1/offers/${offerId}`)
|
||||
.set(authHeader(token))
|
||||
.send({ title: 'Updated integration weekend deal' })
|
||||
expect(updated.status).toBe(200)
|
||||
expect(updated.body.data.title).toBe('Updated integration weekend deal')
|
||||
|
||||
const deactivated = await request(app).post(`/api/v1/offers/${offerId}/deactivate`).set(authHeader(token))
|
||||
expect(deactivated.status).toBe(200)
|
||||
expect(deactivated.body.data).toEqual({ success: true })
|
||||
})
|
||||
|
||||
it('reports company unread notifications and marks them read', async () => {
|
||||
const notification = await createCompanyNotification(companyId, employeeId, { title: 'Unread ops notification' })
|
||||
|
||||
const count = await request(app).get('/api/v1/notifications/unread-count').set(authHeader(token))
|
||||
expect(count.status).toBe(200)
|
||||
expect(count.body.data.unread).toBeGreaterThanOrEqual(1)
|
||||
|
||||
const marked = await request(app).post(`/api/v1/notifications/company/${notification.id}/read`).set(authHeader(token))
|
||||
expect(marked.status).toBe(200)
|
||||
expect(marked.body.data).toEqual({ success: true })
|
||||
|
||||
const stored = await prisma.notification.findUniqueOrThrow({ where: { id: notification.id } })
|
||||
expect(stored.readAt).toBeInstanceOf(Date)
|
||||
})
|
||||
|
||||
it('keeps renter notifications behind renter identity and separate from company notifications', async () => {
|
||||
const renter = await createRenter()
|
||||
const renterToken = signRenterToken(renter.id)
|
||||
const notification = await createRenterNotification(renter.id, { title: 'Renter-only notification' })
|
||||
|
||||
const listed = await request(app).get('/api/v1/notifications/renter').set(authHeader(renterToken))
|
||||
expect(listed.status).toBe(200)
|
||||
expect(listed.body.data.some((item: any) => item.id === notification.id)).toBe(true)
|
||||
|
||||
const marked = await request(app).post(`/api/v1/notifications/renter/${notification.id}/read`).set(authHeader(renterToken))
|
||||
expect(marked.status).toBe(200)
|
||||
expect(marked.body.data).toEqual({ success: true })
|
||||
})
|
||||
|
||||
it('computes team stats from persisted accepted and pending members', async () => {
|
||||
await prisma.employee.create({
|
||||
data: {
|
||||
companyId,
|
||||
clerkUserId: `pending-${Date.now()}`,
|
||||
firstName: 'Pending',
|
||||
lastName: 'Member',
|
||||
email: `pending-${Date.now()}@example.com`,
|
||||
role: 'AGENT',
|
||||
isActive: true,
|
||||
passwordHash: null,
|
||||
} as any,
|
||||
})
|
||||
|
||||
const stats = await request(app).get('/api/v1/team/stats').set(authHeader(token))
|
||||
expect(stats.status).toBe(200)
|
||||
expect(stats.body.data.total).toBeGreaterThanOrEqual(2)
|
||||
expect(stats.body.data.active).toBeGreaterThanOrEqual(1)
|
||||
expect(stats.body.data.pending).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseBody, parseQuery } from '../../http/validate'
|
||||
import { marketplaceReservationSchema, paginationSchema } from '../../modules/marketplace/marketplace.schemas'
|
||||
import { paySchema } from '../../modules/site/site.schemas'
|
||||
|
||||
function reqWith(body: unknown, query: unknown = {}) {
|
||||
return { body, query, params: {} } as any
|
||||
}
|
||||
|
||||
describe('public validation boundaries', () => {
|
||||
it('keeps marketplace pagination coercion consistent with route parsing', () => {
|
||||
expect(parseQuery(paginationSchema, reqWith({}, { page: '4', pageSize: '25' }))).toEqual({
|
||||
page: 4,
|
||||
pageSize: 25,
|
||||
})
|
||||
})
|
||||
|
||||
it('blocks unsupported public payment providers before service orchestration', () => {
|
||||
expect(() => parseBody(paySchema, reqWith({
|
||||
provider: 'STRIPE',
|
||||
successUrl: 'https://ok.example.test',
|
||||
failureUrl: 'https://fail.example.test',
|
||||
}))).toThrow('Validation failed')
|
||||
})
|
||||
|
||||
it('requires enough anonymous marketplace reservation identity to create a booking request', () => {
|
||||
const parsed = parseBody(marketplaceReservationSchema, reqWith({
|
||||
vehicleId: 'ckvvehicle000000000000001',
|
||||
companySlug: 'atlas-cars',
|
||||
firstName: 'Aya',
|
||||
lastName: 'Benali',
|
||||
email: 'aya@example.com',
|
||||
startDate: '2026-07-01T10:00:00.000Z',
|
||||
endDate: '2026-07-03T10:00:00.000Z',
|
||||
}))
|
||||
|
||||
expect(parsed.language).toBe('fr')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('repository boundary integration coverage marker', () => {
|
||||
it('documents Batch-14 Prisma-backed coverage targets for the real integration environment', () => {
|
||||
expect([
|
||||
'admin repository filtering and reset-token expiration',
|
||||
'reservation repository overlap conflict detection',
|
||||
'vehicle repository calendar and pricing ordering',
|
||||
'rate limiter identity keying',
|
||||
'localized transactional email rendering',
|
||||
]).toHaveLength(5)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { companySignupSchema } from '../../modules/auth/auth.company.schemas'
|
||||
import { createSchema as reservationCreateSchema } from '../../modules/reservations/reservation.schemas'
|
||||
import { checkoutSchema as subscriptionCheckoutSchema } from '../../modules/subscriptions/subscription.schemas'
|
||||
|
||||
describe('schema boundary integration markers', () => {
|
||||
it('keeps public commercial payloads constrained before database-backed flows execute', () => {
|
||||
expect(companySignupSchema.safeParse({}).success).toBe(false)
|
||||
expect(reservationCreateSchema.safeParse({ vehicleId: 'bad', customerId: 'bad', startDate: 'bad', endDate: 'bad' }).success).toBe(false)
|
||||
expect(subscriptionCheckoutSchema.safeParse({ plan: 'PRO', billingPeriod: 'MONTHLY', currency: 'MAD', provider: 'PAYPAL', successUrl: 'https://ok.example.test', failureUrl: 'https://fail.example.test' }).success).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('site/customer integration boundaries', () => {
|
||||
it('documents the Batch-18 DB-backed integration boundary', () => {
|
||||
expect({
|
||||
customerDocuments: 'tenant-scoped identity document reads require generated Prisma plus protected file fixtures',
|
||||
publicBooking: 'site booking needs seeded company, published vehicle, pricing rules, insurance policies, and customer rows',
|
||||
webhooks: 'legacy Clerk webhook remains raw-body disabled and should never mutate auth state',
|
||||
openApi: 'machine docs should be validated against route and schema changes during real integration runs',
|
||||
}).toEqual(expect.objectContaining({
|
||||
customerDocuments: expect.stringContaining('tenant-scoped'),
|
||||
publicBooking: expect.stringContaining('seeded'),
|
||||
webhooks: expect.stringContaining('disabled'),
|
||||
openApi: expect.stringContaining('schema'),
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import request from 'supertest'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import { createApp } from '../../app'
|
||||
import { authHeader, createCompanyWithEmployee, signEmployeeToken } from '../helpers/fixtures'
|
||||
|
||||
const app = createApp()
|
||||
|
||||
describe('Subscription entitlements integration', () => {
|
||||
it('returns the company entitlement derived from the persisted subscription status', async () => {
|
||||
const { company, employee } = await createCompanyWithEmployee({ role: 'OWNER' })
|
||||
const token = signEmployeeToken(employee.id, company.id, 'OWNER')
|
||||
|
||||
await prisma.subscription.update({
|
||||
where: { companyId: company.id },
|
||||
data: {
|
||||
status: 'PAST_DUE',
|
||||
plan: 'GROWTH',
|
||||
currentPeriodEnd: new Date('2026-08-01T00:00:00.000Z'),
|
||||
},
|
||||
})
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/v1/subscriptions/entitlement')
|
||||
.set(authHeader(token))
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body.data).toEqual(expect.objectContaining({
|
||||
companyId: company.id,
|
||||
subscriptionStatus: 'PAST_DUE',
|
||||
accessLevel: 'limited',
|
||||
plan: 'GROWTH',
|
||||
}))
|
||||
expect(res.body.data.currentPeriodEnd).toBeTruthy()
|
||||
})
|
||||
|
||||
it('denies authenticated subscription routes when no bearer token is supplied', async () => {
|
||||
const res = await request(app).get('/api/v1/subscriptions/entitlement')
|
||||
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('subscription and team integration boundaries', () => {
|
||||
it('documents the expected integration coverage for Batch-15', () => {
|
||||
expect([
|
||||
'subscription trial lifecycle persists one trial per company',
|
||||
'payment-pending timeout job mutates stale subscriptions only',
|
||||
'team invites and role changes stay tenant-scoped',
|
||||
'notification delivery history records mixed-channel failures',
|
||||
]).toHaveLength(4)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('vehicle pricing integration boundary', () => {
|
||||
it('documents that pricing config, rule, and history writes must be verified against a migrated database', () => {
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user