fix architecture and write new tests
This commit is contained in:
@@ -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 })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user