fix architecture and write new tests

This commit is contained in:
root
2026-06-10 00:40:19 -04:00
parent 560da1cadf
commit 80a597bc10
377 changed files with 84020 additions and 1337 deletions
+41 -11
View File
@@ -4,8 +4,9 @@ import helmet from 'helmet'
import morgan from 'morgan'
import swaggerUi from 'swagger-ui-express'
import { openApiDocument } from './swagger/openapi'
import { getLegacyStorageRoots, getStorageRoot } from './lib/storage'
import { getPublicStorageRoot } from './lib/storage'
import { authLimiter, apiLimiter, publicLimiter, adminLimiter } from './middleware/rateLimiter'
import { requestIdMiddleware } from './middleware/requestId'
// ─── Module routes ────────────────────────────────────────────
import webhookRouter from './modules/webhooks/webhook.routes'
@@ -78,6 +79,15 @@ export function createApp() {
app.set('trust proxy', 1)
}
app.use(requestIdMiddleware)
app.use((req, res, next) => {
if (req.headers['x-middleware-subrequest']) {
return res.status(400).json({ error: 'bad_request', message: 'Unsupported internal request header', statusCode: 400 })
}
next()
})
app.use(corsMiddleware)
// Customer identity documents must never be anonymously retrievable from the
@@ -95,24 +105,43 @@ export function createApp() {
next()
})
// Serve uploaded assets from the configured storage root, with a legacy fallback
// for older files that were written under the previous API-local path.
app.use('/storage', express.static(getStorageRoot()))
for (const legacyRoot of getLegacyStorageRoots()) {
app.use('/storage', express.static(legacyRoot))
}
// Serve only explicitly public uploaded assets. Private documents are resolved
// through authenticated API routes such as /customers/:id/license-image.
app.use('/storage', express.static(getPublicStorageRoot()))
// Swagger UI — mounted before helmet so its assets are not blocked by CSP
app.use('/docs', swaggerUi.serve, swaggerUi.setup(openApiDocument, { customSiteTitle: 'RentalDriveGo API Docs' }))
app.get('/api/v1/openapi.json', (_req, res) => res.json(openApiDocument))
// Webhook must use raw body BEFORE express.json()
app.use('/api/v1/webhooks', express.raw({ type: 'application/json' }), webhookRouter)
// Webhooks must use raw body BEFORE express.json(); signature verification
// must never reconstruct the payload with JSON.stringify(req.body).
app.use(`${v1}/webhooks`, express.raw({ type: 'application/json' }), webhookRouter)
app.use(`${v1}/payments/webhooks`, express.raw({ type: 'application/json' }))
app.use(`${v1}/subscriptions/webhooks`, express.raw({ type: 'application/json' }))
// Let /storage responses manage CORP explicitly so missing files still return
// a normal cross-origin 404 instead of being blocked by Helmet's default
// same-origin policy.
app.use(helmet({ crossOriginResourcePolicy: false }))
// same-origin policy. Keep CSP explicit instead of letting browser security
// drift into wishful thinking with headers.
app.use(helmet({
crossOriginResourcePolicy: false,
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
baseUri: ["'self'"],
frameAncestors: ["'none'"],
formAction: ["'self'"],
objectSrc: ["'none'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'blob:', 'https:'],
connectSrc: ["'self'", 'https:', 'wss:'],
upgradeInsecureRequests: process.env.NODE_ENV === 'production' ? [] : null,
},
},
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
frameguard: { action: 'deny' },
}))
if (process.env.NODE_ENV !== 'test') app.use(morgan('combined'))
app.use(express.json({ limit: '10mb' }))
@@ -121,6 +150,7 @@ export function createApp() {
app.use(`${v1}/auth/company`, authLimiter, companyAuthRouter)
app.use(`${v1}/auth/employee`, authLimiter, employeeAuthRouter)
app.use(`${v1}/admin/auth`, authLimiter)
app.use(`${v1}/admin`, adminLimiter, adminRouter)
app.use(`${v1}/marketplace`, publicLimiter, marketplaceRouter)
@@ -0,0 +1,99 @@
import { describe, expect, it, vi } from 'vitest'
import type { Response } from 'express'
import { z } from 'zod'
import { AppError, ConflictError, ForbiddenError, NotFoundError, UnauthorizedError, ValidationError } from './index'
import { errorMiddleware } from './errorMiddleware'
function createResponseStub() {
const res = {
status: vi.fn(),
json: vi.fn(),
}
res.status.mockReturnValue(res)
res.json.mockReturnValue(res)
return res as unknown as Response & typeof res
}
function handle(error: unknown) {
const res = createResponseStub()
errorMiddleware(error, {} as any, res, vi.fn())
return res
}
describe('AppError subclasses', () => {
it.each([
[new ValidationError('Bad payload'), 400, 'validation_error'],
[new UnauthorizedError(), 401, 'unauthorized'],
[new ForbiddenError(), 403, 'forbidden'],
[new NotFoundError(), 404, 'not_found'],
[new ConflictError(), 409, 'conflict'],
])('sets stable status and error code for %s', (error, statusCode, code) => {
expect(error).toBeInstanceOf(AppError)
expect(error.statusCode).toBe(statusCode)
expect(error.error).toBe(code)
})
})
describe('errorMiddleware', () => {
it('normalizes Zod errors into validation responses', () => {
const result = z.object({ email: z.string().email() }).safeParse({ email: 'not-an-email' })
expect(result.success).toBe(false)
const res = handle(result.success ? new Error('unexpected') : result.error)
expect(res.status).toHaveBeenCalledWith(400)
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
error: 'validation_error',
message: 'Invalid request body',
statusCode: 400,
issues: expect.any(Array),
}))
})
it('normalizes Prisma not found errors', () => {
const res = handle({ code: 'P2025' })
expect(res.status).toHaveBeenCalledWith(404)
expect(res.json).toHaveBeenCalledWith({
error: 'not_found',
message: 'Resource not found',
statusCode: 404,
})
})
it('normalizes Prisma unique constraint errors', () => {
const res = handle({ code: 'P2002' })
expect(res.status).toHaveBeenCalledWith(409)
expect(res.json).toHaveBeenCalledWith({
error: 'conflict',
message: 'A resource with this value already exists',
statusCode: 409,
})
})
it('preserves AppError metadata in the response body', () => {
const res = handle(new AppError('Plan required', 402, 'payment_required', { requiredPlan: 'PRO' }))
expect(res.status).toHaveBeenCalledWith(402)
expect(res.json).toHaveBeenCalledWith({
error: 'payment_required',
message: 'Plan required',
statusCode: 402,
requiredPlan: 'PRO',
})
})
it('falls back to a 500 internal error response', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
const res = handle(new Error('boom'))
expect(res.status).toHaveBeenCalledWith(500)
expect(res.json).toHaveBeenCalledWith({ error: 'internal_error', message: 'Internal server error', statusCode: 500, requestId: undefined })
spy.mockRestore()
})
})
+30 -11
View File
@@ -1,29 +1,48 @@
import { Request, Response, NextFunction } from 'express'
import { AppError } from './index'
export function errorMiddleware(err: any, _req: Request, res: Response, _next: NextFunction) {
function withRequestId(req: Request, payload: Record<string, unknown>) {
return { ...payload, requestId: req.requestId }
}
export function errorMiddleware(err: any, req: Request, res: Response, _next: NextFunction) {
if (err.name === 'ZodError') {
return res.status(400).json({ error: 'validation_error', message: 'Invalid request body', issues: err.issues, statusCode: 400 })
return res.status(400).json(withRequestId(req, {
error: 'validation_error',
message: 'Invalid request body',
issues: err.issues,
statusCode: 400,
}))
}
if (err.code === 'P2025') {
return res.status(404).json({ error: 'not_found', message: 'Resource not found', statusCode: 404 })
return res.status(404).json(withRequestId(req, { error: 'not_found', message: 'Resource not found', statusCode: 404 }))
}
if (err.code === 'P2002') {
return res.status(409).json({ error: 'conflict', message: 'A resource with this value already exists', statusCode: 409 })
return res.status(409).json(withRequestId(req, { error: 'conflict', message: 'A resource with this value already exists', statusCode: 409 }))
}
if (err instanceof AppError) {
if (err.statusCode >= 500) console.error('[API Error]', err)
return res.status(err.statusCode).json({ error: err.error, message: err.message, statusCode: err.statusCode, ...err.data })
if (err.statusCode >= 500) console.error('[API Error]', { requestId: req.requestId, err })
return res.status(err.statusCode).json(withRequestId(req, {
error: err.error,
message: err.statusCode >= 500 ? 'Internal server error' : err.message,
statusCode: err.statusCode,
...(err.statusCode >= 500 ? {} : err.data),
}))
}
const statusCode = err.statusCode ?? 500
const message = err.message ?? 'Internal server error'
const code = err.code ?? 'internal_error'
const statusCode = typeof err.statusCode === 'number' ? err.statusCode : 500
const safeStatusCode = statusCode >= 400 && statusCode < 600 ? statusCode : 500
if (statusCode >= 500) console.error('[API Error]', err)
if (safeStatusCode >= 500) {
console.error('[API Error]', { requestId: req.requestId, err })
}
res.status(statusCode).json({ error: code, message, statusCode })
res.status(safeStatusCode).json(withRequestId(req, {
error: safeStatusCode >= 500 ? 'internal_error' : (err.code ?? 'request_error'),
message: safeStatusCode >= 500 ? 'Internal server error' : (err.message ?? 'Request failed'),
statusCode: safeStatusCode,
}))
}
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it, vi } from 'vitest'
import type { Response } from 'express'
import { created, noContent, ok } from './index'
function createResponseStub() {
const res = {
status: vi.fn(),
json: vi.fn(),
end: vi.fn(),
}
res.status.mockReturnValue(res)
res.json.mockReturnValue(res)
res.end.mockReturnValue(res)
return res as unknown as Response & typeof res
}
describe('http/respond helpers', () => {
it('ok responds with the expected data envelope', () => {
const res = createResponseStub()
const payload = { id: 'vehicle_1', name: 'Dacia Logan' }
ok(res, payload)
expect(res.status).not.toHaveBeenCalled()
expect(res.json).toHaveBeenCalledWith({ data: payload })
})
it('created responds with status 201 and the expected data envelope', () => {
const res = createResponseStub()
const payload = { id: 'reservation_1' }
created(res, payload)
expect(res.status).toHaveBeenCalledWith(201)
expect(res.json).toHaveBeenCalledWith({ data: payload })
})
it('noContent responds with status 204 and no body', () => {
const res = createResponseStub()
noContent(res)
expect(res.status).toHaveBeenCalledWith(204)
expect(res.end).toHaveBeenCalledWith()
expect(res.json).not.toHaveBeenCalled()
})
})
+56 -7
View File
@@ -1,7 +1,14 @@
import path from 'path'
import multer from 'multer'
import { ValidationError } from '../errors'
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10 MB
const ALLOWED_IMAGE_TYPES = new Map<string, string[]>([
['image/jpeg', ['.jpg', '.jpeg']],
['image/png', ['.png']],
['image/webp', ['.webp']],
['image/gif', ['.gif']],
])
/**
* Shared multer instance used by all upload endpoints.
@@ -9,26 +16,68 @@ const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10 MB
*/
export const imageUpload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: MAX_FILE_SIZE },
limits: { fileSize: MAX_FILE_SIZE, files: 20 },
})
type DetectedFile = { mime: string; ext: string }
export function detectImageType(buffer: Buffer): DetectedFile | null {
if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
return { mime: 'image/jpeg', ext: '.jpg' }
}
if (
buffer.length >= 8 &&
buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47 &&
buffer[4] === 0x0d && buffer[5] === 0x0a && buffer[6] === 0x1a && buffer[7] === 0x0a
) {
return { mime: 'image/png', ext: '.png' }
}
if (buffer.length >= 12 && buffer.subarray(0, 4).toString('ascii') === 'RIFF' && buffer.subarray(8, 12).toString('ascii') === 'WEBP') {
return { mime: 'image/webp', ext: '.webp' }
}
if (buffer.length >= 6) {
const sig = buffer.subarray(0, 6).toString('ascii')
if (sig === 'GIF87a' || sig === 'GIF89a') return { mime: 'image/gif', ext: '.gif' }
}
return null
}
function assertSafeImageContent(file: Express.Multer.File) {
const detected = detectImageType(file.buffer)
if (!detected || !ALLOWED_IMAGE_TYPES.has(detected.mime)) {
throw new ValidationError('Unsupported or spoofed image file')
}
if (file.mimetype !== detected.mime) {
throw new ValidationError(`MIME type does not match file content for "${file.originalname}"`)
}
const ext = path.extname(file.originalname).toLowerCase()
const allowedExtensions = ALLOWED_IMAGE_TYPES.get(detected.mime) ?? []
if (ext && !allowedExtensions.includes(ext)) {
throw new ValidationError(`File extension does not match file content for "${file.originalname}"`)
}
}
/**
* Asserts that a file was provided and is an image MIME type.
* Call inside the route handler after the multer middleware runs.
* Asserts that a file was provided and is an image by content, not by client claims.
*/
export function assertImageFile(
file: Express.Multer.File | undefined,
fieldLabel = 'file',
): asserts file is Express.Multer.File {
if (!file) throw new ValidationError(`A ${fieldLabel} is required`)
if (!file.mimetype.startsWith('image/')) throw new ValidationError('Only image uploads are supported')
assertSafeImageContent(file)
}
/**
* Asserts that at least one file was provided and all files are images.
* Asserts that at least one file was provided and all files are image content.
*/
export function assertImageFiles(files: Express.Multer.File[], fieldLabel = 'photos'): void {
if (!files || files.length === 0) throw new ValidationError(`At least one ${fieldLabel} file is required`)
const nonImage = files.find((f) => !f.mimetype.startsWith('image/'))
if (nonImage) throw new ValidationError(`All uploaded files must be images — "${nonImage.originalname}" is not`)
for (const file of files) assertSafeImageContent(file)
}
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'
import { ValidationError } from '../errors'
import { assertImageFile, assertImageFiles } from './index'
const file = (overrides: Partial<Express.Multer.File> = {}) => ({
fieldname: 'file',
originalname: 'photo.jpg',
encoding: '7bit',
mimetype: 'image/jpeg',
size: 123,
buffer: Buffer.from([0xff, 0xd8, 0xff, 0xdb]),
stream: undefined as any,
destination: '',
filename: '',
path: '',
...overrides,
})
describe('upload assertions', () => {
it('accepts a single image file and narrows the route input', () => {
expect(() => assertImageFile(file())).not.toThrow()
})
it('rejects missing single file uploads with a field-specific error', () => {
expect(() => assertImageFile(undefined, 'license image')).toThrow(ValidationError)
expect(() => assertImageFile(undefined, 'license image')).toThrow('A license image is required')
})
it('rejects non-image single file uploads', () => {
expect(() => assertImageFile(file({ mimetype: 'application/pdf', originalname: 'license.pdf' }))).toThrow('MIME type does not match file content')
})
it('accepts multiple image files and rejects empty or mixed batches', () => {
expect(() => assertImageFiles([file({ originalname: 'front.png', mimetype: 'image/png', buffer: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) })])).not.toThrow()
expect(() => assertImageFiles([], 'inspection photos')).toThrow('At least one inspection photos file is required')
expect(() => assertImageFiles([
file({ originalname: 'front.png', mimetype: 'image/png', buffer: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) }),
file({ originalname: 'report.pdf', mimetype: 'application/pdf', buffer: Buffer.from('%PDF') }),
])).toThrow('Unsupported or spoofed image file')
})
})
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import type { Request } from 'express'
import { z } from 'zod'
import { parseBody, parseParams, parseQuery } from './index'
describe('http/validate parsers', () => {
it('parseBody returns typed and coerced request body values', () => {
const schema = z.object({ seats: z.coerce.number().int().min(1), brand: z.string().min(1) })
const req = { body: { seats: '5', brand: 'Toyota' } } as Request
expect(parseBody(schema, req)).toEqual({ seats: 5, brand: 'Toyota' })
})
it('parseQuery validates query values and throws ZodError for invalid input', () => {
const schema = z.object({ page: z.coerce.number().int().positive() })
const req = { query: { page: '0' } } as unknown as Request
expect(() => parseQuery(schema, req)).toThrow('Number must be greater than 0')
})
it('parseParams returns validated path parameters', () => {
const schema = z.object({ vehicleId: z.string().min(1) })
const req = { params: { vehicleId: 'veh_123' } } as unknown as Request
expect(parseParams(schema, req)).toEqual({ vehicleId: 'veh_123' })
})
})
+18
View File
@@ -0,0 +1,18 @@
import type { Request } from 'express'
import { ValidationError } from './errors'
export function getRawBodyString(req: Request) {
if (!Buffer.isBuffer(req.body)) {
throw new ValidationError('Webhook route must be mounted with express.raw before JSON parsing')
}
return req.body.toString('utf8')
}
export function parseRawJsonBody<T = unknown>(req: Request): T {
const rawBody = getRawBodyString(req)
try {
return JSON.parse(rawBody) as T
} catch {
throw new ValidationError('Malformed webhook JSON')
}
}
+30 -3
View File
@@ -1,11 +1,13 @@
import http from 'http'
import { Server as SocketIOServer } from 'socket.io'
import type { Socket } from 'socket.io'
import cron from 'node-cron'
import jwt from 'jsonwebtoken'
import { redis } from './lib/redis'
import { prisma } from './lib/prisma'
import { assertStorageConfiguration } from './lib/storage'
import { createApp, corsOrigins } from './app'
import { verifyAnyActorToken } from './security/tokens'
import { getSessionCookieName } from './security/sessionCookies'
import { sendNotification } from './services/notificationService'
import {
runTrialExpirationJob,
@@ -24,12 +26,37 @@ const io = new SocketIOServer(server, {
cors: { origin: corsOrigins, credentials: true, methods: ['GET', 'POST'] },
})
function readCookieFromHeader(cookieHeader: string | undefined, name: string): string | null {
if (!cookieHeader) return null
for (const chunk of cookieHeader.split(';')) {
const [rawName, ...rawValue] = chunk.trim().split('=')
if (rawName === name) return decodeURIComponent(rawValue.join('='))
}
return null
}
function getSocketSessionToken(socket: Socket): string | undefined {
const explicitToken = socket.handshake.auth?.token
if (typeof explicitToken === 'string' && explicitToken.trim()) return explicitToken.trim()
const cookieHeader = socket.request.headers.cookie
return (
readCookieFromHeader(cookieHeader, getSessionCookieName('employee')) ??
readCookieFromHeader(cookieHeader, getSessionCookieName('admin')) ??
readCookieFromHeader(cookieHeader, getSessionCookieName('renter')) ??
undefined
)
}
// Authenticate socket connections via JWT before joining user rooms
io.use((socket, next) => {
const token = socket.handshake.auth?.token as string | undefined
const token = getSocketSessionToken(socket)
if (!token) return next() // unauthenticated connections allowed; they just don't join rooms
try {
const payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string }
const payload = verifyAnyActorToken(token)
;(socket as any).authenticatedUserId = payload.sub
next()
} catch {
@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest'
import {
formatDate,
marketplaceReservationEmail,
resetPasswordEmail,
signupEmail,
} from './emailTranslations'
describe('emailTranslations', () => {
const trialEnd = new Date('2026-07-15T12:00:00.000Z')
it('renders signup subjects in every supported locale', () => {
expect(signupEmail.subject('en')).toContain('workspace')
expect(signupEmail.subject('fr')).toContain('espace')
expect(signupEmail.subject('ar')).toContain('جاهزة')
})
it('renders localized signup text with billing and provider details', () => {
const text = signupEmail.text({
firstName: 'Aya',
companyName: 'Atlas Cars',
plan: 'PRO',
billingPeriod: 'ANNUAL',
currency: 'MAD',
paymentProvider: 'AmanPay',
trialEnd,
}, 'fr')
expect(text).toContain('Bonjour Aya')
expect(text).toContain('Atlas Cars')
expect(text).toContain('Forfait : PRO (annuel)')
expect(text).toContain('Fournisseur de paiement principal : AmanPay')
})
it('marks Arabic reset-password HTML as right-to-left and embeds the reset URL', () => {
const html = resetPasswordEmail.html('https://example.test/reset/token', 'Mina', 45, 'ar')
expect(html).toContain('dir="rtl"')
expect(html).toContain('https://example.test/reset/token')
expect(html).toContain('45')
})
it('includes optional contact phone in marketplace reservation HTML when present', () => {
const html = marketplaceReservationEmail.html({
firstName: 'Yassine',
vehicleYear: 2024,
vehicleMake: 'Dacia',
vehicleModel: 'Duster',
companyName: 'Atlas Cars',
startDate: new Date('2026-08-01T00:00:00.000Z'),
endDate: new Date('2026-08-05T00:00:00.000Z'),
totalDays: 4,
rateDisplay: '400.00',
totalDisplay: '1600.00',
email: 'yassine@example.test',
phone: '+212600000000',
}, 'en')
expect(html).toContain('2024 Dacia Duster')
expect(html).toContain('Atlas Cars')
expect(html).toContain('yassine@example.test or +212600000000')
})
it('formats dates with the locale-specific formatter', () => {
expect(formatDate(new Date('2026-02-03T00:00:00.000Z'), 'en')).toContain('2026')
expect(formatDate(new Date('2026-02-03T00:00:00.000Z'), 'fr')).toContain('2026')
expect(formatDate(new Date('2026-02-03T00:00:00.000Z'), 'ar')).toMatch(/2026|٢٠٢٦/)
expect(formatDate(new Date('2026-02-03T00:00:00.000Z'), 'ar')).toContain('فبراير')
})
})
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest'
import { isDatabaseUnavailableError } from './isDatabaseUnavailable'
describe('isDatabaseUnavailableError', () => {
it('detects Prisma P1001 connection failures', () => {
expect(isDatabaseUnavailableError({ code: 'P1001' })).toBe(true)
})
it('detects database reachability failures by message', () => {
expect(isDatabaseUnavailableError({ message: "Can't reach database server at postgres:5432" })).toBe(true)
})
it('rejects unrelated, null, and primitive errors', () => {
expect(isDatabaseUnavailableError({ code: 'P2002' })).toBe(false)
expect(isDatabaseUnavailableError(new Error('network hiccup'))).toBe(false)
expect(isDatabaseUnavailableError(null)).toBe(false)
expect(isDatabaseUnavailableError('P1001')).toBe(false)
})
})
+62
View File
@@ -0,0 +1,62 @@
import fs from 'fs'
import os from 'os'
import path from 'path'
import { afterEach, describe, expect, it } from 'vitest'
import {
assertStorageConfiguration,
deleteImage,
getPrivateStorageRoot,
getProtectedCustomerLicenseImageUrl,
getPublicStorageRoot,
getStorageRoot,
resolveStoredFilePath,
uploadImage,
} from './storage'
const originalEnv = { ...process.env }
describe('storage', () => {
afterEach(() => {
process.env = { ...originalEnv }
})
it('uses FILE_STORAGE_ROOT when configured and builds protected customer license URLs from dashboard URL', () => {
process.env.FILE_STORAGE_ROOT = '/tmp/rdg-storage'
process.env.DASHBOARD_URL = 'https://dashboard.rentaldrivego.test/dashboard/'
expect(getStorageRoot()).toBe('/tmp/rdg-storage')
expect(getPublicStorageRoot()).toBe('/tmp/rdg-storage/public')
expect(getPrivateStorageRoot()).toBe('/tmp/rdg-storage/private')
expect(getProtectedCustomerLicenseImageUrl('customer_1')).toBe('https://dashboard.rentaldrivego.test/dashboard/api/v1/customers/customer_1/license-image')
})
it('rejects implicit in-app storage in production', () => {
delete process.env.FILE_STORAGE_ROOT
process.env.NODE_ENV = 'production'
expect(() => assertStorageConfiguration()).toThrow('FILE_STORAGE_ROOT must be set in production')
})
it('uploads, resolves, and deletes files under the configured storage root', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rdg-storage-'))
process.env.FILE_STORAGE_ROOT = root
process.env.API_URL = 'https://api.rentaldrivego.test/'
const url = await uploadImage(Buffer.from('image-bytes'), 'customers/licenses', 'customer_1')
expect(url).toBe('https://api.rentaldrivego.test/storage/customers/licenses/customer_1.jpg')
const filePath = resolveStoredFilePath(url)
expect(filePath).toBe(path.join(root, 'private/customers/licenses/customer_1.jpg'))
expect(fs.readFileSync(filePath!, 'utf8')).toBe('image-bytes')
await deleteImage(url)
expect(fs.existsSync(filePath!)).toBe(false)
})
it('ignores non-storage URLs when resolving or deleting files', async () => {
process.env.FILE_STORAGE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'rdg-storage-'))
expect(resolveStoredFilePath('https://cdn.test/not-storage/file.jpg')).toBeNull()
await expect(deleteImage('https://cdn.test/not-storage/file.jpg')).resolves.toBeUndefined()
})
})
+34 -7
View File
@@ -10,12 +10,22 @@ const LEGACY_STORAGE_ROOTS = [
path.join(APP_SOURCE_ROOT, 'lib', 'storage'),
]
export type StorageVisibility = 'public' | 'private'
export function getStorageRoot(): string {
return process.env.FILE_STORAGE_ROOT
? path.resolve(process.env.FILE_STORAGE_ROOT)
: DEFAULT_STORAGE_ROOT
}
export function getPublicStorageRoot(): string {
return path.join(getStorageRoot(), 'public')
}
export function getPrivateStorageRoot(): string {
return path.join(getStorageRoot(), 'private')
}
export function getLegacyStorageRoots(): string[] {
return LEGACY_STORAGE_ROOTS
}
@@ -45,10 +55,22 @@ export function assertStorageConfiguration(): string {
return storageRoot
}
function ensureStorageRoot(): string {
const storageRoot = assertStorageConfiguration()
fs.mkdirSync(storageRoot, { recursive: true })
return storageRoot
function ensureStorageRoot(visibility: StorageVisibility): string {
assertStorageConfiguration()
const root = visibility === 'public' ? getPublicStorageRoot() : getPrivateStorageRoot()
fs.mkdirSync(root, { recursive: true })
return root
}
function inferVisibility(folder: string): StorageVisibility {
const normalized = folder.replace(/\\/g, '/')
if (/\/customers\//.test(`/${normalized}/`)) return 'private'
if (/\/licenses?\//.test(`/${normalized}/`)) return 'private'
if (/\/contracts?\//.test(`/${normalized}/`)) return 'private'
if (/\/documents?\//.test(`/${normalized}/`)) return 'private'
if (/\/inspections?\//.test(`/${normalized}/`)) return 'private'
if (/\/internal\//.test(`/${normalized}/`)) return 'private'
return 'public'
}
function getApiBase(): string {
@@ -77,10 +99,14 @@ export function getProtectedCustomerLicenseImageUrl(customerId: string): string
export async function uploadImage(
buffer: Buffer,
folder: string,
publicId?: string
publicId?: string,
visibility: StorageVisibility = inferVisibility(folder),
): Promise<string> {
const storageRoot = ensureStorageRoot()
const storageRoot = ensureStorageRoot(visibility)
const folderPath = path.join(storageRoot, folder)
if (!isWithinPath(folderPath, storageRoot)) {
throw new Error('Upload path escapes storage root')
}
fs.mkdirSync(folderPath, { recursive: true })
const filename = publicId
@@ -96,9 +122,10 @@ export function resolveStoredFilePath(imageUrl: string): string | null {
const relative = getStorageRelativePath(imageUrl)
if (!relative) return null
const roots = [assertStorageConfiguration(), ...getLegacyStorageRoots()]
const roots = [getPublicStorageRoot(), getPrivateStorageRoot(), ...getLegacyStorageRoots(), assertStorageConfiguration()]
for (const root of roots) {
const filePath = path.join(root, relative)
if (!isWithinPath(filePath, root)) continue
if (fs.existsSync(filePath)) {
return filePath
}
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

@@ -0,0 +1,68 @@
import { describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
import { getCookie, sendForbidden, sendPaymentRequired, sendUnauthorized } from './authHelpers'
function responseStub() {
const res = {
status: vi.fn(),
json: vi.fn(),
}
res.status.mockReturnValue(res)
res.json.mockReturnValue(res)
return res as unknown as Response & typeof res
}
describe('authHelpers', () => {
it('extracts and decodes a cookie value by name', () => {
const req = {
headers: {
cookie: 'theme=dark; employee_session=abc%20123%3D; other=value',
},
} as Request
expect(getCookie(req, 'employee_session')).toBe('abc 123=')
})
it('returns null when the cookie header or requested cookie is missing', () => {
expect(getCookie({ headers: {} } as Request, 'employee_session')).toBeNull()
expect(getCookie({ headers: { cookie: 'theme=dark' } } as Request, 'employee_session')).toBeNull()
})
it('sends a uniform unauthorized response', () => {
const res = responseStub()
sendUnauthorized(res, 'invalid_token', 'Invalid token')
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({ error: 'invalid_token', message: 'Invalid token', statusCode: 401 })
})
it('sends forbidden responses with optional metadata', () => {
const res = responseStub()
sendForbidden(res, 'forbidden', 'Nope', { requiredRole: 'OWNER' })
expect(res.status).toHaveBeenCalledWith(403)
expect(res.json).toHaveBeenCalledWith({
error: 'forbidden',
message: 'Nope',
statusCode: 403,
requiredRole: 'OWNER',
})
})
it('sends payment-required responses with optional metadata', () => {
const res = responseStub()
sendPaymentRequired(res, 'subscription_suspended', 'Pay up', { billingUrl: '/billing' })
expect(res.status).toHaveBeenCalledWith(402)
expect(res.json).toHaveBeenCalledWith({
error: 'subscription_suspended',
message: 'Pay up',
statusCode: 402,
billingUrl: '/billing',
})
})
})
+13
View File
@@ -23,6 +23,19 @@ export function getCookie(req: Request, name: string): string | null {
return null
}
export function getBearerToken(req: Request): string | null {
const authHeader = req.headers.authorization
if (!authHeader?.startsWith('Bearer ')) return null
const token = authHeader.slice(7).trim()
return token.length > 0 ? token : null
}
export function getAuthToken(req: Request, cookieName?: string): string | null {
// Prefer HttpOnly cookies over bearer tokens so stale script-readable tokens
// cannot shadow a valid server-managed session during migration.
return (cookieName ? getCookie(req, cookieName) : null) ?? getBearerToken(req)
}
export function sendForbidden(res: Response, error: string, message: string, extra?: Record<string, unknown>) {
return res.status(403).json({ error, message, statusCode: 403, ...extra })
}
@@ -0,0 +1,69 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const createdLimiters: any[] = []
vi.mock('express-rate-limit', () => ({
default: vi.fn((config: any) => {
createdLimiters.push(config)
return config
}),
ipKeyGenerator: vi.fn((ip: string) => `ip:${ip}`),
}))
vi.mock('../security/tokens', () => ({
verifyAnyActorToken: vi.fn((token: string) => {
if (token === 'employee-token') return { type: 'employee', sub: 'employee_1' }
if (token === 'renter-token') return { type: 'renter', sub: 'renter_1' }
throw new Error('Invalid actor token')
}),
}))
import rateLimit, { ipKeyGenerator } from 'express-rate-limit'
describe('rateLimiter middleware configuration', () => {
beforeEach(() => {
createdLimiters.length = 0
vi.resetModules()
})
it('configures auth limiter to count failed attempts only', async () => {
const { authLimiter } = await import('./rateLimiter')
expect(authLimiter.max).toBe(20)
expect(authLimiter.windowMs).toBe(15 * 60 * 1000)
expect(authLimiter.skipSuccessfulRequests).toBe(true)
expect(authLimiter.message).toMatchObject({ error: 'too_many_requests', statusCode: 429 })
expect(rateLimit).toHaveBeenCalled()
})
it('keys general API limits by verified actor identity before falling back to request context', async () => {
const { apiLimiter } = await import('./rateLimiter')
expect(apiLimiter.keyGenerator({
ip: '203.0.113.10',
headers: { cookie: 'theme=dark; employee_session=employee-token' },
companyId: 'company_1',
} as any)).toBe('ip:203.0.113.10:employee:employee_1')
expect(apiLimiter.keyGenerator({
ip: '203.0.113.10',
headers: { authorization: 'Bearer renter-token' },
renterId: 'legacy_renter_context',
} as any)).toBe('ip:203.0.113.10:renter:renter_1')
expect(apiLimiter.keyGenerator({
ip: '203.0.113.10',
headers: { authorization: 'Bearer invalid-token' },
companyId: 'company_1',
} as any)).toBe('ip:203.0.113.10:company_1')
expect(apiLimiter.keyGenerator({ ip: '203.0.113.10', headers: {}, renterId: 'renter_1' } as any)).toBe('ip:203.0.113.10:renter_1')
expect(ipKeyGenerator).toHaveBeenCalledWith('203.0.113.10')
})
it('uses tighter public and admin limits with explicit 429 payloads', async () => {
const { publicLimiter, adminLimiter } = await import('./rateLimiter')
expect(publicLimiter.max).toBe(60)
expect(publicLimiter.message.message).toBe('Rate limit exceeded')
expect(adminLimiter.max).toBe(100)
expect(adminLimiter.message.message).toBe('Too many admin requests')
})
})
+75 -2
View File
@@ -1,5 +1,54 @@
import rateLimit, { ipKeyGenerator } from 'express-rate-limit'
import type { Request } from 'express'
import { verifyAnyActorToken } from '../security/tokens'
import { getSessionCookieName } from '../security/sessionCookies'
const SESSION_COOKIE_NAMES = [
getSessionCookieName('admin'),
getSessionCookieName('employee'),
getSessionCookieName('renter'),
]
function readCookie(cookieHeader: string | undefined, name: string): string | null {
if (!cookieHeader) return null
for (const chunk of cookieHeader.split(';')) {
const [rawName, ...rawValue] = chunk.trim().split('=')
if (rawName === name) return decodeURIComponent(rawValue.join('='))
}
return null
}
function getBearerToken(req: Request): string | null {
const authHeader = req.headers.authorization
if (!authHeader?.startsWith('Bearer ')) return null
const token = authHeader.slice(7).trim()
return token || null
}
function getRequestToken(req: Request): string | null {
const cookieHeader = req.headers.cookie
for (const name of SESSION_COOKIE_NAMES) {
const token = readCookie(cookieHeader, name)
if (token) return token
}
return getBearerToken(req)
}
function getAuthenticatedActorKey(req: Request): string | null {
const token = getRequestToken(req)
if (!token) return null
try {
const payload = verifyAnyActorToken(token)
return `${payload.type}:${payload.sub}`
} catch {
return null
}
}
// req.ip is already the real client IP when app.set('trust proxy', 1) is configured
const getClientIpKey = (req: Request) => ipKeyGenerator(req.ip ?? '')
@@ -25,9 +74,10 @@ export const apiLimiter = rateLimit({
legacyHeaders: false,
keyGenerator: (req) => {
const ip = getClientIpKey(req)
const actorKey = getAuthenticatedActorKey(req)
const companyId = (req as any).companyId ?? ''
const renterId = (req as any).renterId ?? ''
return `${ip}:${companyId || renterId}`
return `${ip}:${actorKey || companyId || renterId || 'anonymous'}`
},
message: { error: 'too_many_requests', message: 'Rate limit exceeded', statusCode: 429 },
})
@@ -48,6 +98,29 @@ export const adminLimiter = rateLimit({
max: 100,
standardHeaders: 'draft-7',
legacyHeaders: false,
keyGenerator: (req) => getClientIpKey(req),
keyGenerator: (req) => {
const ip = getClientIpKey(req)
return `${ip}:${getAuthenticatedActorKey(req) || 'anonymous'}`
},
message: { error: 'too_many_requests', message: 'Too many admin requests', statusCode: 429 },
})
// Applied after authentication so limits can include actor identity rather than
// pretending every employee behind the same NAT is the same organism.
export const actorLimiter = rateLimit({
windowMs: 60 * 1000,
max: 240,
standardHeaders: 'draft-7',
legacyHeaders: false,
keyGenerator: (req) => {
const ip = getClientIpKey(req)
const actorKey = getAuthenticatedActorKey(req)
const companyId = (req as any).companyId ?? ''
const employeeId = (req as any).employee?.id ?? ''
const renterId = (req as any).renterId ?? ''
const adminId = (req as any).admin?.id ?? ''
return `${ip}:${companyId}:${actorKey || employeeId || renterId || adminId || 'anonymous'}`
},
message: { error: 'too_many_requests', message: 'Authenticated rate limit exceeded', statusCode: 429 },
})
+10
View File
@@ -0,0 +1,10 @@
import crypto from 'crypto'
import type { Request, Response, NextFunction } from 'express'
export function requestIdMiddleware(req: Request, res: Response, next: NextFunction) {
const incoming = req.headers['x-request-id']
const requestId = Array.isArray(incoming) ? incoming[0] : incoming
req.requestId = requestId && requestId.length <= 128 ? requestId : `req_${crypto.randomUUID()}`
res.setHeader('X-Request-Id', req.requestId)
next()
}
@@ -0,0 +1,152 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { NextFunction, Request, Response } from 'express'
vi.mock('jsonwebtoken', () => ({
default: { verify: vi.fn() },
}))
vi.mock('../lib/prisma', () => ({
prisma: {
adminUser: { findUnique: vi.fn() },
},
}))
import jwt from 'jsonwebtoken'
import { prisma } from '../lib/prisma'
import { requireAdminAuth, requireAdminRole } from './requireAdminAuth'
function responseStub() {
const res = { status: vi.fn(), json: vi.fn() }
res.status.mockReturnValue(res)
res.json.mockReturnValue(res)
return res as unknown as Response & typeof res
}
describe('requireAdminAuth middleware', () => {
beforeEach(() => {
vi.clearAllMocks()
process.env.JWT_SECRET = 'test-secret'
})
it('rejects requests without an admin bearer token', async () => {
const req = { headers: {} } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await requireAdminAuth(req, res, next)
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({ error: 'unauthenticated', message: 'Admin authentication required', statusCode: 401 })
expect(next).not.toHaveBeenCalled()
})
it('rejects invalid token signatures', async () => {
vi.mocked(jwt.verify).mockImplementation(() => { throw new Error('bad token') })
const req = { headers: { authorization: 'Bearer bad' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await requireAdminAuth(req, res, next)
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({ error: 'invalid_token', message: 'Invalid or expired admin token', statusCode: 401 })
expect(prisma.adminUser.findUnique).not.toHaveBeenCalled()
})
it('rejects non-admin token types', async () => {
vi.mocked(jwt.verify).mockReturnValue({ sub: 'emp_1', type: 'employee' } as any)
const req = { headers: { authorization: 'Bearer employee-token' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await requireAdminAuth(req, res, next)
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({ error: 'invalid_token', message: 'Invalid or expired admin token', statusCode: 401 })
expect(prisma.adminUser.findUnique).not.toHaveBeenCalled()
})
it('rejects inactive admin accounts', async () => {
vi.mocked(jwt.verify).mockReturnValue({ sub: 'admin_1', type: 'admin' } as any)
vi.mocked(prisma.adminUser.findUnique).mockResolvedValue({ id: 'admin_1', isActive: false } as any)
const req = { headers: { authorization: 'Bearer admin-token' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await requireAdminAuth(req, res, next)
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({ error: 'unauthenticated', message: 'Admin account not found or deactivated', statusCode: 401 })
expect(next).not.toHaveBeenCalled()
})
it('attaches the admin record for valid admin tokens', async () => {
const admin = { id: 'admin_1', isActive: true, role: 'SUPPORT', totpEnabled: true }
vi.mocked(jwt.verify).mockReturnValue({ sub: 'admin_1', type: 'admin' } as any)
vi.mocked(prisma.adminUser.findUnique).mockResolvedValue(admin as any)
const req = { headers: { authorization: 'Bearer admin-token' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await requireAdminAuth(req, res, next)
expect(prisma.adminUser.findUnique).toHaveBeenCalledWith({ where: { id: 'admin_1' } })
expect(req.admin).toEqual(admin)
expect(next).toHaveBeenCalledTimes(1)
})
it('blocks non-enrolled admins from privileged routes', async () => {
vi.mocked(jwt.verify).mockReturnValue({ sub: 'admin_1', type: 'admin' } as any)
vi.mocked(prisma.adminUser.findUnique).mockResolvedValue({ id: 'admin_1', isActive: true, role: 'ADMIN', totpEnabled: false } as any)
const req = { headers: { authorization: 'Bearer admin-token' }, path: '/companies' } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await requireAdminAuth(req, res, next)
expect(res.status).toHaveBeenCalledWith(403)
expect(res.json).toHaveBeenCalledWith({ error: 'admin_2fa_required', message: 'Admin 2FA enrollment is required before using privileged admin routes', statusCode: 403 })
expect(next).not.toHaveBeenCalled()
})
})
describe('requireAdminRole middleware', () => {
it('requires requireAdminAuth to run first', () => {
const req = {} as Request
const res = responseStub()
const next = vi.fn() as NextFunction
requireAdminRole('SUPPORT' as any)(req, res, next)
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({ error: 'unauthenticated', message: 'Admin authentication required', statusCode: 401 })
expect(next).not.toHaveBeenCalled()
})
it('blocks admins below the required rank', () => {
const req = { admin: { role: 'VIEWER' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
requireAdminRole('FINANCE' as any)(req, res, next)
expect(res.status).toHaveBeenCalledWith(403)
expect(res.json).toHaveBeenCalledWith({
error: 'forbidden',
message: 'This action requires the FINANCE role or higher',
statusCode: 403,
})
expect(next).not.toHaveBeenCalled()
})
it('allows admins at or above the required rank', () => {
const req = { admin: { role: 'ADMIN' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
requireAdminRole('SUPPORT' as any)(req, res, next)
expect(next).toHaveBeenCalledTimes(1)
expect(res.status).not.toHaveBeenCalled()
})
})
+40 -11
View File
@@ -1,8 +1,9 @@
import { Request, Response, NextFunction } from 'express'
import jwt from 'jsonwebtoken'
import { prisma } from '../lib/prisma'
import { AdminRole } from '@rentaldrivego/database'
import { sendUnauthorized, sendForbidden } from './authHelpers'
import { getAuthToken, sendUnauthorized, sendForbidden } from './authHelpers'
import { verifyActorToken } from '../security/tokens'
import { getSessionCookieName } from '../security/sessionCookies'
const ROLE_RANK: Record<AdminRole, number> = {
SUPER_ADMIN: 5,
@@ -12,35 +13,48 @@ const ROLE_RANK: Record<AdminRole, number> = {
VIEWER: 1,
}
const ADMIN_2FA_ENROLLMENT_EXEMPT_PATHS = new Set([
'/auth/me',
'/auth/logout',
'/auth/2fa/setup',
'/auth/2fa/verify',
])
const FRESH_2FA_WINDOW_MS = Number(process.env.ADMIN_FRESH_2FA_WINDOW_MS ?? 10 * 60 * 1000)
function is2faEnrollmentExempt(req: Request) {
return ADMIN_2FA_ENROLLMENT_EXEMPT_PATHS.has(req.path)
}
/**
* Requires a valid admin Bearer token.
* Requires a valid admin session token.
*
* Guarantees on success:
* req.admin — the full AdminUser record
*/
export async function requireAdminAuth(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
const token = getAuthToken(req, getSessionCookieName('admin'))
if (!token) return sendUnauthorized(res, 'unauthenticated', 'Admin authentication required')
let payload: { sub: string; type: string }
let payload: { sub: string; type: string; last2faAt?: number }
try {
payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string }
payload = verifyActorToken(token, 'admin')
} catch {
return sendUnauthorized(res, 'invalid_token', 'Invalid or expired admin token')
}
if (payload.type !== 'admin') {
return sendUnauthorized(res, 'invalid_token', 'Invalid token type')
}
const admin = await prisma.adminUser.findUnique({ where: { id: payload.sub } })
if (!admin || !admin.isActive) {
return sendUnauthorized(res, 'unauthenticated', 'Admin account not found or deactivated')
}
if (!admin.totpEnabled && !is2faEnrollmentExempt(req)) {
return sendForbidden(res, 'admin_2fa_required', 'Admin 2FA enrollment is required before using privileged admin routes')
}
req.admin = admin
req.adminAuthLast2faAt = typeof payload.last2faAt === 'number' ? payload.last2faAt : undefined
next()
}
@@ -63,3 +77,18 @@ export function requireAdminRole(minimumRole: AdminRole) {
next()
}
}
export function requireFreshAdmin2FA(req: Request, res: Response, next: NextFunction) {
const admin = req.admin
if (!admin) return sendUnauthorized(res, 'unauthenticated', 'Admin authentication required')
if (!admin.totpEnabled) {
return sendForbidden(res, 'admin_2fa_required', 'Admin 2FA enrollment is required for this action')
}
const last2faAt = req.adminAuthLast2faAt
if (!last2faAt || Date.now() - last2faAt > FRESH_2FA_WINDOW_MS) {
return sendForbidden(res, 'fresh_2fa_required', 'Fresh admin 2FA verification is required for this action')
}
next()
}
@@ -0,0 +1,159 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { NextFunction, Request, Response } from 'express'
import { generateCompanyApiKey } from '../security/apiKeys'
vi.mock('../lib/prisma', () => ({
prisma: {
companyApiKey: {
findUnique: vi.fn(),
update: vi.fn(),
},
},
}))
import { prisma } from '../lib/prisma'
import { requireApiKey } from './requireApiKey'
function createResponseStub() {
const res = {
status: vi.fn(),
json: vi.fn(),
}
res.status.mockReturnValue(res)
res.json.mockReturnValue(res)
return res as unknown as Response & typeof res
}
describe('requireApiKey middleware', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('rejects requests with no x-api-key header', async () => {
const req = { headers: {} } as Request
const res = createResponseStub()
const next = vi.fn() as NextFunction
await requireApiKey(req, res, next)
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({
error: 'missing_api_key',
message: 'API key required in x-api-key header',
statusCode: 401,
})
expect((prisma as any).companyApiKey.findUnique).not.toHaveBeenCalled()
expect(next).not.toHaveBeenCalled()
})
it('rejects malformed API keys before database lookup', async () => {
const req = { headers: { 'x-api-key': 'bad-key' } } as unknown as Request
const res = createResponseStub()
const next = vi.fn() as NextFunction
await requireApiKey(req, res, next)
expect((prisma as any).companyApiKey.findUnique).not.toHaveBeenCalled()
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({
error: 'invalid_api_key',
message: 'Invalid API key',
statusCode: 401,
})
expect(next).not.toHaveBeenCalled()
})
it('rejects unknown API key prefixes', async () => {
const generated = generateCompanyApiKey()
vi.mocked((prisma as any).companyApiKey.findUnique).mockResolvedValue(null)
const req = { headers: { 'x-api-key': generated.rawKey } } as unknown as Request
const res = createResponseStub()
const next = vi.fn() as NextFunction
await requireApiKey(req, res, next)
expect((prisma as any).companyApiKey.findUnique).toHaveBeenCalledWith({
where: { prefix: generated.prefix },
include: { company: true },
})
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({
error: 'invalid_api_key',
message: 'Invalid API key',
statusCode: 401,
})
expect(next).not.toHaveBeenCalled()
})
it('rejects revoked API keys', async () => {
const generated = generateCompanyApiKey()
vi.mocked((prisma as any).companyApiKey.findUnique).mockResolvedValue({
id: 'key_1',
companyId: 'company_1',
prefix: generated.prefix,
keyHash: generated.keyHash,
revokedAt: new Date('2026-06-01T00:00:00.000Z'),
company: { id: 'company_1', name: 'Atlas Cars' },
})
const req = { headers: { 'x-api-key': generated.rawKey } } as unknown as Request
const res = createResponseStub()
const next = vi.fn() as NextFunction
await requireApiKey(req, res, next)
expect(res.status).toHaveBeenCalledWith(401)
expect(next).not.toHaveBeenCalled()
})
it('rejects API keys whose secret does not match the stored hash', async () => {
const generated = generateCompanyApiKey()
const other = generateCompanyApiKey()
vi.mocked((prisma as any).companyApiKey.findUnique).mockResolvedValue({
id: 'key_1',
companyId: 'company_1',
prefix: generated.prefix,
keyHash: other.keyHash,
revokedAt: null,
company: { id: 'company_1', name: 'Atlas Cars' },
})
const req = { headers: { 'x-api-key': generated.rawKey } } as unknown as Request
const res = createResponseStub()
const next = vi.fn() as NextFunction
await requireApiKey(req, res, next)
expect(res.status).toHaveBeenCalledWith(401)
expect(next).not.toHaveBeenCalled()
})
it('attaches company context, updates lastUsedAt, and calls next for valid API keys', async () => {
const generated = generateCompanyApiKey()
const company = { id: 'company_1', name: 'Atlas Cars' }
vi.mocked((prisma as any).companyApiKey.findUnique).mockResolvedValue({
id: 'key_1',
companyId: company.id,
prefix: generated.prefix,
keyHash: generated.keyHash,
revokedAt: null,
company,
})
vi.mocked((prisma as any).companyApiKey.update).mockResolvedValue({})
const req = { headers: { 'x-api-key': generated.rawKey } } as unknown as Request
const res = createResponseStub()
const next = vi.fn() as NextFunction
await requireApiKey(req, res, next)
expect((prisma as any).companyApiKey.update).toHaveBeenCalledWith({
where: { id: 'key_1' },
data: { lastUsedAt: expect.any(Date) },
})
expect(req.company).toEqual(company)
expect(req.companyId).toBe('company_1')
expect(next).toHaveBeenCalledTimes(1)
expect(res.status).not.toHaveBeenCalled()
expect(res.json).not.toHaveBeenCalled()
})
})
+20 -4
View File
@@ -1,5 +1,6 @@
import { Request, Response, NextFunction } from 'express'
import { prisma } from '../lib/prisma'
import { getApiKeyPrefix, hashApiKey, timingSafeEqualHex } from '../security/apiKeys'
export async function requireApiKey(req: Request, res: Response, next: NextFunction) {
const apiKey = req.headers['x-api-key'] as string | undefined
@@ -8,12 +9,27 @@ export async function requireApiKey(req: Request, res: Response, next: NextFunct
return res.status(401).json({ error: 'missing_api_key', message: 'API key required in x-api-key header', statusCode: 401 })
}
const company = await prisma.company.findUnique({ where: { apiKey } })
if (!company) {
const prefix = getApiKeyPrefix(apiKey)
if (!prefix) {
return res.status(401).json({ error: 'invalid_api_key', message: 'Invalid API key', statusCode: 401 })
}
req.company = company
req.companyId = company.id
const keyRecord = await (prisma as any).companyApiKey.findUnique({
where: { prefix },
include: { company: true },
})
const hashed = hashApiKey(apiKey)
if (!keyRecord || keyRecord.revokedAt || !timingSafeEqualHex(hashed, keyRecord.keyHash)) {
return res.status(401).json({ error: 'invalid_api_key', message: 'Invalid API key', statusCode: 401 })
}
await (prisma as any).companyApiKey.update({
where: { id: keyRecord.id },
data: { lastUsedAt: new Date() },
})
req.company = keyRecord.company
req.companyId = keyRecord.companyId
next()
}
@@ -0,0 +1,119 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { NextFunction, Request, Response } from 'express'
vi.mock('jsonwebtoken', () => ({
default: { verify: vi.fn() },
}))
vi.mock('../lib/prisma', () => ({
prisma: {
employee: { findUnique: vi.fn() },
},
}))
import jwt from 'jsonwebtoken'
import { prisma } from '../lib/prisma'
import { requireCompanyAuth, requireCompanyDocumentAuth } from './requireCompanyAuth'
function responseStub() {
const res = { status: vi.fn(), json: vi.fn() }
res.status.mockReturnValue(res)
res.json.mockReturnValue(res)
return res as unknown as Response & typeof res
}
describe('requireCompanyAuth middleware', () => {
beforeEach(() => {
vi.clearAllMocks()
process.env.JWT_SECRET = 'test-secret'
})
it('rejects missing bearer tokens', async () => {
const req = { headers: {} } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await requireCompanyAuth(req, res, next)
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({ error: 'unauthenticated', message: 'Authentication required', statusCode: 401 })
expect(prisma.employee.findUnique).not.toHaveBeenCalled()
expect(next).not.toHaveBeenCalled()
})
it('rejects invalid tokens', async () => {
vi.mocked(jwt.verify).mockImplementation(() => { throw new Error('bad token') })
const req = { headers: { authorization: 'Bearer bad' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await requireCompanyAuth(req, res, next)
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({ error: 'invalid_token', message: 'Invalid or expired session token', statusCode: 401 })
expect(next).not.toHaveBeenCalled()
})
it('rejects non-employee token types', async () => {
vi.mocked(jwt.verify).mockReturnValue({ sub: 'renter_1', type: 'renter' } as any)
const req = { headers: { authorization: 'Bearer renter-token' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await requireCompanyAuth(req, res, next)
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({ error: 'invalid_token', message: 'Invalid or expired session token', statusCode: 401 })
expect(prisma.employee.findUnique).not.toHaveBeenCalled()
})
it('rejects inactive or missing employees', async () => {
vi.mocked(jwt.verify).mockReturnValue({ sub: 'emp_1', type: 'employee' } as any)
vi.mocked(prisma.employee.findUnique).mockResolvedValue({ id: 'emp_1', isActive: false } as any)
const req = { headers: { authorization: 'Bearer employee-token' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await requireCompanyAuth(req, res, next)
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({ error: 'unauthenticated', message: 'Employee account not found or inactive', statusCode: 401 })
expect(next).not.toHaveBeenCalled()
})
it('attaches employee and company context for active employees', async () => {
const employee = { id: 'emp_1', companyId: 'company_1', isActive: true, company: { id: 'company_1', name: 'Atlas' } }
vi.mocked(jwt.verify).mockReturnValue({ sub: 'emp_1', type: 'employee' } as any)
vi.mocked(prisma.employee.findUnique).mockResolvedValue(employee as any)
const req = { headers: { authorization: 'Bearer employee-token' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await requireCompanyAuth(req, res, next)
expect(prisma.employee.findUnique).toHaveBeenCalledWith({ where: { id: 'emp_1' }, include: { company: true } })
expect(req.employee).toEqual(employee)
expect(req.company).toEqual(employee.company)
expect(req.companyId).toBe('company_1')
expect(next).toHaveBeenCalledTimes(1)
})
it('accepts employee_session cookies for document routes', async () => {
const employee = { id: 'emp_2', companyId: 'company_2', isActive: true, company: { id: 'company_2' } }
vi.mocked(jwt.verify).mockReturnValue({ sub: 'emp_2', type: 'employee' } as any)
vi.mocked(prisma.employee.findUnique).mockResolvedValue(employee as any)
const req = { headers: { cookie: 'employee_session=cookie-token' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await requireCompanyDocumentAuth(req, res, next)
expect(jwt.verify).toHaveBeenCalledWith('cookie-token', 'test-secret', {
algorithms: ['HS256'],
issuer: 'rentaldrivego-api',
audience: 'employee',
})
expect(req.companyId).toBe('company_2')
expect(next).toHaveBeenCalledTimes(1)
})
})
+9 -14
View File
@@ -1,7 +1,9 @@
import { Request, Response, NextFunction } from 'express'
import jwt from 'jsonwebtoken'
import { prisma } from '../lib/prisma'
import { getCookie, sendUnauthorized } from './authHelpers'
import { getAuthToken, sendUnauthorized } from './authHelpers'
import { verifyActorToken } from '../security/tokens'
import { getSessionCookieName } from '../security/sessionCookies'
import { actorLimiter } from './rateLimiter'
/**
* Validates a company-scoped Bearer token and loads the employee + company onto `req`.
@@ -11,25 +13,18 @@ import { getCookie, sendUnauthorized } from './authHelpers'
* req.company — the employee's company
* req.companyId — string shorthand for req.company.id
*/
async function authenticateCompanyRequest(req: Request, res: Response, next: NextFunction, allowCookie = false) {
const authHeader = req.headers.authorization
const bearerToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
const cookieToken = allowCookie ? getCookie(req, 'employee_token') : null
const token = bearerToken ?? cookieToken
async function authenticateCompanyRequest(req: Request, res: Response, next: NextFunction) {
const token = getAuthToken(req, getSessionCookieName('employee'))
if (!token) return sendUnauthorized(res, 'unauthenticated', 'Authentication required')
let payload: { sub: string; type: string }
try {
payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string }
payload = verifyActorToken(token, 'employee')
} catch {
return sendUnauthorized(res, 'invalid_token', 'Invalid or expired session token')
}
if (payload.type !== 'employee') {
return sendUnauthorized(res, 'invalid_token', 'Invalid token type for this endpoint')
}
const employee = await prisma.employee.findUnique({
where: { id: payload.sub },
include: { company: true },
@@ -42,7 +37,7 @@ async function authenticateCompanyRequest(req: Request, res: Response, next: Nex
req.employee = employee
req.company = employee.company
req.companyId = employee.companyId
next()
return actorLimiter(req, res, next)
}
export async function requireCompanyAuth(req: Request, res: Response, next: NextFunction) {
@@ -50,5 +45,5 @@ export async function requireCompanyAuth(req: Request, res: Response, next: Next
}
export async function requireCompanyDocumentAuth(req: Request, res: Response, next: NextFunction) {
return authenticateCompanyRequest(req, res, next, true)
return authenticateCompanyRequest(req, res, next)
}
@@ -0,0 +1,22 @@
import type { Request, Response, NextFunction } from 'express'
import { sendForbidden, sendUnauthorized } from './authHelpers'
import type { EmployeeRole } from '@rentaldrivego/database'
import { CompanyPolicy, type CompanyPolicyAction } from '../security/policies/companyPolicy'
export function requireCompanyPolicy(action: CompanyPolicyAction) {
return (req: Request, res: Response, next: NextFunction) => {
const employee = req.employee
if (!employee) return sendUnauthorized(res, 'unauthenticated', 'Authentication required')
const allowedRoles: readonly EmployeeRole[] = CompanyPolicy[action]
if (!allowedRoles.includes(employee.role)) {
return sendForbidden(res, 'forbidden', 'You do not have permission to perform this action', {
action,
allowedRoles,
yourRole: employee.role,
})
}
next()
}
}
@@ -0,0 +1,107 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { NextFunction, Request, Response } from 'express'
vi.mock('jsonwebtoken', () => ({
default: { verify: vi.fn() },
}))
vi.mock('../lib/prisma', () => ({
prisma: {
renter: { findUnique: vi.fn() },
},
}))
import jwt from 'jsonwebtoken'
import { prisma } from '../lib/prisma'
import { optionalRenterAuth, requireRenterAuth } from './requireRenterAuth'
function responseStub() {
const res = { status: vi.fn(), json: vi.fn() }
res.status.mockReturnValue(res)
res.json.mockReturnValue(res)
return res as unknown as Response & typeof res
}
describe('requireRenterAuth middleware', () => {
beforeEach(() => {
vi.clearAllMocks()
process.env.JWT_SECRET = 'test-secret'
})
it('rejects missing bearer tokens', async () => {
const req = { headers: {} } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await requireRenterAuth(req, res, next)
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({ error: 'unauthenticated', message: 'Renter authentication required', statusCode: 401 })
expect(next).not.toHaveBeenCalled()
})
it('rejects invalid tokens and wrong token types', async () => {
vi.mocked(jwt.verify).mockReturnValue({ sub: 'emp_1', type: 'employee' } as any)
const req = { headers: { authorization: 'Bearer employee-token' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await requireRenterAuth(req, res, next)
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({ error: 'invalid_token', message: 'Invalid or expired token', statusCode: 401 })
expect(prisma.renter.findUnique).not.toHaveBeenCalled()
})
it('rejects inactive renters', async () => {
vi.mocked(jwt.verify).mockReturnValue({ sub: 'renter_1', type: 'renter' } as any)
vi.mocked(prisma.renter.findUnique).mockResolvedValue({ id: 'renter_1', isActive: false } as any)
const req = { headers: { authorization: 'Bearer renter-token' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await requireRenterAuth(req, res, next)
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({ error: 'unauthenticated', message: 'Renter account not found or inactive', statusCode: 401 })
expect(next).not.toHaveBeenCalled()
})
it('attaches renterId for active renters', async () => {
vi.mocked(jwt.verify).mockReturnValue({ sub: 'renter_1', type: 'renter' } as any)
vi.mocked(prisma.renter.findUnique).mockResolvedValue({ id: 'renter_1', isActive: true } as any)
const req = { headers: { authorization: 'Bearer renter-token' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await requireRenterAuth(req, res, next)
expect(req.renterId).toBe('renter_1')
expect(next).toHaveBeenCalledTimes(1)
})
it('optional auth never blocks missing or invalid tokens', async () => {
const noTokenReq = { headers: {} } as Request
const invalidReq = { headers: { authorization: 'Bearer bad' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
vi.mocked(jwt.verify).mockImplementation(() => { throw new Error('bad token') })
await optionalRenterAuth(noTokenReq, res, next)
await optionalRenterAuth(invalidReq, res, next)
expect(next).toHaveBeenCalledTimes(2)
})
it('optional auth attaches renterId only for renter tokens', async () => {
vi.mocked(jwt.verify).mockReturnValue({ sub: 'renter_2', type: 'renter' } as any)
const req = { headers: { authorization: 'Bearer renter-token' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await optionalRenterAuth(req, res, next)
expect(req.renterId).toBe('renter_2')
expect(next).toHaveBeenCalledTimes(1)
})
})
+10 -14
View File
@@ -1,7 +1,9 @@
import { Request, Response, NextFunction } from 'express'
import jwt from 'jsonwebtoken'
import { prisma } from '../lib/prisma'
import { sendUnauthorized } from './authHelpers'
import { getAuthToken, sendUnauthorized } from './authHelpers'
import { verifyActorToken } from '../security/tokens'
import { getSessionCookieName } from '../security/sessionCookies'
import { actorLimiter } from './rateLimiter'
/**
* Requires a valid renter Bearer token.
@@ -10,29 +12,24 @@ import { sendUnauthorized } from './authHelpers'
* req.renterId — the authenticated renter's id
*/
export async function requireRenterAuth(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
const token = getAuthToken(req, getSessionCookieName('renter'))
if (!token) return sendUnauthorized(res, 'unauthenticated', 'Renter authentication required')
let payload: { sub: string; type: string }
try {
payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string }
payload = verifyActorToken(token, 'renter')
} catch {
return sendUnauthorized(res, 'invalid_token', 'Invalid or expired token')
}
if (payload.type !== 'renter') {
return sendUnauthorized(res, 'invalid_token', 'Invalid token type')
}
const renter = await prisma.renter.findUnique({ where: { id: payload.sub } })
if (!renter || !renter.isActive) {
return sendUnauthorized(res, 'unauthenticated', 'Renter account not found or inactive')
}
req.renterId = renter.id
next()
return actorLimiter(req, res, next)
}
/**
@@ -43,13 +40,12 @@ export async function requireRenterAuth(req: Request, res: Response, next: NextF
* req.renterId — the authenticated renter's id (if token is valid)
*/
export async function optionalRenterAuth(req: Request, _res: Response, next: NextFunction) {
const authHeader = req.headers.authorization
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
const token = getAuthToken(req, getSessionCookieName('renter'))
if (!token) return next()
try {
const payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string }
if (payload.type === 'renter') req.renterId = payload.sub
const payload = verifyActorToken(token, 'renter')
req.renterId = payload.sub
} catch {
// Optional — silently ignore invalid tokens
}
@@ -0,0 +1,54 @@
import { describe, expect, it, vi } from 'vitest'
import type { NextFunction, Request, Response } from 'express'
import { requireRole } from './requireRole'
function responseStub() {
const res = { status: vi.fn(), json: vi.fn() }
res.status.mockReturnValue(res)
res.json.mockReturnValue(res)
return res as unknown as Response & typeof res
}
describe('requireRole middleware', () => {
it('rejects requests when company auth did not attach an employee', () => {
const req = {} as Request
const res = responseStub()
const next = vi.fn() as NextFunction
requireRole('AGENT' as any)(req, res, next)
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({ error: 'unauthenticated', message: 'Authentication required', statusCode: 401 })
expect(next).not.toHaveBeenCalled()
})
it('rejects employees below the required role and includes role context', () => {
const req = { employee: { role: 'AGENT' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
requireRole('MANAGER' as any)(req, res, next)
expect(res.status).toHaveBeenCalledWith(403)
expect(res.json).toHaveBeenCalledWith({
error: 'forbidden',
message: 'This action requires the MANAGER role or higher',
statusCode: 403,
requiredRole: 'MANAGER',
yourRole: 'AGENT',
})
expect(next).not.toHaveBeenCalled()
})
it('allows employees at or above the required role', () => {
const req = { employee: { role: 'OWNER' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
requireRole('MANAGER' as any)(req, res, next)
expect(next).toHaveBeenCalledTimes(1)
expect(res.status).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,72 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { NextFunction, Request, Response } from 'express'
import { requireSubscription } from './requireSubscription'
function responseStub() {
const res = { status: vi.fn(), json: vi.fn() }
res.status.mockReturnValue(res)
res.json.mockReturnValue(res)
return res as unknown as Response & typeof res
}
describe('requireSubscription middleware', () => {
beforeEach(() => {
process.env.NEXT_PUBLIC_DASHBOARD_URL = 'https://dashboard.example.test'
})
it('requires tenant/company context first', () => {
const req = {} as Request
const res = responseStub()
const next = vi.fn() as NextFunction
requireSubscription(req, res, next)
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({ error: 'unauthenticated', message: 'No company context', statusCode: 401 })
expect(next).not.toHaveBeenCalled()
})
it('blocks suspended companies with a billing URL', () => {
const req = { company: { status: 'SUSPENDED' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
requireSubscription(req, res, next)
expect(res.status).toHaveBeenCalledWith(402)
expect(res.json).toHaveBeenCalledWith({
error: 'subscription_suspended',
message: 'Your account has been suspended. Please contact support or renew your subscription.',
statusCode: 402,
billingUrl: 'https://dashboard.example.test/billing',
})
expect(next).not.toHaveBeenCalled()
})
it('blocks pending companies with setup guidance', () => {
const req = { company: { status: 'PENDING' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
requireSubscription(req, res, next)
expect(res.status).toHaveBeenCalledWith(402)
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
error: 'subscription_pending',
message: 'Your account is pending activation. Please complete your subscription setup.',
}))
expect(next).not.toHaveBeenCalled()
})
it('allows active companies through', () => {
const req = { company: { status: 'ACTIVE' } } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
requireSubscription(req, res, next)
expect(next).toHaveBeenCalledTimes(1)
expect(res.status).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,67 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { NextFunction, Request, Response } from 'express'
vi.mock('../lib/prisma', () => ({
prisma: {
company: { findUnique: vi.fn() },
},
}))
import { prisma } from '../lib/prisma'
import { requireTenant } from './requireTenant'
function responseStub() {
const res = { status: vi.fn(), json: vi.fn() }
res.status.mockReturnValue(res)
res.json.mockReturnValue(res)
return res as unknown as Response & typeof res
}
describe('requireTenant middleware', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('fails fast when company auth did not set companyId', async () => {
const req = {} as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await requireTenant(req, res, next)
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({
error: 'unauthenticated',
message: 'Tenant context missing — requireCompanyAuth must run first',
statusCode: 401,
})
expect(prisma.company.findUnique).not.toHaveBeenCalled()
})
it('rejects company ids that no longer exist', async () => {
vi.mocked(prisma.company.findUnique).mockResolvedValue(null)
const req = { companyId: 'company_missing' } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await requireTenant(req, res, next)
expect(prisma.company.findUnique).toHaveBeenCalledWith({ where: { id: 'company_missing' } })
expect(res.status).toHaveBeenCalledWith(401)
expect(res.json).toHaveBeenCalledWith({ error: 'company_not_found', message: 'Company not found', statusCode: 401 })
expect(next).not.toHaveBeenCalled()
})
it('attaches the fresh company record and continues', async () => {
const company = { id: 'company_1', status: 'ACTIVE' }
vi.mocked(prisma.company.findUnique).mockResolvedValue(company as any)
const req = { companyId: 'company_1' } as Request
const res = responseStub()
const next = vi.fn() as NextFunction
await requireTenant(req, res, next)
expect(req.company).toEqual(company)
expect(next).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import {
billingAccountUpdateSchema,
billingCreditNoteSchema,
billingRefundSchema,
createBillingInvoiceSchema,
payBillingInvoiceSchema,
} from './admin.schemas'
describe('admin billing schemas', () => {
it('accepts complete draft invoice payloads and preserves nullable billing fields', () => {
const parsed = createBillingInvoiceSchema.parse({
subscriptionId: null,
invoiceType: 'MANUAL',
currency: 'MAD',
dueAt: '2026-08-10',
isSubscriptionBlocking: false,
adminReason: null,
lineItems: [{
type: 'MANUAL_ADJUSTMENT',
description: 'Manual correction',
quantity: 2,
unitAmount: 1500,
periodStart: null,
periodEnd: '2026-08-31T00:00:00.000Z',
}],
})
expect(parsed.lineItems[0]).toMatchObject({ quantity: 2, unitAmount: 1500, periodStart: null })
})
it('rejects invoices without line items because empty invoices are bookkeeping cosplay', () => {
expect(() => createBillingInvoiceSchema.parse({ invoiceType: 'MANUAL', lineItems: [] })).toThrow()
})
it('bounds billing account net terms and validates email formatting', () => {
expect(billingAccountUpdateSchema.parse({
legalName: 'Atlas Cars LLC',
billingEmail: 'billing@example.test',
invoiceTerms: 'NET_30',
netTermsDays: 30,
})).toMatchObject({ netTermsDays: 30 })
expect(() => billingAccountUpdateSchema.parse({ billingEmail: 'not-email', netTermsDays: 366 })).toThrow()
})
it('requires positive money movements for payments, credit notes, and refunds', () => {
expect(payBillingInvoiceSchema.parse({ amount: 5000, paymentMethodId: null })).toEqual({ amount: 5000, paymentMethodId: null })
expect(() => payBillingInvoiceSchema.parse({ amount: 0 })).toThrow()
expect(() => billingCreditNoteSchema.parse({ amount: -1, reason: 'Bad credit' })).toThrow()
expect(() => billingRefundSchema.parse({ amount: 0, reason: 'Bad refund' })).toThrow()
})
})
@@ -315,7 +315,7 @@ async function syncLegacySubscriptionInvoices(companyId?: string) {
})
for (const legacy of unsynced) {
await prisma.$transaction(async (tx) => {
await prisma.$transaction(async (tx: any) => {
const latest = await tx.subscriptionInvoice.findUnique({
where: { id: legacy.id },
include: { subscription: true, attempts: true },
@@ -434,37 +434,39 @@ export async function listBillingAccounts(query: { q?: string; status?: string;
}),
])
const invoiceAggItems = invoiceAgg as any[]
const stats = {
billingAccountCount: total,
openInvoiceCount: invoiceAgg
openInvoiceCount: invoiceAggItems
.filter((item) => ['OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(item.status))
.reduce((sum, item) => sum + item._count._all, 0),
pastDueInvoiceCount: invoiceAgg
pastDueInvoiceCount: invoiceAggItems
.filter((item) => item.status === 'PAST_DUE')
.reduce((sum, item) => sum + item._count._all, 0),
paidInvoiceCount: invoiceAgg
paidInvoiceCount: invoiceAggItems
.filter((item) => item.status === 'PAID')
.reduce((sum, item) => sum + item._count._all, 0),
accountsReceivableTotal: invoiceAgg
accountsReceivableTotal: invoiceAggItems
.filter((item) => ['OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(item.status))
.reduce((sum, item) => sum + (item._sum.amountDue ?? 0), 0),
recognizedRevenueTotal: invoiceAgg
recognizedRevenueTotal: invoiceAggItems
.filter((item) => ['PAID', 'PARTIALLY_REFUNDED', 'REFUNDED'].includes(item.status))
.reduce((sum, item) => sum + (item._sum.totalAmount ?? 0), 0),
}
const data = accounts.map((account) => {
const openBalance = account.invoices
.filter((invoice) => ['OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(invoice.status))
.reduce((sum, invoice) => sum + invoice.amountDue, 0)
const paidBalance = account.invoices
.filter((invoice) => ['PAID', 'PARTIALLY_REFUNDED', 'REFUNDED'].includes(invoice.status))
.reduce((sum, invoice) => sum + invoice.amountPaid, 0)
const data = (accounts as any[]).map((account) => {
const openBalance = (account.invoices as any[])
.filter((invoice: any) => ['OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(invoice.status))
.reduce((sum: number, invoice: any) => sum + invoice.amountDue, 0)
const paidBalance = (account.invoices as any[])
.filter((invoice: any) => ['PAID', 'PARTIALLY_REFUNDED', 'REFUNDED'].includes(invoice.status))
.reduce((sum: number, invoice: any) => sum + invoice.amountPaid, 0)
return {
...account,
openBalance,
paidBalance,
creditBalance: account.creditBalances.reduce((sum, item) => sum + item.balanceAmount, 0),
creditBalance: (account.creditBalances as any[]).reduce((sum: number, item: any) => sum + item.balanceAmount, 0),
}
})
@@ -630,7 +632,7 @@ export async function createDraftInvoice(
) {
if (!data.lineItems.length) throw new ValidationError('Invoice requires at least one line item')
const invoice = await prisma.$transaction(async (tx) => {
const invoice = await prisma.$transaction(async (tx: any) => {
const account = await tx.billingAccount.findUniqueOrThrow({
where: { id: billingAccountId },
include: { company: true },
@@ -696,7 +698,7 @@ export async function createDraftInvoice(
}
export async function finalizeInvoice(invoiceId: string, adminId: string, ip?: string) {
const invoice = await prisma.$transaction(async (tx) => {
const invoice = await prisma.$transaction(async (tx: any) => {
const current = await tx.billingInvoice.findUnique({
where: { id: invoiceId },
include: {
@@ -891,7 +893,7 @@ export async function payInvoice(
adminId: string,
ip?: string,
) {
const invoice = await prisma.$transaction(async (tx) => {
const invoice = await prisma.$transaction(async (tx: any) => {
const current = await tx.billingInvoice.findUniqueOrThrow({
where: { id: invoiceId },
include: {
@@ -994,7 +996,7 @@ export async function retryInvoicePayment(
adminId: string,
ip?: string,
) {
const invoice = await prisma.$transaction(async (tx) => {
const invoice = await prisma.$transaction(async (tx: any) => {
const current = await tx.billingInvoice.findUniqueOrThrow({
where: { id: invoiceId },
include: { billingAccount: true },
@@ -1069,7 +1071,7 @@ export async function retryInvoicePayment(
}
export async function voidInvoice(invoiceId: string, reason: string, adminId: string, ip?: string) {
const invoice = await prisma.$transaction(async (tx) => {
const invoice = await prisma.$transaction(async (tx: any) => {
const current = await tx.billingInvoice.findUniqueOrThrow({ where: { id: invoiceId } })
if (!['DRAFT', 'OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(current.status)) {
throw new ValidationError('Only unpaid invoices can be voided')
@@ -1122,7 +1124,7 @@ export async function voidInvoice(invoiceId: string, reason: string, adminId: st
}
export async function markInvoiceUncollectible(invoiceId: string, reason: string, adminId: string, ip?: string) {
const invoice = await prisma.$transaction(async (tx) => {
const invoice = await prisma.$transaction(async (tx: any) => {
const current = await tx.billingInvoice.findUniqueOrThrow({ where: { id: invoiceId } })
if (!['OPEN', 'PAST_DUE', 'PAYMENT_PENDING', 'PARTIALLY_PAID'].includes(current.status)) {
throw new ValidationError('Only open invoices can be marked uncollectible')
@@ -1177,7 +1179,7 @@ export async function issueCreditNote(
adminId: string,
ip?: string,
) {
const invoice = await prisma.$transaction(async (tx) => {
const invoice = await prisma.$transaction(async (tx: any) => {
const current = await tx.billingInvoice.findUniqueOrThrow({
where: { id: invoiceId },
include: { creditNotes: true },
@@ -1259,7 +1261,7 @@ export async function issueRefund(
adminId: string,
ip?: string,
) {
const invoice = await prisma.$transaction(async (tx) => {
const invoice = await prisma.$transaction(async (tx: any) => {
const current = await tx.billingInvoice.findUniqueOrThrow({
where: { id: invoiceId },
include: {
@@ -1354,7 +1356,7 @@ export async function getInvoicePdf(invoiceId: string) {
if (!invoice) throw new NotFoundError('Invoice not found')
if (!invoice.invoiceNumber) throw new ValidationError('Invoice must be finalized before a PDF can be generated')
const latestPaymentAttempt = invoice.paymentAttempts.find((attempt) => attempt.status === 'SUCCEEDED') ?? invoice.paymentAttempts[0] ?? null
const latestPaymentAttempt = invoice.paymentAttempts.find((attempt: any) => attempt.status === 'SUCCEEDED') ?? invoice.paymentAttempts[0] ?? null
const pdfBuffer = await generateInvoicePdf({
invoiceNumber: invoice.invoiceNumber,
issueDate: invoice.invoiceDate?.toISOString() ?? invoice.createdAt.toISOString(),
@@ -1380,7 +1382,7 @@ export async function getInvoicePdf(invoiceId: string) {
paymentProvider: invoice.paymentProvider ?? 'MANUAL',
transactionId: latestPaymentAttempt?.providerPaymentId ?? null,
paidAt: invoice.paidAt?.toISOString(),
lineItems: invoice.lineItems.map((item) => ({
lineItems: invoice.lineItems.map((item: any) => ({
description: item.description,
amount: item.amount,
currency: item.currency,
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest'
import {
menuItemSchema,
menuPlanAssignmentsSchema,
menuCompanyAssignmentsSchema,
menuPreviewSchema,
promotionCreateSchema,
promotionUpdateSchema,
} from './admin.schemas'
describe('admin menu and promotion schemas', () => {
it('trims labels and applies safe menu item defaults', () => {
expect(menuItemSchema.parse({ label: ' Fleet ', itemType: 'INTERNAL_PAGE' })).toMatchObject({
label: 'Fleet',
itemType: 'INTERNAL_PAGE',
displayOrder: 0,
openInNewTab: false,
isRequired: false,
isActive: true,
roles: [],
subscriptionPlans: [],
companyAssignments: [],
})
})
it('rejects negative menu ordering and invalid role previews', () => {
expect(() => menuItemSchema.parse({ label: 'Fleet', itemType: 'INTERNAL_PAGE', displayOrder: -1 })).toThrow()
expect(() => menuPreviewSchema.parse({ companyId: 'company_1', role: 'SUPER_ADMIN' })).toThrow()
})
it('requires at least one plan or company assignment', () => {
expect(() => menuPlanAssignmentsSchema.parse({ assignments: [] })).toThrow()
expect(() => menuCompanyAssignmentsSchema.parse({ assignments: [] })).toThrow()
expect(menuPlanAssignmentsSchema.parse({ assignments: [{ plan: 'PRO' }] })).toEqual({ assignments: [{ plan: 'PRO' }] })
})
it('accepts only uppercase promotion codes and preserves update partiality', () => {
const valid = {
code: 'SUMMER_26',
name: 'Summer 2026',
discountType: 'PERCENTAGE',
discountValue: 20,
plans: ['STARTER', 'GROWTH'],
periods: ['MONTHLY'],
validFrom: '2026-07-01T00:00:00.000Z',
}
expect(promotionCreateSchema.parse(valid)).toMatchObject({ ...valid, isActive: true })
expect(() => promotionCreateSchema.parse({ ...valid, code: 'summer' })).toThrow()
expect(promotionUpdateSchema.parse({ name: 'Updated name' })).toEqual({ name: 'Updated name' })
})
})
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest'
import { presentAdminSession, presentAdminUser, presentPaginated } from './admin.presenter'
describe('admin.presenter', () => {
it('removes admin secrets from user responses', () => {
const result = presentAdminUser({
id: 'admin_1',
email: 'admin@example.com',
role: 'SUPER_ADMIN',
passwordHash: 'hash',
totpSecret: 'secret',
})
expect(result).toEqual({ id: 'admin_1', email: 'admin@example.com', role: 'SUPER_ADMIN' })
})
it('wraps sessions without leaking credentials', () => {
expect(presentAdminSession({ id: 'admin_1', passwordHash: 'hash', totpSecret: 'secret' }, 'jwt-token')).toEqual({
token: 'jwt-token',
admin: { id: 'admin_1' },
})
})
it('computes pagination metadata and preserves extra aggregate fields', () => {
expect(presentPaginated([{ id: 'row_1' }], 41, 2, 20, { active: 11 })).toEqual({
data: [{ id: 'row_1' }],
total: 41,
page: 2,
pageSize: 20,
totalPages: 3,
active: 11,
})
})
})
@@ -0,0 +1,72 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('../../lib/prisma', () => ({
prisma: {
adminUser: { findFirst: vi.fn(), findUniqueOrThrow: vi.fn(), update: vi.fn() },
auditLog: { create: vi.fn() },
company: { findMany: vi.fn(), count: vi.fn(), findUniqueOrThrow: vi.fn(), update: vi.fn(), delete: vi.fn() },
billingAccount: { findMany: vi.fn(), count: vi.fn() },
$transaction: vi.fn(),
},
}))
import { prisma } from '../../lib/prisma'
import * as repo from './admin.repo'
describe('admin.repo edge queries', () => {
beforeEach(() => vi.clearAllMocks())
it('clears reset token metadata when updating an admin password', async () => {
await repo.updateAdminPassword('admin_1', 'hash_1')
expect(prisma.adminUser.update).toHaveBeenCalledWith({
where: { id: 'admin_1' },
data: {
passwordHash: 'hash_1',
passwordResetToken: null,
passwordResetExpiresAt: null,
},
})
})
it('filters company list by search, status and plan with pagination', async () => {
vi.mocked(prisma.company.findMany).mockResolvedValue([] as never)
vi.mocked(prisma.company.count).mockResolvedValue(0 as never)
await repo.listCompaniesPage({ q: 'atlas', status: 'ACTIVE', plan: 'PRO', page: 3, pageSize: 25 })
const where = {
status: 'ACTIVE',
OR: [
{ name: { contains: 'atlas', mode: 'insensitive' } },
{ email: { contains: 'atlas', mode: 'insensitive' } },
{ slug: { contains: 'atlas', mode: 'insensitive' } },
],
subscription: { plan: 'PRO' },
}
expect(prisma.company.findMany).toHaveBeenCalledWith(expect.objectContaining({
where,
skip: 50,
take: 25,
orderBy: { createdAt: 'desc' },
}))
expect(prisma.company.count).toHaveBeenCalledWith({ where })
})
it('looks up reset tokens only when they have not expired', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-06-01T00:00:00.000Z'))
await repo.findAdminByResetToken('reset-token')
expect(prisma.adminUser.findFirst).toHaveBeenCalledWith({
where: {
passwordResetToken: 'reset-token',
passwordResetExpiresAt: { gt: new Date('2026-06-01T00:00:00.000Z') },
},
})
vi.useRealTimers()
})
})
+23 -1
View File
@@ -58,6 +58,28 @@ export function enableAdminTotp(id: string) {
return prisma.adminUser.update({ where: { id }, data: { totpEnabled: true } })
}
export async function replaceAdminRecoveryCodes(adminUserId: string, codeHashes: string[]) {
return prisma.$transaction(async (tx) => {
await tx.adminRecoveryCode.deleteMany({ where: { adminUserId } })
await tx.adminRecoveryCode.createMany({
data: codeHashes.map((codeHash) => ({ adminUserId, codeHash })),
})
})
}
export function listUnusedAdminRecoveryCodes(adminUserId: string) {
return prisma.adminRecoveryCode.findMany({
where: { adminUserId, usedAt: null },
select: { id: true, codeHash: true },
orderBy: { createdAt: 'asc' },
})
}
export function markAdminRecoveryCodeUsed(id: string) {
return prisma.adminRecoveryCode.update({ where: { id }, data: { usedAt: new Date() } })
}
export function setAdminPasswordReset(id: string, token: string, expiresAt: Date) {
return prisma.adminUser.update({
where: { id },
@@ -136,7 +158,7 @@ export async function applyCompanyUpdate(
brand?: { paymentMethodsEnabled?: any[] | null } | null
},
) {
return prisma.$transaction(async (tx) => {
return prisma.$transaction(async (tx: any) => {
if (body.company) {
const companyData = { ...body.company }
if (companyData.address && typeof companyData.address === 'object' && !Array.isArray(companyData.address)) {
+46 -27
View File
@@ -1,7 +1,8 @@
import { Router } from 'express'
import { requireAdminAuth, requireAdminRole } from '../../middleware/requireAdminAuth'
import { requireAdminAuth, requireAdminRole, requireFreshAdmin2FA } from '../../middleware/requireAdminAuth'
import { parseBody, parseQuery, parseParams } from '../../http/validate'
import { ok, created } from '../../http/respond'
import { setSessionCookie, clearSessionCookie } from '../../security/sessionCookies'
import * as service from './admin.service'
import * as subService from '../subscriptions/subscription.service'
import * as menuService from '../menu/menu.service'
@@ -29,6 +30,10 @@ const adminExtendTrialSchema = z.object({
extraDays: z.number().int().positive(),
reason: z.string().min(1).max(500),
})
const adminImpersonationSchema = z.object({
reason: z.string().min(5).max(500),
durationMinutes: z.number().int().min(1).max(30).default(15),
})
const subIdParamSchema = z.object({ subscriptionId: z.string() })
const router = Router()
@@ -37,15 +42,21 @@ const router = Router()
router.post('/auth/login', async (req, res, next) => {
try {
const { email, password, totpCode } = parseBody(loginSchema, req)
const result = await service.login(email, password, totpCode)
const { email, password, totpCode, recoveryCode } = parseBody(loginSchema, req)
const result = await service.login(email, password, totpCode, recoveryCode)
if (!result) return res.status(401).json({ error: 'invalid_credentials', message: 'Invalid email or password', statusCode: 401 })
if ('totpRequired' in result) return res.status(401).json({ error: 'totp_required', message: '2FA code required', statusCode: 401 })
if ('invalidTotp' in result) return res.status(401).json({ error: 'invalid_totp', message: 'Invalid 2FA code', statusCode: 401 })
setSessionCookie(res, 'admin', result.token, 8 * 60 * 60 * 1000)
ok(res, result)
} catch (err) { next(err) }
})
router.post('/auth/logout', (_req, res) => {
clearSessionCookie(res, 'admin')
ok(res, { success: true })
})
router.post('/auth/forgot-password', async (req, res, next) => {
try {
const { email } = parseBody(forgotPasswordSchema, req)
@@ -78,9 +89,16 @@ router.post('/auth/2fa/setup', requireAdminAuth, async (req, res, next) => {
router.post('/auth/2fa/verify', requireAdminAuth, async (req, res, next) => {
try {
const { code } = parseBody(totpVerifySchema, req)
const valid = await service.verifyTotp(req.admin.id, code)
if (!valid) return res.status(400).json({ error: 'invalid_code', message: 'Invalid 2FA code', statusCode: 400 })
ok(res, { success: true })
const result = await service.verifyTotp(req.admin.id, code)
if (!result) return res.status(400).json({ error: 'invalid_code', message: 'Invalid 2FA code', statusCode: 400 })
setSessionCookie(res, 'admin', result.token, 8 * 60 * 60 * 1000)
ok(res, { success: true, admin: result.admin, recoveryCodes: result.recoveryCodes })
} catch (err) { next(err) }
})
router.post('/auth/2fa/recovery-codes/regenerate', requireAdminAuth, requireFreshAdmin2FA, async (req, res, next) => {
try {
ok(res, await service.regenerateRecoveryCodes(req.admin.id))
} catch (err) { next(err) }
})
@@ -115,7 +133,7 @@ router.patch('/companies/:id/status', requireAdminAuth, requireAdminRole('SUPPOR
} catch (err) { next(err) }
})
router.delete('/companies/:id', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => {
router.delete('/companies/:id', requireAdminAuth, requireAdminRole('ADMIN'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
await service.deleteCompany(id, req.admin.id, req.ip)
@@ -123,10 +141,11 @@ router.delete('/companies/:id', requireAdminAuth, requireAdminRole('ADMIN'), asy
} catch (err) { next(err) }
})
router.post('/companies/:id/impersonate', requireAdminAuth, requireAdminRole('ADMIN'), async (req, res, next) => {
router.post('/companies/:id/impersonate', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
ok(res, await service.impersonateCompany(id, req.admin.id, req.ip))
const { reason, durationMinutes } = parseBody(adminImpersonationSchema, req)
ok(res, await service.impersonateCompany(id, req.admin.id, req.ip, reason, durationMinutes))
} catch (err) { next(err) }
})
@@ -272,20 +291,20 @@ router.get('/admins', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), async (
} catch (err) { next(err) }
})
router.post('/admins', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), async (req, res, next) => {
router.post('/admins', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), requireFreshAdmin2FA, async (req, res, next) => {
try {
created(res, { data: await service.createAdmin(parseBody(createAdminSchema, req)) })
} catch (err) { next(err) }
})
router.patch('/admins/:id', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), async (req, res, next) => {
router.patch('/admins/:id', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
ok(res, await service.updateAdmin(id, parseBody(updateAdminSchema, req)))
} catch (err) { next(err) }
})
router.patch('/admins/:id/role', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), async (req, res, next) => {
router.patch('/admins/:id/role', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const { role } = parseBody(adminRoleSchema, req)
@@ -294,7 +313,7 @@ router.patch('/admins/:id/role', requireAdminAuth, requireAdminRole('SUPER_ADMIN
} catch (err) { next(err) }
})
router.patch('/admins/:id/permissions', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), async (req, res, next) => {
router.patch('/admins/:id/permissions', requireAdminAuth, requireAdminRole('SUPER_ADMIN'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const { permissions } = parseBody(adminPermissionsSchema, req)
@@ -370,7 +389,7 @@ router.post('/billing/invoices/:invoiceId/finalize', requireAdminAuth, requireAd
} catch (err) { next(err) }
})
router.post('/billing/invoices/:invoiceId/pay', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
router.post('/billing/invoices/:invoiceId/pay', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { invoiceId } = parseParams(invoiceIdParamSchema, req)
ok(res, await service.payBillingInvoice(invoiceId, parseBody(payBillingInvoiceSchema, req), req.admin.id, req.ip))
@@ -407,7 +426,7 @@ router.post('/billing/invoices/:invoiceId/credit-notes', requireAdminAuth, requi
} catch (err) { next(err) }
})
router.post('/billing/invoices/:invoiceId/refunds', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
router.post('/billing/invoices/:invoiceId/refunds', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { invoiceId } = parseParams(invoiceIdParamSchema, req)
ok(res, await service.issueBillingRefund(invoiceId, parseBody(billingRefundSchema, req), req.admin.id, req.ip))
@@ -422,7 +441,7 @@ router.get('/pricing', requireAdminAuth, requireAdminRole('FINANCE'), async (req
} catch (err) { next(err) }
})
router.patch('/pricing', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
router.patch('/pricing', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { entries } = parseBody(pricingUpdateSchema, req)
ok(res, await service.updatePricingConfigs(entries, req.admin.id, req.ip))
@@ -435,14 +454,14 @@ router.get('/pricing/features', requireAdminAuth, requireAdminRole('FINANCE'), a
} catch (err) { next(err) }
})
router.post('/pricing/features', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
router.post('/pricing/features', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const data = parseBody(planFeatureCreateSchema, req)
created(res, await service.createPlanFeature(data, req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.patch('/pricing/features/:featureId', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
router.patch('/pricing/features/:featureId', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { featureId } = parseParams(planFeatureIdParamSchema, req)
const data = parseBody(planFeatureUpdateSchema, req)
@@ -450,7 +469,7 @@ router.patch('/pricing/features/:featureId', requireAdminAuth, requireAdminRole(
} catch (err) { next(err) }
})
router.delete('/pricing/features/:featureId', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
router.delete('/pricing/features/:featureId', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { featureId } = parseParams(planFeatureIdParamSchema, req)
await service.deletePlanFeature(featureId, req.admin.id, req.ip)
@@ -466,14 +485,14 @@ router.get('/pricing/promotions', requireAdminAuth, requireAdminRole('FINANCE'),
} catch (err) { next(err) }
})
router.post('/pricing/promotions', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
router.post('/pricing/promotions', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const data = parseBody(promotionCreateSchema, req)
created(res, await service.createPromotion(data as any, req.admin.id, req.ip))
} catch (err) { next(err) }
})
router.patch('/pricing/promotions/:promotionId', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
router.patch('/pricing/promotions/:promotionId', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { promotionId } = parseParams(promotionIdParamSchema, req)
const data = parseBody(promotionUpdateSchema, req)
@@ -481,7 +500,7 @@ router.patch('/pricing/promotions/:promotionId', requireAdminAuth, requireAdminR
} catch (err) { next(err) }
})
router.delete('/pricing/promotions/:promotionId', requireAdminAuth, requireAdminRole('FINANCE'), async (req, res, next) => {
router.delete('/pricing/promotions/:promotionId', requireAdminAuth, requireAdminRole('FINANCE'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { promotionId } = parseParams(promotionIdParamSchema, req)
await service.deletePromotion(promotionId, req.admin.id, req.ip)
@@ -498,7 +517,7 @@ router.get('/subscriptions/:subscriptionId/events', requireAdminAuth, requireAdm
} catch (err) { next(err) }
})
router.post('/subscriptions/:subscriptionId/extend-trial', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
router.post('/subscriptions/:subscriptionId/extend-trial', requireAdminAuth, requireAdminRole('SUPPORT'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { subscriptionId } = parseParams(subIdParamSchema, req)
const { extraDays, reason } = parseBody(adminExtendTrialSchema, req)
@@ -506,7 +525,7 @@ router.post('/subscriptions/:subscriptionId/extend-trial', requireAdminAuth, req
} catch (err) { next(err) }
})
router.post('/subscriptions/:subscriptionId/extend-grace-period', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
router.post('/subscriptions/:subscriptionId/extend-grace-period', requireAdminAuth, requireAdminRole('SUPPORT'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { subscriptionId } = parseParams(subIdParamSchema, req)
const { reason } = parseBody(adminSubOverrideSchema, req)
@@ -514,7 +533,7 @@ router.post('/subscriptions/:subscriptionId/extend-grace-period', requireAdminAu
} catch (err) { next(err) }
})
router.post('/subscriptions/:subscriptionId/suspend', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
router.post('/subscriptions/:subscriptionId/suspend', requireAdminAuth, requireAdminRole('SUPPORT'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { subscriptionId } = parseParams(subIdParamSchema, req)
const { reason } = parseBody(adminSubOverrideSchema, req)
@@ -522,7 +541,7 @@ router.post('/subscriptions/:subscriptionId/suspend', requireAdminAuth, requireA
} catch (err) { next(err) }
})
router.post('/subscriptions/:subscriptionId/reactivate', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
router.post('/subscriptions/:subscriptionId/reactivate', requireAdminAuth, requireAdminRole('SUPPORT'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { subscriptionId } = parseParams(subIdParamSchema, req)
const { reason } = parseBody(adminSubOverrideSchema, req)
@@ -530,7 +549,7 @@ router.post('/subscriptions/:subscriptionId/reactivate', requireAdminAuth, requi
} catch (err) { next(err) }
})
router.post('/subscriptions/:subscriptionId/cancel', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
router.post('/subscriptions/:subscriptionId/cancel', requireAdminAuth, requireAdminRole('SUPPORT'), requireFreshAdmin2FA, async (req, res, next) => {
try {
const { subscriptionId } = parseParams(subIdParamSchema, req)
const { reason } = parseBody(adminSubOverrideSchema, req)
@@ -5,6 +5,7 @@ export const loginSchema = z.object({
email: z.string().email().max(255).trim().toLowerCase(),
password: z.string().max(128),
totpCode: z.string().length(6).optional(),
recoveryCode: z.string().min(8).max(32).optional(),
})
export const forgotPasswordSchema = z.object({
+84 -22
View File
@@ -1,7 +1,7 @@
import bcrypt from 'bcryptjs'
import jwt from 'jsonwebtoken'
import crypto from 'crypto'
import { authenticator } from 'otplib'
import { signActorToken } from '../../security/tokens'
import qrcode from 'qrcode'
import { getMarketplaceHomepageContent, saveMarketplaceHomepageContent } from '../../services/platformContentService'
import { sendTransactionalEmail } from '../../services/notificationService'
@@ -10,9 +10,50 @@ import * as repo from './admin.repo'
import * as billingService from './admin.billing.service'
const ADMIN_RESET_TTL_MINUTES = 60
const ADMIN_RECOVERY_CODE_COUNT = 10
function signAdminToken(adminId: string) {
return jwt.sign({ sub: adminId, type: 'admin' }, process.env.JWT_SECRET!, { expiresIn: '8h' })
function generateRecoveryCode() {
const raw = crypto.randomBytes(9).toString('base64url').replace(/[^a-zA-Z0-9]/g, '').toUpperCase().slice(0, 12)
return `${raw.slice(0, 4)}-${raw.slice(4, 8)}-${raw.slice(8, 12)}`
}
async function issueAdminRecoveryCodes(adminId: string) {
const codes = Array.from({ length: ADMIN_RECOVERY_CODE_COUNT }, generateRecoveryCode)
const hashes = await Promise.all(codes.map((code) => bcrypt.hash(code, 12)))
await repo.replaceAdminRecoveryCodes(adminId, hashes)
await repo.createAuditLog({
adminUserId: adminId,
action: 'ADMIN_2FA_RECOVERY_CODES_ISSUED',
resource: 'AdminUser',
resourceId: adminId,
})
return codes
}
async function consumeAdminRecoveryCode(adminId: string, code: string) {
const normalized = code.trim().toUpperCase()
if (!normalized) return false
const codes = await repo.listUnusedAdminRecoveryCodes(adminId)
for (const candidate of codes) {
if (await bcrypt.compare(normalized, candidate.codeHash)) {
await repo.markAdminRecoveryCodeUsed(candidate.id)
await repo.createAuditLog({
adminUserId: adminId,
action: 'ADMIN_2FA_RECOVERY_CODE_USED',
resource: 'AdminUser',
resourceId: adminId,
})
return true
}
}
return false
}
function signAdminToken(adminId: string, last2faAt?: number) {
return signActorToken(adminId, 'admin', { expiresIn: '8h', last2faAt })
}
function toAuditJson<T>(value: T) {
@@ -31,7 +72,7 @@ function ensureAdminBasePath(baseUrl: string) {
}
}
export async function login(email: string, password: string, totpCode?: string) {
export async function login(email: string, password: string, totpCode?: string, recoveryCode?: string) {
const admin = await repo.findAdminByEmail(email)
if (!admin || !admin.isActive) return null
@@ -39,8 +80,16 @@ export async function login(email: string, password: string, totpCode?: string)
if (!valid) return null
if (admin.totpEnabled) {
if (!totpCode) return { totpRequired: true } as const
if (!authenticator.verify({ token: totpCode, secret: admin.totpSecret! })) {
if (!totpCode && !recoveryCode) return { totpRequired: true } as const
const validTotp = totpCode
? authenticator.verify({ token: totpCode, secret: admin.totpSecret! })
: false
const validRecoveryCode = !validTotp && recoveryCode
? await consumeAdminRecoveryCode(admin.id, recoveryCode)
: false
if (!validTotp && !validRecoveryCode) {
return { invalidTotp: true } as const
}
}
@@ -53,7 +102,7 @@ export async function login(email: string, password: string, totpCode?: string)
resourceId: admin.id,
})
return presenter.presentAdminSession(admin, signAdminToken(admin.id))
return presenter.presentAdminSession(admin, signAdminToken(admin.id, admin.totpEnabled ? Date.now() : undefined))
}
export async function setupTotp(adminId: string, email: string) {
@@ -69,8 +118,24 @@ export async function verifyTotp(adminId: string, code: string) {
if (!admin.totpSecret) return false
const valid = authenticator.verify({ token: code, secret: admin.totpSecret })
if (valid) await repo.enableAdminTotp(adminId)
return valid
if (!valid) return false
await repo.enableAdminTotp(adminId)
await repo.createAuditLog({
adminUserId: adminId,
action: 'ADMIN_2FA_VERIFIED',
resource: 'AdminUser',
resourceId: adminId,
})
const recoveryCodes = await issueAdminRecoveryCodes(adminId)
return {
...presenter.presentAdminSession({ ...admin, totpEnabled: true }, signAdminToken(adminId, Date.now())),
recoveryCodes,
}
}
export async function regenerateRecoveryCodes(adminId: string) {
return { recoveryCodes: await issueAdminRecoveryCodes(adminId) }
}
export async function forgotPassword(email: string) {
@@ -155,18 +220,12 @@ export async function deleteCompany(id: string, adminId: string, ip?: string) {
})
}
export async function impersonateCompany(id: string, adminId: string, ip?: string) {
export async function impersonateCompany(id: string, adminId: string, ip?: string, reason?: string, durationMinutes = 15) {
const company = await repo.getCompanyForImpersonation(id)
const token = jwt.sign(
{
sub: company.employees[0]?.id,
companyId: company.id,
isImpersonation: true,
type: 'employee',
},
process.env.JWT_SECRET!,
{ expiresIn: '30m' },
)
const ttlMinutes = Math.min(Math.max(durationMinutes, 1), 30)
const employeeId = company.employees[0]?.id
if (!employeeId) throw new Error('Company has no employee account to impersonate')
const token = signActorToken(employeeId, 'employee', { expiresIn: `${ttlMinutes}m` as any })
await repo.createAuditLog({
adminUserId: adminId,
@@ -174,10 +233,13 @@ export async function impersonateCompany(id: string, adminId: string, ip?: strin
resource: 'Company',
resourceId: id,
companyId: id,
note: reason,
before: { originalAdminId: adminId },
after: { targetCompanyId: id, durationMinutes: ttlMinutes },
ipAddress: ip,
})
return { token, expiresIn: 1800 }
return { token, expiresIn: ttlMinutes * 60, impersonation: { companyId: id, reason, durationMinutes: ttlMinutes } }
}
export async function listRenters(query: { q?: string; blocked?: string; page: number; pageSize: number }) {
@@ -205,7 +267,7 @@ export async function getAuditLogs(query: { adminId?: string; action?: string; c
export async function listAdmins() {
const admins = await repo.listAdmins()
return admins.map((admin) => presenter.presentAdminUser(admin))
return admins.map((admin: any) => presenter.presentAdminUser(admin))
}
export async function createAdmin(body: { email: string; firstName: string; lastName: string; role: string; password: string; permissions?: any[] }) {
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import { reportQuerySchema, summaryQuerySchema } from './analytics.schemas'
describe('analytics schema contracts', () => {
it('defaults summary period to the 30 day window', () => {
expect(summaryQuerySchema.parse({})).toEqual({ period: '30d' })
})
it('defaults reports to JSON while preserving explicit date range filters', () => {
expect(reportQuerySchema.parse({ from: '2026-01-01', to: '2026-01-31' })).toEqual({
from: '2026-01-01',
to: '2026-01-31',
format: 'JSON',
})
})
it('passes CSV format and period through without lowercasing surprises', () => {
expect(reportQuerySchema.parse({ format: 'CSV', period: 'quarter' })).toEqual({
format: 'CSV',
period: 'quarter',
})
})
})
@@ -0,0 +1,150 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('../../lib/prisma', () => ({
prisma: {
reservation: {
count: vi.fn(),
aggregate: vi.fn(),
findMany: vi.fn(),
groupBy: vi.fn(),
},
vehicle: {
count: vi.fn(),
},
customer: {
count: vi.fn(),
},
subscription: {
findUnique: vi.fn(),
},
accountingSettings: {
findUnique: vi.fn(),
},
},
}))
vi.mock('../../services/financialReportService', () => ({
generateFinancialReport: vi.fn(),
toCsv: vi.fn(),
}))
import { prisma } from '../../lib/prisma'
import { generateFinancialReport, toCsv } from '../../services/financialReportService'
import { getDashboard, getReport, getSources, getSummary } from './analytics.service'
describe('analytics.service', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.setSystemTime(new Date('2026-06-08T12:00:00.000Z'))
})
it('summarizes reservations, available vehicles, revenue, and customers for a day window', async () => {
vi.mocked(prisma.reservation.count).mockResolvedValue(7 as never)
vi.mocked(prisma.vehicle.count).mockResolvedValue(12 as never)
vi.mocked(prisma.reservation.aggregate).mockResolvedValue({ _sum: { totalAmount: 4800 } } as never)
vi.mocked(prisma.customer.count).mockResolvedValue(42 as never)
const result = await getSummary('company_1', '14d')
expect(prisma.reservation.count).toHaveBeenCalledWith({
where: { companyId: 'company_1', createdAt: { gte: new Date('2026-05-25T12:00:00.000Z') } },
})
expect(prisma.vehicle.count).toHaveBeenCalledWith({ where: { companyId: 'company_1', status: 'AVAILABLE' } })
expect(result).toEqual({
totalReservations: 7,
activeVehicles: 12,
totalRevenue: 4800,
totalCustomers: 42,
period: '14d',
})
})
it('builds dashboard KPIs, percentage deltas, recent reservation cards, source breakdown, and subscription summary', async () => {
vi.mocked(prisma.reservation.count)
.mockResolvedValueOnce(10 as never)
.mockResolvedValueOnce(5 as never)
vi.mocked(prisma.vehicle.count)
.mockResolvedValueOnce(4 as never)
.mockResolvedValueOnce(0 as never)
vi.mocked(prisma.customer.count)
.mockResolvedValueOnce(20 as never)
.mockResolvedValueOnce(25 as never)
vi.mocked(prisma.reservation.aggregate)
.mockResolvedValueOnce({ _sum: { totalAmount: 9000 } } as never)
.mockResolvedValueOnce({ _sum: { totalAmount: 3000 } } as never)
vi.mocked(prisma.reservation.findMany).mockResolvedValue([
{
id: 'reservation_abc12345',
contractNumber: null,
customer: { firstName: 'Nora', lastName: 'Driver' },
vehicle: { make: 'Dacia', model: 'Duster' },
startDate: new Date('2026-06-10T00:00:00.000Z'),
endDate: new Date('2026-06-12T00:00:00.000Z'),
status: 'CONFIRMED',
totalAmount: 1200,
},
] as never)
vi.mocked(prisma.reservation.groupBy).mockResolvedValue([
{ source: 'MARKETPLACE', _count: { id: 3 }, _sum: { totalAmount: 3600 } },
{ source: 'DIRECT', _count: { id: 2 }, _sum: { totalAmount: null } },
] as never)
vi.mocked(prisma.subscription.findUnique).mockResolvedValue({
status: 'ACTIVE',
plan: 'PRO',
trialEndAt: new Date('2026-06-30T00:00:00.000Z'),
} as never)
const result = await getDashboard('company_1')
expect(result.kpis).toEqual({
totalBookings: 10,
activeVehicles: 4,
monthlyRevenue: 9000,
totalCustomers: 20,
bookingsChange: 100,
vehiclesChange: 100,
revenueChange: 200,
customersChange: -20,
})
expect(result.recentReservations).toEqual([expect.objectContaining({
id: 'reservation_abc12345',
bookingRef: 'ABC12345',
customerName: 'Nora Driver',
vehicleName: 'Dacia Duster',
status: 'CONFIRMED',
totalAmount: 1200,
})])
expect(result.sourceBreakdown).toEqual([
{ source: 'MARKETPLACE', count: 3, revenue: 3600 },
{ source: 'DIRECT', count: 2, revenue: 0 },
])
expect(result.subscription).toEqual({
status: 'ACTIVE',
planName: 'PRO',
trialEndsAt: new Date('2026-06-30T00:00:00.000Z'),
})
})
it('returns source groups straight from reservation grouping', async () => {
vi.mocked(prisma.reservation.groupBy).mockResolvedValue([{ source: 'DIRECT', _count: { id: 2 } }] as never)
await expect(getSources('company_1')).resolves.toEqual([{ source: 'DIRECT', _count: { id: 2 } }])
expect(prisma.reservation.groupBy).toHaveBeenCalledWith({ by: ['source'], where: { companyId: 'company_1' }, _count: { id: true } })
})
it('uses explicit report dates and emits CSV only when requested', async () => {
vi.mocked(prisma.accountingSettings.findUnique).mockResolvedValue({ reportingPeriod: 'ANNUAL' } as never)
vi.mocked(generateFinancialReport).mockResolvedValue({ rows: [{ label: 'Revenue', amount: 1000 }] } as never)
vi.mocked(toCsv).mockReturnValue('label,amount\nRevenue,1000')
const result = await getReport('company_1', {
from: '2026-05-01',
to: '2026-05-31',
format: 'CSV',
})
expect(generateFinancialReport).toHaveBeenCalledWith('company_1', new Date('2026-05-01'), new Date('2026-05-31'))
expect(toCsv).toHaveBeenCalledWith([{ label: 'Revenue', amount: 1000 }])
expect(result.csv).toBe('label,amount\nRevenue,1000')
})
})
@@ -0,0 +1,112 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('../../lib/prisma', () => ({ prisma: {} }))
import { createCompanySignup } from './auth.company.repo'
function makeDb() {
return {
company: { create: vi.fn().mockResolvedValue({ id: 'company_1', name: 'Atlas Cars', slug: 'atlas-cars' }) },
brandSettings: { create: vi.fn().mockResolvedValue({}) },
contractSettings: { create: vi.fn().mockResolvedValue({}) },
subscription: { create: vi.fn().mockResolvedValue({}) },
employee: { create: vi.fn().mockResolvedValue({ id: 'employee_1', email: 'owner@example.com' }) },
}
}
const baseInput = {
companyName: 'Atlas Cars',
legalName: 'Atlas Cars SARL',
slug: 'atlas-cars',
ownerEmail: 'owner@example.com',
companyEmail: 'contact@example.com',
companyPhone: '+212600000000',
streetAddress: '1 Fleet Street',
city: 'Casablanca',
country: 'Morocco',
zipCode: '20000',
legalForm: 'SARL',
managerName: 'Aya Benali',
iceNumber: 'ICE123',
taxId: 'TAX123',
operatingLicenseNumber: 'LIC123',
operatingLicenseIssuedAt: '2026-01-01',
operatingLicenseIssuedBy: 'Transport Authority',
fax: '',
yearsActive: '5',
representativeName: '',
representativeTitle: '',
responsibleName: 'Omar Alaoui',
responsibleRole: 'Manager',
responsibleIdentityNumber: 'ID123',
responsibleQualification: '',
responsiblePhone: '+212611111111',
responsibleEmail: 'responsible@example.com',
currency: 'MAD' as const,
registrationNumber: 'REG123',
plan: 'GROWTH' as const,
billingPeriod: 'ANNUAL' as const,
preferredLanguage: 'fr' as const,
firstName: 'Aya',
lastName: 'Benali',
passwordHash: 'hashed-password',
now: new Date('2026-06-01T00:00:00.000Z'),
trialEndAt: new Date('2026-08-30T00:00:00.000Z'),
}
describe('auth.company.repo.createCompanySignup', () => {
it('creates the company, tenant settings, trial subscription, and active owner in one transaction client', async () => {
const db = makeDb()
await expect(createCompanySignup(db as never, baseInput)).resolves.toEqual({
company: { id: 'company_1', name: 'Atlas Cars', slug: 'atlas-cars' },
employee: { id: 'employee_1', email: 'owner@example.com' },
})
expect(db.company.create).toHaveBeenCalledWith({ data: expect.objectContaining({
name: 'Atlas Cars',
slug: 'atlas-cars',
email: 'contact@example.com',
status: 'TRIALING',
address: expect.objectContaining({
legalName: 'Atlas Cars SARL',
companyEmail: 'contact@example.com',
fax: null,
representativeName: null,
responsibleEmail: 'responsible@example.com',
}),
}) })
expect(db.brandSettings.create).toHaveBeenCalledWith({ data: expect.objectContaining({
companyId: 'company_1',
displayName: 'Atlas Cars',
subdomain: 'atlas-cars',
defaultLocale: 'fr',
defaultCurrency: 'MAD',
}) })
expect(db.contractSettings.create).toHaveBeenCalledWith({ data: expect.objectContaining({
companyId: 'company_1',
registrationNumber: 'REG123',
taxId: 'TAX123',
}) })
expect(db.subscription.create).toHaveBeenCalledWith({ data: expect.objectContaining({
companyId: 'company_1',
plan: 'GROWTH',
billingPeriod: 'ANNUAL',
status: 'TRIALING',
trialStartAt: baseInput.now,
trialEndAt: baseInput.trialEndAt,
}) })
expect(db.employee.create).toHaveBeenCalledWith({ data: expect.objectContaining({
companyId: 'company_1',
clerkUserId: 'local_owner_company_1',
email: 'owner@example.com',
role: 'OWNER',
preferredLanguage: 'fr',
isActive: true,
}) })
})
})
@@ -0,0 +1,138 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AppError } from '../../http/errors'
import { prisma } from '../../lib/prisma'
import * as repo from './auth.company.repo'
import * as service from './auth.company.service'
import { sendNotification } from '../../services/notificationService'
vi.mock('bcryptjs', () => ({ default: { hash: vi.fn().mockResolvedValue('hashed-password') } }))
vi.mock('../../lib/prisma', () => ({ prisma: { $transaction: vi.fn() } }))
vi.mock('./auth.company.repo', () => ({
findCompanyBySlug: vi.fn(),
findCompanyByEmail: vi.fn(),
findEmployeeByEmail: vi.fn(),
createCompanySignup: vi.fn(),
}))
vi.mock('../../services/notificationService', () => ({ sendNotification: vi.fn() }))
const body = {
firstName: 'Aya',
lastName: 'Benali',
email: 'owner@example.com',
password: 'super-secret',
companyName: 'Atlas & Desert 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' as const,
plan: 'PRO' as const,
billingPeriod: 'MONTHLY' as const,
currency: 'MAD' as const,
paymentProvider: 'PAYPAL' as const,
}
describe('auth.company.service', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(repo.findCompanyByEmail).mockResolvedValue(null)
vi.mocked(repo.findEmployeeByEmail).mockResolvedValue(null)
vi.mocked(repo.findCompanyBySlug).mockResolvedValue(null)
vi.mocked(repo.createCompanySignup).mockResolvedValue({
company: { id: 'company_1', name: 'Atlas & Desert Cars!!!', slug: 'atlas-desert-cars' },
employee: { id: 'employee_1' },
} as never)
vi.mocked(prisma.$transaction).mockImplementation(async (fn: any) => fn({ tx: true }))
vi.mocked(sendNotification).mockResolvedValue([{ channel: 'EMAIL', success: true }] as never)
})
it('rejects duplicate company contact email before hashing or transaction work', async () => {
vi.mocked(repo.findCompanyByEmail).mockResolvedValue({ id: 'company_existing' } as never)
await expect(service.signup(body)).rejects.toMatchObject({ statusCode: 409, error: 'company_email_taken' })
expect(prisma.$transaction).not.toHaveBeenCalled()
})
it('rejects duplicate owner email before creating tenant data', async () => {
vi.mocked(repo.findEmployeeByEmail).mockResolvedValue({ id: 'employee_existing' } as never)
await expect(service.signup(body)).rejects.toMatchObject({ statusCode: 409, error: 'owner_email_taken' })
expect(prisma.$transaction).not.toHaveBeenCalled()
})
it('generates a stable slug, creates the tenant signup, sends notification, and presents the workspace result', async () => {
const result = await service.signup(body)
expect(result).toEqual(expect.objectContaining({
companyId: 'company_1',
companyName: 'Atlas & Desert Cars!!!',
slug: 'atlas-desert-cars',
nextStep: 'workspace_created',
emailDelivery: { channel: 'EMAIL', success: true },
}))
expect(result.trialEndsAt).toEqual(expect.any(String))
expect(repo.findCompanyBySlug).toHaveBeenCalledWith('atlas-desert-cars')
expect(repo.createCompanySignup).toHaveBeenCalledWith({ tx: true }, expect.objectContaining({
slug: 'atlas-desert-cars',
ownerEmail: 'owner@example.com',
passwordHash: 'hashed-password',
managerName: 'Aya Benali',
trialEndAt: expect.any(Date),
}))
expect(sendNotification).toHaveBeenCalledWith(expect.objectContaining({
type: 'ACCOUNT_CREATED',
companyId: 'company_1',
employeeId: 'employee_1',
email: 'owner@example.com',
channels: ['EMAIL', 'IN_APP'],
locale: 'fr',
templateVariables: expect.objectContaining({
firstName: 'Aya',
companyName: 'Atlas & Desert Cars!!!',
paymentProvider: 'PAYPAL',
}),
}))
})
it('increments the slug when the clean base slug is already taken', async () => {
vi.mocked(repo.findCompanyBySlug)
.mockResolvedValueOnce({ id: 'existing' } as never)
.mockResolvedValueOnce(null)
await service.signup(body)
expect(repo.findCompanyBySlug).toHaveBeenNthCalledWith(1, 'atlas-desert-cars')
expect(repo.findCompanyBySlug).toHaveBeenNthCalledWith(2, 'atlas-desert-cars-2')
expect(repo.createCompanySignup).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ slug: 'atlas-desert-cars-2' }))
})
it('keeps signup successful when notification delivery fails', async () => {
vi.mocked(sendNotification).mockRejectedValue(new Error('smtp down'))
await expect(service.signup(body)).resolves.toMatchObject({
companyId: 'company_1',
emailDelivery: { attempted: false, success: false, error: null },
})
})
it('exposes removed legacy auth endpoints as explicit gone errors', () => {
expect(() => service.completeSignupDisabled()).toThrow(AppError)
expect(() => service.verifyEmailDisabled()).toThrow(AppError)
})
})
@@ -45,7 +45,7 @@ export async function signup(body: CompanySignupInput) {
const trialEndAt = new Date(now.getTime() + TRIAL_PERIOD_DAYS * 24 * 60 * 60 * 1000)
const passwordHash = await bcrypt.hash(body.password, 12)
const result = await prisma.$transaction((tx) => repo.createCompanySignup(tx, {
const result = await prisma.$transaction((tx: any) => repo.createCompanySignup(tx, {
companyName: body.companyName,
legalName: body.legalName,
slug,
@@ -0,0 +1,72 @@
import { describe, expect, it, vi } from 'vitest'
const prismaMock = vi.hoisted(() => ({
employee: {
findUnique: vi.fn(),
findFirst: vi.fn(),
update: vi.fn(),
},
}))
vi.mock('../../lib/prisma', () => ({ prisma: prismaMock }))
import * as repo from './auth.employee.repo'
describe('auth.employee.repo query boundaries', () => {
it('loads employee sessions with company context by id', async () => {
await repo.findEmployeeWithCompanyById('employee_1')
expect(prismaMock.employee.findUnique).toHaveBeenCalledWith({
where: { id: 'employee_1' },
include: { company: true },
})
})
it('looks up employee login emails case-insensitively and includes company context', async () => {
await repo.findEmployeeWithCompanyByEmail('Agent@Example.TEST')
expect(prismaMock.employee.findFirst).toHaveBeenCalledWith({
where: { email: { equals: 'Agent@Example.TEST', mode: 'insensitive' } },
include: { company: true },
})
})
it('only sends forgot-password emails to active employees', async () => {
await repo.findActiveEmployeeByEmail('agent@example.test')
expect(prismaMock.employee.findFirst).toHaveBeenCalledWith({
where: {
email: { equals: 'agent@example.test', mode: 'insensitive' },
isActive: true,
},
})
})
it('requires unexpired reset tokens for stored-token password reset lookup', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-06-09T12:00:00.000Z'))
await repo.findEmployeeByResetToken('reset_123')
expect(prismaMock.employee.findFirst).toHaveBeenCalledWith({
where: {
passwordResetToken: 'reset_123',
passwordResetExpiresAt: { gt: new Date('2026-06-09T12:00:00.000Z') },
},
})
vi.useRealTimers()
})
it('clears stored reset token fields when password changes', async () => {
await repo.resetPassword('employee_1', 'hash_new')
expect(prismaMock.employee.update).toHaveBeenCalledWith({
where: { id: 'employee_1' },
data: {
passwordHash: 'hash_new',
passwordResetToken: null,
passwordResetExpiresAt: null,
},
})
})
})
@@ -2,6 +2,7 @@ import { Router } from 'express'
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
import { parseBody } from '../../http/validate'
import { ok } from '../../http/respond'
import { setSessionCookie, clearSessionCookie } from '../../security/sessionCookies'
import { getEmployeeMenu } from '../menu/menu.service'
import {
employeeForgotPasswordSchema,
@@ -28,10 +29,17 @@ router.get('/menu', requireCompanyAuth, async (req, res, next) => {
router.post('/login', async (req, res, next) => {
try {
const body = parseBody(employeeLoginSchema, req)
ok(res, await service.login(body))
const result = await service.login(body)
if ('token' in result) setSessionCookie(res, 'employee', result.token, 8 * 60 * 60 * 1000)
ok(res, result)
} catch (err) { next(err) }
})
router.post('/logout', (_req, res) => {
clearSessionCookie(res, 'employee')
ok(res, { success: true })
})
router.post('/forgot-password', async (req, res, next) => {
try {
const { email } = parseBody(employeeForgotPasswordSchema, req)
@@ -0,0 +1,140 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('bcryptjs', () => ({
default: {
compare: vi.fn(),
hash: vi.fn(),
},
}))
vi.mock('../../services/notificationService', () => ({ sendTransactionalEmail: vi.fn() }))
vi.mock('./auth.employee.repo', () => ({
findEmployeeWithCompanyById: vi.fn(),
findEmployeeWithCompanyByEmail: vi.fn(),
findActiveEmployeeByEmail: vi.fn(),
findEmployeeByResetToken: vi.fn(),
findEmployeeById: vi.fn(),
updatePreferredLanguage: vi.fn(),
resetPassword: vi.fn(),
}))
import bcrypt from 'bcryptjs'
import { AppError } from '../../http/errors'
import { sendTransactionalEmail } from '../../services/notificationService'
import * as repo from './auth.employee.repo'
import * as service from './auth.employee.service'
const employee = {
id: 'employee_1',
email: 'agent@example.test',
firstName: 'Aya',
lastName: 'Agent',
role: 'AGENT',
preferredLanguage: 'fr',
companyId: 'company_1',
isActive: true,
passwordHash: 'hash_old',
company: { name: 'Atlas Cars', slug: 'atlas' },
}
describe('auth.employee.service edge behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
process.env.JWT_SECRET = 'test-secret'
process.env.JWT_EXPIRY = '8h'
delete process.env.DASHBOARD_URL
delete process.env.NEXT_PUBLIC_DASHBOARD_URL
vi.mocked(bcrypt.compare).mockResolvedValue(true as never)
vi.mocked(bcrypt.hash).mockResolvedValue('hash_new' as never)
})
it('rejects inactive employee sessions before presenting tenant data', async () => {
vi.mocked(repo.findEmployeeWithCompanyById).mockResolvedValue({ ...employee, isActive: false } as never)
await expect(service.getMe('employee_1')).rejects.toMatchObject({
statusCode: 401,
error: 'unauthenticated',
})
})
it('logs in active employees with a signed token and company context', async () => {
vi.mocked(repo.findEmployeeWithCompanyByEmail).mockResolvedValue(employee as never)
const result = await service.login({ email: 'agent@example.test', password: 'correct-password' })
expect(bcrypt.compare).toHaveBeenCalledWith('correct-password', 'hash_old')
expect(result).toMatchObject({
token: expect.any(String),
employee: {
id: 'employee_1',
companyId: 'company_1',
companyName: 'Atlas Cars',
companySlug: 'atlas',
},
})
})
it('distinguishes employees without passwords from wrong credentials', async () => {
vi.mocked(repo.findEmployeeWithCompanyByEmail).mockResolvedValue({ ...employee, passwordHash: null } as never)
await expect(service.login({ email: 'agent@example.test', password: 'anything' })).rejects.toMatchObject({
statusCode: 401,
error: 'password_not_set',
})
expect(bcrypt.compare).not.toHaveBeenCalled()
})
it('sends reset links to active employees using the dashboard base path exactly once', async () => {
process.env.DASHBOARD_URL = 'https://tenant.example.test/app'
vi.mocked(repo.findActiveEmployeeByEmail).mockResolvedValue(employee as never)
vi.mocked(sendTransactionalEmail).mockResolvedValue({ success: true } as never)
await expect(service.forgotPassword('agent@example.test')).resolves.toEqual({
message: 'If that email is registered, a reset link has been sent.',
})
expect(sendTransactionalEmail).toHaveBeenCalledWith(expect.objectContaining({
to: 'agent@example.test',
subject: expect.any(String),
html: expect.stringContaining('https://tenant.example.test/app/dashboard/reset-password?token='),
text: expect.stringContaining('https://tenant.example.test/app/dashboard/reset-password?token='),
}))
})
it('does not disclose whether forgot-password emails exist', async () => {
vi.mocked(repo.findActiveEmployeeByEmail).mockResolvedValue(null)
await expect(service.forgotPassword('missing@example.test')).resolves.toEqual({
message: 'If that email is registered, a reset link has been sent.',
})
expect(sendTransactionalEmail).not.toHaveBeenCalled()
})
it('updates employee password from a stored reset token and clears reset state through the repo', async () => {
vi.mocked(repo.findEmployeeByResetToken).mockResolvedValue(employee as never)
await expect(service.resetPassword('stored-token', 'new-secure-password')).resolves.toEqual({
message: 'Password updated successfully. You can now sign in.',
})
expect(bcrypt.hash).toHaveBeenCalledWith('new-secure-password', 12)
expect(repo.resetPassword).toHaveBeenCalledWith('employee_1', 'hash_new')
})
it('rejects invalid reset tokens before hashing new passwords', async () => {
vi.mocked(repo.findEmployeeByResetToken).mockResolvedValue(null)
vi.mocked(repo.findEmployeeById).mockResolvedValue(null)
await expect(service.resetPassword('bad-token', 'new-secure-password')).rejects.toMatchObject({
statusCode: 400,
error: 'invalid_token',
})
expect(bcrypt.hash).not.toHaveBeenCalled()
})
it('delegates language preference updates and returns a stable contract', async () => {
vi.mocked(repo.updatePreferredLanguage).mockResolvedValue({} as never)
await expect(service.updateLanguage('employee_1', 'ar')).resolves.toEqual({ language: 'ar' })
expect(repo.updatePreferredLanguage).toHaveBeenCalledWith('employee_1', 'ar')
})
})
@@ -1,6 +1,7 @@
import bcrypt from 'bcryptjs'
import crypto from 'crypto'
import jwt from 'jsonwebtoken'
import { signActorToken } from '../../security/tokens'
import { AppError } from '../../http/errors'
import { sendTransactionalEmail } from '../../services/notificationService'
import { resetPasswordEmail, type Lang } from '../../lib/emailTranslations'
@@ -20,11 +21,9 @@ type EmployeePasswordResetPayload = jwt.JwtPayload & {
}
function signEmployeeToken(employeeId: string) {
return jwt.sign(
{ sub: employeeId, type: 'employee' },
process.env.JWT_SECRET!,
{ expiresIn: (process.env.JWT_EXPIRY ?? '8h') as jwt.SignOptions['expiresIn'] },
)
return signActorToken(employeeId, 'employee', {
expiresIn: (process.env.JWT_EXPIRY ?? '8h') as jwt.SignOptions['expiresIn'],
})
}
function getEmployeePasswordResetVersion(passwordHash: string | null | undefined) {
@@ -42,13 +41,22 @@ function signEmployeePasswordResetToken(employeeId: string, passwordHash: string
pwdv: getEmployeePasswordResetVersion(passwordHash),
},
process.env.JWT_SECRET!,
{ expiresIn: `${RESET_TOKEN_TTL_MINUTES}m` },
{
algorithm: 'HS256',
issuer: 'rentaldrivego-api',
audience: 'employee_password_reset',
expiresIn: `${RESET_TOKEN_TTL_MINUTES}m`,
},
)
}
function verifyEmployeePasswordResetToken(token: string): EmployeePasswordResetPayload | null {
try {
const payload = jwt.verify(token, process.env.JWT_SECRET!) as jwt.JwtPayload
const payload = jwt.verify(token, process.env.JWT_SECRET!, {
algorithms: ['HS256'],
issuer: 'rentaldrivego-api',
audience: 'employee_password_reset',
}) as jwt.JwtPayload
if (
typeof payload.sub !== 'string' ||
@@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest'
import { presentCompanySignup, presentEmployeeSession, presentRenterProfile } from './auth.presenter'
describe('auth presenters', () => {
it('presents employee sessions without leaking company internals or requiring a token', () => {
const employee = {
id: 'employee_1',
email: 'owner@example.com',
firstName: 'Aya',
lastName: 'Benali',
role: 'OWNER',
preferredLanguage: 'fr',
companyId: 'company_1',
company: { name: 'Atlas Cars', slug: 'atlas-cars' },
}
expect(presentEmployeeSession(employee)).toEqual({
employee: {
id: 'employee_1',
email: 'owner@example.com',
firstName: 'Aya',
lastName: 'Benali',
role: 'OWNER',
preferredLanguage: 'fr',
companyId: 'company_1',
companyName: 'Atlas Cars',
companySlug: 'atlas-cars',
},
})
})
it('adds the token only when one is supplied', () => {
const employee = {
id: 'employee_1',
email: 'owner@example.com',
firstName: 'Aya',
lastName: 'Benali',
role: 'OWNER',
preferredLanguage: null,
companyId: 'company_1',
company: { name: 'Atlas Cars', slug: 'atlas-cars' },
}
expect(presentEmployeeSession(employee, 'jwt-token')).toEqual(expect.objectContaining({
token: 'jwt-token',
employee: expect.objectContaining({ id: 'employee_1' }),
}))
})
it('presents company signup with a deterministic next step and fallback email delivery state', () => {
expect(presentCompanySignup({
company: { id: 'company_1', name: 'Atlas Cars', slug: 'atlas-cars' },
trialEndAt: new Date('2026-09-01T00:00:00.000Z'),
})).toEqual({
companyId: 'company_1',
companyName: 'Atlas Cars',
slug: 'atlas-cars',
invitationId: null,
trialEndsAt: '2026-09-01T00:00:00.000Z',
nextStep: 'workspace_created',
emailDelivery: { attempted: false, success: false, error: null },
})
})
it('hydrates saved renter companies in renter save order and leaves missing brand data explicit', () => {
const renter = {
id: 'renter_1',
firstName: 'Youssef',
lastName: 'Amrani',
email: 'youssef@example.com',
phone: null,
preferredLocale: 'fr',
preferredCurrency: 'MAD',
emailVerified: true,
savedCompanies: [{ companyId: 'company_2' }, { companyId: 'company_missing' }, { companyId: 'company_1' }],
}
const result = presentRenterProfile(renter, [
{ id: 'company_1', brand: { displayName: 'Atlas', subdomain: 'atlas', logoUrl: '/atlas.png' } },
{ id: 'company_2', brand: null },
])
expect(result.savedCompanies).toEqual([
{ id: 'company_2', brand: null },
{ id: 'company_missing', brand: null },
{ id: 'company_1', brand: { displayName: 'Atlas', subdomain: 'atlas', logoUrl: '/atlas.png' } },
])
})
})
@@ -0,0 +1,77 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AppError } from '../../http/errors'
import * as repo from './auth.renter.repo'
import * as service from './auth.renter.service'
vi.mock('./auth.renter.repo', () => ({
findRenterProfile: vi.fn(),
findSavedCompanies: vi.fn(),
updateRenterProfile: vi.fn(),
updateRenterFcmToken: vi.fn(),
}))
describe('auth.renter.service', () => {
beforeEach(() => vi.clearAllMocks())
it('keeps renter self-signup disabled with an explicit product error', () => {
expect(() => service.signupDisabled()).toThrow(AppError)
try {
service.signupDisabled()
} catch (err) {
expect(err).toMatchObject({ statusCode: 403, error: 'renter_signup_disabled' })
}
})
it('keeps renter login disabled with an explicit product error', () => {
expect(() => service.loginDisabled()).toThrow(AppError)
try {
service.loginDisabled()
} catch (err) {
expect(err).toMatchObject({ statusCode: 403, error: 'renter_login_disabled' })
}
})
it('loads saved company brand data only for company IDs attached to the renter profile', async () => {
vi.mocked(repo.findRenterProfile).mockResolvedValue({
id: 'renter_1',
firstName: 'Youssef',
lastName: 'Amrani',
email: 'youssef@example.com',
phone: null,
preferredLocale: 'fr',
preferredCurrency: 'MAD',
emailVerified: true,
savedCompanies: [{ companyId: 'company_2' }, { companyId: 'company_1' }],
} as never)
vi.mocked(repo.findSavedCompanies).mockResolvedValue([
{ id: 'company_1', brand: { displayName: 'Atlas', subdomain: 'atlas', logoUrl: null } },
{ id: 'company_2', brand: { displayName: 'Desert Cars', subdomain: 'desert', logoUrl: '/desert.png' } },
] as never)
await expect(service.getMe('renter_1')).resolves.toMatchObject({
id: 'renter_1',
savedCompanies: [
{ id: 'company_2', brand: { displayName: 'Desert Cars' } },
{ id: 'company_1', brand: { displayName: 'Atlas' } },
],
})
expect(repo.findSavedCompanies).toHaveBeenCalledWith(['company_2', 'company_1'])
})
it('delegates profile updates without inventing side effects in the service layer', async () => {
vi.mocked(repo.updateRenterProfile).mockResolvedValue({ id: 'renter_1', firstName: 'Nora' } as never)
await expect(service.updateMe('renter_1', { firstName: 'Nora', preferredLocale: 'ar' })).resolves.toEqual({
id: 'renter_1',
firstName: 'Nora',
})
expect(repo.updateRenterProfile).toHaveBeenCalledWith('renter_1', { firstName: 'Nora', preferredLocale: 'ar' })
})
it('updates the renter FCM token and returns a stable success contract', async () => {
vi.mocked(repo.updateRenterFcmToken).mockResolvedValue({} as never)
await expect(service.updateFcmToken('renter_1', 'fcm_123')).resolves.toEqual({ success: true })
expect(repo.updateRenterFcmToken).toHaveBeenCalledWith('renter_1', 'fcm_123')
})
})
@@ -16,7 +16,7 @@ export function loginDisabled() {
export async function getMe(renterId: string) {
const renter = await repo.findRenterProfile(renterId)
const savedCompanies = await repo.findSavedCompanies(renter.savedCompanies.map((savedCompany) => savedCompany.companyId))
const savedCompanies = await repo.findSavedCompanies(renter.savedCompanies.map((savedCompany: any) => savedCompany.companyId))
return presentRenterProfile(renter, savedCompanies)
}
@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest'
import { companySignupSchema } from './auth.company.schemas'
import { employeeForgotPasswordSchema, employeeLanguageSchema, employeeLoginSchema, employeeResetPasswordSchema } from './auth.employee.schemas'
import { renterFcmTokenSchema, renterUpdateSchema } from './auth.renter.schemas'
const validCompanySignup = {
firstName: 'Aya',
lastName: 'Haddad',
email: 'owner@example.test',
password: 'securePass123',
companyName: 'Atlas Cars',
legalName: 'Atlas Cars SARL',
legalForm: 'SARL',
registrationNumber: 'RC-1',
iceNumber: 'ICE-1',
taxId: 'TAX-1',
operatingLicenseNumber: 'LIC-1',
operatingLicenseIssuedAt: '2025-01-01',
operatingLicenseIssuedBy: 'Transport Authority',
streetAddress: '1 Main Street',
city: 'Casablanca',
country: 'Morocco',
zipCode: '20000',
companyPhone: '+212600000000',
companyEmail: 'company@example.test',
yearsActive: '3',
responsibleName: 'Aya Haddad',
responsibleRole: 'Owner',
responsibleIdentityNumber: 'ID-1',
responsiblePhone: '+212600000001',
responsibleEmail: 'responsible@example.test',
plan: 'GROWTH',
billingPeriod: 'MONTHLY',
currency: 'MAD',
paymentProvider: 'AMANPAY',
}
describe('role-specific auth schema contracts', () => {
it('defaults company signup language and keeps billing provider constrained', () => {
expect(companySignupSchema.parse(validCompanySignup)).toMatchObject({ preferredLanguage: 'en', paymentProvider: 'AMANPAY' })
expect(() => companySignupSchema.parse({ ...validCompanySignup, paymentProvider: 'WIRE_TRANSFER' })).toThrow()
expect(() => companySignupSchema.parse({ ...validCompanySignup, currency: 'EUR' })).toThrow()
})
it('normalizes employee email fields and validates reset password strength', () => {
expect(employeeLoginSchema.parse({ email: 'OWNER@EXAMPLE.TEST', password: 'secret' })).toEqual({
email: 'owner@example.test',
password: 'secret',
})
expect(employeeForgotPasswordSchema.parse({ email: 'HELP@EXAMPLE.TEST' })).toEqual({ email: 'help@example.test' })
expect(employeeResetPasswordSchema.parse({ token: 'token_1', password: 'new-pass-123' })).toEqual({ token: 'token_1', password: 'new-pass-123' })
expect(() => employeeResetPasswordSchema.parse({ token: '', password: 'short' })).toThrow()
})
it('limits employee language changes to supported locales', () => {
expect(employeeLanguageSchema.parse({ language: 'fr' })).toEqual({ language: 'fr' })
expect(() => employeeLanguageSchema.parse({ language: 'es' })).toThrow()
})
it('normalizes renter profile updates while keeping MAD as the only supported currency', () => {
expect(renterUpdateSchema.parse({ firstName: ' Salma ', lastName: ' Idrissi ', preferredLocale: 'ar', preferredCurrency: 'MAD' })).toEqual({
firstName: 'Salma',
lastName: 'Idrissi',
preferredLocale: 'ar',
preferredCurrency: 'MAD',
})
expect(() => renterUpdateSchema.parse({ preferredCurrency: 'EUR' })).toThrow()
})
it('requires a renter FCM token payload key', () => {
expect(renterFcmTokenSchema.parse({ fcmToken: 'token_abc' })).toEqual({ fcmToken: 'token_abc' })
expect(() => renterFcmTokenSchema.parse({ token: 'token_abc' })).toThrow()
})
})
@@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest'
import { companySignupSchema } from './auth.company.schemas'
import { employeeForgotPasswordSchema, employeeLanguageSchema, employeeLoginSchema, employeeResetPasswordSchema } from './auth.employee.schemas'
import { renterFcmTokenSchema, renterUpdateSchema } from './auth.renter.schemas'
describe('auth schemas', () => {
const validCompanySignup = {
firstName: 'Aya',
lastName: 'Benali',
email: 'owner@example.test',
password: 'safe-password',
companyName: 'Atlas Cars',
legalName: 'Atlas Cars SARL',
legalForm: 'SARL',
registrationNumber: 'RC-1',
iceNumber: 'ICE-1',
taxId: 'TAX-1',
operatingLicenseNumber: 'LIC-1',
operatingLicenseIssuedAt: '2024-01-01',
operatingLicenseIssuedBy: 'Rabat',
streetAddress: '1 Avenue',
city: 'Rabat',
country: 'Morocco',
zipCode: '10000',
companyPhone: '+212600000000',
companyEmail: 'company@example.test',
yearsActive: '3',
responsibleName: 'Aya Benali',
responsibleRole: 'Owner',
responsibleIdentityNumber: 'ID-1',
responsiblePhone: '+212600000001',
responsibleEmail: 'owner@example.test',
plan: 'GROWTH',
billingPeriod: 'MONTHLY',
currency: 'MAD',
paymentProvider: 'AMANPAY',
} as const
it('defaults company signup language and rejects unsupported commercial choices', () => {
const parsed = companySignupSchema.parse(validCompanySignup)
expect(parsed.preferredLanguage).toBe('en')
expect(companySignupSchema.safeParse({ ...validCompanySignup, plan: 'ENTERPRISE' }).success).toBe(false)
expect(companySignupSchema.safeParse({ ...validCompanySignup, currency: 'EUR' }).success).toBe(false)
expect(companySignupSchema.safeParse({ ...validCompanySignup, paymentProvider: 'STRIPE' }).success).toBe(false)
})
it('normalizes employee auth fields and rejects weak reset payloads', () => {
expect(employeeLoginSchema.parse({ email: 'Agent@Example.COM', password: 'password' })).toEqual({
email: 'agent@example.com',
password: 'password',
})
expect(employeeForgotPasswordSchema.parse({ email: 'Reset@Example.COM' }).email).toBe('reset@example.com')
expect(employeeLanguageSchema.safeParse({ language: 'ar' }).success).toBe(true)
expect(employeeLanguageSchema.safeParse({ language: 'es' }).success).toBe(false)
expect(employeeResetPasswordSchema.safeParse({ token: '', password: 'long-enough' }).success).toBe(false)
expect(employeeResetPasswordSchema.safeParse({ token: 'token_1', password: 'short' }).success).toBe(false)
})
it('trims renter profile updates and keeps renter push token required', () => {
expect(renterUpdateSchema.parse({ firstName: ' Aya ', lastName: ' Benali ', phone: ' 0600000000 ', preferredLocale: 'fr', preferredCurrency: 'MAD' })).toEqual({
firstName: 'Aya',
lastName: 'Benali',
phone: '0600000000',
preferredLocale: 'fr',
preferredCurrency: 'MAD',
})
expect(renterUpdateSchema.safeParse({ preferredLocale: 'es' }).success).toBe(false)
expect(renterUpdateSchema.safeParse({ preferredCurrency: 'EUR' }).success).toBe(false)
expect(renterFcmTokenSchema.safeParse({ fcmToken: 'token_1' }).success).toBe(true)
expect(renterFcmTokenSchema.safeParse({}).success).toBe(false)
})
})
@@ -0,0 +1,83 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('../../lib/prisma', () => ({
prisma: {
company: { findUniqueOrThrow: vi.fn(), update: vi.fn(), findFirst: vi.fn() },
brandSettings: { findUnique: vi.fn(), upsert: vi.fn(), findFirst: vi.fn(), updateMany: vi.fn() },
contractSettings: { findUnique: vi.fn(), upsert: vi.fn() },
insurancePolicy: { findMany: vi.fn(), create: vi.fn(), updateMany: vi.fn(), findUniqueOrThrow: vi.fn(), deleteMany: vi.fn() },
pricingRule: { findMany: vi.fn(), create: vi.fn(), updateMany: vi.fn(), findUniqueOrThrow: vi.fn(), deleteMany: vi.fn() },
accountingSettings: { findUnique: vi.fn(), upsert: vi.fn() },
},
}))
import { prisma } from '../../lib/prisma'
import * as repo from './company.repo'
describe('company.repo edge queries', () => {
beforeEach(() => vi.clearAllMocks())
it('loads a company dashboard envelope with brand, subscription and counts', async () => {
await repo.findCompany('company_1')
expect(prisma.company.findUniqueOrThrow).toHaveBeenCalledWith({
where: { id: 'company_1' },
include: {
brand: true,
subscription: true,
_count: { select: { vehicles: true, customers: true, reservations: true } },
},
})
})
it('upserts brand settings with companyId only in the create branch', async () => {
await repo.upsertBrand('company_1', { logoUrl: '/new.png' }, { primaryColor: '#111111' })
expect(prisma.brandSettings.upsert).toHaveBeenCalledWith({
where: { companyId: 'company_1' },
update: { logoUrl: '/new.png' },
create: { companyId: 'company_1', primaryColor: '#111111' },
})
})
it('orders insurance policies by required status, sort order and name', async () => {
await repo.findInsurancePolicies('company_1')
expect(prisma.insurancePolicy.findMany).toHaveBeenCalledWith({
where: { companyId: 'company_1' },
orderBy: [{ isRequired: 'desc' }, { sortOrder: 'asc' }, { name: 'asc' }],
})
})
it('updates and deletes pricing rules inside the tenant boundary', async () => {
await repo.updatePricingRule('rule_1', 'company_1', { isActive: false })
await repo.deletePricingRule('rule_1', 'company_1')
expect(prisma.pricingRule.updateMany).toHaveBeenCalledWith({
where: { id: 'rule_1', companyId: 'company_1' },
data: { isActive: false },
})
expect(prisma.pricingRule.deleteMany).toHaveBeenCalledWith({ where: { id: 'rule_1', companyId: 'company_1' } })
})
it('detects duplicate public branding outside the current company', async () => {
await repo.findBrandBySubdomain('atlas', 'company_1')
await repo.findBrandByCustomDomain('cars.example.test', 'company_1')
expect(prisma.brandSettings.findFirst).toHaveBeenNthCalledWith(1, {
where: { subdomain: 'atlas', companyId: { not: 'company_1' } },
})
expect(prisma.brandSettings.findFirst).toHaveBeenNthCalledWith(2, {
where: { customDomain: 'cars.example.test', companyId: { not: 'company_1' } },
})
})
it('clears custom domain verification metadata in one tenant-scoped mutation', async () => {
await repo.clearCustomDomain('company_1')
expect(prisma.brandSettings.updateMany).toHaveBeenCalledWith({
where: { companyId: 'company_1' },
data: { customDomain: null, customDomainVerified: false, customDomainAddedAt: null },
})
})
})
+25 -5
View File
@@ -1,4 +1,5 @@
import { prisma } from '../../lib/prisma'
import { generateCompanyApiKey } from '../../security/apiKeys'
export async function findCompany(companyId: string) {
return prisma.company.findUniqueOrThrow({
@@ -98,15 +99,34 @@ export async function upsertAccountingSettings(companyId: string, data: any) {
}
export async function findApiKey(companyId: string) {
return prisma.company.findUniqueOrThrow({ where: { id: companyId }, select: { apiKey: true } })
const apiKeys = await (prisma as any).companyApiKey.findMany({
where: { companyId, revokedAt: null },
orderBy: { createdAt: 'desc' },
select: { id: true, name: true, prefix: true, lastUsedAt: true, createdAt: true, revokedAt: true },
})
return { apiKeys }
}
export async function regenerateApiKey(companyId: string) {
return prisma.company.update({
where: { id: companyId },
data: { apiKey: `api_${Math.random().toString(36).slice(2)}${Date.now()}` },
select: { apiKey: true },
const nextKey = generateCompanyApiKey()
await prisma.$transaction(async (tx: any) => {
await (tx as any).companyApiKey.updateMany({
where: { companyId, revokedAt: null },
data: { revokedAt: new Date() },
})
await (tx as any).companyApiKey.create({
data: {
companyId,
name: 'Default API key',
prefix: nextKey.prefix,
keyHash: nextKey.keyHash,
},
})
})
return { apiKey: nextKey.rawKey, prefix: nextKey.prefix, warning: 'This raw API key is shown once. Store it securely.' }
}
export async function findBrandBySubdomain(subdomain: string, excludeCompanyId: string) {
@@ -3,6 +3,7 @@ import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
import { requireTenant } from '../../middleware/requireTenant'
import { requireSubscription } from '../../middleware/requireSubscription'
import { requireRole } from '../../middleware/requireRole'
import { requireCompanyPolicy } from '../../middleware/requireCompanyPolicy'
import { parseBody, parseParams } from '../../http/validate'
import { ok, created, noContent } from '../../http/respond'
import { imageUpload, assertImageFile } from '../../http/upload'
@@ -164,7 +165,7 @@ router.patch('/me/pricing-rules/:id', requireRole('MANAGER'), async (req, res, n
} catch (err) { next(err) }
})
router.delete('/me/pricing-rules/:id', async (req, res, next) => {
router.delete('/me/pricing-rules/:id', requireCompanyPolicy('deletePricingRule'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
await service.deletePricingRule(id, req.companyId)
@@ -179,7 +180,7 @@ router.get('/me/accounting-settings', async (req, res, next) => {
} catch (err) { next(err) }
})
router.patch('/me/accounting-settings', async (req, res, next) => {
router.patch('/me/accounting-settings', requireCompanyPolicy('updateAccountingSettings'), async (req, res, next) => {
try {
const body = parseBody(accountingSettingsSchema, req)
const settings = await service.updateAccountingSettings(req.companyId, body)
@@ -187,14 +188,14 @@ router.patch('/me/accounting-settings', async (req, res, next) => {
} catch (err) { next(err) }
})
router.get('/me/api-key', async (req, res, next) => {
router.get('/me/api-key', requireCompanyPolicy('viewApiKey'), async (req, res, next) => {
try {
const company = await service.getApiKey(req.companyId)
ok(res, company)
} catch (err) { next(err) }
})
router.post('/me/api-key/regenerate', async (req, res, next) => {
router.post('/me/api-key/regenerate', requireCompanyPolicy('regenerateApiKey'), async (req, res, next) => {
try {
const company = await service.regenerateApiKey(req.companyId)
ok(res, company)
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest'
import { accountingSettingsSchema, brandSchema, companySchema, contractSettingsSchema, customDomainSchema, insurancePolicySchema, pricingRuleSchema, subdomainSchema } from './company.schemas'
describe('company schemas edge cases', () => {
it('validates company and brand public configuration boundaries', () => {
expect(companySchema.parse({ email: 'ops@example.test', address: { city: 'Rabat' } })).toMatchObject({ email: 'ops@example.test' })
expect(companySchema.safeParse({ email: 'bad-email' }).success).toBe(false)
const brand = brandSchema.parse({
displayName: 'Atlas',
websiteUrl: 'https://atlas.example.test',
paypalEmail: 'paypal@example.test',
defaultCurrency: 'MAD',
homePageConfig: { showOffers: true, layout: { items: [{ id: 'hero-1', type: 'hero', x: 1, y: 1, w: 12, h: 4 }] } },
})
expect(brand.homePageConfig?.layout?.items[0].type).toBe('hero')
expect(brandSchema.safeParse({ websiteUrl: 'not-url' }).success).toBe(false)
expect(brandSchema.safeParse({ defaultCurrency: 'EUR' }).success).toBe(false)
})
it('keeps contract, accounting, insurance, and pricing settings inside known enums', () => {
expect(contractSettingsSchema.parse({ fuelPolicyType: 'FULL_TO_FULL', additionalDriverCharge: 'PER_DAY', taxRate: 20 })).toMatchObject({ fuelPolicyType: 'FULL_TO_FULL' })
expect(contractSettingsSchema.safeParse({ fuelPolicyType: 'CHAOS' }).success).toBe(false)
expect(accountingSettingsSchema.parse({ reportingPeriod: 'MONTHLY', fiscalYearStart: 1, currency: 'MAD' })).toMatchObject({ fiscalYearStart: 1 })
expect(accountingSettingsSchema.safeParse({ fiscalYearStart: 13 }).success).toBe(false)
expect(insurancePolicySchema.parse({ name: 'CDW', type: 'CDW', chargeType: 'PER_DAY', chargeValue: 100 })).toMatchObject({ isActive: true, isRequired: false, sortOrder: 0 })
expect(insurancePolicySchema.safeParse({ name: 'CDW', type: 'CDW', chargeType: 'PER_DAY', chargeValue: -1 }).success).toBe(false)
expect(pricingRuleSchema.safeParse({ name: 'Young driver', type: 'SURCHARGE', condition: 'AGE_LESS_THAN', conditionValue: 25, adjustmentType: 'PERCENTAGE', adjustmentValue: 10 }).success).toBe(true)
expect(pricingRuleSchema.safeParse({ name: '', type: 'SURCHARGE', condition: 'AGE_LESS_THAN', conditionValue: 25, adjustmentType: 'PERCENTAGE', adjustmentValue: 10 }).success).toBe(false)
})
it('requires minimal custom domain and subdomain lengths', () => {
expect(subdomainSchema.safeParse({ subdomain: 'ab' }).success).toBe(false)
expect(customDomainSchema.safeParse({ customDomain: 'x.io' }).success).toBe(true)
})
})
@@ -0,0 +1,132 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('../../lib/storage', () => ({ uploadImage: vi.fn().mockResolvedValue('/storage/companies/company_1/brand/logo.jpg') }))
vi.mock('./company.repo', () => ({
findCompany: vi.fn(),
updateCompany: vi.fn(),
findBrand: vi.fn(),
upsertBrand: vi.fn(),
findBrandBySubdomain: vi.fn(),
findBrandByCustomDomain: vi.fn(),
clearCustomDomain: vi.fn(),
findContractSettings: vi.fn(),
upsertContractSettings: vi.fn(),
findInsurancePolicies: vi.fn(),
createInsurancePolicy: vi.fn(),
updateInsurancePolicy: vi.fn(),
findInsurancePolicyOrThrow: vi.fn(),
deleteInsurancePolicy: vi.fn(),
findPricingRules: vi.fn(),
createPricingRule: vi.fn(),
updatePricingRule: vi.fn(),
findPricingRuleOrThrow: vi.fn(),
deletePricingRule: vi.fn(),
findAccountingSettings: vi.fn(),
upsertAccountingSettings: vi.fn(),
findApiKey: vi.fn(),
regenerateApiKey: vi.fn(),
}))
const repo = await import('./company.repo')
const service = await import('./company.service')
const currentBrand = {
id: 'brand_1',
companyId: 'company_1',
displayName: 'Atlas Cars',
subdomain: 'atlas',
amanpayMerchantId: 'merchant_1',
amanpaySecretKey: 'secret_1',
paypalEmail: null,
paypalMerchantId: null,
paymentMethodsEnabled: ['AMANPAY'],
}
beforeEach(() => {
vi.clearAllMocks()
delete process.env.NEXT_PUBLIC_CUSTOM_DOMAIN_TARGET
})
describe('company.service edge behavior', () => {
it('preserves existing AmanPay credentials when recomputing enabled payment methods', async () => {
vi.mocked(repo.findBrand).mockResolvedValue(currentBrand as any)
vi.mocked(repo.upsertBrand).mockResolvedValue({ ...currentBrand, paypalEmail: 'billing@example.test' } as any)
const result = await service.updateBrand('company_1', { paypalEmail: 'billing@example.test' }, 'Atlas Cars', 'atlas')
expect(repo.upsertBrand).toHaveBeenCalledWith(
'company_1',
expect.objectContaining({
paypalEmail: 'billing@example.test',
paymentMethodsEnabled: ['AMANPAY', 'PAYPAL'],
}),
expect.objectContaining({
displayName: 'Atlas Cars',
subdomain: 'atlas',
paypalEmail: 'billing@example.test',
paymentMethodsEnabled: ['AMANPAY', 'PAYPAL'],
}),
)
expect(result).not.toHaveProperty('paypalEmail')
expect(result.paypalConfigured).toBe(true)
})
it('does not report AmanPay enabled when only one credential is available', async () => {
vi.mocked(repo.findBrand).mockResolvedValue({ ...currentBrand, amanpaySecretKey: null } as any)
vi.mocked(repo.upsertBrand).mockResolvedValue({ ...currentBrand, amanpaySecretKey: null } as any)
await service.updateBrand('company_1', {}, 'Atlas Cars', 'atlas')
expect(repo.upsertBrand).toHaveBeenCalledWith(
'company_1',
expect.objectContaining({ paymentMethodsEnabled: [] }),
expect.objectContaining({ paymentMethodsEnabled: [] }),
)
})
it('normalizes custom domains and marks them pending verification', async () => {
vi.mocked(repo.findBrandByCustomDomain).mockResolvedValue(null as any)
vi.mocked(repo.upsertBrand).mockResolvedValue({ id: 'brand_1', customDomain: 'cars.example.com' } as any)
await service.setCustomDomain('company_1', 'Atlas Cars', 'atlas', ' CARS.Example.COM ')
expect(repo.findBrandByCustomDomain).toHaveBeenCalledWith('cars.example.com', 'company_1')
expect(repo.upsertBrand).toHaveBeenCalledWith(
'company_1',
expect.objectContaining({ customDomain: 'cars.example.com', customDomainVerified: false, customDomainAddedAt: expect.any(Date) }),
expect.objectContaining({ displayName: 'Atlas Cars', subdomain: 'atlas', customDomain: 'cars.example.com', customDomainVerified: false, customDomainAddedAt: expect.any(Date) }),
)
})
it('rejects custom domains already owned by another company', async () => {
vi.mocked(repo.findBrandByCustomDomain).mockResolvedValue({ id: 'other_brand' } as any)
await expect(service.setCustomDomain('company_1', 'Atlas Cars', 'atlas', 'cars.example.com')).rejects.toMatchObject({ statusCode: 409 })
expect(repo.upsertBrand).not.toHaveBeenCalled()
})
it('returns deterministic custom-domain status and honors configured DNS target', async () => {
process.env.NEXT_PUBLIC_CUSTOM_DOMAIN_TARGET = 'tenant-target.example.net'
vi.mocked(repo.findBrand).mockResolvedValue({ customDomain: 'cars.example.com', customDomainVerified: false } as any)
await expect(service.getCustomDomainStatus('company_1')).resolves.toEqual({
customDomain: 'cars.example.com',
verified: false,
status: 'pending_dns',
dnsTarget: 'tenant-target.example.net',
})
})
it('raises not found when updating a missing insurance policy', async () => {
vi.mocked(repo.updateInsurancePolicy).mockResolvedValue({ count: 0 } as any)
await expect(service.updateInsurancePolicy('policy_1', 'company_1', { name: 'CDW' })).rejects.toMatchObject({ statusCode: 404 })
expect(repo.findInsurancePolicyOrThrow).not.toHaveBeenCalled()
})
it('raises not found when deleting a missing pricing rule', async () => {
vi.mocked(repo.deletePricingRule).mockResolvedValue({ count: 0 } as any)
await expect(service.deletePricingRule('rule_1', 'company_1')).rejects.toMatchObject({ statusCode: 404 })
})
})
@@ -0,0 +1,71 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('../../lib/prisma', () => ({
prisma: {
complaint: { findMany: vi.fn(), count: vi.fn(), findFirst: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn() },
},
}))
import { prisma } from '../../lib/prisma'
import * as repo from './complaint.repo'
describe('complaint.repo edge behavior', () => {
beforeEach(() => vi.clearAllMocks())
it('lists complaints with company-scoped filters and deterministic ordering', async () => {
vi.mocked(prisma.complaint.findMany).mockResolvedValue([] as never)
vi.mocked(prisma.complaint.count).mockResolvedValue(0 as never)
await repo.findMany('company_1', { status: 'OPEN', severity: 'LEVEL_2' }, 40, 20)
const where = { companyId: 'company_1', status: 'OPEN', severity: 'LEVEL_2' }
expect(prisma.complaint.findMany).toHaveBeenCalledWith(expect.objectContaining({
where,
skip: 40,
take: 20,
orderBy: { createdAt: 'desc' },
}))
expect(prisma.complaint.count).toHaveBeenCalledWith({ where })
})
it('creates complaints with linked reservation, review and customer ids preserved', async () => {
await repo.create({
companyId: 'company_1',
reservationId: 'reservation_1',
reviewId: 'review_1',
customerId: 'customer_1',
severity: 'LEVEL_3',
category: 'DAMAGE_CLAIM',
subject: 'Damage dispute',
assignedTo: 'employee_1',
})
expect(prisma.complaint.create).toHaveBeenCalledWith(expect.objectContaining({
data: {
companyId: 'company_1',
reservationId: 'reservation_1',
reviewId: 'review_1',
customerId: 'customer_1',
severity: 'LEVEL_3',
category: 'DAMAGE_CLAIM',
subject: 'Damage dispute',
assignedTo: 'employee_1',
},
}))
})
it('updates by complaint id without accepting a company id bypass from caller data', async () => {
await repo.updateById('complaint_1', { status: 'RESOLVED', resolution: 'Refunded deposit' })
expect(prisma.complaint.update).toHaveBeenCalledWith(expect.objectContaining({
where: { id: 'complaint_1' },
data: { status: 'RESOLVED', resolution: 'Refunded deposit' },
}))
})
it('deletes by primary id', async () => {
await repo.deleteById('complaint_1')
expect(prisma.complaint.delete).toHaveBeenCalledWith({ where: { id: 'complaint_1' } })
})
})
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import { createSchema, updateSchema, listQuerySchema } from './complaint.schemas'
describe('complaint schemas', () => {
it('defaults new complaints to the lowest severity when not provided', () => {
expect(createSchema.parse({ category: 'BILLING', subject: 'Incorrect invoice' })).toMatchObject({
category: 'BILLING',
subject: 'Incorrect invoice',
severity: 'LEVEL_1',
})
})
it('rejects empty complaint subjects', () => {
expect(() => createSchema.parse({ category: 'BILLING', subject: '' })).toThrow()
})
it('accepts lifecycle updates without requiring immutable creation fields', () => {
expect(updateSchema.parse({ status: 'RESOLVED', resolution: 'Refund issued' })).toEqual({
status: 'RESOLVED',
resolution: 'Refund issued',
})
})
it('coerces pagination and applies default page size for list queries', () => {
expect(listQuerySchema.parse({ page: '2', severity: 'LEVEL_3' })).toEqual({
page: 2,
pageSize: 20,
severity: 'LEVEL_3',
})
})
it('rejects pathological page sizes before they hit the database', () => {
expect(() => listQuerySchema.parse({ pageSize: '101' })).toThrow()
})
})
@@ -0,0 +1,86 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('./complaint.repo', () => ({
findMany: vi.fn(),
findById: vi.fn(),
create: vi.fn(),
updateById: vi.fn(),
deleteById: vi.fn(),
}))
import { NotFoundError } from '../../http/errors'
import * as repo from './complaint.repo'
import { createComplaint, deleteComplaint, getComplaint, listComplaints, updateComplaint } from './complaint.service'
describe('complaint.service', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.setSystemTime(new Date('2026-06-08T12:00:00.000Z'))
})
it('builds filtered paginated complaint queries without leaking transport concerns into the repo', async () => {
vi.mocked(repo.findMany).mockResolvedValue([[{ id: 'complaint_1' }], 11] as never)
const result = await listComplaints('company_1', {
status: 'OPEN',
severity: 'HIGH',
category: 'BILLING',
page: 3,
pageSize: 5,
})
expect(repo.findMany).toHaveBeenCalledWith('company_1', {
status: 'OPEN',
severity: 'HIGH',
category: 'BILLING',
}, 10, 5)
expect(result).toEqual({
data: [{ id: 'complaint_1' }],
meta: { total: 11, page: 3, pageSize: 5, totalPages: 3 },
})
})
it('throws NotFoundError when a complaint cannot be found in the company tenant', async () => {
vi.mocked(repo.findById).mockResolvedValue(null as never)
await expect(getComplaint('complaint_1', 'company_1')).rejects.toBeInstanceOf(NotFoundError)
})
it('sets resolvedAt exactly when the complaint transitions into RESOLVED', async () => {
vi.mocked(repo.findById).mockResolvedValue({ id: 'complaint_1', status: 'OPEN' } as never)
vi.mocked(repo.updateById).mockResolvedValue({ id: 'complaint_1', status: 'RESOLVED' } as never)
await updateComplaint('complaint_1', 'company_1', { status: 'RESOLVED', resolution: 'Refund issued' })
expect(repo.updateById).toHaveBeenCalledWith('complaint_1', {
status: 'RESOLVED',
resolution: 'Refund issued',
resolvedAt: new Date('2026-06-08T12:00:00.000Z'),
})
})
it('does not overwrite resolvedAt when an already resolved complaint is edited', async () => {
vi.mocked(repo.findById).mockResolvedValue({ id: 'complaint_1', status: 'RESOLVED' } as never)
await updateComplaint('complaint_1', 'company_1', { notes: 'Follow-up call logged' })
expect(repo.updateById).toHaveBeenCalledWith('complaint_1', { notes: 'Follow-up call logged' })
})
it('creates and deletes complaints through tenant-scoped repository calls', async () => {
vi.mocked(repo.create).mockResolvedValue({ id: 'complaint_1' } as never)
vi.mocked(repo.findById).mockResolvedValue({ id: 'complaint_1' } as never)
await createComplaint('company_1', {
reservationId: 'reservation_1',
severity: 'MEDIUM',
category: 'SERVICE',
subject: 'Late pickup',
})
const deleted = await deleteComplaint('complaint_1', 'company_1')
expect(repo.create).toHaveBeenCalledWith(expect.objectContaining({ companyId: 'company_1', reservationId: 'reservation_1' }))
expect(repo.deleteById).toHaveBeenCalledWith('complaint_1')
expect(deleted).toEqual({ success: true })
})
})
@@ -0,0 +1,119 @@
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('./customer.repo')
vi.mock('../../services/licenseValidationService', () => ({
validateAndFlagLicense: vi.fn().mockResolvedValue({ status: 'VALID' }),
}))
vi.mock('../../lib/storage', () => ({
uploadImage: vi.fn().mockResolvedValue('http://localhost:4000/storage/companies/company_1/customers/customer_1/new-license.jpg'),
deleteImage: vi.fn().mockResolvedValue(undefined),
resolveStoredFilePath: vi.fn().mockReturnValue('/tmp/license.jpg'),
getProtectedCustomerLicenseImageUrl: vi.fn((customerId: string) => `http://localhost:3000/dashboard/api/v1/customers/${customerId}/license-image`),
}))
import * as repo from './customer.repo'
import * as service from './customer.service'
import { validateAndFlagLicense } from '../../services/licenseValidationService'
import { deleteImage, resolveStoredFilePath, uploadImage } from '../../lib/storage'
const customer = {
id: 'customer_1',
companyId: 'company_1',
firstName: 'Aya',
lastName: 'Benali',
email: 'aya@example.test',
licenseImageUrl: null,
flagged: false,
flagReason: null,
}
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(resolveStoredFilePath).mockReturnValue('/tmp/license.jpg')
})
describe('customer service edge cases', () => {
it('trims and caps customer search text before building the query', async () => {
vi.mocked(repo.findMany).mockResolvedValue([[], 0] as any)
const longQuery = ` ${'x'.repeat(150)} `
await service.listCustomers('company_1', { q: longQuery, page: 2, pageSize: 10 })
const [where, skip, take] = vi.mocked(repo.findMany).mock.calls[0]
expect(skip).toBe(10)
expect(take).toBe(10)
expect(where.OR[0].firstName.contains).toHaveLength(100)
expect(where.OR[0].firstName.contains).not.toMatch(/^\s|\s$/)
})
it('parses customer date fields and triggers async license validation on create', async () => {
vi.mocked(repo.create).mockResolvedValue({ ...customer, licenseExpiry: new Date('2028-01-01T00:00:00.000Z') } as any)
await service.createCustomer({
firstName: 'Aya',
lastName: 'Benali',
email: 'aya@example.test',
dateOfBirth: '1998-05-20',
licenseIssuedAt: '2024-01-01',
licenseExpiry: '2028-01-01',
}, 'company_1')
expect(repo.create).toHaveBeenCalledWith(expect.objectContaining({
companyId: 'company_1',
dateOfBirth: expect.any(Date),
licenseIssuedAt: expect.any(Date),
licenseExpiry: expect.any(Date),
}))
expect(validateAndFlagLicense).toHaveBeenCalledWith('customer_1', 'company_1')
})
it('parses changed license dates and validates refreshed customer licenses on update', async () => {
vi.mocked(repo.updateMany).mockResolvedValue({ count: 1 } as any)
vi.mocked(repo.findUniqueOrThrow).mockResolvedValue({ ...customer, licenseExpiry: new Date('2029-01-01T00:00:00.000Z') } as any)
await service.updateCustomer('customer_1', 'company_1', { licenseExpiry: '2029-01-01' })
expect(repo.updateMany).toHaveBeenCalledWith('customer_1', 'company_1', expect.objectContaining({
licenseExpiry: expect.any(Date),
dateOfBirth: undefined,
licenseIssuedAt: undefined,
}))
expect(validateAndFlagLicense).toHaveBeenCalledWith('customer_1', 'company_1')
})
it('replaces existing license images without failing the upload when old-file deletion rejects', async () => {
vi.mocked(repo.findByIdSimple).mockResolvedValue({ ...customer, licenseImageUrl: 'http://localhost:4000/storage/old-license.jpg' } as any)
vi.mocked(repo.updateById).mockResolvedValue({ ...customer, licenseImageUrl: 'http://localhost:4000/storage/companies/company_1/customers/customer_1/new-license.jpg' } as any)
vi.mocked(deleteImage).mockRejectedValue(new Error('old file missing'))
const result = await service.uploadLicenseImage('customer_1', 'company_1', { buffer: Buffer.from('image') } as any)
expect(uploadImage).toHaveBeenCalledWith(expect.any(Buffer), 'companies/company_1/customers/customer_1')
expect(deleteImage).toHaveBeenCalledWith('http://localhost:4000/storage/old-license.jpg')
expect(result.licenseImageUrl).toBe('http://localhost:3000/dashboard/api/v1/customers/customer_1/license-image')
})
it('throws not found when a stored license URL cannot be resolved to a safe file path', async () => {
vi.mocked(repo.findByIdSimple).mockResolvedValue({ ...customer, licenseImageUrl: 'https://evil.example/license.jpg' } as any)
vi.mocked(resolveStoredFilePath).mockReturnValue(null)
await expect(service.getLicenseImageFile('customer_1', 'company_1')).rejects.toThrow('License image not found')
})
it('records approver name, timestamp, and note when denying a license', async () => {
vi.mocked(repo.findByIdSimple).mockResolvedValue(customer as any)
vi.mocked(repo.updateById).mockResolvedValue({ ...customer, licenseValidationStatus: 'DENIED' } as any)
await service.approveLicense('customer_1', 'company_1', 'DENY', 'Unreadable scan', 'Ops Lead')
expect(repo.updateById).toHaveBeenCalledWith('customer_1', expect.objectContaining({
licenseValidationStatus: 'DENIED',
licenseApprovedBy: 'Ops Lead',
licenseApprovedAt: expect.any(Date),
licenseApprovalNote: 'Unreadable scan',
}))
})
})
@@ -0,0 +1,40 @@
import { afterAll, beforeEach, describe, expect, it } from 'vitest'
import { presentCustomer, presentCustomerList } from './customer.presenter'
describe('customer.presenter', () => {
const originalDashboardUrl = process.env.DASHBOARD_URL
const originalPublicDashboardUrl = process.env.NEXT_PUBLIC_DASHBOARD_URL
beforeEach(() => {
process.env.DASHBOARD_URL = 'https://dashboard.example.test'
process.env.NEXT_PUBLIC_DASHBOARD_URL = ''
})
afterAll(() => {
process.env.DASHBOARD_URL = originalDashboardUrl
process.env.NEXT_PUBLIC_DASHBOARD_URL = originalPublicDashboardUrl
})
it('rewrites raw customer license images to the protected dashboard endpoint', () => {
expect(presentCustomer({
id: 'customer_1',
firstName: 'Aya',
licenseImageUrl: '/storage/companies/company_1/customers/customer_1/license.jpg',
})).toMatchObject({
id: 'customer_1',
firstName: 'Aya',
licenseImageUrl: 'https://dashboard.example.test/api/v1/customers/customer_1/license-image',
})
})
it('keeps missing license images null and wraps customer lists with metadata', () => {
expect(presentCustomer({ id: 'customer_1', licenseImageUrl: null }).licenseImageUrl).toBeNull()
expect(presentCustomerList([{ id: 'customer_1' }], { total: 1, page: 1, pageSize: 20, totalPages: 1 })).toEqual({
data: [{ id: 'customer_1' }],
total: 1,
page: 1,
pageSize: 20,
totalPages: 1,
})
})
})
@@ -0,0 +1,61 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('../../lib/prisma', () => ({
prisma: {
$queryRawUnsafe: vi.fn(),
customer: {
findMany: vi.fn(),
count: vi.fn(),
findFirst: vi.fn(),
create: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
findUniqueOrThrow: vi.fn(),
},
},
}))
import { prisma } from '../../lib/prisma'
describe('customer.repo storage-column compatibility', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.resetModules()
})
it('includes licenseImageUrl when the deployed schema supports the column', async () => {
vi.mocked(prisma.$queryRawUnsafe).mockResolvedValue([{ exists: true }] as never)
const repo = await import('./customer.repo')
await repo.findMany({ companyId: 'company_1' }, 0, 20)
expect(prisma.customer.findMany).toHaveBeenCalledWith(expect.objectContaining({
select: expect.objectContaining({ licenseImageUrl: true }),
}))
expect(prisma.customer.count).toHaveBeenCalledWith({ where: { companyId: 'company_1' } })
})
it('strips licenseImageUrl from writes when the old deployed schema lacks the column', async () => {
vi.mocked(prisma.$queryRawUnsafe).mockResolvedValue([{ exists: false }] as never)
const repo = await import('./customer.repo')
await repo.create({ companyId: 'company_1', email: 'renter@example.test', licenseImageUrl: '/storage/private.png' })
expect(prisma.customer.create).toHaveBeenCalledWith({
data: { companyId: 'company_1', email: 'renter@example.test' },
select: expect.not.objectContaining({ licenseImageUrl: true }),
})
})
it('scopes customer updates by id and company id without querying schema support', async () => {
const repo = await import('./customer.repo')
await repo.updateMany('customer_1', 'company_1', { flagged: true })
expect(prisma.customer.updateMany).toHaveBeenCalledWith({
where: { id: 'customer_1', companyId: 'company_1' },
data: { flagged: true },
})
expect(prisma.$queryRawUnsafe).not.toHaveBeenCalled()
})
})
@@ -100,6 +100,10 @@ export async function updateById(id: string, data: any) {
})
}
export async function findUniqueOrThrow(id: string) {
return prisma.customer.findUniqueOrThrow({ where: { id }, select: await getCustomerSelect() })
export async function findUniqueOrThrow(id: string, companyId?: string) {
const select = await getCustomerSelect()
if (companyId) {
return prisma.customer.findFirstOrThrow({ where: { id, companyId }, select })
}
return prisma.customer.findUniqueOrThrow({ where: { id }, select })
}
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest'
import { approveLicenseSchema, customerSchema, flagSchema, listQuerySchema } from './customer.schemas'
describe('customer schema contracts', () => {
it('normalizes create payload identity fields and optional text', () => {
const parsed = customerSchema.parse({
firstName: ' Aya ',
lastName: ' Haddad ',
email: 'AYA@EXAMPLE.TEST',
phone: ' +212600000000 ',
notes: ' VIP renter ',
licenseNumber: ' B-123456 ',
dateOfBirth: '1994-05-01T00:00:00.000Z',
})
expect(parsed).toMatchObject({
firstName: 'Aya',
lastName: 'Haddad',
email: 'aya@example.test',
phone: '+212600000000',
notes: 'VIP renter',
licenseNumber: 'B-123456',
})
})
it('coerces list pagination and preserves supported filters', () => {
expect(listQuerySchema.parse({ page: '3', pageSize: '40', q: 'aya', flagged: 'true' })).toEqual({
page: 3,
pageSize: 40,
q: 'aya',
flagged: 'true',
})
})
it('rejects impossible pagination and unsafe customer field lengths', () => {
expect(() => listQuerySchema.parse({ page: '0' })).toThrow()
expect(() => customerSchema.parse({ firstName: '', lastName: 'Haddad', email: 'aya@example.test' })).toThrow()
expect(() => customerSchema.parse({ firstName: 'Aya', lastName: 'Haddad', email: 'not-email' })).toThrow()
expect(() => customerSchema.parse({ firstName: 'Aya', lastName: 'Haddad', email: 'aya@example.test', notes: 'x'.repeat(2001) })).toThrow()
})
it('limits license approvals to explicit approve or deny decisions', () => {
expect(approveLicenseSchema.parse({ decision: 'APPROVE', note: 'Verified manually' })).toEqual({
decision: 'APPROVE',
note: 'Verified manually',
})
expect(() => approveLicenseSchema.parse({ decision: 'MAYBE' })).toThrow()
})
it('keeps customer flagging reason optional', () => {
expect(flagSchema.parse({})).toEqual({})
expect(flagSchema.parse({ reason: 'Chargeback risk' })).toEqual({ reason: 'Chargeback risk' })
})
})
@@ -0,0 +1,119 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('../../services/licenseValidationService', () => ({
validateAndFlagLicense: vi.fn(),
}))
vi.mock('../../lib/storage', () => ({
uploadImage: vi.fn(),
deleteImage: vi.fn(),
resolveStoredFilePath: vi.fn(),
}))
vi.mock('./customer.presenter', () => ({
presentCustomer: vi.fn((customer: any) => ({ presented: true, ...customer })),
presentCustomerList: vi.fn((customers: any[], meta: any) => ({ data: customers, meta })),
}))
vi.mock('./customer.repo', () => ({
findMany: vi.fn(),
findById: vi.fn(),
create: vi.fn(),
updateMany: vi.fn(),
findUniqueOrThrow: vi.fn(),
findByIdSimple: vi.fn(),
updateById: vi.fn(),
}))
import { NotFoundError } from '../../http/errors'
import { validateAndFlagLicense } from '../../services/licenseValidationService'
import { uploadImage, deleteImage, resolveStoredFilePath } from '../../lib/storage'
import * as repo from './customer.repo'
import * as service from './customer.service'
describe('customer.service boundary behavior', () => {
beforeEach(() => vi.clearAllMocks())
it('trims and caps search while applying flagged and pagination filters', async () => {
vi.mocked(repo.findMany).mockResolvedValue([[{ id: 'customer_1' }], 41] as never)
const result = await service.listCustomers('company_1', {
page: 3,
pageSize: 10,
q: ` ${'x'.repeat(150)} `,
flagged: 'false',
})
const expectedQ = 'x'.repeat(100)
expect(repo.findMany).toHaveBeenCalledWith({
companyId: 'company_1',
flagged: false,
OR: [
{ firstName: { contains: expectedQ, mode: 'insensitive' } },
{ lastName: { contains: expectedQ, mode: 'insensitive' } },
{ email: { contains: expectedQ, mode: 'insensitive' } },
],
}, 20, 10)
expect(result.meta).toEqual({ total: 41, page: 3, pageSize: 10, totalPages: 5 })
})
it('normalizes customer date fields and swallows async license validation failures after create', async () => {
vi.mocked(repo.create).mockResolvedValue({ id: 'customer_1', email: 'renter@example.test' } as never)
vi.mocked(validateAndFlagLicense).mockRejectedValue(new Error('provider down') as never)
await service.createCustomer({
firstName: 'Nora',
dateOfBirth: '1992-02-03',
licenseExpiry: '2027-01-01',
licenseIssuedAt: '2022-01-01',
}, 'company_1')
expect(repo.create).toHaveBeenCalledWith(expect.objectContaining({
companyId: 'company_1',
dateOfBirth: new Date('1992-02-03'),
licenseExpiry: new Date('2027-01-01'),
licenseIssuedAt: new Date('2022-01-01'),
}))
expect(validateAndFlagLicense).toHaveBeenCalledWith('customer_1', 'company_1')
})
it('throws not found when tenant-scoped update touches no rows', async () => {
vi.mocked(repo.updateMany).mockResolvedValue({ count: 0 } as never)
await expect(service.updateCustomer('customer_1', 'company_1', { firstName: 'Nope' })).rejects.toBeInstanceOf(NotFoundError)
expect(repo.findUniqueOrThrow).not.toHaveBeenCalled()
})
it('uploads the new license image and best-effort deletes the previous one', async () => {
vi.mocked(repo.findByIdSimple).mockResolvedValue({ id: 'customer_1', licenseImageUrl: '/storage/old.png' } as never)
vi.mocked(uploadImage).mockResolvedValue('/storage/new.png' as never)
vi.mocked(deleteImage).mockRejectedValue(new Error('already gone') as never)
vi.mocked(repo.updateById).mockResolvedValue({ id: 'customer_1', licenseImageUrl: '/storage/new.png' } as never)
const file = { buffer: Buffer.from('fake') } as Express.Multer.File
const result = await service.uploadLicenseImage('customer_1', 'company_1', file)
expect(uploadImage).toHaveBeenCalledWith(file.buffer, 'companies/company_1/customers/customer_1')
expect(deleteImage).toHaveBeenCalledWith('/storage/old.png')
expect(repo.updateById).toHaveBeenCalledWith('customer_1', { licenseImageUrl: '/storage/new.png' })
expect(result.licenseImageUrl).toBe('/storage/new.png')
})
it('rejects license-image access when the stored URL cannot resolve to a file path', async () => {
vi.mocked(repo.findByIdSimple).mockResolvedValue({ id: 'customer_1', licenseImageUrl: '/storage/missing.png' } as never)
vi.mocked(resolveStoredFilePath).mockReturnValue(null)
await expect(service.getLicenseImageFile('customer_1', 'company_1')).rejects.toBeInstanceOf(NotFoundError)
})
it('records approver metadata when manually approving a license', async () => {
vi.mocked(repo.findByIdSimple).mockResolvedValue({ id: 'customer_1' } as never)
vi.mocked(repo.updateById).mockResolvedValue({ id: 'customer_1', licenseValidationStatus: 'APPROVED' } as never)
await service.approveLicense('customer_1', 'company_1', 'APPROVE', undefined, 'Mina Manager')
expect(repo.updateById).toHaveBeenCalledWith('customer_1', expect.objectContaining({
licenseValidationStatus: 'APPROVED',
licenseApprovedBy: 'Mina Manager',
licenseApprovalNote: null,
licenseApprovedAt: expect.any(Date),
}))
})
})
@@ -37,7 +37,7 @@ export async function createCustomer(data: any, companyId: string) {
licenseIssuedAt: data.licenseIssuedAt ? new Date(data.licenseIssuedAt) : null,
})
if (data.licenseExpiry) {
await validateAndFlagLicense(customer.id).catch(() => null)
await validateAndFlagLicense(customer.id, companyId).catch(() => null)
}
return presentCustomer(customer)
}
@@ -50,8 +50,8 @@ export async function updateCustomer(id: string, companyId: string, data: any) {
licenseIssuedAt: data.licenseIssuedAt ? new Date(data.licenseIssuedAt) : undefined,
})
if (result.count === 0) throw new NotFoundError('Customer not found')
if (data.licenseExpiry) await validateAndFlagLicense(id).catch(() => null)
return presentCustomer(await repo.findUniqueOrThrow(id))
if (data.licenseExpiry) await validateAndFlagLicense(id, companyId).catch(() => null)
return presentCustomer(await repo.findUniqueOrThrow(id, companyId))
}
export async function flagCustomer(id: string, companyId: string, reason?: string) {
@@ -65,7 +65,7 @@ export async function unflagCustomer(id: string, companyId: string) {
export async function validateCustomerLicense(id: string, companyId: string) {
const customer = await repo.findByIdSimple(id, companyId)
if (!customer) throw new NotFoundError('Customer not found')
return validateAndFlagLicense(customer.id)
return validateAndFlagLicense(customer.id, companyId)
}
export async function uploadLicenseImage(id: string, companyId: string, file: Express.Multer.File) {
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import { presentVehicleWithAvailability } from './marketplace.presenter'
describe('marketplace.presenter', () => {
it('adds public availability fields without mutating the original vehicle shape', () => {
const nextAvailableAt = new Date('2026-08-01T10:00:00.000Z')
const vehicle = { id: 'vehicle_1', make: 'Dacia', model: 'Logan', dailyRate: 25000 }
expect(presentVehicleWithAvailability(vehicle, {
available: false,
status: 'RESERVED',
nextAvailableAt,
})).toEqual({
id: 'vehicle_1',
make: 'Dacia',
model: 'Logan',
dailyRate: 25000,
availability: false,
availabilityStatus: 'RESERVED',
nextAvailableAt,
})
expect(vehicle).toEqual({ id: 'vehicle_1', make: 'Dacia', model: 'Logan', dailyRate: 25000 })
})
})
@@ -0,0 +1,141 @@
import { describe, expect, it, vi } from 'vitest'
const prismaMock = vi.hoisted(() => ({
offer: { findMany: vi.fn(), findFirst: vi.fn() },
company: { findMany: vi.fn(), findFirst: vi.fn(), findFirstOrThrow: vi.fn() },
vehicle: { findMany: vi.fn(), findFirst: vi.fn(), findFirstOrThrow: vi.fn() },
reservation: { create: vi.fn(), findUnique: vi.fn(), update: vi.fn() },
customer: { findUnique: vi.fn(), create: vi.fn(), update: vi.fn() },
review: { findMany: vi.fn(), create: vi.fn() },
}))
vi.mock('../../lib/prisma', () => ({ prisma: prismaMock }))
import * as repo from './marketplace.repo'
describe('marketplace.repo public query and write boundaries', () => {
it('finds public offers using active/public/current-window filters and featured ordering', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-06-09T10:00:00.000Z'))
await repo.findPublicOffers()
expect(prismaMock.offer.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: {
isPublic: true,
isActive: true,
validFrom: { lte: new Date('2026-06-09T10:00:00.000Z') },
validUntil: { gte: new Date('2026-06-09T10:00:00.000Z') },
},
orderBy: [{ isFeatured: 'desc' }, { createdAt: 'desc' }],
take: 50,
}))
vi.useRealTimers()
})
it('lists marketplace cities only from active listed companies with a public city', async () => {
await repo.findCitiesFromCompanies()
expect(prismaMock.company.findMany).toHaveBeenCalledWith({
where: {
status: { in: ['ACTIVE', 'TRIALING'] },
brand: { isListedOnMarketplace: true, publicCity: { not: null } },
},
select: { brand: { select: { publicCity: true } } },
})
})
it('requires published vehicles and an active company when loading marketplace vehicle details', async () => {
await repo.findVehicleForMarketplace('vehicle_1', 'atlas')
expect(prismaMock.vehicle.findFirst).toHaveBeenCalledWith({
where: {
id: 'vehicle_1',
isPublished: true,
company: { slug: 'atlas', status: { in: ['ACTIVE', 'TRIALING'] } },
},
include: { company: { include: { brand: true } } },
})
})
it('patches marketplace customer address fields without deleting existing address metadata', async () => {
prismaMock.customer.findUnique.mockResolvedValueOnce({
id: 'customer_1',
address: { fullAddress: 'Old address', loyaltyNote: 'keep', internationalLicenseNumber: 'INT-1' },
})
await repo.upsertMarketplaceCustomer('company_1', {
email: 'renter@example.test',
firstName: 'Nora',
lastName: 'Renter',
identityDocumentNumber: 'ID-9',
internationalLicenseNumber: undefined,
})
expect(prismaMock.customer.update).toHaveBeenCalledWith({
where: { id: 'customer_1' },
data: expect.objectContaining({
firstName: 'Nora',
address: {
fullAddress: 'Old address',
loyaltyNote: 'keep',
internationalLicenseNumber: 'INT-1',
identityDocumentNumber: 'ID-9',
},
}),
})
})
it('creates marketplace reservations as draft marketplace-sourced records', async () => {
const startDate = new Date('2026-07-01T10:00:00.000Z')
const endDate = new Date('2026-07-05T10:00:00.000Z')
await repo.createMarketplaceReservation({
companyId: 'company_1',
vehicleId: 'vehicle_1',
customerId: 'customer_1',
startDate,
endDate,
dailyRate: 500,
totalDays: 4,
totalAmount: 2000,
pickupLocation: 'Airport',
returnLocation: null,
})
expect(prismaMock.reservation.create).toHaveBeenCalledWith({
data: expect.objectContaining({
companyId: 'company_1',
vehicleId: 'vehicle_1',
customerId: 'customer_1',
source: 'MARKETPLACE',
status: 'DRAFT',
}),
})
})
it('submits public reviews as published records and prevents null renter ids from becoming explicit nulls', async () => {
await repo.createReview({
reservationId: 'reservation_1',
companyId: 'company_1',
renterId: null,
overallRating: 5,
vehicleRating: 4,
serviceRating: 5,
comment: 'Clean car',
})
expect(prismaMock.review.create).toHaveBeenCalledWith({
data: {
reservationId: 'reservation_1',
companyId: 'company_1',
renterId: undefined,
overallRating: 5,
vehicleRating: 4,
serviceRating: 5,
comment: 'Clean car',
isPublished: true,
},
})
})
})
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import { marketplaceReservationSchema, paginationSchema, reviewBodySchema, searchSchema } from './marketplace.schemas'
describe('marketplace.schemas', () => {
it('coerces pagination and caps untrusted public page sizes', () => {
expect(paginationSchema.parse({ page: '2', pageSize: '40' })).toEqual({ page: 2, pageSize: 40 })
expect(paginationSchema.parse({})).toEqual({ page: 1, pageSize: 20 })
expect(() => paginationSchema.parse({ pageSize: '500' })).toThrow()
})
it('trims search filters and coerces maxPrice', () => {
expect(searchSchema.parse({ city: ' Rabat ', maxPrice: '500', dropoffMode: 'different' })).toEqual({
city: 'Rabat',
maxPrice: 500,
dropoffMode: 'different',
})
expect(() => searchSchema.parse({ dropoffMode: 'teleport' })).toThrow()
expect(() => searchSchema.parse({ maxPrice: '-1' })).toThrow()
})
it('defaults marketplace reservation language while keeping date and email validation strict', () => {
const parsed = marketplaceReservationSchema.parse({
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')
expect(() => marketplaceReservationSchema.parse({ ...parsed, email: 'bad-email' })).toThrow()
expect(() => marketplaceReservationSchema.parse({ ...parsed, startDate: '2026-07-01' })).toThrow()
})
it('validates review ratings as bounded integers', () => {
expect(reviewBodySchema.parse({ overallRating: 5, comment: 'Clean car' })).toEqual({ overallRating: 5, comment: 'Clean car' })
expect(() => reviewBodySchema.parse({ overallRating: 6 })).toThrow()
expect(() => reviewBodySchema.parse({ overallRating: 4.5 })).toThrow()
})
})
@@ -0,0 +1,83 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('./marketplace.repo', () => ({
findCompanyPage: vi.fn(),
findCompanyBySlug: vi.fn(),
findCompanyReviews: vi.fn(),
findCompanyVehicles: vi.fn(),
findVehicleById: vi.fn(),
findCompanyOffers: vi.fn(),
findOfferByCode: vi.fn(),
}))
vi.mock('../../services/vehicleAvailabilityService', () => ({ getVehicleAvailabilitySummary: vi.fn() }))
vi.mock('../../services/notificationService', () => ({ sendNotification: vi.fn(), sendTransactionalEmail: vi.fn() }))
vi.mock('../../lib/emailTranslations', () => ({
marketplaceReservationEmail: { subject: vi.fn(), html: vi.fn(), text: vi.fn() },
}))
import * as repo from './marketplace.repo'
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
import { getCompanyOffers, getCompanyPage, getCompanyReviews, getCompanyVehicles, getVehicleDetail, validateOfferCode } from './marketplace.service'
beforeEach(() => vi.clearAllMocks())
describe('marketplace.service public page edges', () => {
it('enriches company page vehicles with availability without changing company metadata', async () => {
const nextAvailableAt = new Date('2026-07-14T09:00:00.000Z')
vi.mocked(repo.findCompanyPage).mockResolvedValue({
id: 'company_1',
name: 'Atlas Cars',
vehicles: [{ id: 'vehicle_1', make: 'Dacia' }, { id: 'vehicle_2', make: 'Renault' }],
} as any)
vi.mocked(getVehicleAvailabilitySummary)
.mockResolvedValueOnce({ available: false, status: 'RESERVED', nextAvailableAt, blockingReason: 'RESERVATION' })
.mockResolvedValueOnce({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
const result = await getCompanyPage('atlas-cars')
expect(result).toMatchObject({ id: 'company_1', name: 'Atlas Cars' })
expect(result.vehicles).toEqual([
expect.objectContaining({ id: 'vehicle_1', availability: false, availabilityStatus: 'RESERVED', nextAvailableAt }),
expect.objectContaining({ id: 'vehicle_2', availability: true, availabilityStatus: 'AVAILABLE', nextAvailableAt: null }),
])
})
it('throws when a public company page cannot be found', async () => {
vi.mocked(repo.findCompanyPage).mockResolvedValue(null)
await expect(getCompanyPage('missing-company')).rejects.toThrow('Company not found')
})
it('resolves public company child resources through the company id, not the slug directly', async () => {
vi.mocked(repo.findCompanyBySlug).mockResolvedValue({ id: 'company_1' } as any)
vi.mocked(repo.findCompanyReviews).mockResolvedValue([{ id: 'review_1' }] as any)
vi.mocked(repo.findCompanyVehicles).mockResolvedValue([{ id: 'vehicle_1' }] as any)
vi.mocked(repo.findCompanyOffers).mockResolvedValue([{ id: 'offer_1' }] as any)
await expect(getCompanyReviews('atlas-cars')).resolves.toEqual([{ id: 'review_1' }])
await expect(getCompanyVehicles('atlas-cars')).resolves.toEqual([{ id: 'vehicle_1' }])
await expect(getCompanyOffers('atlas-cars')).resolves.toEqual([{ id: 'offer_1' }])
expect(repo.findCompanyReviews).toHaveBeenCalledWith('company_1')
expect(repo.findCompanyVehicles).toHaveBeenCalledWith('company_1')
expect(repo.findCompanyOffers).toHaveBeenCalledWith('company_1')
})
it('adds availability to vehicle detail responses', async () => {
const nextAvailableAt = new Date('2026-12-01T00:00:00.000Z')
vi.mocked(repo.findVehicleById).mockResolvedValue({ id: 'vehicle_1', make: 'Hyundai' } as any)
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: false, status: 'MAINTENANCE', nextAvailableAt, blockingReason: 'BLOCK' })
await expect(getVehicleDetail('atlas-cars', 'vehicle_1')).resolves.toEqual(expect.objectContaining({
id: 'vehicle_1',
availability: false,
availabilityStatus: 'MAINTENANCE',
nextAvailableAt,
}))
})
it('rejects exhausted offer codes before returning them as valid', async () => {
vi.mocked(repo.findOfferByCode).mockResolvedValue({ code: 'SUMMER', maxRedemptions: 10, redemptionCount: 10 } as any)
await expect(validateOfferCode('SUMMER')).rejects.toThrow('maximum redemptions')
})
})
@@ -95,13 +95,14 @@ export async function searchVehicles(params: {
where.company = { ...where.company, brand: { isListedOnMarketplace: true, publicCity: { contains: params.city, mode: 'insensitive' } } }
}
const vehicles = await repo.findPublishedVehicles(where)
const locationFiltered = vehicles.filter((vehicle) => matchesVehicleLocationRules(vehicle, params))
const vehicles = await repo.findPublishedVehicles(where) as any[]
const locationFiltered = vehicles.filter((vehicle: any) => matchesVehicleLocationRules(vehicle, params))
const pagedVehicles = locationFiltered.slice((page - 1) * pageSize, page * pageSize)
const availability = await Promise.all(
pagedVehicles.map(async (v) => {
pagedVehicles.map(async (v: any) => {
const a = await getVehicleAvailabilitySummary(v.id, {
companyId: v.companyId,
range: params.startDate && params.endDate
? { startDate: new Date(params.startDate), endDate: new Date(params.endDate) }
: undefined,
@@ -109,9 +110,9 @@ export async function searchVehicles(params: {
return [v.id, a] as const
}),
)
const availMap = new Map(availability)
const availMap = new Map<string, any>(availability)
return pagedVehicles.map((v) => ({
return pagedVehicles.map((v: any) => ({
...v,
availability: availMap.get(v.id)?.available ?? null,
availabilityStatus: availMap.get(v.id)?.status ?? 'AVAILABLE',
@@ -156,7 +157,7 @@ export async function createMarketplaceReservation(body: {
}
}
const availability = await getVehicleAvailabilitySummary(body.vehicleId, { range: { startDate, endDate } })
const availability = await getVehicleAvailabilitySummary(vehicle.id, { companyId: vehicle.companyId, range: { startDate, endDate } })
if (!availability.available) {
throw new AppError('Vehicle is not available for the selected dates', 409, 'unavailable', {
nextAvailableAt: availability.nextAvailableAt?.toISOString() ?? null,
@@ -221,8 +222,8 @@ export async function getCompanyPage(slug: string) {
if (!company) throw new NotFoundError('Company not found')
const vehicles = await Promise.all(
company.vehicles.map(async (v) => {
const a = await getVehicleAvailabilitySummary(v.id)
(company.vehicles as any[]).map(async (v: any) => {
const a = await getVehicleAvailabilitySummary(v.id, { companyId: v.companyId })
return { ...v, availability: a.available, availabilityStatus: a.status, nextAvailableAt: a.nextAvailableAt }
}),
)
@@ -241,7 +242,7 @@ export async function getCompanyVehicles(slug: string) {
export async function getVehicleDetail(slug: string, vehicleId: string) {
const vehicle = await repo.findVehicleById(slug, vehicleId)
const availability = await getVehicleAvailabilitySummary(vehicle.id)
const availability = await getVehicleAvailabilitySummary(vehicle.id, { companyId: vehicle.companyId })
return { ...vehicle, availability: availability.available, availabilityStatus: availability.status, nextAvailableAt: availability.nextAvailableAt }
}
@@ -97,6 +97,7 @@ describe('searchVehicles', () => {
const result = await searchVehicles({ startDate: '2025-06-01T00:00:00.000Z', endDate: '2025-06-04T00:00:00.000Z' })
expect(getVehicleAvailabilitySummary).toHaveBeenCalledWith('v-1', {
companyId: 'co-1',
range: { startDate: new Date('2025-06-01T00:00:00.000Z'), endDate: new Date('2025-06-04T00:00:00.000Z') },
})
expect(result[0].availability).toBe(false)
@@ -0,0 +1,173 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('../../lib/prisma', () => ({
prisma: {
menuItem: {
findUnique: vi.fn(),
findUniqueOrThrow: vi.fn(),
findFirst: vi.fn(),
findMany: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
menuItemRoleVisibility: { deleteMany: vi.fn(), createMany: vi.fn() },
subscriptionMenuItem: { deleteMany: vi.fn(), createMany: vi.fn() },
companyMenuItem: { deleteMany: vi.fn(), createMany: vi.fn() },
company: { findMany: vi.fn(), findUniqueOrThrow: vi.fn() },
employee: { findUniqueOrThrow: vi.fn() },
auditLog: { create: vi.fn(), findMany: vi.fn(), count: vi.fn() },
$transaction: vi.fn(),
},
}))
import { prisma } from '../../lib/prisma'
import {
assignMenuItemToCompanies,
assignMenuItemToPlans,
createMenuItem,
listMenuAuditLogs,
setMenuItemStatus,
} from './menu.service'
const admin = { id: 'admin_1', role: 'ADMIN' }
describe('menu.service mutation boundaries', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(prisma.menuItem.findFirst).mockResolvedValue(null as never)
vi.mocked(prisma.menuItem.findUnique).mockResolvedValue(null as never)
vi.mocked(prisma.company.findMany).mockResolvedValue([] as never)
vi.mocked(prisma.$transaction).mockImplementation(async (callback: any) => callback(prisma))
})
it('rejects external links that are not HTTPS before any write', async () => {
await expect(createMenuItem({
label: 'Docs',
itemType: 'EXTERNAL_LINK',
routeOrUrl: 'http://docs.example.test',
}, admin)).rejects.toMatchObject({ statusCode: 400, error: 'validation_error' })
expect(prisma.menuItem.create).not.toHaveBeenCalled()
expect(prisma.auditLog.create).not.toHaveBeenCalled()
})
it('rejects duplicate routes under the same parent with a conflict error', async () => {
vi.mocked(prisma.menuItem.findUnique).mockResolvedValue({ id: 'parent_1', itemType: 'PARENT_MENU' } as never)
vi.mocked(prisma.menuItem.findFirst).mockResolvedValue({ id: 'item_existing' } as never)
await expect(createMenuItem({
label: 'Fleet',
itemType: 'INTERNAL_PAGE',
routeOrUrl: '/fleet',
parentId: 'parent_1',
}, admin)).rejects.toMatchObject({ statusCode: 409, error: 'duplicate_menu_route' })
})
it('normalizes create payloads and syncs role, plan and company assignments transactionally', async () => {
vi.mocked(prisma.company.findMany).mockResolvedValue([{ id: 'company_1' }] as never)
vi.mocked(prisma.menuItem.create).mockResolvedValue({ id: 'item_1' } as never)
vi.mocked(prisma.menuItem.findUniqueOrThrow).mockResolvedValue({ id: 'item_1', label: 'Fleet' } as never)
await createMenuItem({
systemKey: ' fleet ',
label: ' Fleet ',
itemType: 'INTERNAL_PAGE',
routeOrUrl: ' /fleet ',
icon: ' Car ',
displayOrder: 7,
roles: ['MANAGER', 'MANAGER', 'AGENT'],
subscriptionPlans: [{ plan: 'GROWTH' }, { plan: 'PRO', displayOrder: 3, isActive: false }],
companyAssignments: [{ companyId: 'company_1' }],
}, admin)
expect(prisma.menuItem.create).toHaveBeenCalledWith({
data: expect.objectContaining({
systemKey: 'fleet',
label: 'Fleet',
itemType: 'INTERNAL_PAGE',
routeOrUrl: '/fleet',
icon: 'Car',
displayOrder: 7,
openInNewTab: false,
isRequired: false,
isActive: true,
createdBy: 'admin_1',
updatedBy: 'admin_1',
}),
})
expect(prisma.menuItemRoleVisibility.createMany).toHaveBeenCalledWith({
data: [
{ menuItemId: 'item_1', role: 'MANAGER' },
{ menuItemId: 'item_1', role: 'AGENT' },
],
})
expect(prisma.subscriptionMenuItem.createMany).toHaveBeenCalledWith({
data: [
{ menuItemId: 'item_1', plan: 'GROWTH', displayOrder: 7, isActive: true },
{ menuItemId: 'item_1', plan: 'PRO', displayOrder: 3, isActive: false },
],
})
expect(prisma.companyMenuItem.createMany).toHaveBeenCalledWith({
data: [{ menuItemId: 'item_1', companyId: 'company_1', displayOrder: 7, isActive: true }],
})
expect(prisma.auditLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({ action: 'CREATE_MENU_ITEM', resource: 'MenuItem', resourceId: 'item_1' }),
})
})
it('protects required menu items from non-super-admin disable attempts', async () => {
vi.mocked(prisma.menuItem.findUniqueOrThrow).mockResolvedValue({ id: 'item_core', isRequired: true, isActive: true } as never)
await expect(setMenuItemStatus('item_core', false, admin)).rejects.toMatchObject({ statusCode: 403, error: 'forbidden' })
expect(prisma.menuItem.update).not.toHaveBeenCalled()
})
it('replaces only requested plan assignments and writes an audit entry', async () => {
vi.mocked(prisma.menuItem.findUniqueOrThrow).mockResolvedValue({ id: 'item_1' } as never)
await assignMenuItemToPlans('item_1', [{ plan: 'STARTER' }, { plan: 'PRO', displayOrder: 4, isActive: false }], admin)
expect(prisma.subscriptionMenuItem.deleteMany).toHaveBeenCalledWith({
where: { menuItemId: 'item_1', plan: { in: ['STARTER', 'PRO'] } },
})
expect(prisma.subscriptionMenuItem.createMany).toHaveBeenCalledWith({
data: [
{ menuItemId: 'item_1', plan: 'STARTER', displayOrder: 0, isActive: true },
{ menuItemId: 'item_1', plan: 'PRO', displayOrder: 4, isActive: false },
],
})
expect(prisma.auditLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({ action: 'ASSIGN_MENU_ITEM_TO_SUBSCRIPTIONS', resource: 'MenuSubscriptionAssignment' }),
})
})
it('rejects company assignments when any company is cancelled or missing', async () => {
vi.mocked(prisma.menuItem.findUniqueOrThrow).mockResolvedValue({ id: 'item_1' } as never)
vi.mocked(prisma.company.findMany).mockResolvedValue([{ id: 'company_1' }] as never)
await expect(assignMenuItemToCompanies('item_1', [
{ companyId: 'company_1' },
{ companyId: 'company_cancelled' },
], admin)).rejects.toMatchObject({ statusCode: 400, error: 'validation_error' })
expect(prisma.companyMenuItem.deleteMany).not.toHaveBeenCalled()
})
it('paginates menu audit logs across menu-related resources only', async () => {
vi.mocked(prisma.auditLog.findMany).mockResolvedValue([{ id: 'log_1' }] as never)
vi.mocked(prisma.auditLog.count).mockResolvedValue(41 as never)
const result = await listMenuAuditLogs({ page: 3, pageSize: 20 })
expect(prisma.auditLog.findMany).toHaveBeenCalledWith({
where: { resource: { in: ['MenuItem', 'MenuSubscriptionAssignment', 'MenuCompanyAssignment', 'MenuRoleVisibility'] } },
include: { adminUser: { select: { firstName: true, lastName: true, email: true } } },
skip: 40,
take: 20,
orderBy: { createdAt: 'desc' },
})
expect(result).toEqual({ data: [{ id: 'log_1' }], total: 41, page: 3, pageSize: 20, totalPages: 3 })
})
})
+27 -10
View File
@@ -44,6 +44,23 @@ type MenuAuditQuery = {
pageSize: number
}
type EvaluatedMenuItem = {
id: string
systemKey: string | null
label: string
itemType: MenuItemType
routeOrUrl: string | null
icon: string | null
parentId: string | null
openInNewTab: boolean
isRequired: boolean
isActive: boolean
displayOrder: number
source: 'company' | 'subscription' | 'none'
visible: boolean
reasons: string[]
}
const EMPLOYEE_ROLES: EmployeeRole[] = ['OWNER', 'MANAGER', 'AGENT']
const MENU_AUDIT_RESOURCES = ['MenuItem', 'MenuSubscriptionAssignment', 'MenuCompanyAssignment', 'MenuRoleVisibility']
const MENU_ITEM_TYPES_WITHOUT_ROUTE = new Set([
@@ -245,7 +262,7 @@ export async function getMenuItem(id: string) {
export async function createMenuItem(input: MenuItemInput, admin: AdminActor) {
await assertMenuItemPayload(input)
const created = await prisma.$transaction(async (tx) => {
const created = await prisma.$transaction(async (tx: any) => {
const menuItem = await tx.menuItem.create({
data: {
systemKey: normalizeNullableString(input.systemKey) ?? undefined,
@@ -276,7 +293,7 @@ export async function updateMenuItem(id: string, input: MenuItemInput, admin: Ad
const before = await loadMenuItemDetail(id)
await assertMenuItemPayload(input, id)
const updated = await prisma.$transaction(async (tx) => {
const updated = await prisma.$transaction(async (tx: any) => {
const menuItem = await tx.menuItem.update({
where: { id },
data: {
@@ -451,13 +468,13 @@ async function getMenuEvaluationContext(companyId: string, role: EmployeeRole, e
const currentPlan = company.subscription?.plan ?? null
const currentPlanName = currentPlan as Plan | null
const evaluated = menuItems.map((item) => {
const evaluated: EvaluatedMenuItem[] = (menuItems as any[]).map((item: any) => {
const defaultsToAllSubscriptions = item.subscriptionAssignments.length === 0
const subscriptionAssignment = item.subscriptionAssignments.find(
(assignment) => assignment.isActive && (!currentPlan || assignment.plan === currentPlan),
(assignment: any) => assignment.isActive && (!currentPlan || assignment.plan === currentPlan),
)
const companyAssignment = item.companyAssignments.find((assignment) => assignment.isActive)
const roleAllowed = item.roleVisibilities.length === 0 || item.roleVisibilities.some((visibility) => visibility.role === role)
const companyAssignment = item.companyAssignments.find((assignment: any) => assignment.isActive)
const roleAllowed = item.roleVisibilities.length === 0 || item.roleVisibilities.some((visibility: any) => visibility.role === role)
const subscriptionEntitled = Boolean(subscriptionAssignment || (defaultsToAllSubscriptions && currentPlan))
const entitled = Boolean(subscriptionEntitled || companyAssignment)
const visible = Boolean(item.isActive && entitled && roleAllowed && employeeIsActive)
@@ -482,8 +499,8 @@ async function getMenuEvaluationContext(companyId: string, role: EmployeeRole, e
return {
id: item.id,
systemKey: item.systemKey,
label: item.label,
itemType: item.itemType,
label: item.label ?? '',
itemType: item.itemType as MenuItemType,
routeOrUrl: item.routeOrUrl,
icon: item.icon,
parentId: item.parentId,
@@ -523,8 +540,8 @@ function buildMenuTree(items: Array<{
byId.set(item.id, {
id: item.id,
systemKey: item.systemKey,
label: item.label,
itemType: item.itemType,
label: item.label ?? '',
itemType: item.itemType as MenuItemType,
routeOrUrl: item.routeOrUrl,
icon: item.icon,
parentId: item.parentId,
@@ -0,0 +1,109 @@
import { describe, expect, it, vi } from 'vitest'
const prismaMock = vi.hoisted(() => ({
notification: {
findMany: vi.fn(),
count: vi.fn(),
updateMany: vi.fn(),
},
notificationPreference: {
findMany: vi.fn(),
upsert: vi.fn(),
},
}))
vi.mock('../../lib/prisma', () => ({ prisma: prismaMock }))
import * as repo from './notification.repo'
describe('notification.repo query boundaries', () => {
it('scopes company in-app notification lists and applies unread filtering only when requested', async () => {
await repo.findCompany('company_1', 'true')
expect(prismaMock.notification.findMany).toHaveBeenCalledWith({
where: { companyId: 'company_1', channel: 'IN_APP', readAt: null },
orderBy: { createdAt: 'desc' },
take: 50,
})
})
it('marks a single company notification read with tenant scoping', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-06-09T13:00:00.000Z'))
await repo.markRead('notification_1', 'company_1')
expect(prismaMock.notification.updateMany).toHaveBeenCalledWith({
where: { id: 'notification_1', companyId: 'company_1' },
data: { readAt: new Date('2026-06-09T13:00:00.000Z'), status: 'READ' },
})
vi.useRealTimers()
})
it('keeps renter bulk read updates constrained to renter in-app notifications', async () => {
await repo.markAllRenterRead('renter_1')
expect(prismaMock.notification.updateMany).toHaveBeenCalledWith({
where: { renterId: 'renter_1', channel: 'IN_APP', readAt: null },
data: { readAt: expect.any(Date), status: 'READ' },
})
})
it('defaults company notification history to a capped newest-first query', async () => {
await repo.findCompanyHistory('company_1')
expect(prismaMock.notification.findMany).toHaveBeenCalledWith({
where: { companyId: 'company_1' },
orderBy: { createdAt: 'desc' },
take: 200,
})
})
it('upserts employee preferences by composite key without cross-employee mutation', async () => {
await repo.upsertEmployeePreferences('employee_1', [
{ notificationType: 'RESERVATION_CREATED', channel: 'EMAIL', enabled: false },
{ notificationType: 'PAYMENT_RECEIVED', channel: 'IN_APP', enabled: true },
])
expect(prismaMock.notificationPreference.upsert).toHaveBeenCalledTimes(2)
expect(prismaMock.notificationPreference.upsert).toHaveBeenNthCalledWith(1, {
where: {
employeeId_notificationType_channel: {
employeeId: 'employee_1',
notificationType: 'RESERVATION_CREATED',
channel: 'EMAIL',
},
},
create: {
employeeId: 'employee_1',
notificationType: 'RESERVATION_CREATED',
channel: 'EMAIL',
enabled: false,
},
update: { enabled: false },
})
})
it('upserts renter preferences by renter composite key', async () => {
await repo.upsertRenterPreferences('renter_1', [
{ notificationType: 'RESERVATION_CONFIRMED', channel: 'PUSH', enabled: true },
])
expect(prismaMock.notificationPreference.upsert).toHaveBeenCalledWith({
where: {
renterId_notificationType_channel: {
renterId: 'renter_1',
notificationType: 'RESERVATION_CONFIRMED',
channel: 'PUSH',
},
},
create: {
renterId: 'renter_1',
notificationType: 'RESERVATION_CONFIRMED',
channel: 'PUSH',
enabled: true,
},
update: { enabled: true },
})
})
})
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import { historyQuerySchema, idParamSchema, preferencesSchema, unreadQuerySchema } from './notification.schemas'
describe('notification schema contracts', () => {
it('requires complete preference entries', () => {
expect(preferencesSchema.parse([{ notificationType: 'BOOKING_CREATED', channel: 'EMAIL', enabled: true }])).toEqual([
{ notificationType: 'BOOKING_CREATED', channel: 'EMAIL', enabled: true },
])
expect(() => preferencesSchema.parse([{ notificationType: 'BOOKING_CREATED', channel: 'EMAIL' }])).toThrow()
})
it('coerces history limits and caps bulk history requests', () => {
expect(historyQuerySchema.parse({ channel: 'EMAIL', status: 'SENT', limit: '25' })).toEqual({
channel: 'EMAIL',
status: 'SENT',
limit: 25,
})
expect(() => historyQuerySchema.parse({ limit: '0' })).toThrow()
expect(() => historyQuerySchema.parse({ limit: '501' })).toThrow()
})
it('accepts unread query passthrough but rejects empty ids', () => {
expect(unreadQuerySchema.parse({ unread: 'true' })).toEqual({ unread: 'true' })
expect(idParamSchema.parse({ id: 'notification_1' })).toEqual({ id: 'notification_1' })
expect(() => idParamSchema.parse({ id: '' })).toThrow()
})
})
@@ -0,0 +1,74 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('./notification.repo', () => ({
findCompany: vi.fn(),
findCompanyHistory: vi.fn(),
countUnread: vi.fn(),
markRead: vi.fn(),
markAllRead: vi.fn(),
findEmployeePreferences: vi.fn(),
upsertEmployeePreferences: vi.fn(),
findRenter: vi.fn(),
markRenterRead: vi.fn(),
markAllRenterRead: vi.fn(),
findRenterPreferences: vi.fn(),
upsertRenterPreferences: vi.fn(),
}))
import * as repo from './notification.repo'
import * as service from './notification.service'
describe('notification.service', () => {
beforeEach(() => vi.clearAllMocks())
it('delegates company notification reads and history filters without rewriting query semantics', async () => {
vi.mocked(repo.findCompany).mockResolvedValue([{ id: 'n1' }] as never)
vi.mocked(repo.findCompanyHistory).mockResolvedValue([{ id: 'h1' }] as never)
vi.mocked(repo.countUnread).mockResolvedValue(4 as never)
await expect(service.listCompany('company_1', 'true')).resolves.toEqual([{ id: 'n1' }])
await expect(service.listCompanyHistory('company_1', { channel: 'EMAIL', status: 'FAILED', limit: 25 })).resolves.toEqual([{ id: 'h1' }])
await expect(service.countUnread('company_1')).resolves.toBe(4)
expect(repo.findCompany).toHaveBeenCalledWith('company_1', 'true')
expect(repo.findCompanyHistory).toHaveBeenCalledWith('company_1', { channel: 'EMAIL', status: 'FAILED', limit: 25 })
expect(repo.countUnread).toHaveBeenCalledWith('company_1')
})
it('marks company notifications read only within the company tenant', async () => {
await service.markRead('notification_1', 'company_1')
await service.markAllRead('company_1')
expect(repo.markRead).toHaveBeenCalledWith('notification_1', 'company_1')
expect(repo.markAllRead).toHaveBeenCalledWith('company_1')
})
it('reads and writes employee preferences through the employee identity, not the company id', async () => {
const prefs = [{ notificationType: 'BOOKING_CONFIRMED', channel: 'EMAIL', enabled: true }]
vi.mocked(repo.findEmployeePreferences).mockResolvedValue(prefs as never)
await expect(service.getPreferences('employee_1')).resolves.toEqual(prefs)
await service.setPreferences('employee_1', prefs)
expect(repo.findEmployeePreferences).toHaveBeenCalledWith('employee_1')
expect(repo.upsertEmployeePreferences).toHaveBeenCalledWith('employee_1', prefs)
})
it('keeps renter notification operations scoped to the renter identity', async () => {
const prefs = [{ notificationType: 'REVIEW_REQUEST', channel: 'IN_APP', enabled: false }]
vi.mocked(repo.findRenter).mockResolvedValue([{ id: 'rn1' }] as never)
vi.mocked(repo.findRenterPreferences).mockResolvedValue(prefs as never)
await service.listRenter('renter_1')
await service.markRenterRead('notification_1', 'renter_1')
await service.markAllRenterRead('renter_1')
await service.getRenterPreferences('renter_1')
await service.setRenterPreferences('renter_1', prefs)
expect(repo.findRenter).toHaveBeenCalledWith('renter_1')
expect(repo.markRenterRead).toHaveBeenCalledWith('notification_1', 'renter_1')
expect(repo.markAllRenterRead).toHaveBeenCalledWith('renter_1')
expect(repo.findRenterPreferences).toHaveBeenCalledWith('renter_1')
expect(repo.upsertRenterPreferences).toHaveBeenCalledWith('renter_1', prefs)
})
})
@@ -0,0 +1,84 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const tx = vi.hoisted(() => ({
offer: { update: vi.fn(), findUniqueOrThrow: vi.fn() },
offerVehicle: { deleteMany: vi.fn(), createMany: vi.fn() },
}))
vi.mock('../../lib/prisma', () => ({
prisma: {
offer: { findMany: vi.fn(), findFirstOrThrow: vi.fn(), findFirst: vi.fn(), create: vi.fn(), deleteMany: vi.fn(), updateMany: vi.fn() },
reservation: { count: vi.fn() },
$transaction: vi.fn(async (callback: (txArg: typeof tx) => unknown) => callback(tx)),
},
}))
import { prisma } from '../../lib/prisma'
import * as repo from './offer.repo'
describe('offer.repo edge behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
tx.offer.update.mockReset()
tx.offer.findUniqueOrThrow.mockReset()
tx.offerVehicle.deleteMany.mockReset()
tx.offerVehicle.createMany.mockReset()
})
it('converts active and public query strings into boolean filters', () => {
repo.findMany('company_1', { active: 'false', public: 'true' })
expect(prisma.offer.findMany).toHaveBeenCalledWith({
where: { companyId: 'company_1', isActive: false, isPublic: true },
orderBy: { createdAt: 'desc' },
})
})
it('creates vehicle links only when explicit vehicle ids are supplied', async () => {
await repo.create('company_1', {
title: 'Summer',
type: 'PERCENTAGE',
discountValue: 10,
validFrom: '2026-07-01T00:00:00.000Z',
validUntil: '2026-07-31T23:59:59.000Z',
}, ['vehicle_1', 'vehicle_2'])
expect(prisma.offer.create).toHaveBeenCalledWith({
data: expect.objectContaining({
companyId: 'company_1',
validFrom: new Date('2026-07-01T00:00:00.000Z'),
validUntil: new Date('2026-07-31T23:59:59.000Z'),
vehicles: { create: [{ vehicleId: 'vehicle_1' }, { vehicleId: 'vehicle_2' }] },
}),
include: { vehicles: true },
})
})
it('replaces vehicle links inside the update transaction when vehicleIds are provided', async () => {
await repo.update('offer_1', { title: 'Updated', validFrom: '2026-08-01T00:00:00.000Z' }, ['vehicle_3'])
expect(prisma.$transaction).toHaveBeenCalled()
expect(tx.offer.update).toHaveBeenCalledWith({
where: { id: 'offer_1' },
data: expect.objectContaining({ title: 'Updated', validFrom: new Date('2026-08-01T00:00:00.000Z') }),
})
expect(tx.offerVehicle.deleteMany).toHaveBeenCalledWith({ where: { offerId: 'offer_1' } })
expect(tx.offerVehicle.createMany).toHaveBeenCalledWith({ data: [{ offerId: 'offer_1', vehicleId: 'vehicle_3' }] })
expect(tx.offer.findUniqueOrThrow).toHaveBeenCalledWith({ where: { id: 'offer_1' }, include: { vehicles: true } })
})
it('does not touch vehicle links when update omits vehicleIds', async () => {
await repo.update('offer_1', { description: 'Copy only' })
expect(tx.offerVehicle.deleteMany).not.toHaveBeenCalled()
expect(tx.offerVehicle.createMany).not.toHaveBeenCalled()
})
it('combines redemption counter and reservation count for stats', async () => {
vi.mocked(prisma.offer.findFirstOrThrow).mockResolvedValue({ id: 'offer_1', redemptionCount: 7 } as never)
vi.mocked(prisma.reservation.count).mockResolvedValue(3 as never)
await expect(repo.getStats('offer_1', 'company_1')).resolves.toEqual({ redemptions: 7, reservations: 3 })
expect(prisma.reservation.count).toHaveBeenCalledWith({ where: { offerId: 'offer_1' } })
})
})
+1 -1
View File
@@ -29,7 +29,7 @@ export async function create(companyId: string, data: any, vehicleIds: string[])
}
export async function update(id: string, data: any, vehicleIds?: string[]) {
return prisma.$transaction(async (tx) => {
return prisma.$transaction(async (tx: any) => {
await tx.offer.update({
where: { id },
data: {
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'
import { offerSchema, listQuerySchema } from './offer.schemas'
const validOffer = {
title: 'Summer deal',
type: 'PERCENTAGE',
discountValue: 15,
validFrom: '2026-07-01T00:00:00.000Z',
validUntil: '2026-07-31T23:59:59.000Z',
}
describe('offer schemas', () => {
it('applies safe defaults for public all-fleet offers', () => {
expect(offerSchema.parse(validOffer)).toMatchObject({
appliesToAll: true,
categories: [],
vehicleIds: [],
isActive: true,
isPublic: true,
isFeatured: false,
})
})
it('accepts targeted vehicle and category offers', () => {
expect(offerSchema.parse({
...validOffer,
appliesToAll: false,
categories: ['SUV', 'LUXURY'],
vehicleIds: ['vehicle_1', 'vehicle_2'],
})).toMatchObject({ appliesToAll: false, categories: ['SUV', 'LUXURY'], vehicleIds: ['vehicle_1', 'vehicle_2'] })
})
it('rejects non-integer discounts and malformed dates', () => {
expect(() => offerSchema.parse({ ...validOffer, discountValue: 12.5 })).toThrow()
expect(() => offerSchema.parse({ ...validOffer, validFrom: 'tomorrow' })).toThrow()
})
it('keeps list filters as strings because route logic interprets them', () => {
expect(listQuerySchema.parse({ active: 'true', public: 'false' })).toEqual({ active: 'true', public: 'false' })
})
})
@@ -0,0 +1,72 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('./offer.repo', () => ({
findMany: vi.fn(),
findByIdOrThrow: vi.fn(),
create: vi.fn(),
findFirst: vi.fn(),
update: vi.fn(),
deleteMany: vi.fn(),
setActive: vi.fn(),
getStats: vi.fn(),
}))
import { NotFoundError } from '../../http/errors'
import * as repo from './offer.repo'
import { createOffer, deleteOffer, getOffer, getOfferStats, listOffers, setOfferActive, updateOffer } from './offer.service'
describe('offer.service', () => {
beforeEach(() => vi.clearAllMocks())
it('keeps listing filters tenant-scoped and delegates them unchanged to the repository', async () => {
vi.mocked(repo.findMany).mockResolvedValue([{ id: 'offer_1' }] as never)
const result = await listOffers('company_1', { active: 'true', public: 'false' })
expect(repo.findMany).toHaveBeenCalledWith('company_1', { active: 'true', public: 'false' })
expect(result).toEqual([{ id: 'offer_1' }])
})
it('creates offers with separate offer data and vehicle assignment ids', async () => {
vi.mocked(repo.create).mockResolvedValue({ id: 'offer_1' } as never)
const payload = { title: 'Weekend deal', type: 'PERCENTAGE', discountValue: 15 }
await createOffer('company_1', payload, ['vehicle_1', 'vehicle_2'])
expect(repo.create).toHaveBeenCalledWith('company_1', payload, ['vehicle_1', 'vehicle_2'])
})
it('throws NotFoundError before updating an offer outside the tenant boundary', async () => {
vi.mocked(repo.findFirst).mockResolvedValue(null as never)
await expect(updateOffer('offer_1', 'company_1', { title: 'Nope' })).rejects.toBeInstanceOf(NotFoundError)
expect(repo.update).not.toHaveBeenCalled()
})
it('updates an existing offer and preserves explicit vehicle assignment updates', async () => {
vi.mocked(repo.findFirst).mockResolvedValue({ id: 'offer_1' } as never)
vi.mocked(repo.update).mockResolvedValue({ id: 'offer_1', title: 'Summer' } as never)
const result = await updateOffer('offer_1', 'company_1', { title: 'Summer' }, [])
expect(repo.update).toHaveBeenCalledWith('offer_1', { title: 'Summer' }, [])
expect(result).toEqual({ id: 'offer_1', title: 'Summer' })
})
it('activates, deactivates, deletes, and reads stats through tenant-scoped repository methods', async () => {
vi.mocked(repo.setActive).mockResolvedValue({ id: 'offer_1', isActive: true } as never)
vi.mocked(repo.deleteMany).mockResolvedValue({ count: 1 } as never)
vi.mocked(repo.getStats).mockResolvedValue({ redemptions: 3 } as never)
vi.mocked(repo.findByIdOrThrow).mockResolvedValue({ id: 'offer_1' } as never)
await getOffer('offer_1', 'company_1')
await setOfferActive('offer_1', 'company_1', true)
await deleteOffer('offer_1', 'company_1')
await getOfferStats('offer_1', 'company_1')
expect(repo.findByIdOrThrow).toHaveBeenCalledWith('offer_1', 'company_1')
expect(repo.setActive).toHaveBeenCalledWith('offer_1', 'company_1', true)
expect(repo.deleteMany).toHaveBeenCalledWith('offer_1', 'company_1')
expect(repo.getStats).toHaveBeenCalledWith('offer_1', 'company_1')
})
})
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import { createSchema as complaintCreateSchema, listQuerySchema as complaintListQuerySchema, updateSchema as complaintUpdateSchema } from './complaints/complaint.schemas'
import { historyQuerySchema, preferencesSchema, unreadQuerySchema } from './notifications/notification.schemas'
import { offerSchema } from './offers/offer.schemas'
import { cancelSchema, checkoutSchema, startTrialSchema } from './subscriptions/subscription.schemas'
import { inviteSchema, roleSchema } from './team/team.schemas'
import { listQuerySchema as reviewListQuerySchema, replySchema } from './reviews/review.schemas'
import { reportQuerySchema, summaryQuerySchema } from './analytics/analytics.schemas'
describe('operational schemas', () => {
it('validates offers, complaints, and reviews without inventing impossible statuses', () => {
expect(offerSchema.parse({
title: 'Summer',
type: 'PERCENTAGE',
discountValue: 15,
validFrom: '2026-07-01T00:00:00.000Z',
validUntil: '2026-08-01T00:00:00.000Z',
})).toMatchObject({ appliesToAll: true, categories: [], vehicleIds: [], isActive: true, isPublic: true })
expect(offerSchema.safeParse({ title: '', type: 'PERCENTAGE', discountValue: 15, validFrom: 'bad', validUntil: 'bad' }).success).toBe(false)
expect(complaintCreateSchema.parse({ category: 'BILLING', subject: 'Wrong invoice' })).toMatchObject({ severity: 'LEVEL_1' })
expect(complaintUpdateSchema.safeParse({ status: 'IGNORED' }).success).toBe(false)
expect(complaintListQuerySchema.parse({ page: '2', pageSize: '25' })).toMatchObject({ page: 2, pageSize: 25 })
expect(replySchema.safeParse({ companyReply: '' }).success).toBe(false)
expect(reviewListQuerySchema.safeParse({ rating: '6' }).success).toBe(false)
})
it('validates subscriptions, team roles, notifications, and analytics query defaults', () => {
expect(checkoutSchema.safeParse({ plan: 'PRO', billingPeriod: 'MONTHLY', currency: 'MAD', provider: 'PAYPAL', successUrl: 'https://ok.example.test', failureUrl: 'https://fail.example.test' }).success).toBe(true)
expect(startTrialSchema.parse({ plan: 'STARTER', billingPeriod: 'ANNUAL' })).toMatchObject({ currency: 'MAD' })
expect(cancelSchema.parse({})).toEqual({ mode: 'period_end' })
expect(inviteSchema.safeParse({ firstName: 'A', lastName: 'B', email: 'agent@example.test', role: 'OWNER' }).success).toBe(false)
expect(roleSchema.safeParse({ role: 'AGENT' }).success).toBe(true)
expect(preferencesSchema.safeParse([{ notificationType: 'BOOKING', channel: 'EMAIL', enabled: true }]).success).toBe(true)
expect(unreadQuerySchema.parse({ unread: 'true' })).toEqual({ unread: 'true' })
expect(historyQuerySchema.parse({ limit: '100' })).toEqual({ limit: 100 })
expect(historyQuerySchema.safeParse({ limit: '501' }).success).toBe(false)
expect(summaryQuerySchema.parse({})).toEqual({ period: '30d' })
expect(reportQuerySchema.parse({})).toEqual({ format: 'JSON' })
})
})
@@ -0,0 +1,71 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('../../lib/prisma', () => ({
prisma: {
rentalPayment: { findMany: vi.fn(), findFirst: vi.fn(), findFirstOrThrow: vi.fn(), update: vi.fn(), updateMany: vi.fn(), create: vi.fn() },
reservation: { findFirstOrThrow: vi.fn(), update: vi.fn() },
},
}))
import { prisma } from '../../lib/prisma'
import * as repo from './payment.repo'
describe('payment.repo edge queries', () => {
beforeEach(() => vi.clearAllMocks())
it('loads recent company payments with reservation, customer and vehicle context', async () => {
await repo.findByCompany('company_1')
expect(prisma.rentalPayment.findMany).toHaveBeenCalledWith({
where: { companyId: 'company_1' },
include: { reservation: { include: { customer: true, vehicle: true } } },
orderBy: { createdAt: 'desc' },
take: 100,
})
})
it('finds payment by id only within company and reservation scope', async () => {
await repo.findPaymentOrThrow('payment_1', 'company_1', 'reservation_1')
expect(prisma.rentalPayment.findFirstOrThrow).toHaveBeenCalledWith({
where: { id: 'payment_1', companyId: 'company_1', reservationId: 'reservation_1' },
})
})
it('marks a payment as succeeded with a fresh paidAt timestamp', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-08-01T12:00:00.000Z'))
await repo.markPaymentSucceeded('payment_1')
expect(prisma.rentalPayment.update).toHaveBeenCalledWith({
where: { id: 'payment_1' },
data: { status: 'SUCCEEDED', paidAt: new Date('2026-08-01T12:00:00.000Z') },
})
vi.useRealTimers()
})
it('increments paid amount and promotes reservation payment status to paid', async () => {
await repo.incrementReservationPaid('reservation_1', 2500)
expect(prisma.reservation.update).toHaveBeenCalledWith({
where: { id: 'reservation_1' },
data: { paymentStatus: 'PAID', paidAmount: { increment: 2500 } },
})
})
it('maps partial refunds to the correct payment status', async () => {
await repo.setPaymentRefunded('payment_1', true)
await repo.setPaymentRefunded('payment_2', false)
expect(prisma.rentalPayment.update).toHaveBeenNthCalledWith(1, {
where: { id: 'payment_1' },
data: { status: 'PARTIALLY_REFUNDED' },
})
expect(prisma.rentalPayment.update).toHaveBeenNthCalledWith(2, {
where: { id: 'payment_2' },
data: { status: 'REFUNDED' },
})
})
})
@@ -5,6 +5,7 @@ import { requireSubscription } from '../../middleware/requireSubscription'
import { requireRole } from '../../middleware/requireRole'
import { parseBody, parseParams } from '../../http/validate'
import { ok } from '../../http/respond'
import { getRawBodyString, parseRawJsonBody } from '../../http/webhooks'
import * as amanpay from '../../services/amanpayService'
import * as paypal from '../../services/paypalService'
import * as service from './payment.service'
@@ -16,22 +17,24 @@ const router = Router()
router.post('/webhooks/amanpay', async (req, res, next) => {
try {
const rawBody = JSON.stringify(req.body)
const rawBody = getRawBodyString(req)
const payload = parseRawJsonBody(req)
const signature = (req.headers['x-amanpay-signature'] as string) ?? ''
if (!amanpay.isConfigured() || !amanpay.verifyWebhookSignature(rawBody, signature)) {
return res.status(401).json({ error: 'invalid_signature' })
}
await service.handleAmanpayWebhook(req.body)
await service.handleAmanpayWebhook(payload, rawBody)
res.json({ received: true })
} catch (err) { next(err) }
})
router.post('/webhooks/paypal', async (req, res, next) => {
try {
const rawBody = JSON.stringify(req.body)
const rawBody = getRawBodyString(req)
const payload = parseRawJsonBody(req)
const isValid = await paypal.verifyWebhookEvent(req.headers as Record<string, string>, rawBody)
if (!paypal.isConfigured() || !isValid) return res.status(401).json({ error: 'invalid_signature' })
await service.handlePaypalWebhook(req.body)
await service.handlePaypalWebhook(payload, rawBody)
res.json({ received: true })
} catch (err) { next(err) }
})
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { capturePaypalSchema, chargeSchema, manualPaymentSchema, paymentParamSchema, refundSchema, reservationParamSchema } from './payment.schemas'
describe('payment schemas edge cases', () => {
it('defaults charge and manual payment currency/type while rejecting unsupported providers', () => {
expect(chargeSchema.parse({ provider: 'PAYPAL', successUrl: 'https://ok.example.test', failureUrl: 'https://fail.example.test' })).toMatchObject({
provider: 'PAYPAL',
type: 'CHARGE',
currency: 'MAD',
})
expect(chargeSchema.safeParse({ provider: 'STRIPE', successUrl: 'https://ok.example.test', failureUrl: 'https://fail.example.test' }).success).toBe(false)
expect(manualPaymentSchema.parse({ amount: 500, paymentMethod: 'CASH' })).toMatchObject({ amount: 500, currency: 'MAD', type: 'CHARGE' })
expect(manualPaymentSchema.safeParse({ amount: 0, paymentMethod: 'CASH' }).success).toBe(false)
})
it('validates refund/capture/payment parameter payloads', () => {
expect(refundSchema.parse({})).toEqual({})
expect(refundSchema.safeParse({ amount: -1 }).success).toBe(false)
expect(capturePaypalSchema.safeParse({ paypalOrderId: 'order_1' }).success).toBe(true)
expect(capturePaypalSchema.safeParse({}).success).toBe(false)
expect(reservationParamSchema.safeParse({ id: '' }).success).toBe(false)
expect(paymentParamSchema.safeParse({ reservationId: 'reservation_1', paymentId: 'payment_1' }).success).toBe(true)
})
})
@@ -0,0 +1,206 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('../../services/amanpayService', () => ({
isConfigured: vi.fn(),
createCheckout: vi.fn(),
refundTransaction: vi.fn(),
}))
vi.mock('../../services/paypalService', () => ({
isConfigured: vi.fn(),
createOrder: vi.fn(),
captureOrder: vi.fn(),
refundCapture: vi.fn(),
}))
vi.mock('./payment.repo', () => ({
findByCompany: vi.fn(),
findByReservation: vi.fn(),
findByAmanpay: vi.fn(),
findByPaypal: vi.fn(),
findByPaypalForCompany: vi.fn(),
findPaymentOrThrow: vi.fn(),
findReservationOrThrow: vi.fn(),
findReservation: vi.fn(),
markPaymentSucceeded: vi.fn(),
markPaymentFailed: vi.fn(),
incrementReservationPaid: vi.fn(),
createPayment: vi.fn(),
updatePaypalCapture: vi.fn(),
setReservationPaidAmount: vi.fn(),
setReservationRefunded: vi.fn(),
setPaymentRefunded: vi.fn(),
}))
import { ConflictError, ValidationError } from '../../http/errors'
import * as amanpay from '../../services/amanpayService'
import * as paypal from '../../services/paypalService'
import * as repo from './payment.repo'
import {
capturePaypal,
handleAmanpayWebhook,
handlePaypalWebhook,
initCharge,
recordManualPayment,
refundPayment,
} from './payment.service'
const reservation = {
id: 'reservation_1',
companyId: 'company_1',
paymentStatus: 'UNPAID',
depositAmount: 300,
totalAmount: 1200,
paidAmount: 200,
vehicle: { make: 'Dacia', model: 'Duster' },
customer: { firstName: 'Nora', lastName: 'Driver', email: 'nora@example.com' },
}
describe('payment.service', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-06-08T10:15:00.000Z'))
delete process.env.API_URL
})
afterEach(() => {
vi.useRealTimers()
delete process.env.API_URL
})
it('creates an AmanPay deposit checkout using reservation, customer, and webhook details', async () => {
vi.mocked(repo.findReservationOrThrow).mockResolvedValue(reservation as never)
vi.mocked(amanpay.isConfigured).mockReturnValue(true)
vi.mocked(amanpay.createCheckout).mockResolvedValue({ checkoutUrl: 'https://pay.example/checkout', transactionId: 'aman_txn_1' } as never)
vi.mocked(repo.createPayment).mockResolvedValue({ id: 'payment_1', status: 'PENDING' } as never)
const result = await initCharge('reservation_1', 'company_1', {
provider: 'AMANPAY',
type: 'DEPOSIT',
currency: 'MAD',
successUrl: 'https://app.example/success',
failureUrl: 'https://app.example/failure',
})
expect(amanpay.createCheckout).toHaveBeenCalledWith(expect.objectContaining({
amount: 300,
currency: 'MAD',
orderId: 'reservation_1-DEPOSIT-1780913700000',
description: 'Deposit: Dacia Duster',
customerEmail: 'nora@example.com',
customerName: 'Nora Driver',
webhookUrl: 'http://localhost:4000/api/v1/payments/webhooks/amanpay',
}))
expect(repo.createPayment).toHaveBeenCalledWith(expect.objectContaining({
companyId: 'company_1',
reservationId: 'reservation_1',
amount: 300,
status: 'PENDING',
type: 'DEPOSIT',
paymentProvider: 'AMANPAY',
amanpayTransactionId: 'aman_txn_1',
paypalCaptureId: null,
}))
expect(result).toEqual({ payment: { id: 'payment_1', status: 'PENDING' }, checkoutUrl: 'https://pay.example/checkout' })
})
it('refuses to initialize a charge for a fully paid reservation before touching gateways', async () => {
vi.mocked(repo.findReservationOrThrow).mockResolvedValue({ ...reservation, paymentStatus: 'PAID' } as never)
await expect(initCharge('reservation_1', 'company_1', {
provider: 'PAYPAL',
type: 'CHARGE',
currency: 'MAD',
successUrl: 'https://app.example/success',
failureUrl: 'https://app.example/failure',
})).rejects.toBeInstanceOf(ConflictError)
expect(paypal.isConfigured).not.toHaveBeenCalled()
expect(repo.createPayment).not.toHaveBeenCalled()
})
it('records manual payments and marks the reservation as partially or fully paid from the balance math', async () => {
vi.mocked(repo.findReservation).mockResolvedValue({ id: 'reservation_1', totalAmount: 1000, paidAmount: 700 } as never)
vi.mocked(repo.createPayment).mockResolvedValue({ id: 'payment_1', amount: 300, status: 'SUCCEEDED' } as never)
const payment = await recordManualPayment('reservation_1', 'company_1', {
amount: 300,
currency: 'MAD',
type: 'CHARGE',
paymentMethod: 'CASH',
})
expect(repo.createPayment).toHaveBeenCalledWith(expect.objectContaining({
status: 'SUCCEEDED',
paymentProvider: 'AMANPAY',
paymentMethod: 'CASH',
paidAt: expect.any(Date),
}))
expect(repo.setReservationPaidAmount).toHaveBeenCalledWith('reservation_1', 1000, 'PAID')
expect(payment).toEqual({ id: 'payment_1', amount: 300, status: 'SUCCEEDED' })
})
it('rejects manual overpayments instead of silently corrupting the reservation balance', async () => {
vi.mocked(repo.findReservation).mockResolvedValue({ id: 'reservation_1', totalAmount: 1000, paidAmount: 950 } as never)
await expect(recordManualPayment('reservation_1', 'company_1', {
amount: 100,
currency: 'MAD',
type: 'CHARGE',
paymentMethod: 'BANK_TRANSFER',
})).rejects.toBeInstanceOf(ValidationError)
expect(repo.createPayment).not.toHaveBeenCalled()
expect(repo.setReservationPaidAmount).not.toHaveBeenCalled()
})
it('applies paid AmanPay and denied PayPal webhook events to the matching records', async () => {
vi.mocked(repo.findByAmanpay).mockResolvedValue({ id: 'payment_1', reservationId: 'reservation_1', amount: 450 } as never)
vi.mocked(repo.findByPaypal).mockResolvedValue({ id: 'payment_2', reservationId: 'reservation_2', amount: 500 } as never)
await handleAmanpayWebhook({ transaction_id: 'aman_txn_1', status: 'paid' })
await handlePaypalWebhook({ event_type: 'PAYMENT.CAPTURE.COMPLETED', resource: { id: 'paypal_capture_1' } })
await handlePaypalWebhook({ event_type: 'PAYMENT.CAPTURE.DENIED', resource: { id: 'paypal_capture_2' } })
expect(repo.markPaymentSucceeded).toHaveBeenCalledWith('payment_1')
expect(repo.incrementReservationPaid).toHaveBeenCalledWith('reservation_1', 450)
expect(repo.markPaymentSucceeded).toHaveBeenCalledWith('payment_2')
expect(repo.incrementReservationPaid).toHaveBeenCalledWith('reservation_2', 500)
expect(repo.markPaymentFailed).toHaveBeenCalledWith({ paypalCaptureId: 'paypal_capture_2' })
})
it('captures PayPal orders, stores the capture id, and increments the original reservation payment', async () => {
vi.mocked(repo.findByPaypalForCompany).mockResolvedValue({ id: 'payment_1', reservationId: 'reservation_1', amount: 800 } as never)
vi.mocked(paypal.captureOrder).mockResolvedValue({ purchase_units: [{ payments: { captures: [{ id: 'capture_123' }] } }] } as never)
vi.mocked(repo.updatePaypalCapture).mockResolvedValue({ id: 'payment_1', status: 'SUCCEEDED', paypalCaptureId: 'capture_123' } as never)
const result = await capturePaypal('reservation_1', 'company_1', 'order_123')
expect(repo.findByPaypalForCompany).toHaveBeenCalledWith('order_123', 'company_1')
expect(repo.updatePaypalCapture).toHaveBeenCalledWith('payment_1', 'capture_123')
expect(repo.incrementReservationPaid).toHaveBeenCalledWith('reservation_1', 800)
expect(result).toEqual({ id: 'payment_1', status: 'SUCCEEDED', paypalCaptureId: 'capture_123' })
})
it('refunds gateway payments and only marks the reservation refunded on full refunds', async () => {
vi.mocked(repo.findPaymentOrThrow).mockResolvedValue({
id: 'payment_1',
reservationId: 'reservation_1',
status: 'SUCCEEDED',
amount: 1000,
currency: 'MAD',
paymentProvider: 'PAYPAL',
paypalCaptureId: 'capture_123',
amanpayTransactionId: null,
} as never)
vi.mocked(repo.setPaymentRefunded).mockResolvedValue({ id: 'payment_1', status: 'PARTIALLY_REFUNDED' } as never)
const result = await refundPayment('reservation_1', 'payment_1', 'company_1', 250, 'Customer request')
expect(paypal.refundCapture).toHaveBeenCalledWith('capture_123', 250, 'MAD', 'Customer request')
expect(repo.setPaymentRefunded).toHaveBeenCalledWith('payment_1', true)
expect(repo.setReservationRefunded).not.toHaveBeenCalled()
expect(result).toEqual({ id: 'payment_1', status: 'PARTIALLY_REFUNDED' })
})
})
@@ -2,6 +2,7 @@ import { ConflictError, ValidationError } from '../../http/errors'
import * as amanpay from '../../services/amanpayService'
import * as paypal from '../../services/paypalService'
import * as repo from './payment.repo'
import { getWebhookEventId, processWebhookOnce } from '../../security/webhookIdempotency'
export function listByCompany(companyId: string) {
return repo.findByCompany(companyId)
@@ -11,12 +12,12 @@ export function listByReservation(reservationId: string, companyId: string) {
return repo.findByReservation(reservationId, companyId)
}
export async function handleAmanpayWebhook(event: any) {
async function applyAmanpayWebhook(event: any) {
const transactionId = event.transaction_id ?? event.id
const status = event.status?.toUpperCase()
if (status === 'PAID' || status === 'SUCCEEDED') {
const payment = await repo.findByAmanpay(transactionId)
if (payment) {
if (payment && payment.status !== 'SUCCEEDED') {
await repo.markPaymentSucceeded(payment.id)
await repo.incrementReservationPaid(payment.reservationId, payment.amount)
}
@@ -25,12 +26,12 @@ export async function handleAmanpayWebhook(event: any) {
}
}
export async function handlePaypalWebhook(event: any) {
async function applyPaypalWebhook(event: any) {
const eventType = event.event_type as string
if (eventType === 'PAYMENT.CAPTURE.COMPLETED') {
const captureId = event.resource?.id as string
const payment = await repo.findByPaypal(captureId)
if (payment) {
if (payment && payment.status !== 'SUCCEEDED') {
await repo.markPaymentSucceeded(payment.id)
await repo.incrementReservationPaid(payment.reservationId, payment.amount)
}
@@ -39,6 +40,26 @@ export async function handlePaypalWebhook(event: any) {
}
}
export async function handleAmanpayWebhook(event: any, rawBody: string | Buffer = JSON.stringify(event)) {
return processWebhookOnce({
provider: 'amanpay:payments',
providerEventId: getWebhookEventId('amanpay', event),
eventType: String(event.status ?? 'unknown'),
rawBody,
handle: () => applyAmanpayWebhook(event),
})
}
export async function handlePaypalWebhook(event: any, rawBody: string | Buffer = JSON.stringify(event)) {
return processWebhookOnce({
provider: 'paypal:payments',
providerEventId: getWebhookEventId('paypal', event),
eventType: String(event.event_type ?? 'unknown'),
rawBody,
handle: () => applyPaypalWebhook(event),
})
}
export async function initCharge(reservationId: string, companyId: string, body: {
provider: 'AMANPAY' | 'PAYPAL'; type: 'CHARGE' | 'DEPOSIT'
currency: 'MAD'; successUrl: string; failureUrl: string
@@ -0,0 +1,47 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('../../services/additionalDriverService', () => ({
applyAdditionalDriversToReservation: vi.fn(),
calculateAdditionalDriverCharge: vi.fn(),
}))
vi.mock('./reservation.repo', () => ({
findAdditionalDriver: vi.fn(),
updateAdditionalDriver: vi.fn(),
}))
import * as repo from './reservation.repo'
import { approveAdditionalDriver } from './reservation.additional-driver.service'
describe('reservation.additional-driver.service', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.setSystemTime(new Date('2026-06-09T12:00:00.000Z'))
})
it('records manager approval metadata when an additional driver is approved', async () => {
vi.mocked(repo.findAdditionalDriver).mockResolvedValue({ id: 'driver_1', approvalNote: 'License expiring' } as never)
vi.mocked(repo.updateAdditionalDriver).mockResolvedValue({ id: 'driver_1', approvedBy: 'manager_1' } as never)
await approveAdditionalDriver('reservation_1', 'driver_1', 'company_1', true, 'Approved after review', 'manager_1')
expect(repo.findAdditionalDriver).toHaveBeenCalledWith('driver_1', 'reservation_1', 'company_1')
expect(repo.updateAdditionalDriver).toHaveBeenCalledWith('driver_1', {
approvedAt: new Date('2026-06-09T12:00:00.000Z'),
approvedBy: 'manager_1',
approvalNote: 'Approved after review',
})
})
it('clears approval metadata on rejection while preserving the prior note if no new note is supplied', async () => {
vi.mocked(repo.findAdditionalDriver).mockResolvedValue({ id: 'driver_1', approvalNote: 'Manual review needed' } as never)
await approveAdditionalDriver('reservation_1', 'driver_1', 'company_1', false, undefined, 'manager_1')
expect(repo.updateAdditionalDriver).toHaveBeenCalledWith('driver_1', {
approvedAt: null,
approvedBy: null,
approvalNote: 'Manual review needed',
})
})
})
@@ -0,0 +1,68 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('../../lib/prisma', () => ({
prisma: {
$transaction: vi.fn(),
contractSettings: { upsert: vi.fn() },
},
}))
import { buildReservationInvoiceLineItems, formatDocumentNumber } from './reservation.document.service'
describe('reservation document helpers', () => {
it('formats contract and invoice sequence numbers with stable padding', () => {
expect(formatDocumentNumber('CTR', 1)).toBe('CTR-000001')
expect(formatDocumentNumber('INV', 42)).toBe('INV-000042')
expect(formatDocumentNumber('INV', 1234567)).toBe('INV-1234567')
})
it('builds invoice line items from rental, insurance, additional drivers, pricing adjustments, discounts, and deposits', () => {
const result = buildReservationInvoiceLineItems({
dailyRate: 300,
totalDays: 4,
discountAmount: 120,
depositAmount: 1000,
pricingRulesApplied: [
{ name: 'Young driver fee', amount: 80, type: 'SURCHARGE' },
{ name: '', amount: -50, type: 'DISCOUNT' },
],
insurances: [
{ id: 'ins_1', policyName: 'Collision waiver', chargeType: 'PER_DAY', chargeValue: 20, totalCharge: 80 },
{ id: 'ins_2', policyName: 'Roadside assistance', chargeType: 'PER_RENTAL', chargeValue: 45, totalCharge: 45 },
],
additionalDrivers: [
{ id: 'driver_1', firstName: 'Sam', lastName: 'Driver', totalCharge: 70 },
{ id: 'driver_2', firstName: 'Free', lastName: 'Driver', totalCharge: 0 },
],
vehicle: { year: 2024, make: 'Toyota', model: 'Corolla' },
})
expect(result).toEqual([
{ description: '2024 Toyota Corolla', qty: 4, unitPrice: 300, total: 1200, category: 'RENTAL' },
{ description: 'Collision waiver', qty: 4, unitPrice: 20, total: 80, category: 'INSURANCE' },
{ description: 'Roadside assistance', qty: 1, unitPrice: 45, total: 45, category: 'INSURANCE' },
{ description: 'Additional driver - Sam Driver', qty: 1, unitPrice: 70, total: 70, category: 'ADDITIONAL_DRIVER' },
{ description: 'Young driver fee', qty: 1, unitPrice: 80, total: 80, category: 'PRICING_RULE' },
{ description: 'Pricing adjustment 2', qty: 1, unitPrice: -50, total: -50, category: 'PRICING_RULE' },
{ description: 'Discount', qty: 1, unitPrice: -120, total: -120, category: 'DISCOUNT' },
{ description: 'Security deposit', qty: 1, unitPrice: 1000, total: 1000, category: 'DEPOSIT' },
])
})
it('omits zero-value optional invoice lines', () => {
const result = buildReservationInvoiceLineItems({
dailyRate: 100,
totalDays: 2,
discountAmount: 0,
depositAmount: 0,
pricingRulesApplied: null,
insurances: [],
additionalDrivers: [{ id: 'driver_1', firstName: 'No', lastName: 'Charge', totalCharge: 0 }],
vehicle: { year: 2020, make: 'Dacia', model: 'Logan' },
})
expect(result).toEqual([
{ description: '2020 Dacia Logan', qty: 2, unitPrice: 100, total: 200, category: 'RENTAL' },
])
})
})
@@ -6,7 +6,7 @@ export function formatDocumentNumber(prefix: string, sequence: number): string {
}
export async function ensureReservationDocumentNumbers(companyId: string, reservationId: string) {
return prisma.$transaction(async (tx) => {
return prisma.$transaction(async (tx: any) => {
const reservation = await tx.reservation.findFirstOrThrow({
where: { id: reservationId, companyId },
select: { id: true, contractNumber: true, invoiceNumber: true },
@@ -0,0 +1,123 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('../../lib/prisma', () => ({
prisma: {
damageInspection: { upsert: vi.fn() },
damageReport: { upsert: vi.fn() },
reservation: { update: vi.fn() },
},
}))
vi.mock('./reservation.repo', () => ({
findInspections: vi.fn(),
findForInspection: vi.fn(),
}))
import { prisma } from '../../lib/prisma'
import * as repo from './reservation.repo'
import { getInspections, upsertInspection } from './reservation.inspection.service'
const editableReservation = {
id: 'reservation_1',
status: 'CONFIRMED',
contractNumber: null,
invoiceNumber: null,
extras: {},
customer: { firstName: 'Mina', lastName: 'Alami' },
}
describe('reservation.inspection.service', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.setSystemTime(new Date('2026-06-09T12:00:00.000Z'))
})
it('delegates inspection lookup to the repository with tenant scoping', async () => {
vi.mocked(repo.findInspections).mockResolvedValue([{ id: 'inspection_1' }] as never)
await expect(getInspections('reservation_1', 'company_1')).resolves.toEqual([{ id: 'inspection_1' }])
expect(repo.findInspections).toHaveBeenCalledWith('reservation_1', 'company_1')
})
it('upserts check-in inspection, mirrors damage report data, and stores check-in mileage/fuel on reservation', async () => {
vi.mocked(repo.findForInspection).mockResolvedValue(editableReservation as never)
vi.mocked(prisma.damageInspection.upsert).mockResolvedValue({ id: 'inspection_1', type: 'CHECKIN' } as never)
const body = {
mileage: 42000,
fuelLevel: 'FULL',
fuelCharge: 0,
generalCondition: 'Clean exterior',
employeeNotes: 'Small scratch on rear bumper',
customerAgreed: true,
damagePoints: [
{ viewType: 'REAR', x: 52, y: 61, damageType: 'SCRATCH', description: 'Rear bumper scratch' },
],
}
await expect(upsertInspection('reservation_1', 'company_1', 'CHECKIN', body, 'employee_1')).resolves.toEqual({ id: 'inspection_1', type: 'CHECKIN' })
expect(prisma.damageInspection.upsert).toHaveBeenCalledWith(expect.objectContaining({
where: { reservationId_type: { reservationId: 'reservation_1', type: 'CHECKIN' } },
update: expect.objectContaining({
mileage: 42000,
fuelLevel: 'FULL',
customerAgreed: true,
damagePoints: { deleteMany: {}, create: body.damagePoints },
}),
create: expect.objectContaining({ reservationId: 'reservation_1', companyId: 'company_1', type: 'CHECKIN' }),
include: { damagePoints: true },
}))
expect(prisma.damageReport.upsert).toHaveBeenCalledWith(expect.objectContaining({
where: { reservationId_type: { reservationId: 'reservation_1', type: 'CHECKIN' } },
update: expect.objectContaining({
customerName: 'Mina Alami',
customerSignedAt: new Date('2026-06-09T12:00:00.000Z'),
damages: [{ viewType: 'REAR', x: 52, y: 61, damageType: 'SCRATCH', severity: 'MINOR', note: 'Rear bumper scratch', isPreExisting: false }],
}),
}))
expect(prisma.reservation.update).toHaveBeenCalledWith({
where: { id: 'reservation_1' },
data: { checkInMileage: 42000, checkInFuelLevel: 'FULL' },
})
})
it('upserts checkout inspection only after completion and records checkout return values', async () => {
vi.mocked(repo.findForInspection).mockResolvedValue({ ...editableReservation, status: 'COMPLETED' } as never)
vi.mocked(prisma.damageInspection.upsert).mockResolvedValue({ id: 'inspection_2', type: 'CHECKOUT' } as never)
await upsertInspection('reservation_1', 'company_1', 'CHECKOUT', {
mileage: 42450,
fuelLevel: 'HALF',
customerAgreed: false,
damagePoints: [{ viewType: 'FRONT', x: 10, y: 20, damageType: 'DENT', severity: 'MAJOR', isPreExisting: true }],
}, 'employee_2')
expect(prisma.damageReport.upsert).toHaveBeenCalledWith(expect.objectContaining({
update: expect.objectContaining({ customerSignedAt: null }),
create: expect.objectContaining({ photos: [], customerSignedAt: null }),
}))
expect(prisma.reservation.update).toHaveBeenCalledWith({
where: { id: 'reservation_1' },
data: { checkOutMileage: 42450, checkOutFuelLevel: 'HALF' },
})
})
it('rejects check-in edits once reservation workflow is closed', async () => {
vi.mocked(repo.findForInspection).mockResolvedValue({
...editableReservation,
extras: { reservationClosedAt: '2026-06-08T10:00:00.000Z' },
} as never)
await expect(upsertInspection('reservation_1', 'company_1', 'CHECKIN', { fuelLevel: 'FULL' }, 'employee_1'))
.rejects.toMatchObject({ error: 'reservation_closed', statusCode: 400 })
expect(prisma.damageInspection.upsert).not.toHaveBeenCalled()
})
it('rejects checkout inspections before the reservation is completed', async () => {
vi.mocked(repo.findForInspection).mockResolvedValue(editableReservation as never)
await expect(upsertInspection('reservation_1', 'company_1', 'CHECKOUT', { fuelLevel: 'FULL' }, 'employee_1'))
.rejects.toMatchObject({ error: 'invalid_status', statusCode: 400 })
})
})

Some files were not shown because too many files have changed in this diff Show More