fix architecture and write new tests
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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' } })
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,129 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({
|
||||
prisma: {
|
||||
reservation: { update: vi.fn() },
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../services/notificationService', () => ({
|
||||
sendNotification: vi.fn().mockResolvedValue(undefined),
|
||||
sendTransactionalEmail: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
vi.mock('./reservation.repo', () => ({
|
||||
findByIdSimple: vi.fn(),
|
||||
findForLicenseCheck: vi.fn(),
|
||||
updateById: vi.fn(),
|
||||
updateVehicleStatus: vi.fn(),
|
||||
findCustomerById: vi.fn(),
|
||||
findCompanyWithBrand: vi.fn(),
|
||||
findVehicle: vi.fn(),
|
||||
findByIdForCheckout: vi.fn(),
|
||||
findForClose: vi.fn(),
|
||||
findConflict: vi.fn(),
|
||||
}))
|
||||
|
||||
import { AppError } from '../../http/errors'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import * as repo from './reservation.repo'
|
||||
import { cancelReservation, checkinReservation, confirmReservation, extendReservation } from './reservation.lifecycle.service'
|
||||
|
||||
const validLicenseReservation = {
|
||||
customer: { licenseExpiry: new Date('2027-06-09T12:00:00.000Z'), licenseValidationStatus: 'VALID' },
|
||||
additionalDrivers: [],
|
||||
}
|
||||
|
||||
describe('reservation.lifecycle.service', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.setSystemTime(new Date('2026-06-09T12:00:00.000Z'))
|
||||
})
|
||||
|
||||
it('confirms only draft reservations after license compliance and reserves the vehicle', async () => {
|
||||
vi.mocked(repo.findByIdSimple).mockResolvedValue({
|
||||
id: 'reservation_1',
|
||||
status: 'DRAFT',
|
||||
vehicleId: 'vehicle_1',
|
||||
customerId: 'customer_1',
|
||||
renterId: null,
|
||||
pickupLocation: 'Airport desk',
|
||||
startDate: new Date('2026-06-12T10:00:00.000Z'),
|
||||
endDate: new Date('2026-06-15T10:00:00.000Z'),
|
||||
} as never)
|
||||
vi.mocked(repo.findForLicenseCheck).mockResolvedValue(validLicenseReservation as never)
|
||||
vi.mocked(repo.updateById).mockResolvedValue({ id: 'reservation_1', status: 'CONFIRMED' } as never)
|
||||
vi.mocked(repo.findCustomerById).mockResolvedValue({ firstName: 'Sara', email: 'sara@example.com' } as never)
|
||||
vi.mocked(repo.findCompanyWithBrand).mockResolvedValue({ name: 'Atlas Cars', brand: { displayName: 'Atlas', defaultLocale: 'en' } } as never)
|
||||
vi.mocked(repo.findVehicle).mockResolvedValue({ year: 2024, make: 'Toyota', model: 'Yaris' } as never)
|
||||
|
||||
await expect(confirmReservation('reservation_1', 'company_1')).resolves.toEqual({ id: 'reservation_1', status: 'CONFIRMED' })
|
||||
expect(repo.updateById).toHaveBeenCalledWith('reservation_1', { status: 'CONFIRMED' })
|
||||
expect(repo.updateVehicleStatus).toHaveBeenCalledWith('vehicle_1', 'RESERVED')
|
||||
})
|
||||
|
||||
it('blocks check-in when the primary driver license is expired', async () => {
|
||||
vi.mocked(repo.findByIdSimple).mockResolvedValue({ id: 'reservation_1', status: 'CONFIRMED', vehicleId: 'vehicle_1' } as never)
|
||||
vi.mocked(repo.findForLicenseCheck).mockResolvedValue({
|
||||
customer: { licenseExpiry: new Date('2026-06-01T12:00:00.000Z'), licenseValidationStatus: 'VALID' },
|
||||
additionalDrivers: [],
|
||||
} as never)
|
||||
|
||||
await expect(checkinReservation('reservation_1', 'company_1')).rejects.toMatchObject({ error: 'invalid_primary_license' })
|
||||
expect(repo.updateById).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('extends active reservations only when the new period is conflict-free and after the current end date', async () => {
|
||||
const currentEnd = new Date('2026-06-15T10:00:00.000Z')
|
||||
const newEnd = new Date('2026-06-18T10:00:00.000Z')
|
||||
vi.mocked(repo.findByIdSimple).mockResolvedValue({
|
||||
id: 'reservation_1',
|
||||
status: 'ACTIVE',
|
||||
vehicleId: 'vehicle_1',
|
||||
endDate: currentEnd,
|
||||
totalDays: 5,
|
||||
dailyRate: 100,
|
||||
totalAmount: 500,
|
||||
extras: {},
|
||||
} as never)
|
||||
vi.mocked(repo.findConflict).mockResolvedValue(null as never)
|
||||
vi.mocked(prisma.reservation.update).mockResolvedValue({ id: 'reservation_1', totalDays: 8, totalAmount: 800 } as never)
|
||||
|
||||
await extendReservation('reservation_1', 'company_1', newEnd, 'Customer requested extra days')
|
||||
|
||||
expect(repo.findConflict).toHaveBeenCalledWith('vehicle_1', currentEnd, newEnd, 'reservation_1')
|
||||
expect(prisma.reservation.update).toHaveBeenCalledWith({
|
||||
where: { id: 'reservation_1' },
|
||||
data: expect.objectContaining({
|
||||
endDate: newEnd,
|
||||
totalDays: 8,
|
||||
totalAmount: 800,
|
||||
extras: expect.objectContaining({ extensions: [expect.objectContaining({ additionalDays: 3, additionalAmount: 300 })] }),
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects extensions that overlap another reservation', async () => {
|
||||
vi.mocked(repo.findByIdSimple).mockResolvedValue({
|
||||
id: 'reservation_1', status: 'CONFIRMED', vehicleId: 'vehicle_1', endDate: new Date('2026-06-15'), dailyRate: 100, totalDays: 5, totalAmount: 500, extras: {},
|
||||
} as never)
|
||||
vi.mocked(repo.findConflict).mockResolvedValue({ id: 'reservation_2' } as never)
|
||||
|
||||
await expect(extendReservation('reservation_1', 'company_1', new Date('2026-06-16'), 'Need more time')).rejects.toBeInstanceOf(AppError)
|
||||
expect(prisma.reservation.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cancels cancellable reservations and releases the vehicle back to availability', async () => {
|
||||
vi.mocked(repo.findByIdSimple).mockResolvedValue({ id: 'reservation_1', status: 'CONFIRMED', vehicleId: 'vehicle_1' } as never)
|
||||
vi.mocked(repo.updateById).mockResolvedValue({ id: 'reservation_1', status: 'CANCELLED' } as never)
|
||||
|
||||
await cancelReservation('reservation_1', 'company_1', 'Customer changed plans')
|
||||
|
||||
expect(repo.updateById).toHaveBeenCalledWith('reservation_1', {
|
||||
status: 'CANCELLED',
|
||||
cancelReason: 'Customer changed plans',
|
||||
cancelledBy: 'COMPANY',
|
||||
})
|
||||
expect(repo.updateVehicleStatus).toHaveBeenCalledWith('vehicle_1', 'AVAILABLE')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({
|
||||
prisma: {
|
||||
reservation: { findFirst: vi.fn() },
|
||||
reservationPhoto: {
|
||||
findMany: vi.fn(),
|
||||
create: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../lib/storage', () => ({
|
||||
uploadImage: vi.fn(),
|
||||
deleteImage: vi.fn(),
|
||||
}))
|
||||
|
||||
import { NotFoundError } from '../../http/errors'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import { deleteImage, uploadImage } from '../../lib/storage'
|
||||
import { deletePhoto, listPhotos, uploadPhoto } from './reservation.photo.service'
|
||||
|
||||
describe('reservation.photo.service', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('rejects photo listing when the reservation does not belong to the company', async () => {
|
||||
vi.mocked(prisma.reservation.findFirst).mockResolvedValue(null as never)
|
||||
|
||||
await expect(listPhotos('reservation_1', 'company_1')).rejects.toBeInstanceOf(NotFoundError)
|
||||
expect(prisma.reservationPhoto.findMany).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('lists photos in creation order after confirming reservation ownership', async () => {
|
||||
vi.mocked(prisma.reservation.findFirst).mockResolvedValue({ id: 'reservation_1' } as never)
|
||||
vi.mocked(prisma.reservationPhoto.findMany).mockResolvedValue([{ id: 'photo_1' }] as never)
|
||||
|
||||
await expect(listPhotos('reservation_1', 'company_1')).resolves.toEqual([{ id: 'photo_1' }])
|
||||
expect(prisma.reservationPhoto.findMany).toHaveBeenCalledWith({
|
||||
where: { reservationId: 'reservation_1' },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
})
|
||||
|
||||
it('uploads images under the company/reservation scoped storage prefix before creating the photo row', async () => {
|
||||
vi.mocked(prisma.reservation.findFirst).mockResolvedValue({ id: 'reservation_1' } as never)
|
||||
vi.mocked(uploadImage).mockResolvedValue('/storage/companies/company_1/reservations/reservation_1/photos/photo.jpg')
|
||||
vi.mocked(prisma.reservationPhoto.create).mockResolvedValue({ id: 'photo_1' } as never)
|
||||
|
||||
await uploadPhoto('reservation_1', 'company_1', 'PICKUP', Buffer.from('fake-image'))
|
||||
|
||||
expect(uploadImage).toHaveBeenCalledWith(Buffer.from('fake-image'), 'companies/company_1/reservations/reservation_1/photos')
|
||||
expect(prisma.reservationPhoto.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
reservationId: 'reservation_1',
|
||||
type: 'PICKUP',
|
||||
url: '/storage/companies/company_1/reservations/reservation_1/photos/photo.jpg',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('deletes the stored object before deleting the reservation photo row', async () => {
|
||||
vi.mocked(prisma.reservationPhoto.findFirst).mockResolvedValue({ id: 'photo_1', url: '/storage/photo.jpg' } as never)
|
||||
|
||||
await deletePhoto('photo_1', 'reservation_1', 'company_1')
|
||||
|
||||
expect(deleteImage).toHaveBeenCalledWith('/storage/photo.jpg')
|
||||
expect(prisma.reservationPhoto.delete).toHaveBeenCalledWith({ where: { id: 'photo_1' } })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../lib/storage', () => ({
|
||||
getProtectedCustomerLicenseImageUrl: vi.fn((customerId: string) => `/api/v1/customers/${customerId}/license-image`),
|
||||
}))
|
||||
|
||||
import {
|
||||
buildReservationWorkflow,
|
||||
normalizeOptionalString,
|
||||
parseReservationExtras,
|
||||
serializeReservationForDashboard,
|
||||
} from './reservation.presenter'
|
||||
|
||||
describe('reservation.presenter boundary behavior', () => {
|
||||
it('treats missing, array and primitive extras as empty objects', () => {
|
||||
expect(parseReservationExtras(null)).toEqual({})
|
||||
expect(parseReservationExtras(['paymentMode'])).toEqual({})
|
||||
expect(parseReservationExtras('cash')).toEqual({})
|
||||
expect(parseReservationExtras({ paymentMode: 'CASH' })).toEqual({ paymentMode: 'CASH' })
|
||||
})
|
||||
|
||||
it('normalizes optional strings without preserving whitespace-only values', () => {
|
||||
expect(normalizeOptionalString(' hello ')).toBe('hello')
|
||||
expect(normalizeOptionalString(' ')).toBeNull()
|
||||
expect(normalizeOptionalString(null)).toBeNull()
|
||||
expect(normalizeOptionalString(undefined)).toBeNull()
|
||||
})
|
||||
|
||||
it('locks core edits after contract generation even when reservation is still confirmed', () => {
|
||||
expect(buildReservationWorkflow({
|
||||
status: 'CONFIRMED',
|
||||
contractNumber: 'CTR-1',
|
||||
invoiceNumber: null,
|
||||
extras: {},
|
||||
})).toMatchObject({
|
||||
contractGenerated: true,
|
||||
closed: false,
|
||||
coreEditable: false,
|
||||
checkInInspectionEditable: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('marks closed reservations as immutable and exposes close metadata from extras only when strings', () => {
|
||||
expect(buildReservationWorkflow({
|
||||
status: 'COMPLETED',
|
||||
contractNumber: null,
|
||||
invoiceNumber: null,
|
||||
extras: { reservationClosedAt: '2026-06-01T12:00:00.000Z', reservationClosedBy: 123 },
|
||||
})).toMatchObject({
|
||||
closed: true,
|
||||
closedAt: '2026-06-01T12:00:00.000Z',
|
||||
closedBy: null,
|
||||
coreEditable: false,
|
||||
returnEditable: false,
|
||||
checkOutInspectionEditable: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('serializes dashboard reservations with protected license URLs and payment mode extraction', () => {
|
||||
const result = serializeReservationForDashboard({
|
||||
id: 'reservation_1',
|
||||
status: 'DRAFT',
|
||||
contractNumber: null,
|
||||
invoiceNumber: null,
|
||||
extras: { paymentMode: 'CARD' },
|
||||
customer: { id: 'customer_1', licenseImageUrl: '/storage/raw-license.jpg' },
|
||||
})
|
||||
|
||||
expect(result.paymentMode).toBe('CARD')
|
||||
expect(result.customer?.licenseImageUrl).toBe('/api/v1/customers/customer_1/license-image')
|
||||
expect(result.workflow.coreEditable).toBe(true)
|
||||
})
|
||||
|
||||
it('does not invent protected license URLs when customer has no stored license image', () => {
|
||||
const result = serializeReservationForDashboard({
|
||||
id: 'reservation_1',
|
||||
status: 'DRAFT',
|
||||
contractNumber: null,
|
||||
invoiceNumber: null,
|
||||
extras: { paymentMode: 42 },
|
||||
customer: { id: 'customer_1', licenseImageUrl: null },
|
||||
})
|
||||
|
||||
expect(result.paymentMode).toBeNull()
|
||||
expect(result.customer?.licenseImageUrl).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({
|
||||
prisma: {
|
||||
pricingRule: { findMany: vi.fn() },
|
||||
customer: { findUniqueOrThrow: vi.fn() },
|
||||
},
|
||||
}))
|
||||
|
||||
import { calculateUpdatedAdditionalDriverCharge, calculateUpdatedInsuranceCharge } from './reservation.pricing.service'
|
||||
|
||||
describe('reservation pricing helpers', () => {
|
||||
it('calculates insurance charges for every supported billing model', () => {
|
||||
expect(calculateUpdatedInsuranceCharge('PER_DAY', 40, 5, 1000)).toBe(200)
|
||||
expect(calculateUpdatedInsuranceCharge('PER_RENTAL', 125, 5, 1000)).toBe(125)
|
||||
expect(calculateUpdatedInsuranceCharge('PERCENTAGE_OF_RENTAL', 12.5, 5, 1000)).toBe(125)
|
||||
})
|
||||
|
||||
it('rounds percentage insurance charges to whole minor units', () => {
|
||||
expect(calculateUpdatedInsuranceCharge('PERCENTAGE_OF_RENTAL', 7.5, 3, 999)).toBe(75)
|
||||
})
|
||||
|
||||
it('calculates additional driver charges for paid and free models', () => {
|
||||
expect(calculateUpdatedAdditionalDriverCharge('PER_DAY', 25, 4)).toBe(100)
|
||||
expect(calculateUpdatedAdditionalDriverCharge('FLAT', 60, 4)).toBe(60)
|
||||
expect(calculateUpdatedAdditionalDriverCharge('FREE', 999, 4)).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({
|
||||
prisma: {
|
||||
reservation: {
|
||||
findMany: vi.fn(),
|
||||
count: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findFirstOrThrow: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
offer: {
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
vehicle: {
|
||||
findFirstOrThrow: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
customer: {
|
||||
findFirstOrThrow: vi.fn(),
|
||||
findUniqueOrThrow: vi.fn(),
|
||||
},
|
||||
company: { findUnique: vi.fn() },
|
||||
brandSettings: { findUnique: vi.fn() },
|
||||
damageInspection: { findMany: vi.fn() },
|
||||
additionalDriver: { findFirstOrThrow: vi.fn(), update: vi.fn() },
|
||||
},
|
||||
}))
|
||||
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import * as repo from './reservation.repo'
|
||||
|
||||
describe('reservation.repo edge queries', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('findMany applies dashboard include, pagination and newest-first ordering', async () => {
|
||||
vi.mocked(prisma.reservation.findMany).mockResolvedValue([{ id: 'reservation_1' }] as never)
|
||||
vi.mocked(prisma.reservation.count).mockResolvedValue(1 as never)
|
||||
|
||||
await expect(repo.findMany({ companyId: 'company_1' }, 20, 10)).resolves.toEqual([[{ id: 'reservation_1' }], 1])
|
||||
|
||||
expect(prisma.reservation.findMany).toHaveBeenCalledWith({
|
||||
where: { companyId: 'company_1' },
|
||||
include: { vehicle: true, customer: true },
|
||||
skip: 20,
|
||||
take: 10,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
expect(prisma.reservation.count).toHaveBeenCalledWith({ where: { companyId: 'company_1' } })
|
||||
})
|
||||
|
||||
it('builds overlap conflict query and excludes current reservation during updates', async () => {
|
||||
const start = new Date('2026-09-01T10:00:00.000Z')
|
||||
const end = new Date('2026-09-03T10:00:00.000Z')
|
||||
|
||||
await repo.findConflict('vehicle_1', start, end, 'reservation_1')
|
||||
|
||||
expect(prisma.reservation.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
vehicleId: 'vehicle_1',
|
||||
status: { in: ['CONFIRMED', 'ACTIVE'] },
|
||||
startDate: { lt: end },
|
||||
endDate: { gt: start },
|
||||
id: { not: 'reservation_1' },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('finds active offers by company, code, active flag and current validity window', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-09-15T12:00:00.000Z'))
|
||||
|
||||
await repo.findActiveOffer('company_1', 'SUMMER20')
|
||||
|
||||
expect(prisma.offer.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
companyId: 'company_1',
|
||||
promoCode: 'SUMMER20',
|
||||
isActive: true,
|
||||
validFrom: { lte: new Date('2026-09-15T12:00:00.000Z') },
|
||||
validUntil: { gte: new Date('2026-09-15T12:00:00.000Z') },
|
||||
},
|
||||
})
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('updates vehicle status without writing mileage unless explicitly supplied', async () => {
|
||||
await repo.updateVehicleStatus('vehicle_1', 'AVAILABLE')
|
||||
expect(prisma.vehicle.update).toHaveBeenLastCalledWith({ where: { id: 'vehicle_1' }, data: { status: 'AVAILABLE' } })
|
||||
|
||||
await repo.updateVehicleStatus('vehicle_1', 'AVAILABLE', 45500)
|
||||
expect(prisma.vehicle.update).toHaveBeenLastCalledWith({
|
||||
where: { id: 'vehicle_1' },
|
||||
data: { status: 'AVAILABLE', mileage: 45500 },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { additionalDriverSchema, approvalSchema, createSchema, extendSchema, inspectionParamSchema, inspectionSchema, listQuerySchema, updateSchema } from './reservation.schemas'
|
||||
|
||||
describe('reservation schemas edge cases', () => {
|
||||
it('defaults reservation creation arrays and deposit while requiring cuid identifiers', () => {
|
||||
const parsed = createSchema.parse({
|
||||
vehicleId: 'ckvvehicle000000000000001',
|
||||
customerId: 'ckvcustomer00000000000001',
|
||||
startDate: '2026-07-01T10:00:00.000Z',
|
||||
endDate: '2026-07-03T10:00:00.000Z',
|
||||
})
|
||||
|
||||
expect(parsed.depositAmount).toBe(0)
|
||||
expect(parsed.selectedInsurancePolicyIds).toEqual([])
|
||||
expect(parsed.additionalDrivers).toEqual([])
|
||||
expect(createSchema.safeParse({ ...parsed, vehicleId: 'vehicle_1' }).success).toBe(false)
|
||||
})
|
||||
|
||||
it('validates additional drivers and inspection damage defaults', () => {
|
||||
expect(additionalDriverSchema.parse({ firstName: 'A', lastName: 'B', driverLicense: 'DL-1' })).toMatchObject({ driverLicense: 'DL-1' })
|
||||
expect(additionalDriverSchema.safeParse({ firstName: '', lastName: 'B', driverLicense: 'DL-1' }).success).toBe(false)
|
||||
expect(additionalDriverSchema.safeParse({ firstName: 'A', lastName: 'B', driverLicense: 'DL-1', licenseExpiry: 'tomorrow' }).success).toBe(false)
|
||||
|
||||
const parsed = inspectionSchema.parse({
|
||||
fuelLevel: 'FULL',
|
||||
damagePoints: [{ viewType: 'FRONT', x: 12.5, y: 42, damageType: 'SCRATCH' }],
|
||||
})
|
||||
expect(parsed.customerAgreed).toBe(false)
|
||||
expect(parsed.damagePoints[0]).toMatchObject({ severity: 'MINOR', isPreExisting: false })
|
||||
expect(inspectionSchema.safeParse({ fuelLevel: 'OVERFLOWING_WITH_OPTIMISM' }).success).toBe(false)
|
||||
})
|
||||
|
||||
it('coerces list pagination and rejects unsafe update/extension payloads', () => {
|
||||
expect(listQuerySchema.parse({ page: '3', pageSize: '50', status: 'CONFIRMED' })).toMatchObject({ page: 3, pageSize: 50 })
|
||||
expect(listQuerySchema.parse({})).toMatchObject({ page: 1, pageSize: 20 })
|
||||
expect(listQuerySchema.safeParse({ pageSize: 101 }).success).toBe(false)
|
||||
expect(updateSchema.safeParse({ depositAmount: -1 }).success).toBe(false)
|
||||
expect(extendSchema.safeParse({ newEndDate: '2026-07-05T10:00:00.000Z', reason: '' }).success).toBe(false)
|
||||
expect(approvalSchema.parse({ approved: true })).toEqual({ approved: true })
|
||||
expect(inspectionParamSchema.parse({ id: 'reservation_1', type: 'CHECKIN' })).toEqual({ id: 'reservation_1', type: 'CHECKIN' })
|
||||
})
|
||||
})
|
||||
@@ -103,7 +103,7 @@ export async function createReservation(companyId: string, body: {
|
||||
await applyAdditionalDriversToReservation(reservation.id, companyId, additionalDrivers, totalDays)
|
||||
}
|
||||
|
||||
await validateAndFlagLicense(body.customerId).catch(() => null)
|
||||
await validateAndFlagLicense(body.customerId, companyId).catch(() => null)
|
||||
return reservation
|
||||
}
|
||||
|
||||
@@ -160,8 +160,8 @@ export async function updateReservation(id: string, companyId: string, body: {
|
||||
totalCharge: calculateUpdatedAdditionalDriverCharge(d.chargeType, d.chargeValue, totalDays),
|
||||
}))
|
||||
|
||||
const insuranceTotal = insuranceUpdates.reduce((s, i) => s + i.totalCharge, 0)
|
||||
const additionalDriverTotal = driverUpdates.reduce((s, d) => s + d.totalCharge, 0)
|
||||
const insuranceTotal = insuranceUpdates.reduce((s: number, i: any) => s + i.totalCharge, 0)
|
||||
const additionalDriverTotal = driverUpdates.reduce((s: number, d: any) => s + d.totalCharge, 0)
|
||||
const depositAmount = body.depositAmount ?? reservation.depositAmount
|
||||
const totalAmount = baseAmount - reservation.discountAmount + pricingRulesTotal + insuranceTotal + additionalDriverTotal + depositAmount
|
||||
|
||||
@@ -191,10 +191,10 @@ export async function updateReservation(id: string, companyId: string, body: {
|
||||
extras: (Object.keys(extras).length > 0 ? extras : {}) as any,
|
||||
},
|
||||
}),
|
||||
...insuranceUpdates.map((ins) =>
|
||||
...insuranceUpdates.map((ins: any) =>
|
||||
prisma.reservationInsurance.update({ where: { id: ins.id }, data: { totalCharge: ins.totalCharge } })
|
||||
),
|
||||
...driverUpdates.map((d) =>
|
||||
...driverUpdates.map((d: any) =>
|
||||
prisma.additionalDriver.update({ where: { id: d.id }, data: { totalCharge: d.totalCharge } })
|
||||
),
|
||||
])
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({
|
||||
prisma: {
|
||||
review: { findMany: vi.fn(), count: vi.fn(), findFirst: vi.fn(), update: vi.fn() },
|
||||
reservation: { update: vi.fn() },
|
||||
},
|
||||
}))
|
||||
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import * as repo from './review.repo'
|
||||
|
||||
describe('review.repo edge behavior', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('merges caller filters with company boundary and paginates newest first', async () => {
|
||||
vi.mocked(prisma.review.findMany).mockResolvedValue([] as never)
|
||||
vi.mocked(prisma.review.count).mockResolvedValue(0 as never)
|
||||
|
||||
await repo.findMany('company_1', { overallRating: 5 }, 20, 10)
|
||||
|
||||
const where = { companyId: 'company_1', overallRating: 5 }
|
||||
expect(prisma.review.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where,
|
||||
skip: 20,
|
||||
take: 10,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}))
|
||||
expect(prisma.review.count).toHaveBeenCalledWith({ where })
|
||||
})
|
||||
|
||||
it('computes rounded review stats while ignoring missing sub-ratings', async () => {
|
||||
vi.mocked(prisma.review.findMany).mockResolvedValue([
|
||||
{ overallRating: 5, vehicleRating: 5, serviceRating: 4 },
|
||||
{ overallRating: 4, vehicleRating: null, serviceRating: 5 },
|
||||
{ overallRating: 2, vehicleRating: 3, serviceRating: null },
|
||||
] as never)
|
||||
|
||||
await expect(repo.getStats('company_1')).resolves.toEqual({
|
||||
total: 3,
|
||||
averageOverall: 3.7,
|
||||
averageVehicle: 4,
|
||||
averageService: 4.5,
|
||||
byRating: { 1: 0, 2: 1, 3: 0, 4: 1, 5: 1 },
|
||||
})
|
||||
})
|
||||
|
||||
it('returns zeroed stats when no reviews exist', async () => {
|
||||
vi.mocked(prisma.review.findMany).mockResolvedValue([] as never)
|
||||
|
||||
await expect(repo.getStats('company_1')).resolves.toEqual({
|
||||
total: 0,
|
||||
averageOverall: 0,
|
||||
averageVehicle: 0,
|
||||
averageService: 0,
|
||||
byRating: { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 },
|
||||
})
|
||||
})
|
||||
|
||||
it('writes the requested reminder timestamp field only', async () => {
|
||||
const sentAt = new Date('2026-12-01T10:00:00.000Z')
|
||||
|
||||
await repo.sendReminder('reservation_1', sentAt, 'reviewFinalReminderSentAt')
|
||||
|
||||
expect(prisma.reservation.update).toHaveBeenCalledWith({
|
||||
where: { id: 'reservation_1' },
|
||||
data: { reviewFinalReminderSentAt: sentAt },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { replySchema, listQuerySchema } from './review.schemas'
|
||||
|
||||
describe('review schemas', () => {
|
||||
it('requires a non-empty company reply', () => {
|
||||
expect(() => replySchema.parse({ companyReply: '' })).toThrow()
|
||||
expect(replySchema.parse({ companyReply: 'Thank you for the feedback.' })).toEqual({ companyReply: 'Thank you for the feedback.' })
|
||||
})
|
||||
|
||||
it('coerces rating query values to bounded integers', () => {
|
||||
expect(listQuerySchema.parse({ rating: '5', page: '3', pageSize: '10' })).toEqual({
|
||||
rating: 5,
|
||||
page: 3,
|
||||
pageSize: 10,
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects ratings outside the public five-star scale', () => {
|
||||
expect(() => listQuerySchema.parse({ rating: '0' })).toThrow()
|
||||
expect(() => listQuerySchema.parse({ rating: '6' })).toThrow()
|
||||
})
|
||||
|
||||
it('defaults pagination when filters are omitted', () => {
|
||||
expect(listQuerySchema.parse({})).toEqual({ page: 1, pageSize: 20 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,128 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../services/notificationService', () => ({
|
||||
sendTransactionalEmail: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./review.repo', () => ({
|
||||
findMany: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
updateById: vi.fn(),
|
||||
getStats: vi.fn(),
|
||||
sendReminder: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../reservations/reservation.repo', () => ({
|
||||
findCompanyWithBrand: vi.fn(),
|
||||
}))
|
||||
|
||||
import { AppError, NotFoundError } from '../../http/errors'
|
||||
import { sendTransactionalEmail } from '../../services/notificationService'
|
||||
import * as repo from './review.repo'
|
||||
import * as reservationRepo from '../reservations/reservation.repo'
|
||||
import { getReview, listReviews, replyToReview, sendReviewReminder } from './review.service'
|
||||
|
||||
function reviewWithReservation(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'review_1',
|
||||
reservation: {
|
||||
id: 'reservation_1',
|
||||
reviewPaused: false,
|
||||
reviewReminderSentAt: null,
|
||||
reviewToken: 'token_123',
|
||||
customer: {
|
||||
firstName: 'Nora',
|
||||
email: 'nora@example.com',
|
||||
reviewOptOut: false,
|
||||
},
|
||||
vehicle: {
|
||||
year: 2025,
|
||||
make: 'Dacia',
|
||||
model: 'Duster',
|
||||
},
|
||||
...overrides,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('review.service', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.setSystemTime(new Date('2026-06-08T12:00:00.000Z'))
|
||||
process.env.MARKETPLACE_URL = 'https://market.example'
|
||||
})
|
||||
|
||||
it('returns reviews with rating filters and pagination metadata', async () => {
|
||||
vi.mocked(repo.findMany).mockResolvedValue([[{ id: 'review_1', overallRating: 5 }], 8] as never)
|
||||
|
||||
const result = await listReviews('company_1', { rating: 5, page: 2, pageSize: 3 })
|
||||
|
||||
expect(repo.findMany).toHaveBeenCalledWith('company_1', { overallRating: 5 }, 3, 3)
|
||||
expect(result).toEqual({
|
||||
data: [{ id: 'review_1', overallRating: 5 }],
|
||||
meta: { total: 8, page: 2, pageSize: 3, totalPages: 3 },
|
||||
})
|
||||
})
|
||||
|
||||
it('throws for missing reviews and timestamps company replies on successful replies', async () => {
|
||||
vi.mocked(repo.findById).mockResolvedValueOnce(null as never)
|
||||
await expect(getReview('missing', 'company_1')).rejects.toBeInstanceOf(NotFoundError)
|
||||
|
||||
vi.mocked(repo.findById).mockResolvedValueOnce({ id: 'review_1' } as never)
|
||||
vi.mocked(repo.updateById).mockResolvedValue({ id: 'review_1', companyReply: 'Thanks' } as never)
|
||||
|
||||
const updated = await replyToReview('review_1', 'company_1', 'Thanks')
|
||||
|
||||
expect(repo.updateById).toHaveBeenCalledWith('review_1', {
|
||||
companyReply: 'Thanks',
|
||||
companyRepliedAt: new Date('2026-06-08T12:00:00.000Z'),
|
||||
})
|
||||
expect(updated).toEqual({ id: 'review_1', companyReply: 'Thanks' })
|
||||
})
|
||||
|
||||
it('sends the first review reminder with localized branding and records the first reminder field', async () => {
|
||||
vi.mocked(repo.findById).mockResolvedValue(reviewWithReservation() as never)
|
||||
vi.mocked(reservationRepo.findCompanyWithBrand).mockResolvedValue({
|
||||
name: 'Rental Co',
|
||||
brand: { defaultLocale: 'en', displayName: 'RentalDriveGo' },
|
||||
} as never)
|
||||
|
||||
const result = await sendReviewReminder('review_1', 'company_1')
|
||||
|
||||
expect(sendTransactionalEmail).toHaveBeenCalledWith(expect.objectContaining({
|
||||
to: 'nora@example.com',
|
||||
subject: expect.stringContaining('Dacia Duster'),
|
||||
html: expect.stringContaining('https://market.example/review?token=token_123'),
|
||||
text: expect.stringContaining('RentalDriveGo'),
|
||||
}))
|
||||
expect(repo.sendReminder).toHaveBeenCalledWith('reservation_1', new Date('2026-06-08T12:00:00.000Z'), 'reviewReminderSentAt')
|
||||
expect(result).toEqual({ success: true, field: 'reviewReminderSentAt' })
|
||||
})
|
||||
|
||||
it('uses the final reminder field after the first reminder has already been sent', async () => {
|
||||
vi.mocked(repo.findById).mockResolvedValue(reviewWithReservation({
|
||||
reviewReminderSentAt: new Date('2026-06-01T00:00:00.000Z'),
|
||||
}) as never)
|
||||
vi.mocked(reservationRepo.findCompanyWithBrand).mockResolvedValue({ name: 'Rental Co', brand: null } as never)
|
||||
|
||||
const result = await sendReviewReminder('review_1', 'company_1')
|
||||
|
||||
expect(repo.sendReminder).toHaveBeenCalledWith('reservation_1', expect.any(Date), 'reviewFinalReminderSentAt')
|
||||
expect(result.field).toBe('reviewFinalReminderSentAt')
|
||||
})
|
||||
|
||||
it('blocks review reminders for paused reservations, opted-out customers, missing emails, and missing tokens', async () => {
|
||||
vi.mocked(repo.findById)
|
||||
.mockResolvedValueOnce(reviewWithReservation({ reviewPaused: true }) as never)
|
||||
.mockResolvedValueOnce(reviewWithReservation({ customer: { firstName: 'Nora', email: 'nora@example.com', reviewOptOut: true } }) as never)
|
||||
.mockResolvedValueOnce(reviewWithReservation({ customer: { firstName: 'Nora', email: null, reviewOptOut: false } }) as never)
|
||||
.mockResolvedValueOnce(reviewWithReservation({ reviewToken: null }) as never)
|
||||
|
||||
await expect(sendReviewReminder('review_1', 'company_1')).rejects.toMatchObject({ error: 'review_paused' } satisfies Partial<AppError>)
|
||||
await expect(sendReviewReminder('review_1', 'company_1')).rejects.toMatchObject({ error: 'opted_out' } satisfies Partial<AppError>)
|
||||
await expect(sendReviewReminder('review_1', 'company_1')).rejects.toMatchObject({ error: 'no_email' } satisfies Partial<AppError>)
|
||||
await expect(sendReviewReminder('review_1', 'company_1')).rejects.toMatchObject({ error: 'no_token' } satisfies Partial<AppError>)
|
||||
|
||||
expect(sendTransactionalEmail).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { presentBrand } from './site.presenter'
|
||||
|
||||
describe('site.presenter', () => {
|
||||
it('exposes only public brand and company fields for anonymous site clients', () => {
|
||||
const result = presentBrand({
|
||||
id: 'company_1',
|
||||
slug: 'atlas-cars',
|
||||
name: 'Atlas Cars',
|
||||
phone: '+212600000000',
|
||||
brand: {
|
||||
displayName: 'Atlas Cars',
|
||||
tagline: 'Premium rentals',
|
||||
logoUrl: 'https://cdn.test/logo.png',
|
||||
amanpaySecretKey: 'must-not-leak',
|
||||
amanpayMerchantId: 'must-not-leak',
|
||||
paypalEmail: 'billing@example.com',
|
||||
paymentMethodsEnabled: ['PAYPAL'],
|
||||
defaultLocale: 'fr',
|
||||
defaultCurrency: 'MAD',
|
||||
isListedOnMarketplace: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
company: { id: 'company_1', slug: 'atlas-cars', name: 'Atlas Cars', phone: '+212600000000' },
|
||||
brand: {
|
||||
displayName: 'Atlas Cars',
|
||||
tagline: 'Premium rentals',
|
||||
logoUrl: 'https://cdn.test/logo.png',
|
||||
paypalEmail: 'billing@example.com',
|
||||
paymentMethodsEnabled: ['PAYPAL'],
|
||||
defaultLocale: 'fr',
|
||||
defaultCurrency: 'MAD',
|
||||
isListedOnMarketplace: true,
|
||||
},
|
||||
})
|
||||
expect(result.brand).not.toHaveProperty('amanpaySecretKey')
|
||||
expect(result.brand).not.toHaveProperty('amanpayMerchantId')
|
||||
})
|
||||
|
||||
it('returns a null brand when company branding has not been configured', () => {
|
||||
expect(presentBrand({ id: 'company_1', slug: 'atlas-cars', name: 'Atlas Cars', phone: null, brand: null })).toEqual({
|
||||
company: { id: 'company_1', slug: 'atlas-cars', name: 'Atlas Cars', phone: null },
|
||||
brand: null,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -37,3 +37,40 @@ export function presentBrand(company: {
|
||||
} : null,
|
||||
}
|
||||
}
|
||||
|
||||
function maskEmail(email?: string | null) {
|
||||
if (!email || !email.includes('@')) return undefined
|
||||
const [name = '', domain = ''] = email.split('@')
|
||||
return `${name.slice(0, 1)}***@${domain}`
|
||||
}
|
||||
|
||||
function maskPhone(phone?: string | null) {
|
||||
if (!phone) return undefined
|
||||
const visible = phone.replace(/\D/g, '').slice(-4)
|
||||
return visible ? `***${visible}` : undefined
|
||||
}
|
||||
|
||||
export function presentPublicBooking(reservation: any) {
|
||||
return {
|
||||
id: reservation.id,
|
||||
status: reservation.status,
|
||||
pickupAt: reservation.startDate instanceof Date ? reservation.startDate.toISOString() : reservation.startDate,
|
||||
returnAt: reservation.endDate instanceof Date ? reservation.endDate.toISOString() : reservation.endDate,
|
||||
vehicle: {
|
||||
make: reservation.vehicle?.make,
|
||||
model: reservation.vehicle?.model,
|
||||
year: reservation.vehicle?.year,
|
||||
imageUrl: Array.isArray(reservation.vehicle?.images) ? reservation.vehicle.images[0] : reservation.vehicle?.imageUrl,
|
||||
},
|
||||
customer: {
|
||||
displayName: [reservation.customer?.firstName, reservation.customer?.lastName?.slice(0, 1)].filter(Boolean).join(' '),
|
||||
maskedEmail: maskEmail(reservation.customer?.email),
|
||||
maskedPhone: maskPhone(reservation.customer?.phone),
|
||||
},
|
||||
totals: {
|
||||
amountDue: reservation.totalAmount - (reservation.paidAmount ?? 0),
|
||||
currency: 'MAD',
|
||||
},
|
||||
paymentStatus: reservation.paymentStatus,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({
|
||||
prisma: {
|
||||
company: { findFirstOrThrow: vi.fn() },
|
||||
vehicle: { findMany: vi.fn(), findFirstOrThrow: vi.fn() },
|
||||
offer: { findMany: vi.fn(), findFirst: vi.fn() },
|
||||
insurancePolicy: { findMany: vi.fn() },
|
||||
customer: { findUnique: vi.fn(), create: vi.fn(), update: vi.fn() },
|
||||
reservation: { create: vi.fn(), findUniqueOrThrow: vi.fn(), findFirstOrThrow: vi.fn(), update: vi.fn() },
|
||||
rentalPayment: { create: vi.fn(), findFirstOrThrow: vi.fn(), update: vi.fn() },
|
||||
},
|
||||
}))
|
||||
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import * as repo from './site.repo'
|
||||
|
||||
describe('site.repo public booking boundaries', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('only exposes companies with public-rental statuses by slug', async () => {
|
||||
await repo.findCompanyBySlug('atlas-rentals')
|
||||
|
||||
expect(prisma.company.findFirstOrThrow).toHaveBeenCalledWith({
|
||||
where: { slug: 'atlas-rentals', status: { in: ['ACTIVE', 'TRIALING', 'PAST_DUE', 'SUSPENDED'] } },
|
||||
include: { brand: true, contractSettings: true },
|
||||
})
|
||||
})
|
||||
|
||||
it('loads only published vehicles for the public site', async () => {
|
||||
await repo.findPublishedVehicles('company_1')
|
||||
|
||||
expect(prisma.vehicle.findMany).toHaveBeenCalledWith({
|
||||
where: { companyId: 'company_1', isPublished: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
})
|
||||
|
||||
it('merges existing customer address metadata when upserting a public booking customer', async () => {
|
||||
vi.mocked(prisma.customer.findUnique).mockResolvedValue({
|
||||
id: 'customer_1',
|
||||
address: { city: 'Marrakesh', old: 'kept' },
|
||||
} as never)
|
||||
|
||||
await repo.upsertCustomer('company_1', {
|
||||
email: 'customer@example.test',
|
||||
firstName: 'Nora',
|
||||
lastName: 'Guest',
|
||||
phone: '+212600000000',
|
||||
driverLicense: 'DL-1',
|
||||
dateOfBirth: '1995-01-02',
|
||||
licenseExpiry: '2031-05-06',
|
||||
licenseIssuedAt: '2021-05-06',
|
||||
nationality: 'MA',
|
||||
identityDocumentNumber: 'ID-9',
|
||||
fullAddress: 'New address',
|
||||
licenseCountry: 'MA',
|
||||
licenseCategory: 'B',
|
||||
internationalLicenseNumber: undefined,
|
||||
})
|
||||
|
||||
expect(prisma.customer.update).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: { id: 'customer_1' },
|
||||
data: expect.objectContaining({
|
||||
firstName: 'Nora',
|
||||
address: { city: 'Marrakesh', old: 'kept', fullAddress: 'New address', identityDocumentNumber: 'ID-9', internationalLicenseNumber: null },
|
||||
licenseNumber: 'DL-1',
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('creates pending rental payments with normalized charge metadata', async () => {
|
||||
await repo.createRentalPayment({
|
||||
companyId: 'company_1',
|
||||
reservationId: 'reservation_1',
|
||||
amount: 1200,
|
||||
currency: 'MAD',
|
||||
paymentProvider: 'PAYPAL',
|
||||
paypalCaptureId: 'order_1',
|
||||
})
|
||||
|
||||
expect(prisma.rentalPayment.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
companyId: 'company_1',
|
||||
reservationId: 'reservation_1',
|
||||
amount: 1200,
|
||||
currency: 'MAD',
|
||||
paymentProvider: 'PAYPAL',
|
||||
paypalCaptureId: 'order_1',
|
||||
status: 'PENDING',
|
||||
type: 'CHARGE',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('captures PayPal payments by updating payment and reservation atomically in sequence', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-11-10T09:30:00.000Z'))
|
||||
|
||||
await repo.capturePaypalPayment('payment_1', 'capture_1', 'reservation_1', 1500)
|
||||
|
||||
expect(prisma.rentalPayment.update).toHaveBeenCalledWith({
|
||||
where: { id: 'payment_1' },
|
||||
data: { status: 'SUCCEEDED', paidAt: new Date('2026-11-10T09:30:00.000Z'), paypalCaptureId: 'capture_1' },
|
||||
})
|
||||
expect(prisma.reservation.update).toHaveBeenCalledWith({
|
||||
where: { id: 'reservation_1' },
|
||||
data: { paymentStatus: 'PAID', paidAmount: { increment: 1500 } },
|
||||
})
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
@@ -141,3 +141,23 @@ export async function capturePaypalPayment(paymentId: string, captureId: string,
|
||||
await prisma.rentalPayment.update({ where: { id: paymentId }, data: { status: 'SUCCEEDED', paidAt: new Date(), paypalCaptureId: captureId } })
|
||||
await prisma.reservation.update({ where: { id: reservationId }, data: { paymentStatus: 'PAID', paidAmount: { increment: amount } } })
|
||||
}
|
||||
|
||||
|
||||
export async function createReservationPublicAccess(reservationId: string, tokenHash: string, expiresAt: Date) {
|
||||
return (prisma as any).reservationPublicAccess.create({
|
||||
data: { reservationId, tokenHash, expiresAt },
|
||||
})
|
||||
}
|
||||
|
||||
export async function findReservationPublicAccess(reservationId: string, tokenHash: string) {
|
||||
return (prisma as any).reservationPublicAccess.findFirst({
|
||||
where: { reservationId, tokenHash, revokedAt: null, expiresAt: { gt: new Date() } },
|
||||
})
|
||||
}
|
||||
|
||||
export async function markReservationPublicAccessUsed(id: string) {
|
||||
return (prisma as any).reservationPublicAccess.update({
|
||||
where: { id },
|
||||
data: { usedAt: new Date() },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -125,7 +125,8 @@ router.post('/:slug/book', async (req, res, next) => {
|
||||
router.get('/:slug/booking/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { slug, id } = parseParams(bookingParamSchema, req)
|
||||
ok(res, await service.getBooking(slug, id))
|
||||
const accessToken = typeof req.query.token === 'string' ? req.query.token : undefined
|
||||
ok(res, await service.getBooking(slug, id, accessToken))
|
||||
} catch (err) {
|
||||
if (isDatabaseUnavailableError(err)) {
|
||||
return res.status(503).json({ error: 'database_unavailable', message: 'Booking details are temporarily unavailable', statusCode: 503 })
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { availabilitySchema, bookSchema, capturePaypalSchema, contactSchema, paySchema } from './site.schemas'
|
||||
|
||||
describe('site.schemas', () => {
|
||||
const baseBooking = {
|
||||
vehicleId: 'ckvvehicle000000000000001',
|
||||
startDate: '2026-07-01T10:00:00.000Z',
|
||||
endDate: '2026-07-03T10:00:00.000Z',
|
||||
firstName: 'Aya',
|
||||
lastName: 'Benali',
|
||||
email: 'aya@example.com',
|
||||
phone: '+212600000000',
|
||||
driverLicense: 'DL-123',
|
||||
dateOfBirth: '1995-01-01T00:00:00.000Z',
|
||||
licenseExpiry: '2028-01-01T00:00:00.000Z',
|
||||
licenseIssuedAt: '2020-01-01T00:00:00.000Z',
|
||||
nationality: 'Moroccan',
|
||||
identityDocumentNumber: 'ID-123',
|
||||
fullAddress: '12 Main Street',
|
||||
licenseCountry: 'MA',
|
||||
licenseCategory: 'B',
|
||||
}
|
||||
|
||||
it('defaults optional public booking arrays and source', () => {
|
||||
expect(bookSchema.parse(baseBooking)).toMatchObject({
|
||||
source: 'PUBLIC_SITE',
|
||||
selectedInsurancePolicyIds: [],
|
||||
additionalDrivers: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('validates nested additional drivers instead of letting anonymous payloads drift into services', () => {
|
||||
expect(bookSchema.parse({
|
||||
...baseBooking,
|
||||
additionalDrivers: [{ firstName: 'Omar', lastName: 'Alaoui', email: 'omar@example.com', driverLicense: 'DL-2' }],
|
||||
}).additionalDrivers).toHaveLength(1)
|
||||
|
||||
expect(() => bookSchema.parse({
|
||||
...baseBooking,
|
||||
additionalDrivers: [{ firstName: 'Omar', lastName: 'Alaoui', email: 'not-email', driverLicense: 'DL-2' }],
|
||||
})).toThrow()
|
||||
})
|
||||
|
||||
it('rejects malformed availability and payment payloads at the schema layer', () => {
|
||||
expect(availabilitySchema.parse({
|
||||
vehicleId: 'ckvvehicle000000000000001',
|
||||
startDate: '2026-07-01T10:00:00.000Z',
|
||||
endDate: '2026-07-03T10:00:00.000Z',
|
||||
})).toMatchObject({ vehicleId: 'ckvvehicle000000000000001' })
|
||||
|
||||
expect(() => availabilitySchema.parse({ vehicleId: 'not-cuid', startDate: 'x', endDate: 'y' })).toThrow()
|
||||
expect(() => paySchema.parse({ provider: 'STRIPE', successUrl: 'https://ok.test', failureUrl: 'https://fail.test' })).toThrow()
|
||||
expect(paySchema.parse({ provider: 'PAYPAL', successUrl: 'https://ok.test', failureUrl: 'https://fail.test', accessToken: 'booking-access-token-123' }).currency).toBe('MAD')
|
||||
})
|
||||
|
||||
it('keeps contact, PayPal capture, and required identity fields strict', () => {
|
||||
expect(contactSchema.parse({ name: 'Aya', email: 'aya@example.com', message: 'Hello' })).toEqual({
|
||||
name: 'Aya', email: 'aya@example.com', message: 'Hello',
|
||||
})
|
||||
expect(capturePaypalSchema.parse({ paypalOrderId: 'ORDER-1' })).toEqual({ paypalOrderId: 'ORDER-1' })
|
||||
expect(() => bookSchema.parse({ ...baseBooking, email: 'bad-email' })).toThrow()
|
||||
expect(() => contactSchema.parse({ name: '', email: 'bad', message: '' })).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -53,6 +53,7 @@ export const paySchema = z.object({
|
||||
currency: z.literal('MAD').default('MAD'),
|
||||
successUrl: z.string().url(),
|
||||
failureUrl: z.string().url(),
|
||||
accessToken: z.string().min(20),
|
||||
})
|
||||
|
||||
export const capturePaypalSchema = z.object({ paypalOrderId: z.string() })
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@rentaldrivego/types', () => ({
|
||||
PLAN_FEATURES: { STARTER: ['fallback feature'] },
|
||||
PLAN_PRICES: { STARTER: { MONTHLY: { MAD: 199 } } },
|
||||
}))
|
||||
vi.mock('../../lib/prisma', () => ({
|
||||
prisma: {
|
||||
pricingConfig: { findMany: vi.fn() },
|
||||
planFeature: { findMany: vi.fn() },
|
||||
},
|
||||
}))
|
||||
vi.mock('../../services/platformContentService', () => ({ getMarketplaceHomepageContent: vi.fn() }))
|
||||
vi.mock('../../services/vehicleAvailabilityService', () => ({ getVehicleAvailabilitySummary: vi.fn() }))
|
||||
vi.mock('../../services/insuranceService', () => ({ applyInsurancesToReservation: vi.fn() }))
|
||||
vi.mock('../../services/additionalDriverService', () => ({ applyAdditionalDriversToReservation: vi.fn() }))
|
||||
vi.mock('../../services/pricingRuleService', () => ({ applyPricingRules: vi.fn() }))
|
||||
vi.mock('../../services/licenseValidationService', () => ({ validateLicense: vi.fn(), validateAndFlagLicense: vi.fn().mockResolvedValue(undefined) }))
|
||||
vi.mock('../../services/amanpayService', () => ({ isConfigured: vi.fn(), createCheckout: vi.fn(), verifyWebhookSignature: vi.fn(), findTransactionFromWebhook: vi.fn() }))
|
||||
vi.mock('../../services/paypalService', () => ({ createOrder: vi.fn(), captureOrder: vi.fn() }))
|
||||
vi.mock('./site.presenter', () => ({ presentBrand: vi.fn((company: any) => ({ company: { id: company.id, slug: company.slug }, brand: company.brand ?? null })) }))
|
||||
vi.mock('./site.repo', () => ({
|
||||
findCompanyBySlug: vi.fn(),
|
||||
findPublishedVehicles: vi.fn(),
|
||||
findVehicleById: vi.fn(),
|
||||
findActiveOffers: vi.fn(),
|
||||
findInsurancePolicies: vi.fn(),
|
||||
findOfferByPromoCode: vi.fn(),
|
||||
upsertCustomer: vi.fn(),
|
||||
createReservation: vi.fn(),
|
||||
findReservationWithDetails: vi.fn(),
|
||||
findBooking: vi.fn(),
|
||||
findReservationForPayment: vi.fn(),
|
||||
createReservationPublicAccess: vi.fn(),
|
||||
findReservationPublicAccess: vi.fn(),
|
||||
markReservationPublicAccessUsed: vi.fn(),
|
||||
createRentalPayment: vi.fn(),
|
||||
findPaymentByPaypalOrderId: vi.fn(),
|
||||
capturePaypalPayment: vi.fn(),
|
||||
}))
|
||||
|
||||
import { AppError } from '../../http/errors'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
|
||||
import { applyPricingRules } from '../../services/pricingRuleService'
|
||||
import { applyInsurancesToReservation } from '../../services/insuranceService'
|
||||
import { applyAdditionalDriversToReservation } from '../../services/additionalDriverService'
|
||||
import { validateLicense, validateAndFlagLicense } from '../../services/licenseValidationService'
|
||||
import * as amanpay from '../../services/amanpayService'
|
||||
import * as paypal from '../../services/paypalService'
|
||||
import * as repo from './site.repo'
|
||||
import * as service from './site.service'
|
||||
|
||||
const company = { id: 'company_1', slug: 'atlas', email: 'office@example.test', contractSettings: { depositRequired: true }, brand: { publicEmail: 'hello@example.test' } }
|
||||
|
||||
function bookingBody(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
vehicleId: 'vehicle_1',
|
||||
startDate: '2026-07-01T00:00:00.000Z',
|
||||
endDate: '2026-07-04T00:00:00.000Z',
|
||||
firstName: 'Nora',
|
||||
lastName: 'Renter',
|
||||
email: 'nora@example.test',
|
||||
phone: '+212600000000',
|
||||
driverLicense: 'DL-1',
|
||||
dateOfBirth: '1992-01-01',
|
||||
licenseExpiry: '2027-01-01',
|
||||
licenseIssuedAt: '2022-01-01',
|
||||
nationality: 'MA',
|
||||
identityDocumentNumber: 'ID-1',
|
||||
fullAddress: 'Casablanca',
|
||||
licenseCountry: 'MA',
|
||||
licenseCategory: 'B',
|
||||
...overrides,
|
||||
} as any
|
||||
}
|
||||
|
||||
describe('site.service public booking/payment boundaries', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(repo.createReservationPublicAccess).mockResolvedValue({ id: 'access_1' } as never)
|
||||
vi.mocked(repo.findReservationPublicAccess).mockResolvedValue({ id: 'access_1' } as never)
|
||||
vi.mocked(repo.markReservationPublicAccessUsed).mockResolvedValue({ id: 'access_1' } as never)
|
||||
vi.mocked(repo.findCompanyBySlug).mockResolvedValue(company as never)
|
||||
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null } as never)
|
||||
vi.mocked(applyPricingRules).mockResolvedValue({ applied: [{ code: 'WEEKEND' }], total: 90 } as never)
|
||||
vi.mocked(validateLicense).mockReturnValue({ status: 'VALID', requiresApproval: false, expired: false, expiringSoon: false } as never)
|
||||
})
|
||||
|
||||
it('uses configured pricing and configured feature labels when platform pricing exists', async () => {
|
||||
vi.mocked(prisma.pricingConfig.findMany).mockResolvedValue([
|
||||
{ plan: 'STARTER', billingPeriod: 'MONTHLY', amount: 299 },
|
||||
{ plan: 'PRO', billingPeriod: 'YEARLY', amount: 2999 },
|
||||
] as never)
|
||||
vi.mocked(prisma.planFeature.findMany).mockResolvedValue([
|
||||
{ plan: 'STARTER', label: '3 vehicles' },
|
||||
{ plan: 'PRO', label: 'Priority support' },
|
||||
] as never)
|
||||
|
||||
await expect(service.getPlatformPricing()).resolves.toEqual({
|
||||
prices: { STARTER: { MONTHLY: { MAD: 299 } }, PRO: { YEARLY: { MAD: 2999 } } },
|
||||
planFeatures: { STARTER: ['3 vehicles'] },
|
||||
})
|
||||
})
|
||||
|
||||
it('calculates booking totals from base rate, free-day promo, pricing rules, insurance and additional drivers', async () => {
|
||||
vi.mocked(repo.findVehicleById).mockResolvedValue({ id: 'vehicle_1', companyId: 'company_1', dailyRate: 500, category: 'SUV' } as never)
|
||||
vi.mocked(repo.upsertCustomer).mockResolvedValue({ id: 'customer_1' } as never)
|
||||
vi.mocked(repo.findOfferByPromoCode).mockResolvedValue({ id: 'offer_1', type: 'FREE_DAY', discountValue: 1 } as never)
|
||||
vi.mocked(repo.createReservation).mockResolvedValue({ id: 'reservation_1' } as never)
|
||||
vi.mocked(repo.findReservationWithDetails).mockResolvedValue({ id: 'reservation_1', additionalDrivers: [{ requiresApproval: true }] } as never)
|
||||
|
||||
const result = await service.createBooking('atlas', bookingBody({
|
||||
promoCodeUsed: 'FREEDAY',
|
||||
selectedInsurancePolicyIds: ['insurance_1'],
|
||||
additionalDrivers: [{ firstName: 'Second' }],
|
||||
}))
|
||||
|
||||
expect(repo.createReservation).toHaveBeenCalledWith(expect.objectContaining({
|
||||
totalDays: 3,
|
||||
dailyRate: 500,
|
||||
discountAmount: 500,
|
||||
pricingRulesTotal: 90,
|
||||
totalAmount: 1090,
|
||||
status: 'DRAFT',
|
||||
source: 'PUBLIC_SITE',
|
||||
}))
|
||||
expect(applyInsurancesToReservation).toHaveBeenCalledWith('reservation_1', 'company_1', ['insurance_1'], 3, 1500)
|
||||
expect(applyAdditionalDriversToReservation).toHaveBeenCalledWith('reservation_1', 'company_1', [{ firstName: 'Second' }], 3)
|
||||
expect(validateAndFlagLicense).toHaveBeenCalledWith('customer_1')
|
||||
expect(result.requiresManualApproval).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects payment initialization for already-paid reservations before provider calls', async () => {
|
||||
vi.mocked(repo.findReservationForPayment).mockResolvedValue({ paymentStatus: 'PAID' } as never)
|
||||
|
||||
await expect(service.initPayment('atlas', 'reservation_1', {
|
||||
provider: 'AMANPAY',
|
||||
accessToken: 'booking-access-token-123',
|
||||
successUrl: 'https://example.test/success',
|
||||
failureUrl: 'https://example.test/failure',
|
||||
})).rejects.toMatchObject({ statusCode: 409, error: 'already_paid' })
|
||||
expect(amanpay.createCheckout).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks payment when the primary or additional driver license requires review', async () => {
|
||||
vi.mocked(validateLicense).mockReturnValue({ status: 'EXPIRING_SOON', requiresApproval: true } as never)
|
||||
vi.mocked(repo.findReservationForPayment).mockResolvedValue({
|
||||
id: 'reservation_1',
|
||||
paymentStatus: 'UNPAID',
|
||||
totalAmount: 400,
|
||||
customer: { licenseExpiry: new Date('2026-07-01'), licenseValidationStatus: 'PENDING' },
|
||||
additionalDrivers: [],
|
||||
vehicle: { make: 'Dacia', model: 'Duster' },
|
||||
} as never)
|
||||
|
||||
await expect(service.initPayment('atlas', 'reservation_1', {
|
||||
provider: 'PAYPAL',
|
||||
accessToken: 'booking-access-token-123',
|
||||
successUrl: 'https://example.test/success',
|
||||
failureUrl: 'https://example.test/failure',
|
||||
})).rejects.toMatchObject({ statusCode: 409, error: 'license_review_required' })
|
||||
expect(paypal.createOrder).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes public contact messages to the brand public email when configured', async () => {
|
||||
await expect(service.handleContact('atlas', { name: 'Visitor', email: 'visitor@example.test', message: 'Hi' })).resolves.toEqual({
|
||||
success: true,
|
||||
deliveredTo: 'hello@example.test',
|
||||
preview: { name: 'Visitor', email: 'visitor@example.test', message: 'Hi' },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -10,7 +10,43 @@ import { prisma } from '../../lib/prisma'
|
||||
import * as amanpay from '../../services/amanpayService'
|
||||
import * as paypal from '../../services/paypalService'
|
||||
import * as repo from './site.repo'
|
||||
import { presentBrand } from './site.presenter'
|
||||
import { presentBrand, presentPublicBooking } from './site.presenter'
|
||||
import { generatePublicAccessToken, hashPublicAccessToken } from '../../security/publicAccessTokens'
|
||||
|
||||
function assertAllowedPaymentRedirect(urlValue: string, company: any) {
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(urlValue)
|
||||
} catch {
|
||||
throw new AppError('Invalid payment redirect URL', 400, 'invalid_redirect_url')
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'production' && parsed.protocol !== 'https:') {
|
||||
throw new AppError('Payment redirect URLs must use HTTPS', 400, 'invalid_redirect_url')
|
||||
}
|
||||
|
||||
const allowedHosts = new Set<string>()
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
allowedHosts.add('localhost:3000')
|
||||
allowedHosts.add('localhost:4000')
|
||||
allowedHosts.add('127.0.0.1:3000')
|
||||
allowedHosts.add('127.0.0.1:4000')
|
||||
}
|
||||
for (const value of [process.env.MARKETPLACE_URL, process.env.DASHBOARD_URL, process.env.NEXT_PUBLIC_MARKETPLACE_URL, process.env.NEXT_PUBLIC_DASHBOARD_URL]) {
|
||||
if (!value) continue
|
||||
try { allowedHosts.add(new URL(value).host) } catch {}
|
||||
}
|
||||
|
||||
const brand = company.brand as any
|
||||
if (brand?.customDomain && brand?.customDomainVerified) allowedHosts.add(brand.customDomain)
|
||||
if (brand?.subdomain && process.env.PUBLIC_SITE_BASE_DOMAIN) {
|
||||
allowedHosts.add(`${brand.subdomain}.${process.env.PUBLIC_SITE_BASE_DOMAIN}`)
|
||||
}
|
||||
|
||||
if (!allowedHosts.has(parsed.host)) {
|
||||
throw new AppError('Payment redirect host is not allowed', 400, 'invalid_redirect_url')
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPlatformHomepage() {
|
||||
return getMarketplaceHomepageContent()
|
||||
@@ -26,7 +62,7 @@ export async function getPlatformPricing() {
|
||||
|
||||
const prices = configs.length === 0
|
||||
? PLAN_PRICES
|
||||
: configs.reduce<Record<string, Record<string, Record<string, number>>>>((acc, config) => {
|
||||
: configs.reduce<Record<string, Record<string, Record<string, number>>>>((acc: Record<string, Record<string, Record<string, number>>>, config: any) => {
|
||||
if (!acc[config.plan]) acc[config.plan] = {}
|
||||
acc[config.plan]![config.billingPeriod] = { MAD: config.amount }
|
||||
return acc
|
||||
@@ -34,7 +70,7 @@ export async function getPlatformPricing() {
|
||||
|
||||
const planFeatures = Object.fromEntries(
|
||||
Object.entries(PLAN_FEATURES).map(([plan, fallback]) => {
|
||||
const labels = features.filter((feature) => feature.plan === plan).map((feature) => feature.label)
|
||||
const labels = features.filter((feature: any) => feature.plan === plan).map((feature: any) => feature.label)
|
||||
return [plan, labels.length > 0 ? labels : fallback]
|
||||
}),
|
||||
)
|
||||
@@ -51,8 +87,8 @@ export async function getPublicVehicles(slug: string) {
|
||||
const company = await repo.findCompanyBySlug(slug)
|
||||
const vehicles = await repo.findPublishedVehicles(company.id)
|
||||
return Promise.all(
|
||||
vehicles.map(async (v) => {
|
||||
const a = await getVehicleAvailabilitySummary(v.id)
|
||||
vehicles.map(async (v: any) => {
|
||||
const a = await getVehicleAvailabilitySummary(v.id, { companyId: v.companyId })
|
||||
return { ...v, availability: a.available, availabilityStatus: a.status, nextAvailableAt: a.nextAvailableAt }
|
||||
}),
|
||||
)
|
||||
@@ -61,7 +97,7 @@ export async function getPublicVehicles(slug: string) {
|
||||
export async function getVehicleDetail(slug: string, vehicleId: string) {
|
||||
const company = await repo.findCompanyBySlug(slug)
|
||||
const vehicle = await repo.findVehicleById(vehicleId, company.id)
|
||||
const a = await getVehicleAvailabilitySummary(vehicle.id)
|
||||
const a = await getVehicleAvailabilitySummary(vehicle.id, { companyId: vehicle.companyId })
|
||||
return { ...vehicle, availability: a.available, availabilityStatus: a.status, nextAvailableAt: a.nextAvailableAt }
|
||||
}
|
||||
|
||||
@@ -77,8 +113,10 @@ export async function getBookingOptions(slug: string) {
|
||||
}
|
||||
|
||||
export async function checkAvailability(slug: string, vehicleId: string, startDate: string, endDate: string) {
|
||||
await repo.findCompanyBySlug(slug)
|
||||
const availability = await getVehicleAvailabilitySummary(vehicleId, {
|
||||
const company = await repo.findCompanyBySlug(slug)
|
||||
const vehicle = await repo.findVehicleById(vehicleId, company.id)
|
||||
const availability = await getVehicleAvailabilitySummary(vehicle.id, {
|
||||
companyId: company.id,
|
||||
range: { startDate: new Date(startDate), endDate: new Date(endDate) },
|
||||
})
|
||||
return { available: availability.available, nextAvailableAt: availability.nextAvailableAt }
|
||||
@@ -106,7 +144,7 @@ export async function createBooking(slug: string, body: {
|
||||
const end = new Date(body.endDate)
|
||||
if (end <= start) throw new AppError('End date must be after start date', 400, 'invalid_dates')
|
||||
|
||||
const availability = await getVehicleAvailabilitySummary(vehicle.id, { range: { startDate: start, endDate: end } })
|
||||
const availability = await getVehicleAvailabilitySummary(vehicle.id, { companyId: company.id, range: { startDate: start, endDate: end } })
|
||||
if (!availability.available) {
|
||||
throw new AppError('Vehicle is not available for the selected dates', 409, 'unavailable', {
|
||||
nextAvailableAt: availability.nextAvailableAt?.toISOString() ?? null,
|
||||
@@ -185,24 +223,41 @@ export async function createBooking(slug: string, body: {
|
||||
await validateAndFlagLicense(customer.id).catch(() => null)
|
||||
}
|
||||
|
||||
const publicAccess = generatePublicAccessToken()
|
||||
await repo.createReservationPublicAccess(
|
||||
reservation.id,
|
||||
publicAccess.tokenHash,
|
||||
new Date(Date.now() + 14 * 24 * 60 * 60 * 1000),
|
||||
)
|
||||
|
||||
const refreshed = await repo.findReservationWithDetails(reservation.id)
|
||||
return {
|
||||
...refreshed,
|
||||
publicAccessToken: publicAccess.token,
|
||||
requiresManualApproval:
|
||||
primaryLicenseResult.requiresApproval ||
|
||||
refreshed.additionalDrivers.some((d) => d.requiresApproval),
|
||||
refreshed.additionalDrivers.some((d: any) => d.requiresApproval),
|
||||
}
|
||||
}
|
||||
|
||||
export async function getBooking(slug: string, reservationId: string) {
|
||||
async function assertPublicBookingAccess(reservationId: string, token: string | undefined) {
|
||||
if (!token) throw new AppError('Booking access token is required', 403, 'booking_token_required')
|
||||
const access = await repo.findReservationPublicAccess(reservationId, hashPublicAccessToken(token))
|
||||
if (!access) throw new AppError('Booking not found', 404, 'not_found')
|
||||
await repo.markReservationPublicAccessUsed(access.id)
|
||||
}
|
||||
|
||||
export async function getBooking(slug: string, reservationId: string, accessToken?: string) {
|
||||
const company = await repo.findCompanyBySlug(slug)
|
||||
return repo.findBooking(reservationId, company.id)
|
||||
await assertPublicBookingAccess(reservationId, accessToken)
|
||||
return presentPublicBooking(await repo.findBooking(reservationId, company.id))
|
||||
}
|
||||
|
||||
export async function initPayment(slug: string, reservationId: string, body: {
|
||||
provider: 'AMANPAY' | 'PAYPAL'; currency?: 'MAD'; successUrl: string; failureUrl: string
|
||||
provider: 'AMANPAY' | 'PAYPAL'; currency?: 'MAD'; successUrl: string; failureUrl: string; accessToken?: string
|
||||
}) {
|
||||
const company = await repo.findCompanyBySlug(slug)
|
||||
await assertPublicBookingAccess(reservationId, body.accessToken)
|
||||
const reservation = await repo.findReservationForPayment(reservationId, company.id)
|
||||
|
||||
if (reservation.paymentStatus === 'PAID') {
|
||||
@@ -214,12 +269,15 @@ export async function initPayment(slug: string, reservationId: string, body: {
|
||||
reservation.customer.licenseValidationStatus === 'DENIED' ||
|
||||
customerLicenseResult.status === 'EXPIRED' ||
|
||||
(customerLicenseResult.requiresApproval && reservation.customer.licenseValidationStatus !== 'APPROVED') ||
|
||||
reservation.additionalDrivers.some((d) => d.licenseExpired || (d.requiresApproval && !d.approvedAt))
|
||||
reservation.additionalDrivers.some((d: any) => d.licenseExpired || (d.requiresApproval && !d.approvedAt))
|
||||
|
||||
if (licenseBlocked) {
|
||||
throw new AppError('This reservation requires license review before payment can be processed', 409, 'license_review_required')
|
||||
}
|
||||
|
||||
assertAllowedPaymentRedirect(body.successUrl, company)
|
||||
assertAllowedPaymentRedirect(body.failureUrl, company)
|
||||
|
||||
const currency = body.currency ?? 'MAD'
|
||||
const amount = reservation.totalAmount
|
||||
const description = `Rental: ${reservation.vehicle.make} ${reservation.vehicle.model}`
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { AppError, NotFoundError } from '../../http/errors'
|
||||
|
||||
vi.mock('@rentaldrivego/types', () => ({
|
||||
PLAN_FEATURES: { STARTER: ['fallback feature'] },
|
||||
PLAN_PRICES: { STARTER: { MONTHLY: { MAD: 199 } } },
|
||||
}))
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({
|
||||
prisma: {
|
||||
pricingConfig: { findMany: vi.fn() },
|
||||
planFeature: { findMany: vi.fn() },
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('./site.repo', () => ({
|
||||
findCompanyBySlug: vi.fn(),
|
||||
findPublishedVehicles: vi.fn(),
|
||||
@@ -13,6 +25,9 @@ vi.mock('./site.repo', () => ({
|
||||
findReservationWithDetails: vi.fn(),
|
||||
findBooking: vi.fn(),
|
||||
findReservationForPayment: vi.fn(),
|
||||
createReservationPublicAccess: vi.fn(),
|
||||
findReservationPublicAccess: vi.fn(),
|
||||
markReservationPublicAccessUsed: vi.fn(),
|
||||
createRentalPayment: vi.fn(),
|
||||
findPaymentByPaypalOrderId: vi.fn(),
|
||||
capturePaypalPayment: vi.fn(),
|
||||
@@ -83,7 +98,12 @@ function makeVehicle(overrides: object = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => { vi.clearAllMocks() })
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(repo.createReservationPublicAccess).mockResolvedValue({ id: 'access_1' } as never)
|
||||
vi.mocked(repo.findReservationPublicAccess).mockResolvedValue({ id: 'access_1' } as never)
|
||||
vi.mocked(repo.markReservationPublicAccessUsed).mockResolvedValue({ id: 'access_1' } as never)
|
||||
})
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
describe('getBrand', () => {
|
||||
@@ -126,11 +146,12 @@ describe('getPublicVehicles', () => {
|
||||
describe('checkAvailability', () => {
|
||||
it('returns availability result for given date range', async () => {
|
||||
vi.mocked(repo.findCompanyBySlug).mockResolvedValue(makeCompany() as any)
|
||||
vi.mocked(repo.findVehicleById).mockResolvedValue(makeVehicle() as any)
|
||||
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
|
||||
|
||||
const result = await checkAvailability(SLUG, 'v-1', '2025-06-01T00:00:00.000Z', '2025-06-04T00:00:00.000Z')
|
||||
expect(result.available).toBe(true)
|
||||
expect(getVehicleAvailabilitySummary).toHaveBeenCalledWith('v-1', { range: { startDate: new Date('2025-06-01T00:00:00.000Z'), endDate: new Date('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') } })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -195,7 +216,7 @@ describe('createBooking', () => {
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
describe('initPayment — payment guard paths', () => {
|
||||
const payBody = { provider: 'PAYPAL' as const, successUrl: 'http://ok', failureUrl: 'http://fail' }
|
||||
const payBody = { provider: 'PAYPAL' as const, successUrl: 'http://localhost:3000/ok', failureUrl: 'http://localhost:3000/fail', accessToken: 'booking-access-token-123' }
|
||||
|
||||
it('throws AppError when reservation is already paid', async () => {
|
||||
vi.mocked(repo.findCompanyBySlug).mockResolvedValue(makeCompany() as any)
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({
|
||||
prisma: {
|
||||
subscription: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import { requireActiveSubscription, requireFullAccess } from './subscription.entitlement'
|
||||
|
||||
function createRes() {
|
||||
const res = {
|
||||
status: vi.fn().mockReturnThis(),
|
||||
json: vi.fn().mockReturnThis(),
|
||||
}
|
||||
return res as unknown as Response & { status: ReturnType<typeof vi.fn>; json: ReturnType<typeof vi.fn> }
|
||||
}
|
||||
|
||||
describe('subscription.entitlement middleware', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('rejects unauthenticated requests before touching subscription storage', async () => {
|
||||
const req = {} as Request
|
||||
const res = createRes()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
await requireActiveSubscription(req, res, next)
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401)
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'unauthenticated' })
|
||||
expect(prisma.subscription.findUnique).not.toHaveBeenCalled()
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects missing or expired subscriptions with a precise entitlement payload', async () => {
|
||||
vi.mocked(prisma.subscription.findUnique).mockResolvedValue(null as never)
|
||||
const req = { companyId: 'company_1' } as Request
|
||||
const res = createRes()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
await requireActiveSubscription(req, res, next)
|
||||
|
||||
expect(prisma.subscription.findUnique).toHaveBeenCalledWith({ where: { companyId: 'company_1' } })
|
||||
expect(res.status).toHaveBeenCalledWith(403)
|
||||
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
|
||||
error: 'subscription_required',
|
||||
subscriptionStatus: 'EXPIRED',
|
||||
accessLevel: 'none',
|
||||
}))
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows limited subscriptions through requireActiveSubscription but annotates the request', async () => {
|
||||
vi.mocked(prisma.subscription.findUnique).mockResolvedValue({ status: 'PAST_DUE' } as never)
|
||||
const req = { companyId: 'company_1' } as Request & { subscriptionStatus?: string; accessLevel?: string }
|
||||
const res = createRes()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
await requireActiveSubscription(req, res, next)
|
||||
|
||||
expect(req.subscriptionStatus).toBe('PAST_DUE')
|
||||
expect(req.accessLevel).toBe('limited')
|
||||
expect(next).toHaveBeenCalledOnce()
|
||||
expect(res.status).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('requires full access for write-protected actions', async () => {
|
||||
vi.mocked(prisma.subscription.findUnique).mockResolvedValue({ status: 'SUSPENDED' } as never)
|
||||
const req = { companyId: 'company_1' } as Request
|
||||
const res = createRes()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
await requireFullAccess(req, res, next)
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403)
|
||||
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
|
||||
error: 'insufficient_access',
|
||||
subscriptionStatus: 'SUSPENDED',
|
||||
accessLevel: 'read_only',
|
||||
}))
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes active subscriptions through requireFullAccess', async () => {
|
||||
vi.mocked(prisma.subscription.findUnique).mockResolvedValue({ status: 'ACTIVE' } as never)
|
||||
const req = { companyId: 'company_1' } as Request & { subscriptionStatus?: string; accessLevel?: string }
|
||||
const res = createRes()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
await requireFullAccess(req, res, next)
|
||||
|
||||
expect(req.subscriptionStatus).toBe('ACTIVE')
|
||||
expect(req.accessLevel).toBe('full')
|
||||
expect(next).toHaveBeenCalledOnce()
|
||||
expect(res.status).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ACCESS_MAP, getAccessLevel, hasAnyAccess, hasFullAccess, SUBSCRIPTION_POLICY } from './subscription.policy'
|
||||
|
||||
describe('subscription.policy', () => {
|
||||
it('maps each known subscription status to the intended access level', () => {
|
||||
expect(ACCESS_MAP).toMatchObject({
|
||||
TRIALING: 'full',
|
||||
ACTIVE: 'full',
|
||||
PAYMENT_PENDING: 'full',
|
||||
UNPAID: 'full',
|
||||
PAST_DUE: 'limited',
|
||||
SUSPENDED: 'read_only',
|
||||
PAUSED: 'read_only',
|
||||
CANCELLED: 'none',
|
||||
EXPIRED: 'none',
|
||||
})
|
||||
})
|
||||
|
||||
it('defaults unknown statuses to no access', () => {
|
||||
expect(getAccessLevel('MYSTERY_BOX')).toBe('none')
|
||||
expect(hasAnyAccess('MYSTERY_BOX')).toBe(false)
|
||||
expect(hasFullAccess('MYSTERY_BOX')).toBe(false)
|
||||
})
|
||||
|
||||
it('distinguishes full access from degraded but nonzero access', () => {
|
||||
expect(hasFullAccess('ACTIVE')).toBe(true)
|
||||
expect(hasAnyAccess('ACTIVE')).toBe(true)
|
||||
|
||||
expect(hasFullAccess('PAST_DUE')).toBe(false)
|
||||
expect(hasAnyAccess('PAST_DUE')).toBe(true)
|
||||
|
||||
expect(hasFullAccess('SUSPENDED')).toBe(false)
|
||||
expect(hasAnyAccess('SUSPENDED')).toBe(true)
|
||||
|
||||
expect(hasFullAccess('CANCELLED')).toBe(false)
|
||||
expect(hasAnyAccess('CANCELLED')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps payment retry policy internally coherent', () => {
|
||||
expect(SUBSCRIPTION_POLICY.trial.durationDays).toBeGreaterThan(0)
|
||||
expect(SUBSCRIPTION_POLICY.payment.retryScheduleDays).toHaveLength(SUBSCRIPTION_POLICY.payment.maxRetryAttempts)
|
||||
expect(SUBSCRIPTION_POLICY.payment.retryScheduleDays).toEqual([...SUBSCRIPTION_POLICY.payment.retryScheduleDays].sort((a, b) => a - b))
|
||||
expect(SUBSCRIPTION_POLICY.suspension.cancelAfterDays).toBeGreaterThan(SUBSCRIPTION_POLICY.pastDue.timeoutDays)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,140 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({
|
||||
prisma: {
|
||||
subscription: {
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
subscriptionInvoice: {
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findFirstOrThrow: vi.fn(),
|
||||
findUniqueOrThrow: vi.fn(),
|
||||
},
|
||||
paymentAttempt: { create: vi.fn(), findMany: vi.fn() },
|
||||
subscriptionEvent: { create: vi.fn(), findMany: vi.fn() },
|
||||
},
|
||||
}))
|
||||
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import * as repo from './subscription.repo'
|
||||
|
||||
describe('subscription.repo edge queries and mutations', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-06-01T12:00:00.000Z'))
|
||||
})
|
||||
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it('returns existing subscriptions without creating duplicates', async () => {
|
||||
const existing = { id: 'sub_1', companyId: 'company_1' }
|
||||
vi.mocked(prisma.subscription.findUnique).mockResolvedValue(existing as never)
|
||||
|
||||
const result = await repo.findOrCreateSubscription('company_1', 'PRO', 'ANNUAL', 'MAD')
|
||||
|
||||
expect(result).toBe(existing)
|
||||
expect(prisma.subscription.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('creates new subscriptions in payment-pending state when none exists', async () => {
|
||||
vi.mocked(prisma.subscription.findUnique).mockResolvedValue(null as never)
|
||||
vi.mocked(prisma.subscription.create).mockResolvedValue({ id: 'sub_2' } as never)
|
||||
|
||||
await repo.findOrCreateSubscription('company_1', 'GROWTH', 'MONTHLY', 'MAD')
|
||||
|
||||
expect(prisma.subscription.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
companyId: 'company_1',
|
||||
plan: 'GROWTH',
|
||||
billingPeriod: 'MONTHLY',
|
||||
currency: 'MAD',
|
||||
status: 'PAYMENT_PENDING',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('sets payment pending due dates seven days from the mutation time', async () => {
|
||||
await repo.setPaymentPending('sub_1')
|
||||
|
||||
expect(prisma.subscription.update).toHaveBeenCalledWith({
|
||||
where: { id: 'sub_1' },
|
||||
data: {
|
||||
status: 'PAYMENT_PENDING',
|
||||
paymentPendingSince: new Date('2026-06-01T12:00:00.000Z'),
|
||||
paymentDueAt: new Date('2026-06-08T12:00:00.000Z'),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('distinguishes immediate cancellation from period-end cancellation', async () => {
|
||||
await repo.setCancelled('sub_1', true)
|
||||
expect(prisma.subscription.update).toHaveBeenLastCalledWith({
|
||||
where: { id: 'sub_1' },
|
||||
data: {
|
||||
status: 'CANCELLED',
|
||||
cancelledAt: new Date('2026-06-01T12:00:00.000Z'),
|
||||
endedAt: new Date('2026-06-01T12:00:00.000Z'),
|
||||
},
|
||||
})
|
||||
|
||||
await repo.setCancelled('sub_1', false)
|
||||
expect(prisma.subscription.update).toHaveBeenLastCalledWith({
|
||||
where: { id: 'sub_1' },
|
||||
data: { cancelAtPeriodEnd: true },
|
||||
})
|
||||
})
|
||||
|
||||
it('queries scheduled job candidates with status-specific cutoffs', async () => {
|
||||
const cutoff = new Date('2026-05-25T12:00:00.000Z')
|
||||
|
||||
await repo.findPaymentPendingTimedOut(cutoff)
|
||||
expect(prisma.subscription.findMany).toHaveBeenLastCalledWith({
|
||||
where: { status: 'PAYMENT_PENDING', paymentPendingSince: { lte: cutoff } },
|
||||
include: { company: true },
|
||||
})
|
||||
|
||||
await repo.findPastDueTimedOut(cutoff)
|
||||
expect(prisma.subscription.findMany).toHaveBeenLastCalledWith({
|
||||
where: { status: 'PAST_DUE', pastDueSince: { lte: cutoff } },
|
||||
include: { company: true },
|
||||
})
|
||||
|
||||
await repo.findSubscriptionsToExpireAtPeriodEnd()
|
||||
expect(prisma.subscription.findMany).toHaveBeenLastCalledWith({
|
||||
where: {
|
||||
status: 'ACTIVE',
|
||||
cancelAtPeriodEnd: true,
|
||||
currentPeriodEnd: { lte: new Date('2026-06-01T12:00:00.000Z') },
|
||||
},
|
||||
include: { company: true },
|
||||
})
|
||||
})
|
||||
|
||||
it('creates subscription events with system source and current occurrence time by default', async () => {
|
||||
await repo.createEvent({
|
||||
subscriptionId: 'sub_1',
|
||||
companyId: 'company_1',
|
||||
eventType: 'trial.expired',
|
||||
payload: { trialEndAt: '2026-06-01' },
|
||||
})
|
||||
|
||||
expect(prisma.subscriptionEvent.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
subscriptionId: 'sub_1',
|
||||
companyId: 'company_1',
|
||||
eventType: 'trial.expired',
|
||||
payload: { trialEndAt: '2026-06-01' },
|
||||
source: 'system',
|
||||
occurredAt: new Date('2026-06-01T12:00:00.000Z'),
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,7 @@ import { requireTenant } from '../../middleware/requireTenant'
|
||||
import { requireRole } from '../../middleware/requireRole'
|
||||
import { parseBody } 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 './subscription.service'
|
||||
@@ -23,7 +24,7 @@ const router = Router()
|
||||
// ─── Public ────────────────────────────────────────────────────
|
||||
|
||||
publicRouter.get('/plans', (_req, res, next) => {
|
||||
service.getPlans().then((d) => ok(res, d)).catch(next)
|
||||
service.getPlans().then((d: any) => ok(res, d)).catch(next)
|
||||
})
|
||||
|
||||
publicRouter.get('/providers', (_req, res) => {
|
||||
@@ -31,29 +32,31 @@ publicRouter.get('/providers', (_req, res) => {
|
||||
})
|
||||
|
||||
publicRouter.get('/features', (_req, res, next) => {
|
||||
service.getPlanFeatures().then((d) => ok(res, d)).catch(next)
|
||||
service.getPlanFeatures().then((d: any) => ok(res, d)).catch(next)
|
||||
})
|
||||
|
||||
// ─── Webhooks (no auth) ────────────────────────────────────────
|
||||
|
||||
webhookRouter.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) }
|
||||
})
|
||||
|
||||
webhookRouter.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,61 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
cancelSchema,
|
||||
capturePaypalSchema,
|
||||
changePlanSchema,
|
||||
checkoutSchema,
|
||||
reactivateSchema,
|
||||
startTrialSchema,
|
||||
} from './subscription.schemas'
|
||||
|
||||
describe('subscription.schemas edge contracts', () => {
|
||||
it('accepts checkout only for MAD and valid hosted payment urls', () => {
|
||||
const payload = {
|
||||
plan: 'PRO',
|
||||
billingPeriod: 'ANNUAL',
|
||||
currency: 'MAD',
|
||||
provider: 'PAYPAL',
|
||||
successUrl: 'https://app.example.test/success',
|
||||
failureUrl: 'https://app.example.test/failure',
|
||||
}
|
||||
|
||||
expect(checkoutSchema.parse(payload)).toEqual(payload)
|
||||
expect(checkoutSchema.safeParse({ ...payload, currency: 'USD' }).success).toBe(false)
|
||||
expect(checkoutSchema.safeParse({ ...payload, successUrl: '/relative' }).success).toBe(false)
|
||||
})
|
||||
|
||||
it('allows plan changes across supported currencies while trial and reactivation stay MAD-only', () => {
|
||||
expect(changePlanSchema.parse({ plan: 'STARTER', billingPeriod: 'MONTHLY', currency: 'USD' })).toEqual({
|
||||
plan: 'STARTER',
|
||||
billingPeriod: 'MONTHLY',
|
||||
currency: 'USD',
|
||||
})
|
||||
|
||||
expect(startTrialSchema.parse({ plan: 'GROWTH', billingPeriod: 'MONTHLY' })).toEqual({
|
||||
plan: 'GROWTH',
|
||||
billingPeriod: 'MONTHLY',
|
||||
currency: 'MAD',
|
||||
})
|
||||
|
||||
expect(reactivateSchema.safeParse({
|
||||
plan: 'GROWTH',
|
||||
billingPeriod: 'MONTHLY',
|
||||
currency: 'EUR',
|
||||
provider: 'AMANPAY',
|
||||
successUrl: 'https://app.example.test/success',
|
||||
failureUrl: 'https://app.example.test/failure',
|
||||
}).success).toBe(false)
|
||||
})
|
||||
|
||||
it('defaults cancellation to period-end and caps cancellation reasons', () => {
|
||||
expect(cancelSchema.parse({})).toEqual({ mode: 'period_end' })
|
||||
expect(cancelSchema.parse({ mode: 'immediate', reason: 'closing account' })).toEqual({ mode: 'immediate', reason: 'closing account' })
|
||||
expect(cancelSchema.safeParse({ mode: 'tomorrow' }).success).toBe(false)
|
||||
expect(cancelSchema.safeParse({ reason: 'x'.repeat(501) }).success).toBe(false)
|
||||
})
|
||||
|
||||
it('requires a PayPal order id for manual capture', () => {
|
||||
expect(capturePaypalSchema.parse({ paypalOrderId: 'order_1' })).toEqual({ paypalOrderId: 'order_1' })
|
||||
expect(capturePaypalSchema.safeParse({}).success).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,196 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({
|
||||
prisma: {
|
||||
pricingConfig: { findMany: vi.fn(), findUnique: vi.fn() },
|
||||
planFeature: { findMany: vi.fn() },
|
||||
company: { findUniqueOrThrow: vi.fn() },
|
||||
subscription: { update: vi.fn() },
|
||||
},
|
||||
}))
|
||||
vi.mock('../../services/amanpayService', () => ({
|
||||
isConfigured: vi.fn(),
|
||||
createCheckout: vi.fn(),
|
||||
}))
|
||||
vi.mock('../../services/paypalService', () => ({
|
||||
isConfigured: vi.fn(),
|
||||
createOrder: vi.fn(),
|
||||
captureOrder: vi.fn(),
|
||||
}))
|
||||
vi.mock('./subscription.repo', () => ({
|
||||
findByCompany: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
findInvoices: vi.fn(),
|
||||
getEvents: vi.fn(),
|
||||
startTrial: vi.fn(),
|
||||
createEvent: vi.fn(),
|
||||
findInvoiceByAmanpay: vi.fn(),
|
||||
findInvoiceByPaypal: vi.fn(),
|
||||
findInvoiceByPaypalForCompany: vi.fn(),
|
||||
findOrCreateSubscription: vi.fn(),
|
||||
createInvoice: vi.fn(),
|
||||
markInvoicePaid: vi.fn(),
|
||||
markInvoiceFailed: vi.fn(),
|
||||
createPaymentAttempt: vi.fn(),
|
||||
incrementRetryCount: vi.fn(),
|
||||
activateSubscription: vi.fn(),
|
||||
setPaymentPending: vi.fn(),
|
||||
updateInvoicePaypal: vi.fn(),
|
||||
updatePlan: vi.fn(),
|
||||
setCancelled: vi.fn(),
|
||||
setCancelAtPeriodEnd: vi.fn(),
|
||||
findTrialsEndedWithoutConversion: vi.fn(),
|
||||
findPaymentPendingTimedOut: vi.fn(),
|
||||
findPastDueTimedOut: vi.fn(),
|
||||
findSuspendedTimedOut: vi.fn(),
|
||||
findSubscriptionsToExpireAtPeriodEnd: vi.fn(),
|
||||
setExpired: vi.fn(),
|
||||
setPastDue: vi.fn(),
|
||||
setSuspended: vi.fn(),
|
||||
}))
|
||||
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import * as amanpay from '../../services/amanpayService'
|
||||
import * as paypal from '../../services/paypalService'
|
||||
import * as repo from './subscription.repo'
|
||||
import * as service from './subscription.service'
|
||||
|
||||
describe('subscription.service operational edges', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-06-01T00:00:00.000Z'))
|
||||
process.env.API_URL = 'https://api.example.test'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
delete process.env.API_URL
|
||||
})
|
||||
|
||||
it('builds plans from pricing config rows when platform overrides exist', async () => {
|
||||
vi.mocked(prisma.pricingConfig.findMany).mockResolvedValue([
|
||||
{ plan: 'STARTER', billingPeriod: 'MONTHLY', amount: 9900 },
|
||||
{ plan: 'STARTER', billingPeriod: 'ANNUAL', amount: 99000 },
|
||||
{ plan: 'PRO', billingPeriod: 'MONTHLY', amount: 29900 },
|
||||
] as never)
|
||||
|
||||
await expect(service.getPlans()).resolves.toEqual({
|
||||
STARTER: {
|
||||
MONTHLY: { MAD: 9900 },
|
||||
ANNUAL: { MAD: 99000 },
|
||||
},
|
||||
PRO: {
|
||||
MONTHLY: { MAD: 29900 },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('starts trials only after enforcing one-trial-per-company policy', async () => {
|
||||
vi.mocked(repo.findByCompany).mockResolvedValue({ id: 'sub_old', trialUsed: true } as never)
|
||||
|
||||
await expect(service.startTrial('company_1', 'PRO', 'MONTHLY', 'MAD')).rejects.toThrow('already used its free trial')
|
||||
expect(repo.startTrial).not.toHaveBeenCalled()
|
||||
|
||||
vi.mocked(repo.findByCompany).mockResolvedValue(null as never)
|
||||
vi.mocked(repo.startTrial).mockResolvedValue({ id: 'sub_1' } as never)
|
||||
|
||||
await service.startTrial('company_1', 'PRO', 'MONTHLY', 'MAD')
|
||||
|
||||
expect(repo.startTrial).toHaveBeenCalledWith(
|
||||
'company_1',
|
||||
'PRO',
|
||||
'MONTHLY',
|
||||
'MAD',
|
||||
new Date('2026-06-15T00:00:00.000Z'),
|
||||
)
|
||||
expect(repo.createEvent).toHaveBeenCalledWith(expect.objectContaining({
|
||||
subscriptionId: 'sub_1',
|
||||
companyId: 'company_1',
|
||||
eventType: 'trial.started',
|
||||
}))
|
||||
})
|
||||
|
||||
it('creates AmanPay checkout invoices with webhook metadata and due dates', async () => {
|
||||
vi.mocked(prisma.pricingConfig.findUnique).mockResolvedValue({ amount: 19900 } as never)
|
||||
vi.mocked(prisma.company.findUniqueOrThrow).mockResolvedValue({ email: 'owner@example.test', name: 'Atlas Cars' } as never)
|
||||
vi.mocked(repo.findOrCreateSubscription).mockResolvedValue({ id: 'sub_1' } as never)
|
||||
vi.mocked(amanpay.isConfigured).mockReturnValue(true)
|
||||
vi.mocked(amanpay.createCheckout).mockResolvedValue({ checkoutUrl: 'https://pay.example.test/checkout', transactionId: 'txn_1' } as never)
|
||||
vi.mocked(repo.createInvoice).mockResolvedValue({ id: 'invoice_1' } as never)
|
||||
|
||||
await expect(service.checkout('company_1', {
|
||||
plan: 'GROWTH',
|
||||
billingPeriod: 'MONTHLY',
|
||||
currency: 'MAD',
|
||||
provider: 'AMANPAY',
|
||||
successUrl: 'https://app.example.test/success',
|
||||
failureUrl: 'https://app.example.test/failure',
|
||||
})).resolves.toEqual({ invoice: { id: 'invoice_1' }, checkoutUrl: 'https://pay.example.test/checkout' })
|
||||
|
||||
expect(amanpay.createCheckout).toHaveBeenCalledWith(expect.objectContaining({
|
||||
amount: 19900,
|
||||
currency: 'MAD',
|
||||
customerEmail: 'owner@example.test',
|
||||
customerName: 'Atlas Cars',
|
||||
webhookUrl: 'https://api.example.test/api/v1/subscriptions/webhooks/amanpay',
|
||||
}))
|
||||
expect(repo.createInvoice).toHaveBeenCalledWith(expect.objectContaining({
|
||||
companyId: 'company_1',
|
||||
subscriptionId: 'sub_1',
|
||||
amount: 19900,
|
||||
paymentProvider: 'AMANPAY',
|
||||
amanpayTransactionId: 'txn_1',
|
||||
paypalCaptureId: null,
|
||||
dueAt: new Date('2026-06-08T00:00:00.000Z'),
|
||||
}))
|
||||
})
|
||||
|
||||
it('keeps paid PayPal capture idempotent and avoids provider capture', async () => {
|
||||
vi.mocked(repo.findInvoiceByPaypalForCompany).mockResolvedValue({ status: 'PAID' } as never)
|
||||
|
||||
await expect(service.capturePaypal('company_1', 'order_1')).resolves.toEqual({ success: true })
|
||||
|
||||
expect(paypal.captureOrder).not.toHaveBeenCalled()
|
||||
expect(repo.activateSubscription).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('advances payment-pending subscriptions to past-due in the scheduled job', async () => {
|
||||
vi.mocked(repo.findPaymentPendingTimedOut).mockResolvedValue([
|
||||
{ id: 'sub_1', companyId: 'company_1', paymentPendingSince: new Date('2026-05-20T00:00:00.000Z') },
|
||||
{ id: 'sub_2', companyId: 'company_2', paymentPendingSince: new Date('2026-05-21T00:00:00.000Z') },
|
||||
] as never)
|
||||
|
||||
await expect(service.runPaymentPendingTimeoutJob()).resolves.toBe(2)
|
||||
|
||||
expect(repo.findPaymentPendingTimedOut).toHaveBeenCalledWith(new Date('2026-05-25T00:00:00.000Z'))
|
||||
expect(repo.setPastDue).toHaveBeenCalledTimes(2)
|
||||
expect(repo.createEvent).toHaveBeenCalledWith(expect.objectContaining({
|
||||
subscriptionId: 'sub_1',
|
||||
companyId: 'company_1',
|
||||
eventType: 'subscription.past_due',
|
||||
}))
|
||||
})
|
||||
|
||||
it('rejects user cancellation when no subscription exists', async () => {
|
||||
vi.mocked(repo.findByCompany).mockResolvedValue(null as never)
|
||||
|
||||
await expect(service.cancel('company_1')).rejects.toThrow('No subscription found')
|
||||
expect(repo.setCancelled).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('records admin suspension events with actor and reason', async () => {
|
||||
vi.mocked(repo.findById).mockResolvedValue({ id: 'sub_1', companyId: 'company_1' } as never)
|
||||
|
||||
await expect(service.adminSuspendSubscription('sub_1', 'admin_1', 'fraud review')).resolves.toEqual({ success: true })
|
||||
|
||||
expect(repo.setSuspended).toHaveBeenCalledWith('sub_1')
|
||||
expect(repo.createEvent).toHaveBeenCalledWith({
|
||||
subscriptionId: 'sub_1',
|
||||
companyId: 'company_1',
|
||||
eventType: 'subscription.suspended',
|
||||
source: 'admin',
|
||||
payload: { adminId: 'admin_1', reason: 'fraud review' },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,7 @@ import * as amanpay from '../../services/amanpayService'
|
||||
import * as paypal from '../../services/paypalService'
|
||||
import * as repo from './subscription.repo'
|
||||
import { SUBSCRIPTION_POLICY, getAccessLevel } from './subscription.policy'
|
||||
import { getWebhookEventId, processWebhookOnce } from '../../security/webhookIdempotency'
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────
|
||||
|
||||
@@ -48,7 +49,7 @@ export function getInvoices(companyId: string) {
|
||||
}
|
||||
|
||||
export function getEvents(companyId: string) {
|
||||
return repo.findByCompany(companyId).then((sub) =>
|
||||
return repo.findByCompany(companyId).then((sub: any) =>
|
||||
sub ? repo.getEvents(sub.id) : [],
|
||||
)
|
||||
}
|
||||
@@ -160,7 +161,7 @@ async function handlePaymentFailure(
|
||||
|
||||
// ─── Webhook handlers ─────────────────────────────────────────
|
||||
|
||||
export async function handleAmanpayWebhook(event: any) {
|
||||
async function applyAmanpayWebhook(event: any) {
|
||||
const transactionId = event.transaction_id ?? event.id
|
||||
const status = event.status?.toUpperCase()
|
||||
|
||||
@@ -175,7 +176,7 @@ export async function handleAmanpayWebhook(event: any) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function handlePaypalWebhook(event: any) {
|
||||
async function applyPaypalWebhook(event: any) {
|
||||
if (event.event_type === 'PAYMENT.CAPTURE.COMPLETED') {
|
||||
const captureId = event.resource?.id as string
|
||||
const invoice = await repo.findInvoiceByPaypal(captureId)
|
||||
@@ -189,6 +190,26 @@ export async function handlePaypalWebhook(event: any) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleAmanpayWebhook(event: any, rawBody: string | Buffer = JSON.stringify(event)) {
|
||||
return processWebhookOnce({
|
||||
provider: 'amanpay:subscriptions',
|
||||
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:subscriptions',
|
||||
providerEventId: getWebhookEventId('paypal', event),
|
||||
eventType: String(event.event_type ?? 'unknown'),
|
||||
rawBody,
|
||||
handle: () => applyPaypalWebhook(event),
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Checkout ─────────────────────────────────────────────────
|
||||
|
||||
export async function checkout(companyId: string, body: {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { idParamSchema, inviteSchema, roleSchema } from './team.schemas'
|
||||
|
||||
describe('team.schemas edge contracts', () => {
|
||||
it('accepts only invite payloads with real names, emails and assignable non-owner roles', () => {
|
||||
expect(inviteSchema.parse({
|
||||
firstName: 'Aya',
|
||||
lastName: 'Haddad',
|
||||
email: 'aya@example.test',
|
||||
role: 'MANAGER',
|
||||
})).toEqual({
|
||||
firstName: 'Aya',
|
||||
lastName: 'Haddad',
|
||||
email: 'aya@example.test',
|
||||
role: 'MANAGER',
|
||||
})
|
||||
|
||||
expect(inviteSchema.safeParse({ firstName: '', lastName: 'Haddad', email: 'aya@example.test', role: 'MANAGER' }).success).toBe(false)
|
||||
expect(inviteSchema.safeParse({ firstName: 'Aya', lastName: 'Haddad', email: 'not-email', role: 'MANAGER' }).success).toBe(false)
|
||||
expect(inviteSchema.safeParse({ firstName: 'Aya', lastName: 'Haddad', email: 'aya@example.test', role: 'OWNER' }).success).toBe(false)
|
||||
})
|
||||
|
||||
it('restricts role changes to operational roles only', () => {
|
||||
expect(roleSchema.parse({ role: 'AGENT' })).toEqual({ role: 'AGENT' })
|
||||
expect(roleSchema.parse({ role: 'MANAGER' })).toEqual({ role: 'MANAGER' })
|
||||
expect(roleSchema.safeParse({ role: 'OWNER' }).success).toBe(false)
|
||||
expect(roleSchema.safeParse({ role: 'ADMIN' }).success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects empty team member route params', () => {
|
||||
expect(idParamSchema.parse({ id: 'employee_1' })).toEqual({ id: 'employee_1' })
|
||||
expect(idParamSchema.safeParse({ id: '' }).success).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../services/teamService', () => ({
|
||||
listEmployees: vi.fn(),
|
||||
inviteEmployee: vi.fn(),
|
||||
updateEmployeeRole: vi.fn(),
|
||||
deactivateEmployee: vi.fn(),
|
||||
reactivateEmployee: vi.fn(),
|
||||
removeEmployee: vi.fn(),
|
||||
}))
|
||||
|
||||
import { listEmployees } from '../../services/teamService'
|
||||
import { getMembers, getMemberStats } from './team.service'
|
||||
|
||||
describe('team.service module facade', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('returns the tenant member list without modifying teamService output', async () => {
|
||||
const members = [{ id: 'emp_1', role: 'OWNER', isActive: true, invitationStatus: 'accepted' }]
|
||||
vi.mocked(listEmployees).mockResolvedValue(members as never)
|
||||
|
||||
await expect(getMembers('company_1')).resolves.toBe(members)
|
||||
expect(listEmployees).toHaveBeenCalledWith('company_1')
|
||||
})
|
||||
|
||||
it('counts active, pending, and inactive accepted team members explicitly', async () => {
|
||||
vi.mocked(listEmployees).mockResolvedValue([
|
||||
{ id: 'owner_1', isActive: true, invitationStatus: 'accepted' },
|
||||
{ id: 'manager_1', isActive: true, invitationStatus: 'accepted' },
|
||||
{ id: 'agent_1', isActive: false, invitationStatus: 'accepted' },
|
||||
{ id: 'agent_2', isActive: true, invitationStatus: 'pending' },
|
||||
{ id: 'agent_3', isActive: false, invitationStatus: 'revoked' },
|
||||
] as never)
|
||||
|
||||
await expect(getMemberStats('company_1')).resolves.toEqual({
|
||||
total: 5,
|
||||
active: 2,
|
||||
pending: 1,
|
||||
inactive: 1,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { presentVehicle, presentVehicleList } from './vehicle.presenter'
|
||||
|
||||
describe('vehicle.presenter edge normalization', () => {
|
||||
it('normalizes nullable and malformed location fields without leaking non-string values', () => {
|
||||
const result = presentVehicle({
|
||||
id: 'vehicle_1',
|
||||
pickupLocations: ['Casablanca', null, 123, 'Rabat'],
|
||||
allowDifferentDropoff: 0,
|
||||
dropoffLocations: ['Airport', false, undefined, 'Hotel'],
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
id: 'vehicle_1',
|
||||
pickupLocations: ['Casablanca', 'Rabat'],
|
||||
allowDifferentDropoff: false,
|
||||
dropoffLocations: ['Airport', 'Hotel'],
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves pagination metadata and does not normalize list rows twice behind the caller’s back', () => {
|
||||
const row = { id: 'vehicle_1', pickupLocations: ['Raw'], allowDifferentDropoff: true, dropoffLocations: [] }
|
||||
|
||||
expect(presentVehicleList([row], { total: 1, page: 2, pageSize: 10, totalPages: 1 })).toEqual({
|
||||
data: [row],
|
||||
total: 1,
|
||||
page: 2,
|
||||
pageSize: 10,
|
||||
totalPages: 1,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,188 @@
|
||||
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('./vehicle.repo')
|
||||
vi.mock('../../lib/storage', () => ({ uploadImage: vi.fn() }))
|
||||
|
||||
import * as repo from './vehicle.repo'
|
||||
import * as service from './vehicle.service'
|
||||
|
||||
const vehicle = {
|
||||
id: 'vehicle_1',
|
||||
companyId: 'company_1',
|
||||
make: 'Toyota',
|
||||
model: 'Corolla',
|
||||
year: 2024,
|
||||
dailyRate: 4000,
|
||||
photos: [],
|
||||
pickupLocations: [],
|
||||
allowDifferentDropoff: false,
|
||||
dropoffLocations: [],
|
||||
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-01-02T00:00:00.000Z'),
|
||||
}
|
||||
|
||||
const config = {
|
||||
id: 'pricing_config_1',
|
||||
vehicleId: 'vehicle_1',
|
||||
pricingMode: 'MANUAL',
|
||||
baseDailyRate: 4000,
|
||||
weeklyRate: 24000,
|
||||
weekendRate: null,
|
||||
holidayRate: null,
|
||||
monthlyRate: null,
|
||||
longTermDailyRate: null,
|
||||
minimumDailyRate: null,
|
||||
maximumDailyRate: null,
|
||||
maxDailyPriceMovementPct: 10,
|
||||
automaticPricingEnabled: false,
|
||||
approvalRequired: false,
|
||||
rules: [],
|
||||
history: [],
|
||||
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-01-02T00:00:00.000Z'),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('vehicle pricing service boundaries', () => {
|
||||
it('creates a default pricing configuration from the vehicle when one does not exist', async () => {
|
||||
vi.mocked(repo.findById).mockResolvedValue(vehicle as any)
|
||||
vi.mocked(repo.findPricingConfiguration).mockResolvedValue(null as any)
|
||||
vi.mocked(repo.createPricingConfiguration).mockResolvedValue(config as any)
|
||||
|
||||
const result = await service.getVehiclePricing('vehicle_1', 'company_1')
|
||||
|
||||
expect(repo.createPricingConfiguration).toHaveBeenCalledWith(expect.objectContaining({
|
||||
vehicleId: 'vehicle_1',
|
||||
pricingMode: 'MANUAL',
|
||||
baseDailyRate: 4000,
|
||||
weeklyRate: 28000,
|
||||
}))
|
||||
expect(result.configuration.baseDailyRate).toBe(4000)
|
||||
expect(result.preview.next14Days).toHaveLength(14)
|
||||
})
|
||||
|
||||
it('returns a transient pricing configuration when pricing tables are missing', async () => {
|
||||
vi.mocked(repo.findById).mockResolvedValue(vehicle as any)
|
||||
vi.mocked(repo.findPricingConfiguration).mockRejectedValue({ code: 'P2021' })
|
||||
|
||||
const result = await service.getVehiclePricing('vehicle_1', 'company_1')
|
||||
|
||||
expect(result.configuration.id).toBe('transient-vehicle_1')
|
||||
expect(result.configuration.baseDailyRate).toBe(4000)
|
||||
expect(repo.createPricingConfiguration).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects pricing configuration bounds before persisting invalid rates', async () => {
|
||||
vi.mocked(repo.findById).mockResolvedValue(vehicle as any)
|
||||
vi.mocked(repo.findPricingConfiguration).mockResolvedValue(config as any)
|
||||
|
||||
await expect(service.updateVehiclePricing('vehicle_1', 'company_1', 'employee_1', {
|
||||
pricingMode: 'MANUAL',
|
||||
baseDailyRate: 4000,
|
||||
minimumDailyRate: 5000,
|
||||
maximumDailyRate: 3000,
|
||||
maxDailyPriceMovementPct: 10,
|
||||
automaticPricingEnabled: false,
|
||||
approvalRequired: false,
|
||||
})).rejects.toThrow('Daily minimum rate must be less than or equal to the maximum rate')
|
||||
|
||||
expect(repo.updatePricingConfiguration).not.toHaveBeenCalled()
|
||||
expect(repo.createPriceHistory).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('syncs vehicle daily rate and writes price history when pricing configuration changes', async () => {
|
||||
const updatedConfig = { ...config, baseDailyRate: 4500, weeklyRate: 27000 }
|
||||
vi.mocked(repo.findById).mockResolvedValue(vehicle as any)
|
||||
vi.mocked(repo.findPricingConfiguration)
|
||||
.mockResolvedValueOnce(config as any)
|
||||
.mockResolvedValueOnce(updatedConfig as any)
|
||||
vi.mocked(repo.updatePricingConfiguration).mockResolvedValue(updatedConfig as any)
|
||||
vi.mocked(repo.updateById).mockResolvedValue({ ...vehicle, dailyRate: 4500 } as any)
|
||||
vi.mocked(repo.createPriceHistory).mockResolvedValue({ id: 'history_1' } as any)
|
||||
|
||||
await service.updateVehiclePricing('vehicle_1', 'company_1', 'employee_1', {
|
||||
pricingMode: 'MANUAL',
|
||||
baseDailyRate: 4500,
|
||||
weeklyRate: 27000,
|
||||
maxDailyPriceMovementPct: 10,
|
||||
automaticPricingEnabled: false,
|
||||
approvalRequired: false,
|
||||
note: 'Seasonal update',
|
||||
})
|
||||
|
||||
expect(repo.updatePricingConfiguration).toHaveBeenCalledWith('pricing_config_1', expect.objectContaining({ baseDailyRate: 4500 }))
|
||||
expect(repo.updateById).toHaveBeenCalledWith('vehicle_1', { dailyRate: 4500 })
|
||||
expect(repo.createPriceHistory).toHaveBeenCalledWith(expect.objectContaining({
|
||||
source: 'CONFIG_UPDATE',
|
||||
changedByEmployeeId: 'employee_1',
|
||||
previousDailyRate: 4000,
|
||||
nextDailyRate: 4500,
|
||||
note: 'Seasonal update',
|
||||
}))
|
||||
})
|
||||
|
||||
it('rejects pricing rules that do not define any pricing effect', async () => {
|
||||
vi.mocked(repo.findById).mockResolvedValue(vehicle as any)
|
||||
vi.mocked(repo.findPricingConfiguration).mockResolvedValue(config as any)
|
||||
|
||||
await expect(service.createVehiclePricingRule('vehicle_1', 'company_1', 'employee_1', {
|
||||
name: 'Empty rule',
|
||||
ruleType: 'DATE_RANGE',
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-10',
|
||||
isActive: true,
|
||||
sortOrder: 0,
|
||||
})).rejects.toThrow('Pricing rules must define at least one rate, bound, or adjustment')
|
||||
|
||||
expect(repo.createPricingRule).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects pricing rules whose end date is before the start date', async () => {
|
||||
vi.mocked(repo.findById).mockResolvedValue(vehicle as any)
|
||||
vi.mocked(repo.findPricingConfiguration).mockResolvedValue(config as any)
|
||||
|
||||
await expect(service.createVehiclePricingRule('vehicle_1', 'company_1', 'employee_1', {
|
||||
name: 'Broken rule',
|
||||
ruleType: 'DATE_RANGE',
|
||||
startDate: '2026-07-10',
|
||||
endDate: '2026-07-01',
|
||||
dailyRate: 5000,
|
||||
isActive: true,
|
||||
sortOrder: 0,
|
||||
})).rejects.toThrow('Rule end date must be on or after the start date')
|
||||
})
|
||||
|
||||
it('deletes an existing pricing rule and records the rollback target in history', async () => {
|
||||
const rule = {
|
||||
id: 'rule_1',
|
||||
name: 'Weekend bump',
|
||||
ruleType: 'WEEKEND',
|
||||
startDate: new Date('2026-07-01T00:00:00.000Z'),
|
||||
dailyRate: 5200,
|
||||
weeklyRate: null,
|
||||
}
|
||||
vi.mocked(repo.findById).mockResolvedValue(vehicle as any)
|
||||
vi.mocked(repo.findPricingConfiguration)
|
||||
.mockResolvedValueOnce(config as any)
|
||||
.mockResolvedValueOnce({ ...config, rules: [] } as any)
|
||||
vi.mocked(repo.findPricingRule).mockResolvedValue(rule as any)
|
||||
vi.mocked(repo.deletePricingRule).mockResolvedValue({ count: 1 } as any)
|
||||
vi.mocked(repo.createPriceHistory).mockResolvedValue({ id: 'history_1' } as any)
|
||||
|
||||
await service.deleteVehiclePricingRule('vehicle_1', 'company_1', 'rule_1', 'employee_1')
|
||||
|
||||
expect(repo.deletePricingRule).toHaveBeenCalledWith('rule_1', 'pricing_config_1')
|
||||
expect(repo.createPriceHistory).toHaveBeenCalledWith(expect.objectContaining({
|
||||
source: 'RULE_DELETED',
|
||||
previousDailyRate: 5200,
|
||||
nextDailyRate: 4000,
|
||||
note: 'Deleted pricing rule: Weekend bump',
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,96 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({
|
||||
prisma: {
|
||||
vehicle: {
|
||||
findMany: vi.fn(),
|
||||
count: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
reservation: { findMany: vi.fn() },
|
||||
vehicleCalendarBlock: { findMany: vi.fn(), create: vi.fn(), deleteMany: vi.fn() },
|
||||
maintenanceLog: { findMany: vi.fn(), create: vi.fn() },
|
||||
vehiclePricingConfiguration: { findUnique: vi.fn(), create: vi.fn(), update: vi.fn() },
|
||||
vehiclePricingRule: { findFirst: vi.fn(), create: vi.fn(), update: vi.fn(), deleteMany: vi.fn() },
|
||||
vehiclePriceHistory: { create: vi.fn() },
|
||||
},
|
||||
}))
|
||||
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import * as repo from './vehicle.repo'
|
||||
|
||||
describe('vehicle.repo edge queries', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('soft deletes by taking the vehicle out of service and unpublishing it inside the tenant boundary', async () => {
|
||||
await repo.softDelete('vehicle_1', 'company_1')
|
||||
|
||||
expect(prisma.vehicle.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: 'vehicle_1', companyId: 'company_1' },
|
||||
data: { status: 'OUT_OF_SERVICE', isPublished: false },
|
||||
})
|
||||
})
|
||||
|
||||
it('publishes and unpublishes inside the tenant boundary', async () => {
|
||||
await repo.setPublished('vehicle_1', 'company_1', true)
|
||||
expect(prisma.vehicle.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: 'vehicle_1', companyId: 'company_1' },
|
||||
data: { isPublished: true },
|
||||
})
|
||||
})
|
||||
|
||||
it('finds reservation conflicts by overlap and active statuses only', async () => {
|
||||
const start = new Date('2026-09-01T00:00:00.000Z')
|
||||
const end = new Date('2026-09-07T00:00:00.000Z')
|
||||
|
||||
await repo.findReservationConflicts('vehicle_1', 'company_1', start, end)
|
||||
|
||||
expect(prisma.reservation.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
vehicleId: 'vehicle_1',
|
||||
companyId: 'company_1',
|
||||
status: { in: ['CONFIRMED', 'ACTIVE'] },
|
||||
startDate: { lt: end },
|
||||
endDate: { gt: start },
|
||||
},
|
||||
select: { id: true, startDate: true, endDate: true, status: true },
|
||||
})
|
||||
})
|
||||
|
||||
it('loads calendar reservations and blocks in chronological order for the requested range', async () => {
|
||||
const rangeStart = new Date('2026-10-01T00:00:00.000Z')
|
||||
const rangeEnd = new Date('2026-10-31T23:59:59.000Z')
|
||||
|
||||
await repo.findCalendarEvents('vehicle_1', 'company_1', rangeStart, rangeEnd)
|
||||
|
||||
expect(prisma.reservation.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
vehicleId: 'vehicle_1',
|
||||
companyId: 'company_1',
|
||||
status: { in: ['DRAFT', 'CONFIRMED', 'ACTIVE'] },
|
||||
startDate: { lt: rangeEnd },
|
||||
endDate: { gt: rangeStart },
|
||||
}),
|
||||
orderBy: { startDate: 'asc' },
|
||||
}))
|
||||
expect(prisma.vehicleCalendarBlock.findMany).toHaveBeenCalledWith({
|
||||
where: { vehicleId: 'vehicle_1', startDate: { lt: rangeEnd }, endDate: { gt: rangeStart } },
|
||||
orderBy: { startDate: 'asc' },
|
||||
})
|
||||
})
|
||||
|
||||
it('loads pricing configuration with deterministic rule and history ordering', async () => {
|
||||
await repo.findPricingConfiguration('vehicle_1')
|
||||
|
||||
expect(prisma.vehiclePricingConfiguration.findUnique).toHaveBeenCalledWith({
|
||||
where: { vehicleId: 'vehicle_1' },
|
||||
include: {
|
||||
rules: { orderBy: [{ sortOrder: 'asc' }, { startDate: 'asc' }, { createdAt: 'asc' }] },
|
||||
history: { orderBy: { createdAt: 'desc' }, take: 20 },
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
availabilityQuerySchema,
|
||||
calendarBlockSchema,
|
||||
calendarQuerySchema,
|
||||
listQuerySchema,
|
||||
pricingConfigSchema,
|
||||
pricingRuleSchema,
|
||||
vehicleSchema,
|
||||
} from './vehicle.schemas'
|
||||
|
||||
describe('vehicle.schemas', () => {
|
||||
it('applies safe create defaults without mutating required vehicle identity fields', () => {
|
||||
const parsed = vehicleSchema.parse({
|
||||
make: 'Toyota',
|
||||
model: 'Yaris',
|
||||
year: 2026,
|
||||
licensePlate: '123-A-45',
|
||||
category: 'ECONOMY',
|
||||
})
|
||||
|
||||
expect(parsed).toMatchObject({
|
||||
color: '',
|
||||
seats: 5,
|
||||
transmission: 'AUTOMATIC',
|
||||
fuelType: 'GASOLINE',
|
||||
features: [],
|
||||
isPublished: true,
|
||||
pickupLocations: [],
|
||||
allowDifferentDropoff: false,
|
||||
dropoffLocations: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects impossible vehicle years and invalid fleet capacity inputs', () => {
|
||||
expect(() => vehicleSchema.parse({
|
||||
make: 'Toyota',
|
||||
model: 'Yaris',
|
||||
year: 1989,
|
||||
licensePlate: '123-A-45',
|
||||
category: 'ECONOMY',
|
||||
})).toThrow()
|
||||
|
||||
expect(() => vehicleSchema.parse({
|
||||
make: 'Ford',
|
||||
model: 'Transit',
|
||||
year: 2026,
|
||||
licensePlate: '456-B-78',
|
||||
category: 'VAN',
|
||||
seats: 0,
|
||||
})).toThrow()
|
||||
})
|
||||
|
||||
it('coerces list and calendar query params while enforcing page and month bounds', () => {
|
||||
expect(listQuerySchema.parse({ page: '3', pageSize: '50', status: 'AVAILABLE' })).toEqual({
|
||||
page: 3,
|
||||
pageSize: 50,
|
||||
status: 'AVAILABLE',
|
||||
})
|
||||
expect(calendarQuerySchema.parse({ year: '2026', month: '6' })).toEqual({ year: 2026, month: 6 })
|
||||
expect(() => calendarQuerySchema.parse({ year: '2026', month: '13' })).toThrow()
|
||||
expect(() => listQuerySchema.parse({ page: '0' })).toThrow()
|
||||
})
|
||||
|
||||
it('accepts date-only and offset timestamps for calendar blocks', () => {
|
||||
expect(calendarBlockSchema.parse({ startDate: '2026-07-01', endDate: '2026-07-03' })).toMatchObject({
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-03',
|
||||
type: 'MANUAL',
|
||||
})
|
||||
expect(calendarBlockSchema.parse({
|
||||
startDate: '2026-07-01T10:00:00.000Z',
|
||||
endDate: '2026-07-03T10:00:00.000Z',
|
||||
type: 'MAINTENANCE',
|
||||
}).type).toBe('MAINTENANCE')
|
||||
})
|
||||
|
||||
it('protects automatic pricing configuration bounds', () => {
|
||||
expect(pricingConfigSchema.parse({
|
||||
pricingMode: 'AUTOMATIC',
|
||||
baseDailyRate: 350,
|
||||
maxDailyPriceMovementPct: 25,
|
||||
automaticPricingEnabled: true,
|
||||
approvalRequired: true,
|
||||
})).toMatchObject({
|
||||
pricingMode: 'AUTOMATIC',
|
||||
baseDailyRate: 350,
|
||||
maxDailyPriceMovementPct: 25,
|
||||
automaticPricingEnabled: true,
|
||||
})
|
||||
|
||||
expect(() => pricingConfigSchema.parse({
|
||||
pricingMode: 'AUTOMATIC',
|
||||
baseDailyRate: 350,
|
||||
maxDailyPriceMovementPct: 101,
|
||||
})).toThrow()
|
||||
})
|
||||
|
||||
it('keeps pricing rule adjustments inside expected rate limits', () => {
|
||||
expect(pricingRuleSchema.parse({
|
||||
name: 'Summer peak',
|
||||
startDate: '2026-08-01',
|
||||
endDate: '2026-08-31',
|
||||
automaticAdjustmentPct: 50,
|
||||
})).toMatchObject({
|
||||
name: 'Summer peak',
|
||||
ruleType: 'DATE_RANGE',
|
||||
automaticAdjustmentPct: 50,
|
||||
isActive: true,
|
||||
sortOrder: 0,
|
||||
})
|
||||
|
||||
expect(() => pricingRuleSchema.parse({
|
||||
name: 'Broken multiplier',
|
||||
startDate: '2026-08-01',
|
||||
endDate: '2026-08-31',
|
||||
automaticAdjustmentPct: 250,
|
||||
})).toThrow()
|
||||
})
|
||||
|
||||
it('does not validate date ordering in query-only availability input', () => {
|
||||
expect(availabilityQuerySchema.parse({ startDate: '2026-08-10', endDate: '2026-08-01' })).toEqual({
|
||||
startDate: '2026-08-10',
|
||||
endDate: '2026-08-01',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,131 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../lib/prisma', () => ({ prisma: {} }))
|
||||
vi.mock('../../lib/storage', () => ({ uploadImage: vi.fn().mockResolvedValue('https://cdn.example.test/photo.jpg') }))
|
||||
vi.mock('./vehicle.repo', () => ({
|
||||
create: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
updateById: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
findCalendarEvents: vi.fn(),
|
||||
createMaintenanceLog: vi.fn(),
|
||||
findPricingConfiguration: vi.fn(),
|
||||
updatePricingConfiguration: vi.fn(),
|
||||
createPriceHistory: vi.fn(),
|
||||
}))
|
||||
|
||||
const repo = await import('./vehicle.repo')
|
||||
const service = await import('./vehicle.service')
|
||||
|
||||
const vehicle = {
|
||||
id: 'vehicle_1',
|
||||
companyId: 'company_1',
|
||||
make: 'Toyota',
|
||||
model: 'Yaris',
|
||||
year: 2024,
|
||||
status: 'AVAILABLE',
|
||||
isPublished: true,
|
||||
dailyRate: 30000,
|
||||
photos: [],
|
||||
pickupLocations: ['Casablanca'],
|
||||
allowDifferentDropoff: true,
|
||||
dropoffLocations: ['Rabat'],
|
||||
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
describe('vehicle.service edge behavior', () => {
|
||||
it('deduplicates and trims location settings when creating vehicles', async () => {
|
||||
vi.mocked(repo.create).mockResolvedValue({ ...vehicle, pickupLocations: ['Casablanca', 'Rabat'], dropoffLocations: ['Airport'] } as any)
|
||||
|
||||
await service.createVehicle({
|
||||
make: 'Toyota',
|
||||
pickupLocations: [' Casablanca ', 'casablanca', '', 42, 'Rabat'],
|
||||
allowDifferentDropoff: true,
|
||||
dropoffLocations: [' Airport ', 'airport', null],
|
||||
}, 'company_1')
|
||||
|
||||
expect(repo.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
companyId: 'company_1',
|
||||
pickupLocations: ['Casablanca', 'Rabat'],
|
||||
allowDifferentDropoff: true,
|
||||
dropoffLocations: ['Airport'],
|
||||
}))
|
||||
})
|
||||
|
||||
it('clears drop-off locations when different drop-off is disabled', async () => {
|
||||
vi.mocked(repo.findFirst).mockResolvedValue(vehicle as any)
|
||||
vi.mocked(repo.updateById).mockResolvedValue({ ...vehicle, allowDifferentDropoff: false, dropoffLocations: [] } as any)
|
||||
|
||||
await service.updateVehicle('vehicle_1', 'company_1', {
|
||||
allowDifferentDropoff: false,
|
||||
dropoffLocations: ['Rabat'],
|
||||
})
|
||||
|
||||
expect(repo.updateById).toHaveBeenCalledWith('vehicle_1', expect.objectContaining({
|
||||
allowDifferentDropoff: false,
|
||||
dropoffLocations: [],
|
||||
}))
|
||||
})
|
||||
|
||||
it('rejects enabling different drop-off without any usable drop-off location', async () => {
|
||||
await expect(service.createVehicle({
|
||||
allowDifferentDropoff: true,
|
||||
dropoffLocations: [' ', null],
|
||||
}, 'company_1')).rejects.toThrow('Drop-off locations are required')
|
||||
|
||||
expect(repo.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('builds calendar labels from customers and block reasons', async () => {
|
||||
vi.mocked(repo.findById).mockResolvedValue(vehicle as any)
|
||||
vi.mocked(repo.findCalendarEvents).mockResolvedValue([
|
||||
[{
|
||||
id: 'reservation_1',
|
||||
startDate: new Date('2026-09-01T00:00:00.000Z'),
|
||||
endDate: new Date('2026-09-03T00:00:00.000Z'),
|
||||
status: 'CONFIRMED',
|
||||
customer: { firstName: 'Aya', lastName: 'Benali' },
|
||||
}],
|
||||
[{
|
||||
id: 'block_1',
|
||||
startDate: new Date('2026-09-10T00:00:00.000Z'),
|
||||
endDate: new Date('2026-09-11T00:00:00.000Z'),
|
||||
type: 'MAINTENANCE',
|
||||
reason: null,
|
||||
}],
|
||||
] as any)
|
||||
|
||||
const result = await service.getCalendar('vehicle_1', 'company_1', 2026, 9)
|
||||
|
||||
expect(repo.findCalendarEvents).toHaveBeenCalledWith(
|
||||
'vehicle_1',
|
||||
'company_1',
|
||||
new Date(2026, 8, 1),
|
||||
new Date(2026, 9, 1),
|
||||
)
|
||||
expect(result).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ id: 'reservation_1', type: 'RESERVATION', label: 'Aya Benali' }),
|
||||
expect.objectContaining({ id: 'block_1', type: 'MAINTENANCE', label: 'Maintenance' }),
|
||||
]))
|
||||
})
|
||||
|
||||
it('normalizes maintenance log dates before persistence', async () => {
|
||||
vi.mocked(repo.findById).mockResolvedValue(vehicle as any)
|
||||
vi.mocked(repo.createMaintenanceLog).mockResolvedValue({ id: 'maintenance_1' } as any)
|
||||
|
||||
await service.createMaintenanceLog('vehicle_1', 'company_1', {
|
||||
description: 'Oil change',
|
||||
performedAt: '2026-07-01T12:00:00.000Z',
|
||||
nextDueAt: '2026-10-01T12:00:00.000Z',
|
||||
})
|
||||
|
||||
expect(repo.createMaintenanceLog).toHaveBeenCalledWith(expect.objectContaining({
|
||||
vehicleId: 'vehicle_1',
|
||||
performedAt: new Date('2026-07-01T12:00:00.000Z'),
|
||||
nextDueAt: new Date('2026-10-01T12:00:00.000Z'),
|
||||
}))
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user