refractor code,

This commit is contained in:
root
2026-05-21 12:35:49 -04:00
parent e74681e810
commit f009ca10c6
158 changed files with 215801 additions and 5884 deletions
+241
View File
@@ -0,0 +1,241 @@
import bcrypt from 'bcryptjs'
import jwt from 'jsonwebtoken'
import { prisma } from '../../lib/prisma'
const JWT_SECRET = process.env.JWT_SECRET ?? 'test-secret'
let counter = 0
function uid() {
return `${Date.now()}-${++counter}-${Math.random().toString(36).slice(2, 7)}`
}
export async function createCompanyWithEmployee(overrides: {
role?: 'OWNER' | 'MANAGER' | 'AGENT'
companyStatus?: 'ACTIVE' | 'TRIALING' | 'PAST_DUE' | 'SUSPENDED' | 'PENDING'
} = {}) {
const slug = `test-${uid()}`
const company = await prisma.company.create({
data: {
name: `Test Company ${slug}`,
slug,
email: `company-${slug}@test.com`,
status: (overrides.companyStatus ?? 'ACTIVE') as any,
subscription: {
create: { status: 'TRIALING', plan: 'STARTER' },
},
},
})
const employee = await prisma.employee.create({
data: {
companyId: company.id,
clerkUserId: `clerk_${uid()}`,
firstName: 'Test',
lastName: 'User',
email: `employee-${uid()}@test.com`,
role: overrides.role ?? 'OWNER',
},
})
return { company, employee }
}
export async function createVehicle(companyId: string, overrides: Record<string, unknown> = {}) {
return prisma.vehicle.create({
data: {
companyId,
make: 'Toyota',
model: 'Corolla',
year: 2022,
color: 'White',
licensePlate: `PL-${uid()}`,
dailyRate: 500,
status: 'AVAILABLE',
isPublished: true,
...overrides,
} as any,
})
}
export async function createCustomer(companyId: string, overrides: Record<string, unknown> = {}) {
return prisma.customer.create({
data: {
companyId,
firstName: 'Jane',
lastName: 'Doe',
email: `customer-${uid()}@test.com`,
...overrides,
},
})
}
export async function createRenter(overrides: {
firstName?: string
lastName?: string
email?: string
password?: string
isActive?: boolean
} = {}) {
return prisma.renter.create({
data: {
firstName: overrides.firstName ?? 'Renter',
lastName: overrides.lastName ?? 'User',
email: overrides.email ?? `renter-${uid()}@test.com`,
passwordHash: await bcrypt.hash(overrides.password ?? 'RenterPass123!', 10),
isActive: overrides.isActive ?? true,
},
})
}
export async function createAdminUser(overrides: {
email?: string
firstName?: string
lastName?: string
password?: string
role?: 'SUPER_ADMIN' | 'ADMIN' | 'SUPPORT' | 'FINANCE' | 'VIEWER'
isActive?: boolean
totpEnabled?: boolean
totpSecret?: string | null
} = {}) {
return prisma.adminUser.create({
data: {
email: overrides.email ?? `admin-${uid()}@test.com`,
firstName: overrides.firstName ?? 'Admin',
lastName: overrides.lastName ?? 'User',
passwordHash: await bcrypt.hash(overrides.password ?? 'AdminPass123!', 10),
role: (overrides.role ?? 'ADMIN') as any,
isActive: overrides.isActive ?? true,
totpEnabled: overrides.totpEnabled ?? false,
totpSecret: overrides.totpSecret ?? null,
},
})
}
export async function createReservation(
companyId: string,
vehicleId: string,
customerId: string,
overrides: Record<string, unknown> = {},
) {
return prisma.reservation.create({
data: {
companyId,
vehicleId,
customerId,
startDate: new Date(Date.now() + 24 * 60 * 60 * 1000),
endDate: new Date(Date.now() + 4 * 24 * 60 * 60 * 1000),
dailyRate: 500,
totalDays: 3,
totalAmount: 1500,
depositAmount: 300,
status: 'CONFIRMED',
paymentStatus: 'UNPAID',
paidAmount: 0,
...overrides,
} as any,
})
}
export async function createRentalPayment(
companyId: string,
reservationId: string,
overrides: Record<string, unknown> = {},
) {
return prisma.rentalPayment.create({
data: {
companyId,
reservationId,
amount: 500,
currency: 'MAD',
status: 'SUCCEEDED',
type: 'CHARGE',
paymentProvider: 'AMANPAY',
amanpayTransactionId: `aman-${uid()}`,
paidAt: new Date(),
...overrides,
} as any,
})
}
export async function createSubscriptionInvoice(
companyId: string,
subscriptionId: string,
overrides: Record<string, unknown> = {},
) {
return prisma.subscriptionInvoice.create({
data: {
companyId,
subscriptionId,
amount: 29900,
currency: 'MAD',
status: 'PENDING',
paymentProvider: 'AMANPAY',
amanpayTransactionId: `sub-${uid()}`,
...overrides,
} as any,
})
}
export async function createCompanyNotification(
companyId: string,
employeeId?: string,
overrides: Record<string, unknown> = {},
) {
return prisma.notification.create({
data: {
companyId,
employeeId,
type: 'PAYMENT_RECEIVED',
title: 'Payment received',
body: 'A payment was recorded.',
channel: 'IN_APP',
status: 'PENDING',
...overrides,
} as any,
})
}
export async function createRenterNotification(
renterId: string,
overrides: Record<string, unknown> = {},
) {
return prisma.notification.create({
data: {
renterId,
type: 'BOOKING_CONFIRMED',
title: 'Booking confirmed',
body: 'Your booking is confirmed.',
channel: 'IN_APP',
status: 'PENDING',
...overrides,
} as any,
})
}
export function signEmployeeToken(employeeId: string, companyId: string, role: string = 'OWNER') {
return jwt.sign(
{ sub: employeeId, companyId, role, type: 'employee' },
JWT_SECRET,
{ expiresIn: '1h' },
)
}
export function signAdminToken(adminId: string) {
return jwt.sign(
{ sub: adminId, type: 'admin' },
JWT_SECRET,
{ expiresIn: '1h' },
)
}
export function signRenterToken(renterId: string) {
return jwt.sign(
{ sub: renterId, type: 'renter' },
JWT_SECRET,
{ expiresIn: '1h' },
)
}
export function authHeader(token: string) {
return { Authorization: `Bearer ${token}` }
}
@@ -0,0 +1,139 @@
import request from 'supertest'
import { createApp } from '../../app'
import {
authHeader,
createAdminUser,
createCompanyWithEmployee,
createRenter,
signAdminToken,
} from '../helpers/fixtures'
const app = createApp()
describe('Admin API', () => {
let adminToken: string
let financeToken: string
let viewerToken: string
let adminId: string
let companyId: string
beforeAll(async () => {
const admin = await createAdminUser({
email: 'admin-login@test.com',
firstName: 'Primary',
lastName: 'Admin',
role: 'ADMIN',
password: 'AdminPass123!',
})
adminId = admin.id
adminToken = signAdminToken(admin.id)
const financeAdmin = await createAdminUser({
email: 'finance-admin@test.com',
role: 'FINANCE',
password: 'FinancePass123!',
})
financeToken = signAdminToken(financeAdmin.id)
const viewerAdmin = await createAdminUser({
email: 'viewer-admin@test.com',
role: 'VIEWER',
password: 'ViewerPass123!',
})
viewerToken = signAdminToken(viewerAdmin.id)
const { company } = await createCompanyWithEmployee()
companyId = company.id
await createRenter()
})
describe('POST /api/v1/admin/auth/login', () => {
it('returns 401 for invalid credentials', async () => {
const res = await request(app)
.post('/api/v1/admin/auth/login')
.send({
email: 'admin-login@test.com',
password: 'wrong-password',
})
expect(res.status).toBe(401)
expect(res.body.error).toBe('invalid_credentials')
})
it('returns a token for valid credentials', async () => {
const res = await request(app)
.post('/api/v1/admin/auth/login')
.send({
email: 'admin-login@test.com',
password: 'AdminPass123!',
})
expect(res.status).toBe(200)
expect(typeof res.body.data.data.token).toBe('string')
expect(res.body.data.data.admin.id).toBe(adminId)
})
})
describe('GET /api/v1/admin/auth/me', () => {
it('returns the authenticated admin profile', async () => {
const res = await request(app)
.get('/api/v1/admin/auth/me')
.set(authHeader(adminToken))
expect(res.status).toBe(200)
expect(res.body.data.data.id).toBe(adminId)
expect(res.body.data.data).not.toHaveProperty('passwordHash')
})
})
describe('GET /api/v1/admin/companies', () => {
it('returns paginated companies', async () => {
const res = await request(app)
.get('/api/v1/admin/companies')
.set(authHeader(adminToken))
expect(res.status).toBe(200)
expect(Array.isArray(res.body.data.data)).toBe(true)
expect(typeof res.body.data.total).toBe('number')
expect(res.body.data.data.some((item: any) => item.id === companyId)).toBe(true)
})
})
describe('GET /api/v1/admin/renters', () => {
it('returns paginated renters', async () => {
const res = await request(app)
.get('/api/v1/admin/renters')
.set(authHeader(adminToken))
expect(res.status).toBe(200)
expect(Array.isArray(res.body.data.data)).toBe(true)
expect(typeof res.body.data.total).toBe('number')
expect(res.body.data.total).toBeGreaterThanOrEqual(1)
})
})
describe('GET /api/v1/admin/metrics', () => {
it('returns 403 for VIEWER role', async () => {
const res = await request(app)
.get('/api/v1/admin/metrics')
.set(authHeader(viewerToken))
expect(res.status).toBe(403)
})
it('returns platform metrics for FINANCE role', async () => {
const res = await request(app)
.get('/api/v1/admin/metrics')
.set(authHeader(financeToken))
expect(res.status).toBe(200)
expect(res.body.data.data).toEqual(
expect.objectContaining({
totalCompanies: expect.any(Number),
totalRenters: expect.any(Number),
totalReservations: expect.any(Number),
}),
)
})
})
})
@@ -0,0 +1,131 @@
import request from 'supertest'
import { createApp } from '../../app'
import {
createCompanyWithEmployee,
createCustomer,
signEmployeeToken,
authHeader,
} from '../helpers/fixtures'
const app = createApp()
describe('Customers API', () => {
let companyId: string
let token: string
let agentToken: string
beforeAll(async () => {
const { company, employee } = await createCompanyWithEmployee({ role: 'OWNER' })
companyId = company.id
token = signEmployeeToken(employee.id, companyId, 'OWNER')
const { employee: agent } = await createCompanyWithEmployee({ role: 'AGENT' })
agentToken = signEmployeeToken(agent.id, agent.companyId, 'AGENT')
})
describe('GET /api/v1/customers', () => {
it('returns 401 without auth', async () => {
const res = await request(app).get('/api/v1/customers')
expect(res.status).toBe(401)
})
it('returns paginated customer list', async () => {
await createCustomer(companyId)
await createCustomer(companyId)
const res = await request(app)
.get('/api/v1/customers')
.set(authHeader(token))
expect(res.status).toBe(200)
expect(typeof res.body.total).toBe('number')
expect(res.body.total).toBeGreaterThanOrEqual(2)
expect(Array.isArray(res.body.data)).toBe(true)
})
it('filters by search query q', async () => {
await createCustomer(companyId, { firstName: 'Unique', lastName: 'Xyzzy' })
const res = await request(app)
.get('/api/v1/customers?q=Xyzzy')
.set(authHeader(token))
expect(res.status).toBe(200)
expect(res.body.total).toBeGreaterThanOrEqual(1)
expect(res.body.data[0].lastName).toBe('Xyzzy')
})
})
describe('POST /api/v1/customers', () => {
it('creates a new customer', async () => {
const res = await request(app)
.post('/api/v1/customers')
.set(authHeader(token))
.send({
firstName: 'Alice',
lastName: 'Smith',
email: `alice-${Date.now()}@example.com`,
})
expect(res.status).toBe(201)
expect(res.body.data.firstName).toBe('Alice')
expect(res.body.data.lastName).toBe('Smith')
})
it('returns 400 for missing required fields', async () => {
const res = await request(app)
.post('/api/v1/customers')
.set(authHeader(token))
.send({ firstName: 'Noemail' })
expect(res.status).toBe(400)
})
})
describe('GET /api/v1/customers/:id', () => {
it('returns a single customer', async () => {
const customer = await createCustomer(companyId, { firstName: 'Bob', lastName: 'Test' })
const res = await request(app)
.get(`/api/v1/customers/${customer.id}`)
.set(authHeader(token))
expect(res.status).toBe(200)
expect(res.body.data.id).toBe(customer.id)
expect(res.body.data.firstName).toBe('Bob')
})
it('returns 404 for unknown id', async () => {
const res = await request(app)
.get('/api/v1/customers/clzunknownidzzzzzzzzzzz')
.set(authHeader(token))
expect(res.status).toBe(404)
})
})
describe('POST /api/v1/customers/:id/flag', () => {
it('flags a customer (MANAGER+)', async () => {
const customer = await createCustomer(companyId)
const res = await request(app)
.post(`/api/v1/customers/${customer.id}/flag`)
.set(authHeader(token))
.send({ reason: 'Suspicious activity' })
expect(res.status).toBe(200)
expect(res.body.data.success).toBe(true)
})
it('returns 403 for AGENT role', async () => {
const customer = await createCustomer(companyId)
const res = await request(app)
.post(`/api/v1/customers/${customer.id}/flag`)
.set(authHeader(agentToken))
.send({ reason: 'Test' })
expect(res.status).toBe(403)
})
})
})
@@ -0,0 +1,21 @@
import request from 'supertest'
import { createApp } from '../../app'
const app = createApp()
describe('GET /health', () => {
it('returns 200 with status ok', async () => {
const res = await request(app).get('/health')
expect(res.status).toBe(200)
expect(res.body.status).toBe('ok')
expect(res.body.version).toBe('1.0.0')
expect(typeof res.body.timestamp).toBe('string')
})
it('GET /api/v1/docs returns route index', async () => {
const res = await request(app).get('/api/v1/docs')
expect(res.status).toBe(200)
expect(res.body.name).toBe('rentaldrivego-api')
expect(Array.isArray(res.body.routes)).toBe(true)
})
})
@@ -0,0 +1,146 @@
import request from 'supertest'
import { createApp } from '../../app'
import {
authHeader,
createCompanyNotification,
createCompanyWithEmployee,
createRenter,
createRenterNotification,
signEmployeeToken,
signRenterToken,
} from '../helpers/fixtures'
const app = createApp()
describe('Notifications API', () => {
let companyId: string
let employeeId: string
let ownerToken: string
let renterId: string
let renterToken: string
beforeAll(async () => {
const { company, employee } = await createCompanyWithEmployee({ role: 'OWNER' })
companyId = company.id
employeeId = employee.id
ownerToken = signEmployeeToken(employee.id, companyId, 'OWNER')
const renter = await createRenter()
renterId = renter.id
renterToken = signRenterToken(renter.id)
})
describe('Company notifications', () => {
it('returns unread count', async () => {
const res = await request(app)
.get('/api/v1/notifications/unread-count')
.set(authHeader(ownerToken))
expect(res.status).toBe(200)
expect(typeof res.body.data.data.unread).toBe('number')
})
it('lists company notifications and supports unread filtering', async () => {
const unread = await createCompanyNotification(companyId, employeeId, { title: 'Unread company notice' })
await createCompanyNotification(companyId, employeeId, {
title: 'Read company notice',
readAt: new Date(),
status: 'READ',
})
const res = await request(app)
.get('/api/v1/notifications/company?unread=true')
.set(authHeader(ownerToken))
expect(res.status).toBe(200)
expect(Array.isArray(res.body.data.data)).toBe(true)
expect(res.body.data.data.some((item: any) => item.id === unread.id)).toBe(true)
expect(res.body.data.data.every((item: any) => item.readAt === null)).toBe(true)
})
it('marks a company notification as read', async () => {
const notification = await createCompanyNotification(companyId, employeeId)
const res = await request(app)
.post(`/api/v1/notifications/company/${notification.id}/read`)
.set(authHeader(ownerToken))
expect(res.status).toBe(200)
expect(res.body.data.data.success).toBe(true)
})
it('updates company notification preferences', async () => {
const payload = [
{ notificationType: 'PAYMENT_RECEIVED', channel: 'EMAIL', enabled: false },
]
const patchRes = await request(app)
.patch('/api/v1/notifications/company/preferences')
.set(authHeader(ownerToken))
.send(payload)
expect(patchRes.status).toBe(200)
expect(patchRes.body.data.data.success).toBe(true)
const getRes = await request(app)
.get('/api/v1/notifications/company/preferences')
.set(authHeader(ownerToken))
expect(getRes.status).toBe(200)
expect(getRes.body.data.data).toEqual(
expect.arrayContaining([
expect.objectContaining({
employeeId,
notificationType: 'PAYMENT_RECEIVED',
channel: 'EMAIL',
enabled: false,
}),
]),
)
})
})
describe('Renter notifications', () => {
it('lists renter notifications', async () => {
const notification = await createRenterNotification(renterId)
const res = await request(app)
.get('/api/v1/notifications/renter')
.set(authHeader(renterToken))
expect(res.status).toBe(200)
expect(Array.isArray(res.body.data.data)).toBe(true)
expect(res.body.data.data.some((item: any) => item.id === notification.id)).toBe(true)
})
it('updates renter notification preferences', async () => {
const payload = [
{ notificationType: 'BOOKING_CONFIRMED', channel: 'PUSH', enabled: true },
]
const patchRes = await request(app)
.patch('/api/v1/notifications/renter/preferences')
.set(authHeader(renterToken))
.send(payload)
expect(patchRes.status).toBe(200)
expect(patchRes.body.data.data.success).toBe(true)
const getRes = await request(app)
.get('/api/v1/notifications/renter/preferences')
.set(authHeader(renterToken))
expect(getRes.status).toBe(200)
expect(getRes.body.data.data).toEqual(
expect.arrayContaining([
expect.objectContaining({
renterId,
notificationType: 'BOOKING_CONFIRMED',
channel: 'PUSH',
enabled: true,
}),
]),
)
})
})
})
@@ -0,0 +1,159 @@
import request from 'supertest'
import { prisma } from '../../lib/prisma'
import { createApp } from '../../app'
import {
authHeader,
createCompanyWithEmployee,
createCustomer,
createRentalPayment,
createReservation,
createVehicle,
signEmployeeToken,
} from '../helpers/fixtures'
const app = createApp()
function uniqueEmail(prefix: string) {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@test.com`
}
describe('Payments API', () => {
let companyId: string
let ownerToken: string
let agentToken: string
beforeAll(async () => {
const { company, employee } = await createCompanyWithEmployee({ role: 'OWNER' })
companyId = company.id
ownerToken = signEmployeeToken(employee.id, companyId, 'OWNER')
const agent = await prisma.employee.create({
data: {
companyId,
clerkUserId: `clerk_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
firstName: 'Agent',
lastName: 'User',
email: uniqueEmail('agent'),
role: 'AGENT',
},
})
agentToken = signEmployeeToken(agent.id, companyId, 'AGENT')
})
describe('GET /api/v1/payments/company', () => {
it('returns 401 without auth', async () => {
const res = await request(app).get('/api/v1/payments/company')
expect(res.status).toBe(401)
})
it('returns company payments', async () => {
const vehicle = await createVehicle(companyId)
const customer = await createCustomer(companyId)
const reservation = await createReservation(companyId, vehicle.id, customer.id)
const payment = await createRentalPayment(companyId, reservation.id)
const res = await request(app)
.get('/api/v1/payments/company')
.set(authHeader(ownerToken))
expect(res.status).toBe(200)
expect(Array.isArray(res.body.data.data)).toBe(true)
expect(res.body.data.data.some((item: any) => item.id === payment.id)).toBe(true)
})
})
describe('POST /api/v1/payments/reservations/:id/manual', () => {
it('records a manual payment and updates the reservation balance', async () => {
const vehicle = await createVehicle(companyId)
const customer = await createCustomer(companyId)
const reservation = await createReservation(companyId, vehicle.id, customer.id, {
totalAmount: 1200,
paidAmount: 0,
})
const res = await request(app)
.post(`/api/v1/payments/reservations/${reservation.id}/manual`)
.set(authHeader(ownerToken))
.send({
amount: 400,
currency: 'MAD',
type: 'CHARGE',
paymentMethod: 'CASH',
})
expect(res.status).toBe(200)
expect(res.body.data.data.amount).toBe(400)
expect(res.body.data.data.status).toBe('SUCCEEDED')
const updated = await prisma.reservation.findUniqueOrThrow({ where: { id: reservation.id } })
expect(updated.paidAmount).toBe(400)
expect(updated.paymentStatus).toBe('PARTIAL')
})
it('returns 403 for AGENT role', async () => {
const vehicle = await createVehicle(companyId)
const customer = await createCustomer(companyId)
const reservation = await createReservation(companyId, vehicle.id, customer.id)
const res = await request(app)
.post(`/api/v1/payments/reservations/${reservation.id}/manual`)
.set(authHeader(agentToken))
.send({
amount: 300,
currency: 'MAD',
type: 'CHARGE',
paymentMethod: 'CASH',
})
expect(res.status).toBe(403)
})
})
describe('POST /api/v1/payments/reservations/:id/charge', () => {
it('returns 409 when the reservation is already fully paid', async () => {
const vehicle = await createVehicle(companyId)
const customer = await createCustomer(companyId)
const reservation = await createReservation(companyId, vehicle.id, customer.id, {
totalAmount: 900,
paidAmount: 900,
paymentStatus: 'PAID',
})
const res = await request(app)
.post(`/api/v1/payments/reservations/${reservation.id}/charge`)
.set(authHeader(ownerToken))
.send({
provider: 'PAYPAL',
type: 'CHARGE',
currency: 'MAD',
successUrl: 'https://example.com/success',
failureUrl: 'https://example.com/failure',
})
expect(res.status).toBe(409)
expect(res.body.error).toBe('conflict')
})
})
describe('POST /api/v1/payments/reservations/:reservationId/payments/:paymentId/refund', () => {
it('rejects refunding a manual payment through the gateway flow', async () => {
const vehicle = await createVehicle(companyId)
const customer = await createCustomer(companyId)
const reservation = await createReservation(companyId, vehicle.id, customer.id)
const payment = await createRentalPayment(companyId, reservation.id, {
paymentMethod: 'CASH',
paymentProvider: 'AMANPAY',
amanpayTransactionId: null,
paypalCaptureId: null,
})
const res = await request(app)
.post(`/api/v1/payments/reservations/${reservation.id}/payments/${payment.id}/refund`)
.set(authHeader(ownerToken))
.send({ amount: 100, reason: 'Test refund' })
expect(res.status).toBe(400)
expect(res.body.message).toContain('Manual payments')
})
})
})
@@ -0,0 +1,140 @@
import request from 'supertest'
import { createApp } from '../../app'
import {
createCompanyWithEmployee,
createVehicle,
createCustomer,
signEmployeeToken,
authHeader,
} from '../helpers/fixtures'
const app = createApp()
const future = (days: number) =>
new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString()
describe('Reservations API', () => {
let companyId: string
let token: string
let vehicleId: string
let customerId: string
beforeAll(async () => {
const { company, employee } = await createCompanyWithEmployee({ role: 'OWNER' })
companyId = company.id
token = signEmployeeToken(employee.id, companyId, 'OWNER')
const v = await createVehicle(companyId)
vehicleId = v.id
const c = await createCustomer(companyId)
customerId = c.id
})
describe('GET /api/v1/reservations', () => {
it('returns 401 without auth', async () => {
const res = await request(app).get('/api/v1/reservations')
expect(res.status).toBe(401)
})
it('returns paginated list', async () => {
const res = await request(app)
.get('/api/v1/reservations')
.set(authHeader(token))
expect(res.status).toBe(200)
expect(typeof res.body.total).toBe('number')
expect(Array.isArray(res.body.data)).toBe(true)
})
})
describe('POST /api/v1/reservations', () => {
it('creates a reservation with valid payload', async () => {
const res = await request(app)
.post('/api/v1/reservations')
.set(authHeader(token))
.send({
vehicleId,
customerId,
startDate: future(10),
endDate: future(15),
})
expect(res.status).toBe(201)
expect(res.body.data.vehicleId).toBe(vehicleId)
expect(res.body.data.customerId).toBe(customerId)
expect(res.body.data.status).toBe('DRAFT')
})
it('returns 400 for missing vehicleId', async () => {
const res = await request(app)
.post('/api/v1/reservations')
.set(authHeader(token))
.send({
customerId,
startDate: future(10),
endDate: future(15),
})
expect(res.status).toBe(400)
})
it('returns 409 when vehicle is not available for requested dates', async () => {
// Create a reservation blocking the vehicle
const createRes = await request(app)
.post('/api/v1/reservations')
.set(authHeader(token))
.send({ vehicleId, customerId, startDate: future(20), endDate: future(25) })
.expect(201)
await request(app)
.post(`/api/v1/reservations/${createRes.body.data.id}/confirm`)
.set(authHeader(token))
.expect(200)
const res = await request(app)
.post('/api/v1/reservations')
.set(authHeader(token))
.send({ vehicleId, customerId, startDate: future(21), endDate: future(24) })
// Overlapping dates should conflict
expect([409, 422]).toContain(res.status)
})
})
describe('Reservation state machine', () => {
let reservationId: string
beforeEach(async () => {
const v = await createVehicle(companyId)
const res = await request(app)
.post('/api/v1/reservations')
.set(authHeader(token))
.send({ vehicleId: v.id, customerId, startDate: future(30), endDate: future(35) })
expect(res.status).toBe(201)
reservationId = res.body.data.id
})
it('DRAFT → CONFIRMED via POST /:id/confirm', async () => {
const res = await request(app)
.post(`/api/v1/reservations/${reservationId}/confirm`)
.set(authHeader(token))
expect(res.status).toBe(200)
expect(res.body.data.status).toBe('CONFIRMED')
})
it('CONFIRMED → ACTIVE via POST /:id/checkin', async () => {
await request(app)
.post(`/api/v1/reservations/${reservationId}/confirm`)
.set(authHeader(token))
.expect(200)
const res = await request(app)
.post(`/api/v1/reservations/${reservationId}/checkin`)
.set(authHeader(token))
.send({ fuelLevel: 'FULL' })
expect(res.status).toBe(200)
expect(res.body.data.status).toBe('ACTIVE')
})
})
})
@@ -0,0 +1,130 @@
import request from 'supertest'
import { prisma } from '../../lib/prisma'
import { createApp } from '../../app'
import {
authHeader,
createCompanyWithEmployee,
createSubscriptionInvoice,
signEmployeeToken,
} from '../helpers/fixtures'
const app = createApp()
function uniqueEmail(prefix: string) {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@test.com`
}
describe('Subscriptions API', () => {
let companyId: string
let subscriptionId: string
let ownerToken: string
let agentToken: string
beforeAll(async () => {
const { company, employee } = await createCompanyWithEmployee({ role: 'OWNER' })
companyId = company.id
ownerToken = signEmployeeToken(employee.id, companyId, 'OWNER')
const subscription = await prisma.subscription.findUniqueOrThrow({ where: { companyId } })
subscriptionId = subscription.id
const agent = await prisma.employee.create({
data: {
companyId,
clerkUserId: `clerk_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
firstName: 'Agent',
lastName: 'User',
email: uniqueEmail('sub-agent'),
role: 'AGENT',
},
})
agentToken = signEmployeeToken(agent.id, companyId, 'AGENT')
})
describe('Public endpoints', () => {
it('returns subscription plans', async () => {
const res = await request(app).get('/api/v1/subscriptions/plans')
expect(res.status).toBe(200)
expect(res.body.data.data).toHaveProperty('STARTER')
expect(res.body.data.data).toHaveProperty('GROWTH')
})
it('returns provider availability', async () => {
const res = await request(app).get('/api/v1/subscriptions/providers')
expect(res.status).toBe(200)
expect(typeof res.body.data.data.amanpay).toBe('boolean')
expect(typeof res.body.data.data.paypal).toBe('boolean')
})
})
describe('Authenticated endpoints', () => {
it('returns the current subscription', async () => {
const res = await request(app)
.get('/api/v1/subscriptions/me')
.set(authHeader(ownerToken))
expect(res.status).toBe(200)
expect(res.body.data.data.companyId).toBe(companyId)
expect(res.body.data.data.id).toBe(subscriptionId)
})
it('returns company invoices', async () => {
const invoice = await createSubscriptionInvoice(companyId, subscriptionId)
const res = await request(app)
.get('/api/v1/subscriptions/invoices')
.set(authHeader(ownerToken))
expect(res.status).toBe(200)
expect(Array.isArray(res.body.data.data)).toBe(true)
expect(res.body.data.data.some((item: any) => item.id === invoice.id)).toBe(true)
})
it('changes the subscription plan for OWNER', async () => {
const res = await request(app)
.post('/api/v1/subscriptions/change-plan')
.set(authHeader(ownerToken))
.send({
plan: 'GROWTH',
billingPeriod: 'ANNUAL',
currency: 'EUR',
})
expect(res.status).toBe(200)
expect(res.body.data.data.plan).toBe('GROWTH')
expect(res.body.data.data.billingPeriod).toBe('ANNUAL')
expect(res.body.data.data.currency).toBe('EUR')
})
it('returns 403 for AGENT on plan changes', async () => {
const res = await request(app)
.post('/api/v1/subscriptions/change-plan')
.set(authHeader(agentToken))
.send({
plan: 'PRO',
billingPeriod: 'MONTHLY',
currency: 'MAD',
})
expect(res.status).toBe(403)
})
it('cancels and resumes at period end', async () => {
const cancelRes = await request(app)
.post('/api/v1/subscriptions/cancel')
.set(authHeader(ownerToken))
expect(cancelRes.status).toBe(200)
expect(cancelRes.body.data.data.cancelAtPeriodEnd).toBe(true)
const resumeRes = await request(app)
.post('/api/v1/subscriptions/resume')
.set(authHeader(ownerToken))
expect(resumeRes.status).toBe(200)
expect(resumeRes.body.data.data.cancelAtPeriodEnd).toBe(false)
})
})
})
@@ -0,0 +1,146 @@
import request from 'supertest'
import { createApp } from '../../app'
import {
createCompanyWithEmployee,
createVehicle,
signEmployeeToken,
authHeader,
} from '../helpers/fixtures'
const app = createApp()
describe('Vehicles API', () => {
let companyId: string
let token: string
beforeAll(async () => {
const { company, employee } = await createCompanyWithEmployee({ role: 'OWNER' })
companyId = company.id
token = signEmployeeToken(employee.id, companyId, 'OWNER')
})
describe('GET /api/v1/vehicles', () => {
it('returns 401 without auth', async () => {
const res = await request(app).get('/api/v1/vehicles')
expect(res.status).toBe(401)
})
it('returns paginated vehicle list', async () => {
await createVehicle(companyId)
await createVehicle(companyId)
const res = await request(app)
.get('/api/v1/vehicles')
.set(authHeader(token))
expect(res.status).toBe(200)
expect(typeof res.body.total).toBe('number')
expect(res.body.total).toBeGreaterThanOrEqual(2)
expect(Array.isArray(res.body.data)).toBe(true)
})
it('filters by status', async () => {
await createVehicle(companyId, { status: 'MAINTENANCE' })
const res = await request(app)
.get('/api/v1/vehicles?status=MAINTENANCE')
.set(authHeader(token))
expect(res.status).toBe(200)
res.body.data.forEach((v: any) => expect(v.status).toBe('MAINTENANCE'))
})
})
describe('POST /api/v1/vehicles', () => {
it('creates a vehicle with valid payload', async () => {
const res = await request(app)
.post('/api/v1/vehicles')
.set(authHeader(token))
.send({
make: 'Honda',
model: 'Civic',
year: 2023,
licensePlate: `TEST-${Date.now()}`,
category: 'COMPACT',
dailyRate: 400,
})
expect(res.status).toBe(201)
expect(res.body.data.make).toBe('Honda')
expect(res.body.data.model).toBe('Civic')
})
it('returns 400 for missing required fields', async () => {
const res = await request(app)
.post('/api/v1/vehicles')
.set(authHeader(token))
.send({ make: 'Honda' })
expect(res.status).toBe(400)
})
})
describe('GET /api/v1/vehicles/:id', () => {
it('returns a single vehicle', async () => {
const vehicle = await createVehicle(companyId, { make: 'BMW', model: 'X5' })
const res = await request(app)
.get(`/api/v1/vehicles/${vehicle.id}`)
.set(authHeader(token))
expect(res.status).toBe(200)
expect(res.body.data.id).toBe(vehicle.id)
expect(res.body.data.make).toBe('BMW')
})
it('returns 404 for unknown vehicle', async () => {
const res = await request(app)
.get('/api/v1/vehicles/clzunknownidzzzzzzzzzzz')
.set(authHeader(token))
expect(res.status).toBe(404)
})
})
describe('PATCH /api/v1/vehicles/:id', () => {
it('updates vehicle notes', async () => {
const vehicle = await createVehicle(companyId)
const res = await request(app)
.patch(`/api/v1/vehicles/${vehicle.id}`)
.set(authHeader(token))
.send({ notes: 'Updated via integration test' })
expect(res.status).toBe(200)
expect(res.body.data.notes).toBe('Updated via integration test')
})
it('sets isPublished=false when status=MAINTENANCE', async () => {
const vehicle = await createVehicle(companyId)
const res = await request(app)
.patch(`/api/v1/vehicles/${vehicle.id}`)
.set(authHeader(token))
.send({ status: 'MAINTENANCE' })
expect(res.status).toBe(200)
expect(res.body.data.status).toBe('MAINTENANCE')
expect(res.body.data.isPublished).toBe(false)
})
})
describe('GET /api/v1/vehicles/:id/availability', () => {
it('reports vehicle available for future dates', async () => {
const vehicle = await createVehicle(companyId)
const start = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
const end = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
const res = await request(app)
.get(`/api/v1/vehicles/${vehicle.id}/availability?startDate=${start}&endDate=${end}`)
.set(authHeader(token))
expect(res.status).toBe(200)
expect(res.body.data.available).toBe(true)
})
})
})
+51
View File
@@ -0,0 +1,51 @@
import { beforeAll, afterAll } from 'vitest'
import { prisma } from '../lib/prisma'
const delegates = [
'auditLog',
'adminPermission',
'adminUser',
'damagePoint',
'damageInspection',
'damageReport',
'additionalDriver',
'reservationInsurance',
'notificationPreference',
'notification',
'review',
'rentalPayment',
'reservation',
'customer',
'renterSavedCompany',
'renter',
'offerVehicle',
'offer',
'vehicleCalendarBlock',
'maintenanceLog',
'vehicle',
'employee',
'pricingRule',
'insurancePolicy',
'accountingSettings',
'contractSettings',
'brandSettings',
'subscriptionInvoice',
'subscription',
'company',
] as const
// Wipe all data between test files in a safe order that respects FK constraints.
async function cleanDatabase() {
for (const delegate of delegates) {
await (prisma as any)[delegate].deleteMany({})
}
}
beforeAll(async () => {
await cleanDatabase()
})
afterAll(async () => {
await cleanDatabase()
await prisma.$disconnect()
})