fix architecture and write new tests

This commit is contained in:
root
2026-06-10 00:40:19 -04:00
parent 560da1cadf
commit 80a597bc10
377 changed files with 84020 additions and 1337 deletions
@@ -0,0 +1,83 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('../../lib/prisma', () => ({
prisma: {
company: { findUniqueOrThrow: vi.fn(), update: vi.fn(), findFirst: vi.fn() },
brandSettings: { findUnique: vi.fn(), upsert: vi.fn(), findFirst: vi.fn(), updateMany: vi.fn() },
contractSettings: { findUnique: vi.fn(), upsert: vi.fn() },
insurancePolicy: { findMany: vi.fn(), create: vi.fn(), updateMany: vi.fn(), findUniqueOrThrow: vi.fn(), deleteMany: vi.fn() },
pricingRule: { findMany: vi.fn(), create: vi.fn(), updateMany: vi.fn(), findUniqueOrThrow: vi.fn(), deleteMany: vi.fn() },
accountingSettings: { findUnique: vi.fn(), upsert: vi.fn() },
},
}))
import { prisma } from '../../lib/prisma'
import * as repo from './company.repo'
describe('company.repo edge queries', () => {
beforeEach(() => vi.clearAllMocks())
it('loads a company dashboard envelope with brand, subscription and counts', async () => {
await repo.findCompany('company_1')
expect(prisma.company.findUniqueOrThrow).toHaveBeenCalledWith({
where: { id: 'company_1' },
include: {
brand: true,
subscription: true,
_count: { select: { vehicles: true, customers: true, reservations: true } },
},
})
})
it('upserts brand settings with companyId only in the create branch', async () => {
await repo.upsertBrand('company_1', { logoUrl: '/new.png' }, { primaryColor: '#111111' })
expect(prisma.brandSettings.upsert).toHaveBeenCalledWith({
where: { companyId: 'company_1' },
update: { logoUrl: '/new.png' },
create: { companyId: 'company_1', primaryColor: '#111111' },
})
})
it('orders insurance policies by required status, sort order and name', async () => {
await repo.findInsurancePolicies('company_1')
expect(prisma.insurancePolicy.findMany).toHaveBeenCalledWith({
where: { companyId: 'company_1' },
orderBy: [{ isRequired: 'desc' }, { sortOrder: 'asc' }, { name: 'asc' }],
})
})
it('updates and deletes pricing rules inside the tenant boundary', async () => {
await repo.updatePricingRule('rule_1', 'company_1', { isActive: false })
await repo.deletePricingRule('rule_1', 'company_1')
expect(prisma.pricingRule.updateMany).toHaveBeenCalledWith({
where: { id: 'rule_1', companyId: 'company_1' },
data: { isActive: false },
})
expect(prisma.pricingRule.deleteMany).toHaveBeenCalledWith({ where: { id: 'rule_1', companyId: 'company_1' } })
})
it('detects duplicate public branding outside the current company', async () => {
await repo.findBrandBySubdomain('atlas', 'company_1')
await repo.findBrandByCustomDomain('cars.example.test', 'company_1')
expect(prisma.brandSettings.findFirst).toHaveBeenNthCalledWith(1, {
where: { subdomain: 'atlas', companyId: { not: 'company_1' } },
})
expect(prisma.brandSettings.findFirst).toHaveBeenNthCalledWith(2, {
where: { customDomain: 'cars.example.test', companyId: { not: 'company_1' } },
})
})
it('clears custom domain verification metadata in one tenant-scoped mutation', async () => {
await repo.clearCustomDomain('company_1')
expect(prisma.brandSettings.updateMany).toHaveBeenCalledWith({
where: { companyId: 'company_1' },
data: { customDomain: null, customDomainVerified: false, customDomainAddedAt: null },
})
})
})
+25 -5
View File
@@ -1,4 +1,5 @@
import { prisma } from '../../lib/prisma'
import { generateCompanyApiKey } from '../../security/apiKeys'
export async function findCompany(companyId: string) {
return prisma.company.findUniqueOrThrow({
@@ -98,15 +99,34 @@ export async function upsertAccountingSettings(companyId: string, data: any) {
}
export async function findApiKey(companyId: string) {
return prisma.company.findUniqueOrThrow({ where: { id: companyId }, select: { apiKey: true } })
const apiKeys = await (prisma as any).companyApiKey.findMany({
where: { companyId, revokedAt: null },
orderBy: { createdAt: 'desc' },
select: { id: true, name: true, prefix: true, lastUsedAt: true, createdAt: true, revokedAt: true },
})
return { apiKeys }
}
export async function regenerateApiKey(companyId: string) {
return prisma.company.update({
where: { id: companyId },
data: { apiKey: `api_${Math.random().toString(36).slice(2)}${Date.now()}` },
select: { apiKey: true },
const nextKey = generateCompanyApiKey()
await prisma.$transaction(async (tx: any) => {
await (tx as any).companyApiKey.updateMany({
where: { companyId, revokedAt: null },
data: { revokedAt: new Date() },
})
await (tx as any).companyApiKey.create({
data: {
companyId,
name: 'Default API key',
prefix: nextKey.prefix,
keyHash: nextKey.keyHash,
},
})
})
return { apiKey: nextKey.rawKey, prefix: nextKey.prefix, warning: 'This raw API key is shown once. Store it securely.' }
}
export async function findBrandBySubdomain(subdomain: string, excludeCompanyId: string) {
@@ -3,6 +3,7 @@ import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
import { requireTenant } from '../../middleware/requireTenant'
import { requireSubscription } from '../../middleware/requireSubscription'
import { requireRole } from '../../middleware/requireRole'
import { requireCompanyPolicy } from '../../middleware/requireCompanyPolicy'
import { parseBody, parseParams } from '../../http/validate'
import { ok, created, noContent } from '../../http/respond'
import { imageUpload, assertImageFile } from '../../http/upload'
@@ -164,7 +165,7 @@ router.patch('/me/pricing-rules/:id', requireRole('MANAGER'), async (req, res, n
} catch (err) { next(err) }
})
router.delete('/me/pricing-rules/:id', async (req, res, next) => {
router.delete('/me/pricing-rules/:id', requireCompanyPolicy('deletePricingRule'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
await service.deletePricingRule(id, req.companyId)
@@ -179,7 +180,7 @@ router.get('/me/accounting-settings', async (req, res, next) => {
} catch (err) { next(err) }
})
router.patch('/me/accounting-settings', async (req, res, next) => {
router.patch('/me/accounting-settings', requireCompanyPolicy('updateAccountingSettings'), async (req, res, next) => {
try {
const body = parseBody(accountingSettingsSchema, req)
const settings = await service.updateAccountingSettings(req.companyId, body)
@@ -187,14 +188,14 @@ router.patch('/me/accounting-settings', async (req, res, next) => {
} catch (err) { next(err) }
})
router.get('/me/api-key', async (req, res, next) => {
router.get('/me/api-key', requireCompanyPolicy('viewApiKey'), async (req, res, next) => {
try {
const company = await service.getApiKey(req.companyId)
ok(res, company)
} catch (err) { next(err) }
})
router.post('/me/api-key/regenerate', async (req, res, next) => {
router.post('/me/api-key/regenerate', requireCompanyPolicy('regenerateApiKey'), async (req, res, next) => {
try {
const company = await service.regenerateApiKey(req.companyId)
ok(res, company)
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest'
import { accountingSettingsSchema, brandSchema, companySchema, contractSettingsSchema, customDomainSchema, insurancePolicySchema, pricingRuleSchema, subdomainSchema } from './company.schemas'
describe('company schemas edge cases', () => {
it('validates company and brand public configuration boundaries', () => {
expect(companySchema.parse({ email: 'ops@example.test', address: { city: 'Rabat' } })).toMatchObject({ email: 'ops@example.test' })
expect(companySchema.safeParse({ email: 'bad-email' }).success).toBe(false)
const brand = brandSchema.parse({
displayName: 'Atlas',
websiteUrl: 'https://atlas.example.test',
paypalEmail: 'paypal@example.test',
defaultCurrency: 'MAD',
homePageConfig: { showOffers: true, layout: { items: [{ id: 'hero-1', type: 'hero', x: 1, y: 1, w: 12, h: 4 }] } },
})
expect(brand.homePageConfig?.layout?.items[0].type).toBe('hero')
expect(brandSchema.safeParse({ websiteUrl: 'not-url' }).success).toBe(false)
expect(brandSchema.safeParse({ defaultCurrency: 'EUR' }).success).toBe(false)
})
it('keeps contract, accounting, insurance, and pricing settings inside known enums', () => {
expect(contractSettingsSchema.parse({ fuelPolicyType: 'FULL_TO_FULL', additionalDriverCharge: 'PER_DAY', taxRate: 20 })).toMatchObject({ fuelPolicyType: 'FULL_TO_FULL' })
expect(contractSettingsSchema.safeParse({ fuelPolicyType: 'CHAOS' }).success).toBe(false)
expect(accountingSettingsSchema.parse({ reportingPeriod: 'MONTHLY', fiscalYearStart: 1, currency: 'MAD' })).toMatchObject({ fiscalYearStart: 1 })
expect(accountingSettingsSchema.safeParse({ fiscalYearStart: 13 }).success).toBe(false)
expect(insurancePolicySchema.parse({ name: 'CDW', type: 'CDW', chargeType: 'PER_DAY', chargeValue: 100 })).toMatchObject({ isActive: true, isRequired: false, sortOrder: 0 })
expect(insurancePolicySchema.safeParse({ name: 'CDW', type: 'CDW', chargeType: 'PER_DAY', chargeValue: -1 }).success).toBe(false)
expect(pricingRuleSchema.safeParse({ name: 'Young driver', type: 'SURCHARGE', condition: 'AGE_LESS_THAN', conditionValue: 25, adjustmentType: 'PERCENTAGE', adjustmentValue: 10 }).success).toBe(true)
expect(pricingRuleSchema.safeParse({ name: '', type: 'SURCHARGE', condition: 'AGE_LESS_THAN', conditionValue: 25, adjustmentType: 'PERCENTAGE', adjustmentValue: 10 }).success).toBe(false)
})
it('requires minimal custom domain and subdomain lengths', () => {
expect(subdomainSchema.safeParse({ subdomain: 'ab' }).success).toBe(false)
expect(customDomainSchema.safeParse({ customDomain: 'x.io' }).success).toBe(true)
})
})
@@ -0,0 +1,132 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('../../lib/storage', () => ({ uploadImage: vi.fn().mockResolvedValue('/storage/companies/company_1/brand/logo.jpg') }))
vi.mock('./company.repo', () => ({
findCompany: vi.fn(),
updateCompany: vi.fn(),
findBrand: vi.fn(),
upsertBrand: vi.fn(),
findBrandBySubdomain: vi.fn(),
findBrandByCustomDomain: vi.fn(),
clearCustomDomain: vi.fn(),
findContractSettings: vi.fn(),
upsertContractSettings: vi.fn(),
findInsurancePolicies: vi.fn(),
createInsurancePolicy: vi.fn(),
updateInsurancePolicy: vi.fn(),
findInsurancePolicyOrThrow: vi.fn(),
deleteInsurancePolicy: vi.fn(),
findPricingRules: vi.fn(),
createPricingRule: vi.fn(),
updatePricingRule: vi.fn(),
findPricingRuleOrThrow: vi.fn(),
deletePricingRule: vi.fn(),
findAccountingSettings: vi.fn(),
upsertAccountingSettings: vi.fn(),
findApiKey: vi.fn(),
regenerateApiKey: vi.fn(),
}))
const repo = await import('./company.repo')
const service = await import('./company.service')
const currentBrand = {
id: 'brand_1',
companyId: 'company_1',
displayName: 'Atlas Cars',
subdomain: 'atlas',
amanpayMerchantId: 'merchant_1',
amanpaySecretKey: 'secret_1',
paypalEmail: null,
paypalMerchantId: null,
paymentMethodsEnabled: ['AMANPAY'],
}
beforeEach(() => {
vi.clearAllMocks()
delete process.env.NEXT_PUBLIC_CUSTOM_DOMAIN_TARGET
})
describe('company.service edge behavior', () => {
it('preserves existing AmanPay credentials when recomputing enabled payment methods', async () => {
vi.mocked(repo.findBrand).mockResolvedValue(currentBrand as any)
vi.mocked(repo.upsertBrand).mockResolvedValue({ ...currentBrand, paypalEmail: 'billing@example.test' } as any)
const result = await service.updateBrand('company_1', { paypalEmail: 'billing@example.test' }, 'Atlas Cars', 'atlas')
expect(repo.upsertBrand).toHaveBeenCalledWith(
'company_1',
expect.objectContaining({
paypalEmail: 'billing@example.test',
paymentMethodsEnabled: ['AMANPAY', 'PAYPAL'],
}),
expect.objectContaining({
displayName: 'Atlas Cars',
subdomain: 'atlas',
paypalEmail: 'billing@example.test',
paymentMethodsEnabled: ['AMANPAY', 'PAYPAL'],
}),
)
expect(result).not.toHaveProperty('paypalEmail')
expect(result.paypalConfigured).toBe(true)
})
it('does not report AmanPay enabled when only one credential is available', async () => {
vi.mocked(repo.findBrand).mockResolvedValue({ ...currentBrand, amanpaySecretKey: null } as any)
vi.mocked(repo.upsertBrand).mockResolvedValue({ ...currentBrand, amanpaySecretKey: null } as any)
await service.updateBrand('company_1', {}, 'Atlas Cars', 'atlas')
expect(repo.upsertBrand).toHaveBeenCalledWith(
'company_1',
expect.objectContaining({ paymentMethodsEnabled: [] }),
expect.objectContaining({ paymentMethodsEnabled: [] }),
)
})
it('normalizes custom domains and marks them pending verification', async () => {
vi.mocked(repo.findBrandByCustomDomain).mockResolvedValue(null as any)
vi.mocked(repo.upsertBrand).mockResolvedValue({ id: 'brand_1', customDomain: 'cars.example.com' } as any)
await service.setCustomDomain('company_1', 'Atlas Cars', 'atlas', ' CARS.Example.COM ')
expect(repo.findBrandByCustomDomain).toHaveBeenCalledWith('cars.example.com', 'company_1')
expect(repo.upsertBrand).toHaveBeenCalledWith(
'company_1',
expect.objectContaining({ customDomain: 'cars.example.com', customDomainVerified: false, customDomainAddedAt: expect.any(Date) }),
expect.objectContaining({ displayName: 'Atlas Cars', subdomain: 'atlas', customDomain: 'cars.example.com', customDomainVerified: false, customDomainAddedAt: expect.any(Date) }),
)
})
it('rejects custom domains already owned by another company', async () => {
vi.mocked(repo.findBrandByCustomDomain).mockResolvedValue({ id: 'other_brand' } as any)
await expect(service.setCustomDomain('company_1', 'Atlas Cars', 'atlas', 'cars.example.com')).rejects.toMatchObject({ statusCode: 409 })
expect(repo.upsertBrand).not.toHaveBeenCalled()
})
it('returns deterministic custom-domain status and honors configured DNS target', async () => {
process.env.NEXT_PUBLIC_CUSTOM_DOMAIN_TARGET = 'tenant-target.example.net'
vi.mocked(repo.findBrand).mockResolvedValue({ customDomain: 'cars.example.com', customDomainVerified: false } as any)
await expect(service.getCustomDomainStatus('company_1')).resolves.toEqual({
customDomain: 'cars.example.com',
verified: false,
status: 'pending_dns',
dnsTarget: 'tenant-target.example.net',
})
})
it('raises not found when updating a missing insurance policy', async () => {
vi.mocked(repo.updateInsurancePolicy).mockResolvedValue({ count: 0 } as any)
await expect(service.updateInsurancePolicy('policy_1', 'company_1', { name: 'CDW' })).rejects.toMatchObject({ statusCode: 404 })
expect(repo.findInsurancePolicyOrThrow).not.toHaveBeenCalled()
})
it('raises not found when deleting a missing pricing rule', async () => {
vi.mocked(repo.deletePricingRule).mockResolvedValue({ count: 0 } as any)
await expect(service.deletePricingRule('rule_1', 'company_1')).rejects.toMatchObject({ statusCode: 404 })
})
})