fix bug 16 and 17
Build & Push / Pipeline Tests (push) Successful in 1m53s
Test / Type Check (all packages) (push) Successful in 56s
Build & Push / Build & Push Docker Image (push) Failing after 4m53s
Test / API Unit Tests (push) Successful in 1m15s
Test / Homepage Unit Tests (push) Successful in 46s
Test / Carplace Unit Tests (push) Successful in 41s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Successful in 42s
Test / API Integration Tests (push) Successful in 1m6s
Build & Push / Pipeline Tests (push) Successful in 1m53s
Test / Type Check (all packages) (push) Successful in 56s
Build & Push / Build & Push Docker Image (push) Failing after 4m53s
Test / API Unit Tests (push) Successful in 1m15s
Test / Homepage Unit Tests (push) Successful in 46s
Test / Carplace Unit Tests (push) Successful in 41s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Successful in 42s
Test / API Integration Tests (push) Successful in 1m6s
This commit is contained in:
@@ -492,7 +492,7 @@ export function listPlanFeatures() {
|
||||
})
|
||||
}
|
||||
|
||||
export function createPlanFeature(data: { plan: 'STARTER' | 'GROWTH' | 'PRO'; label: string; sortOrder?: number }) {
|
||||
export function createPlanFeature(data: { plan: 'STARTER' | 'GROWTH' | 'PRO' | 'ENTERPRISE'; label: string; sortOrder?: number }) {
|
||||
return prisma.planFeature.create({
|
||||
data: {
|
||||
plan: data.plan,
|
||||
@@ -502,7 +502,7 @@ export function createPlanFeature(data: { plan: 'STARTER' | 'GROWTH' | 'PRO'; la
|
||||
})
|
||||
}
|
||||
|
||||
export function updatePlanFeature(id: string, data: Partial<{ plan: 'STARTER' | 'GROWTH' | 'PRO'; label: string; sortOrder: number }>) {
|
||||
export function updatePlanFeature(id: string, data: Partial<{ plan: 'STARTER' | 'GROWTH' | 'PRO' | 'ENTERPRISE'; label: string; sortOrder: number }>) {
|
||||
return prisma.planFeature.update({
|
||||
where: { id },
|
||||
data,
|
||||
|
||||
@@ -150,7 +150,7 @@ export const adminCompanyUpdateSchema = z.object({
|
||||
}).optional(),
|
||||
}).optional(),
|
||||
subscription: z.object({
|
||||
plan: z.enum(['STARTER', 'GROWTH', 'PRO']).optional(),
|
||||
plan: z.enum(['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE']).optional(),
|
||||
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']).optional(),
|
||||
status: z.enum(['TRIALING', 'ACTIVE', 'PAST_DUE', 'CANCELLED', 'UNPAID']).optional(),
|
||||
currency: z.literal('MAD').optional(),
|
||||
@@ -316,14 +316,14 @@ export const homepageUpdateSchema = z.object({
|
||||
|
||||
export const pricingUpdateSchema = z.object({
|
||||
entries: z.array(z.object({
|
||||
plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
|
||||
plan: z.enum(['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE']),
|
||||
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
|
||||
amount: z.number().int().positive(),
|
||||
})).min(1),
|
||||
})
|
||||
|
||||
const planFeatureSchema = z.object({
|
||||
plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
|
||||
plan: z.enum(['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE']),
|
||||
label: z.string().min(1).max(120),
|
||||
sortOrder: z.number().int().min(0).default(0),
|
||||
})
|
||||
@@ -336,7 +336,7 @@ export const promotionCreateSchema = z.object({
|
||||
description: z.string().max(500).optional(),
|
||||
discountType: z.enum(['PERCENTAGE', 'FIXED']),
|
||||
discountValue: z.number().int().positive(),
|
||||
plans: z.array(z.enum(['STARTER', 'GROWTH', 'PRO'])),
|
||||
plans: z.array(z.enum(['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE'])),
|
||||
periods: z.array(z.enum(['MONTHLY', 'ANNUAL'])),
|
||||
maxUses: z.number().int().positive().nullable().optional(),
|
||||
validFrom: z.string().datetime(),
|
||||
@@ -350,7 +350,7 @@ export const planFeatureIdParamSchema = z.object({ featureId: z.string().min(1)
|
||||
export const promotionIdParamSchema = z.object({ promotionId: z.string().min(1) })
|
||||
|
||||
const employeeRoleSchema = z.enum(['OWNER', 'MANAGER', 'AGENT'])
|
||||
const planSchema = z.enum(['STARTER', 'GROWTH', 'PRO'])
|
||||
const planSchema = z.enum(['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE'])
|
||||
const menuItemTypeSchema = z.enum(['INTERNAL_PAGE', 'EXTERNAL_LINK', 'PARENT_MENU', 'SECTION_LABEL', 'DIVIDER'])
|
||||
|
||||
export const menuItemSchema = z.object({
|
||||
|
||||
@@ -11,6 +11,7 @@ export const accountStartSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8).max(128),
|
||||
preferredLanguage: z.enum(['en', 'fr', 'ar']).default('en'),
|
||||
subscriptionPlan: z.enum(['STARTER', 'GROWTH', 'PRO']).optional(),
|
||||
})
|
||||
|
||||
export const companyProfileSchema = z.object({
|
||||
@@ -57,6 +58,6 @@ export const paymentSetupSchema = z.object({
|
||||
})
|
||||
|
||||
export const planSchema = z.object({
|
||||
plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
|
||||
plan: z.enum(['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE']),
|
||||
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
|
||||
})
|
||||
|
||||
@@ -63,7 +63,7 @@ export async function startAccount(body: AccountStartInput) {
|
||||
await tx.subscription.create({
|
||||
data: {
|
||||
companyId: company.id,
|
||||
plan: 'STARTER',
|
||||
plan: body.subscriptionPlan ?? 'STARTER',
|
||||
billingPeriod: 'MONTHLY',
|
||||
currency: 'MAD',
|
||||
status: 'TRIALING',
|
||||
|
||||
@@ -32,7 +32,7 @@ type CompanySignupInput = {
|
||||
responsibleEmail: string
|
||||
currency: 'MAD'
|
||||
registrationNumber: string
|
||||
plan: 'STARTER' | 'GROWTH' | 'PRO'
|
||||
plan: 'STARTER' | 'GROWTH' | 'PRO' | 'ENTERPRISE'
|
||||
billingPeriod: 'MONTHLY' | 'ANNUAL'
|
||||
preferredLanguage: 'en' | 'fr' | 'ar'
|
||||
firstName: string
|
||||
|
||||
@@ -31,7 +31,7 @@ export const companySignupSchema = z.object({
|
||||
responsiblePhone: z.string().min(1).max(80),
|
||||
responsibleEmail: z.string().email(),
|
||||
preferredLanguage: z.enum(['en', 'fr', 'ar']).default('en'),
|
||||
plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
|
||||
plan: z.enum(['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE']),
|
||||
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
|
||||
currency: z.literal('MAD'),
|
||||
paymentProvider: z.enum(['AMANPAY', 'PAYPAL']),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { accountStartSchema } from './auth.account.schemas'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { companySignupSchema } from './auth.company.schemas'
|
||||
import { employeeForgotPasswordSchema, employeeLanguageSchema, employeeLoginSchema, employeeResetPasswordSchema } from './auth.employee.schemas'
|
||||
@@ -36,15 +37,30 @@ describe('auth schemas', () => {
|
||||
paymentProvider: 'AMANPAY',
|
||||
} as const
|
||||
|
||||
it('defaults company signup language and rejects unsupported commercial choices', () => {
|
||||
it('defaults company signup language and validates 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, plan: 'ENTERPRISE' }).success).toBe(true)
|
||||
expect(companySignupSchema.safeParse({ ...validCompanySignup, currency: 'EUR' }).success).toBe(false)
|
||||
expect(companySignupSchema.safeParse({ ...validCompanySignup, paymentProvider: 'STRIPE' }).success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts optional subscription plan for minimal account start', () => {
|
||||
const parsed = accountStartSchema.parse({
|
||||
firstName: 'Aya',
|
||||
lastName: 'Benali',
|
||||
companyName: 'Atlas Cars',
|
||||
email: 'owner@example.test',
|
||||
password: 'safe-password',
|
||||
preferredLanguage: 'fr',
|
||||
subscriptionPlan: 'PRO',
|
||||
})
|
||||
|
||||
expect(parsed.subscriptionPlan).toBe('PRO')
|
||||
expect(accountStartSchema.safeParse({ ...parsed, subscriptionPlan: 'TEAM' }).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',
|
||||
|
||||
@@ -30,7 +30,7 @@ export type SettingsSectionKey =
|
||||
| 'accounting'
|
||||
|
||||
type Locale = 'en' | 'fr' | 'ar'
|
||||
type Plan = 'STARTER' | 'GROWTH' | 'PRO'
|
||||
type Plan = 'STARTER' | 'GROWTH' | 'PRO' | 'ENTERPRISE'
|
||||
type SubscriptionStatus =
|
||||
| 'TRIALING'
|
||||
| 'ACTIVE'
|
||||
@@ -44,9 +44,9 @@ type SubscriptionStatus =
|
||||
|
||||
type MenuState = 'ENABLED' | 'LOCKED'
|
||||
|
||||
const ALL_PLANS: Plan[] = ['STARTER', 'GROWTH', 'PRO']
|
||||
const GROWTH_PLUS: Plan[] = ['GROWTH', 'PRO']
|
||||
const PRO_ONLY: Plan[] = ['PRO']
|
||||
const ALL_PLANS: Plan[] = ['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE']
|
||||
const GROWTH_PLUS: Plan[] = ['GROWTH', 'PRO', 'ENTERPRISE']
|
||||
const PRO_ONLY: Plan[] = ['PRO', 'ENTERPRISE']
|
||||
|
||||
const SECTION_COPY: Record<Locale, Record<SettingsSectionKey, { label: string; description: string }>> = {
|
||||
en: {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { prisma } from '../../lib/prisma'
|
||||
import { getAccessLevel, hasFullAccess, type AccessLevel } from '../subscriptions/subscription.policy'
|
||||
|
||||
type EmployeeRole = 'OWNER' | 'MANAGER' | 'AGENT'
|
||||
type Plan = 'STARTER' | 'GROWTH' | 'PRO'
|
||||
type Plan = 'STARTER' | 'GROWTH' | 'PRO' | 'ENTERPRISE'
|
||||
type MenuItemType = 'INTERNAL_PAGE' | 'EXTERNAL_LINK' | 'PARENT_MENU' | 'SECTION_LABEL' | 'DIVIDER'
|
||||
|
||||
type AdminActor = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const planEnum = z.enum(['STARTER', 'GROWTH', 'PRO'])
|
||||
const planEnum = z.enum(['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE'])
|
||||
const billingPeriodEnum = z.enum(['MONTHLY', 'ANNUAL'])
|
||||
const providerEnum = z.enum(['STRIPE'])
|
||||
const currencyEnum = z.enum(['MAD', 'EUR', 'USD'])
|
||||
|
||||
@@ -259,7 +259,7 @@ export async function handleStripeWebhook(event: any, rawBody: string | Buffer =
|
||||
// ─── Checkout ─────────────────────────────────────────────────
|
||||
|
||||
export async function checkout(companyId: string, body: {
|
||||
plan: 'STARTER' | 'GROWTH' | 'PRO'; billingPeriod: 'MONTHLY' | 'ANNUAL'
|
||||
plan: 'STARTER' | 'GROWTH' | 'PRO' | 'ENTERPRISE'; billingPeriod: 'MONTHLY' | 'ANNUAL'
|
||||
currency: 'MAD'; provider: 'STRIPE'
|
||||
successUrl: string; failureUrl: string
|
||||
}) {
|
||||
@@ -367,7 +367,7 @@ export async function resume(companyId: string) {
|
||||
// ─── Reactivation ────────────────────────────────────────────
|
||||
|
||||
export async function reactivate(companyId: string, body: {
|
||||
plan: 'STARTER' | 'GROWTH' | 'PRO'; billingPeriod: 'MONTHLY' | 'ANNUAL'
|
||||
plan: 'STARTER' | 'GROWTH' | 'PRO' | 'ENTERPRISE'; billingPeriod: 'MONTHLY' | 'ANNUAL'
|
||||
currency: 'MAD'; provider: 'STRIPE'
|
||||
successUrl: string; failureUrl: string
|
||||
}) {
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
import { prisma } from '../../lib/prisma'
|
||||
|
||||
export async function getCompanySubscription(companyId: string) {
|
||||
return prisma.subscription.findUnique({ where: { companyId } })
|
||||
}
|
||||
|
||||
export async function findPlanFeatures(plan: string) {
|
||||
return prisma.planFeature.findMany({
|
||||
where: { plan: plan as any },
|
||||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
|
||||
})
|
||||
}
|
||||
|
||||
export async function countActiveFleetVehicles(companyId: string) {
|
||||
return prisma.vehicle.count({
|
||||
where: {
|
||||
companyId,
|
||||
status: { not: 'OUT_OF_SERVICE' },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function findMany(where: any, skip: number, take: number) {
|
||||
return Promise.all([
|
||||
prisma.vehicle.findMany({ where, skip, take, orderBy: { createdAt: 'desc' } }),
|
||||
|
||||
@@ -4,6 +4,9 @@ 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(),
|
||||
getCompanySubscription: vi.fn(),
|
||||
findPlanFeatures: vi.fn(),
|
||||
countActiveFleetVehicles: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
updateById: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
@@ -35,6 +38,11 @@ const vehicle = {
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
beforeEach(() => {
|
||||
vi.mocked(repo.getCompanySubscription).mockResolvedValue({ plan: 'STARTER' } as any)
|
||||
vi.mocked(repo.findPlanFeatures).mockResolvedValue([{ label: 'Up to 25 vehicles' }] as any)
|
||||
vi.mocked(repo.countActiveFleetVehicles).mockResolvedValue(0)
|
||||
})
|
||||
|
||||
describe('vehicle.service edge behavior', () => {
|
||||
it('deduplicates and trims location settings when creating vehicles', async () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { PLAN_FEATURES } from '@rentaldrivego/types'
|
||||
import { uploadImage } from '../../lib/storage'
|
||||
import { AppError, NotFoundError, ValidationError } from '../../http/errors'
|
||||
import { presentVehicle, presentVehicleList } from './vehicle.presenter'
|
||||
@@ -349,6 +350,13 @@ const VEHICLE_STATUSES = ['AVAILABLE', 'RESERVED', 'READY', 'RENTED', 'RETURNED'
|
||||
const VEHICLE_CATEGORIES = ['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK'] as const
|
||||
const VEHICLE_TRANSMISSIONS = ['AUTOMATIC', 'MANUAL'] as const
|
||||
const VEHICLE_FUEL_TYPES = ['GASOLINE', 'DIESEL', 'ELECTRIC', 'HYBRID'] as const
|
||||
const ACTIVE_FLEET_STATUSES = new Set(VEHICLE_STATUSES.filter((status) => status !== 'OUT_OF_SERVICE'))
|
||||
const FALLBACK_VEHICLE_LIMITS: Record<string, number | null> = {
|
||||
STARTER: 25,
|
||||
GROWTH: 75,
|
||||
PRO: 150,
|
||||
ENTERPRISE: null,
|
||||
}
|
||||
|
||||
function listTextVariants(value: string) {
|
||||
const lower = value.toLowerCase()
|
||||
@@ -357,6 +365,47 @@ function listTextVariants(value: string) {
|
||||
return Array.from(new Set([value, lower, upper, title]))
|
||||
}
|
||||
|
||||
function parseVehicleLimit(labels: string[]) {
|
||||
for (const label of labels) {
|
||||
if (!/\bvehicles?\b/i.test(label)) continue
|
||||
if (/\bunlimited\b/i.test(label)) return null
|
||||
|
||||
const openEndedMatch = label.match(/\b(\d+)\s*\+/)
|
||||
if (openEndedMatch) return null
|
||||
|
||||
const match = label.match(/\b(\d+)\b/)
|
||||
if (match) return Number(match[1])
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
async function getVehicleLimitForCompany(companyId: string) {
|
||||
const subscription = await repo.getCompanySubscription(companyId)
|
||||
const plan = subscription?.plan
|
||||
if (!plan) {
|
||||
throw new ValidationError('A subscription plan is required before adding vehicles')
|
||||
}
|
||||
|
||||
const persistedFeatures = await repo.findPlanFeatures(plan)
|
||||
const persistedLimit = parseVehicleLimit(persistedFeatures.map((feature: any) => feature.label))
|
||||
if (persistedLimit !== undefined) return persistedLimit
|
||||
|
||||
const fallbackLimit = parseVehicleLimit(PLAN_FEATURES[plan] ?? [])
|
||||
if (fallbackLimit !== undefined) return fallbackLimit
|
||||
|
||||
return FALLBACK_VEHICLE_LIMITS[plan] ?? null
|
||||
}
|
||||
|
||||
async function assertCanAddActiveFleetVehicle(companyId: string) {
|
||||
const limit = await getVehicleLimitForCompany(companyId)
|
||||
if (limit == null) return
|
||||
|
||||
const activeCount = await repo.countActiveFleetVehicles(companyId)
|
||||
if (activeCount >= limit) {
|
||||
throw new ValidationError(`Your subscription plan allows up to ${limit} active vehicles. Upgrade your plan or retire a vehicle before adding another one.`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function listVehicles(companyId: string, query: { status?: string; category?: string; published?: string; search?: string; page?: number; pageSize?: number }) {
|
||||
const page = query.page ?? 1
|
||||
const pageSize = query.pageSize ?? 20
|
||||
@@ -413,9 +462,15 @@ export async function getVehicle(id: string, companyId: string) {
|
||||
}
|
||||
|
||||
export async function createVehicle(data: any, companyId: string) {
|
||||
const patch = applyLocationSettings(data)
|
||||
const status = patch.status ?? 'AVAILABLE'
|
||||
if (ACTIVE_FLEET_STATUSES.has(status as any)) {
|
||||
await assertCanAddActiveFleetVehicle(companyId)
|
||||
}
|
||||
|
||||
return presentVehicle(await repo.create({
|
||||
dailyRate: 0,
|
||||
...applyLocationSettings(data),
|
||||
...patch,
|
||||
companyId,
|
||||
}))
|
||||
}
|
||||
@@ -424,11 +479,14 @@ const PUBLISHED_STATUSES = new Set(['AVAILABLE', 'RESERVED', 'READY', 'RENTED'])
|
||||
|
||||
export async function updateVehicle(id: string, companyId: string, data: any) {
|
||||
const patch = applyLocationSettings(data)
|
||||
if (patch.status) {
|
||||
if (typeof patch.status === 'string') {
|
||||
patch.isPublished = PUBLISHED_STATUSES.has(patch.status)
|
||||
}
|
||||
const existing = await repo.findFirst(id, companyId)
|
||||
if (!existing) throw new NotFoundError('Vehicle not found')
|
||||
if (existing.status === 'OUT_OF_SERVICE' && typeof patch.status === 'string' && ACTIVE_FLEET_STATUSES.has(patch.status as any)) {
|
||||
await assertCanAddActiveFleetVehicle(companyId)
|
||||
}
|
||||
const updated = await repo.updateById(id, patch)
|
||||
if (patch.dailyRate !== undefined && typeof patch.dailyRate === 'number') {
|
||||
await syncPricingBaseRateFromVehicle(id, existing.dailyRate, patch.dailyRate)
|
||||
@@ -439,6 +497,9 @@ export async function updateVehicle(id: string, companyId: string, data: any) {
|
||||
export async function setStatus(id: string, companyId: string, status: string) {
|
||||
const existing = await repo.findFirst(id, companyId)
|
||||
if (!existing) throw new NotFoundError('Vehicle not found')
|
||||
if (existing.status === 'OUT_OF_SERVICE' && ACTIVE_FLEET_STATUSES.has(status as any)) {
|
||||
await assertCanAddActiveFleetVehicle(companyId)
|
||||
}
|
||||
const isPublished = PUBLISHED_STATUSES.has(status)
|
||||
return presentVehicle(await repo.updateById(id, { status, isPublished }))
|
||||
}
|
||||
|
||||
@@ -25,6 +25,9 @@ const mockVehicle = {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(repo.getCompanySubscription).mockResolvedValue({ plan: 'STARTER' } as any)
|
||||
vi.mocked(repo.findPlanFeatures).mockResolvedValue([{ label: 'Up to 25 vehicles' }] as any)
|
||||
vi.mocked(repo.countActiveFleetVehicles).mockResolvedValue(0)
|
||||
})
|
||||
|
||||
describe('vehicle.service', () => {
|
||||
@@ -91,6 +94,7 @@ describe('vehicle.service', () => {
|
||||
it('creates a vehicle with companyId', async () => {
|
||||
vi.mocked(repo.create).mockResolvedValue(mockVehicle as any)
|
||||
const result = await service.createVehicle({ make: 'Toyota', model: 'Camry', year: 2022, licensePlate: 'ABC-123', dailyRate: 500 }, 'comp_1')
|
||||
expect(repo.countActiveFleetVehicles).toHaveBeenCalledWith('comp_1')
|
||||
expect(repo.create).toHaveBeenCalledWith(expect.objectContaining({ companyId: 'comp_1' }))
|
||||
expect(result).toEqual(mockVehicle)
|
||||
})
|
||||
@@ -100,6 +104,57 @@ describe('vehicle.service', () => {
|
||||
await service.createVehicle({ make: 'Toyota', model: 'Camry', year: 2022, licensePlate: 'ABC-123' }, 'comp_1')
|
||||
expect(repo.create).toHaveBeenCalledWith(expect.objectContaining({ companyId: 'comp_1', dailyRate: 0 }))
|
||||
})
|
||||
|
||||
it('rejects active vehicle creation when the subscription vehicle limit is reached', async () => {
|
||||
vi.mocked(repo.countActiveFleetVehicles).mockResolvedValue(25)
|
||||
|
||||
await expect(service.createVehicle({
|
||||
make: 'Toyota',
|
||||
model: 'Camry',
|
||||
year: 2022,
|
||||
licensePlate: 'ABC-123',
|
||||
}, 'comp_1')).rejects.toThrow('allows up to 25 active vehicles')
|
||||
|
||||
expect(repo.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses the numeric vehicle limit from persisted plan features', async () => {
|
||||
vi.mocked(repo.findPlanFeatures).mockResolvedValue([{ label: 'Up to 3 vehicles' }] as any)
|
||||
vi.mocked(repo.countActiveFleetVehicles).mockResolvedValue(3)
|
||||
|
||||
await expect(service.createVehicle({
|
||||
make: 'Toyota',
|
||||
model: 'Camry',
|
||||
year: 2022,
|
||||
licensePlate: 'ABC-123',
|
||||
}, 'comp_1')).rejects.toThrow('allows up to 3 active vehicles')
|
||||
})
|
||||
|
||||
it('enforces the PRO 150 vehicle limit', async () => {
|
||||
vi.mocked(repo.getCompanySubscription).mockResolvedValue({ plan: 'PRO' } as any)
|
||||
vi.mocked(repo.findPlanFeatures).mockResolvedValue([{ label: 'Up to 150 vehicles' }] as any)
|
||||
vi.mocked(repo.countActiveFleetVehicles).mockResolvedValue(150)
|
||||
|
||||
await expect(service.createVehicle({
|
||||
make: 'Toyota',
|
||||
model: 'Camry',
|
||||
year: 2022,
|
||||
licensePlate: 'ABC-123',
|
||||
}, 'comp_1')).rejects.toThrow('allows up to 150 active vehicles')
|
||||
|
||||
expect(repo.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows open-ended enterprise vehicle plans', async () => {
|
||||
vi.mocked(repo.getCompanySubscription).mockResolvedValue({ plan: 'ENTERPRISE' } as any)
|
||||
vi.mocked(repo.findPlanFeatures).mockResolvedValue([{ label: '150+ vehicles' }] as any)
|
||||
vi.mocked(repo.create).mockResolvedValue(mockVehicle as any)
|
||||
|
||||
await service.createVehicle({ make: 'Toyota', model: 'Camry', year: 2022, licensePlate: 'ABC-123' }, 'comp_1')
|
||||
|
||||
expect(repo.countActiveFleetVehicles).not.toHaveBeenCalled()
|
||||
expect(repo.create).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateVehicle', () => {
|
||||
@@ -121,6 +176,15 @@ describe('vehicle.service', () => {
|
||||
await service.updateVehicle('veh_1', 'comp_1', { status: 'AVAILABLE' })
|
||||
expect(repo.updateById).toHaveBeenCalledWith('veh_1', expect.objectContaining({ isPublished: true }))
|
||||
})
|
||||
|
||||
it('rejects reactivating an out-of-service vehicle when the fleet limit is reached', async () => {
|
||||
vi.mocked(repo.findFirst).mockResolvedValue({ ...mockVehicle, status: 'OUT_OF_SERVICE' } as any)
|
||||
vi.mocked(repo.countActiveFleetVehicles).mockResolvedValue(25)
|
||||
|
||||
await expect(service.updateVehicle('veh_1', 'comp_1', { status: 'AVAILABLE' })).rejects.toThrow('allows up to 25 active vehicles')
|
||||
|
||||
expect(repo.updateById).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('uploadPhotos', () => {
|
||||
|
||||
Reference in New Issue
Block a user