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