From 6cf2c23170f237b9fc1038cd8b064b04b269f6bb Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Thu, 16 Apr 2026 17:43:48 +0700 Subject: [PATCH] feat(listings): add source field to PriceHistory + unit tests - Add `source` column to PriceHistory Prisma model (manual_update, admin_override, market_adjustment) - Add migration for the new column with default 'manual_update' - Update ListingPriceChangedEvent domain event with optional source parameter - Update RecordPriceHistoryHandler to persist source - Update GetPriceHistoryHandler to return source in query results - Add unit tests for RecordPriceHistoryHandler (5 cases) - Add unit tests for GetPriceHistoryHandler (3 cases) - Add ListingPriceChangedEvent tests to domain events spec (4 cases) - Add getPriceHistory controller tests (2 cases) All 1805 tests pass, typecheck clean. Co-Authored-By: Paperclip --- .../get-price-history.handler.spec.ts | 58 ++++++++++++ .../record-price-history.handler.spec.ts | 94 +++++++++++++++++++ .../record-price-history.handler.ts | 1 + .../get-price-history.handler.ts | 2 + .../domain/__tests__/listing-events.spec.ts | 29 ++++++ .../events/listing-price-changed.event.ts | 3 + .../__tests__/listings.controller.spec.ts | 23 +++++ .../migration.sql | 2 + prisma/schema.prisma | 1 + 9 files changed, 213 insertions(+) create mode 100644 apps/api/src/modules/listings/application/__tests__/get-price-history.handler.spec.ts create mode 100644 apps/api/src/modules/listings/application/__tests__/record-price-history.handler.spec.ts create mode 100644 prisma/migrations/20260416600000_add_source_to_price_history/migration.sql diff --git a/apps/api/src/modules/listings/application/__tests__/get-price-history.handler.spec.ts b/apps/api/src/modules/listings/application/__tests__/get-price-history.handler.spec.ts new file mode 100644 index 0000000..41ee1e5 --- /dev/null +++ b/apps/api/src/modules/listings/application/__tests__/get-price-history.handler.spec.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { GetPriceHistoryHandler } from '../queries/get-price-history/get-price-history.handler'; +import { GetPriceHistoryQuery } from '../queries/get-price-history/get-price-history.query'; + +describe('GetPriceHistoryHandler', () => { + let handler: GetPriceHistoryHandler; + let mockPrisma: { priceHistory: { findMany: ReturnType } }; + + beforeEach(() => { + mockPrisma = { + priceHistory: { findMany: vi.fn() }, + }; + handler = new GetPriceHistoryHandler(mockPrisma as any); + }); + + it('should query price history for the given listing ordered by changedAt desc', async () => { + const mockHistory = [ + { id: 'ph-2', oldPrice: 5_000_000_000n, newPrice: 6_000_000_000n, source: 'manual_update', changedAt: new Date('2026-04-16') }, + { id: 'ph-1', oldPrice: 4_000_000_000n, newPrice: 5_000_000_000n, source: 'manual_update', changedAt: new Date('2026-04-10') }, + ]; + mockPrisma.priceHistory.findMany.mockResolvedValue(mockHistory); + + const query = new GetPriceHistoryQuery('listing-1'); + const result = await handler.execute(query); + + expect(result).toEqual(mockHistory); + expect(mockPrisma.priceHistory.findMany).toHaveBeenCalledWith({ + where: { listingId: 'listing-1' }, + orderBy: { changedAt: 'desc' }, + select: { + id: true, + oldPrice: true, + newPrice: true, + source: true, + changedAt: true, + }, + }); + }); + + it('should return empty array when no history exists', async () => { + mockPrisma.priceHistory.findMany.mockResolvedValue([]); + + const query = new GetPriceHistoryQuery('listing-no-history'); + const result = await handler.execute(query); + + expect(result).toEqual([]); + }); + + it('should include source field in the select', async () => { + mockPrisma.priceHistory.findMany.mockResolvedValue([ + { id: 'ph-1', oldPrice: 1n, newPrice: 2n, source: 'admin_override', changedAt: new Date() }, + ]); + + const result = await handler.execute(new GetPriceHistoryQuery('listing-1')); + + expect(result[0].source).toBe('admin_override'); + }); +}); diff --git a/apps/api/src/modules/listings/application/__tests__/record-price-history.handler.spec.ts b/apps/api/src/modules/listings/application/__tests__/record-price-history.handler.spec.ts new file mode 100644 index 0000000..1c51cf1 --- /dev/null +++ b/apps/api/src/modules/listings/application/__tests__/record-price-history.handler.spec.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { RecordPriceHistoryHandler } from '../event-handlers/record-price-history.handler'; +import { ListingPriceChangedEvent } from '../../domain/events/listing-price-changed.event'; + +describe('RecordPriceHistoryHandler', () => { + let handler: RecordPriceHistoryHandler; + let mockPrisma: { priceHistory: { create: ReturnType } }; + let mockLogger: { debug: ReturnType; error: ReturnType }; + + beforeEach(() => { + mockPrisma = { + priceHistory: { create: vi.fn().mockResolvedValue({ id: 'ph-1' }) }, + }; + mockLogger = { + debug: vi.fn(), + error: vi.fn(), + }; + handler = new RecordPriceHistoryHandler(mockPrisma as any, mockLogger as any); + }); + + it('should persist a price history record with correct data', async () => { + const event = new ListingPriceChangedEvent( + 'listing-1', + 5_000_000_000n, + 6_000_000_000n, + 'manual_update', + ); + + await handler.handle(event); + + expect(mockPrisma.priceHistory.create).toHaveBeenCalledWith({ + data: { + listingId: 'listing-1', + oldPrice: 5_000_000_000n, + newPrice: 6_000_000_000n, + source: 'manual_update', + changedAt: event.occurredAt, + }, + }); + }); + + it('should persist source as admin_override when provided', async () => { + const event = new ListingPriceChangedEvent( + 'listing-2', + 3_000_000_000n, + 4_500_000_000n, + 'admin_override', + ); + + await handler.handle(event); + + expect(mockPrisma.priceHistory.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ source: 'admin_override' }), + }), + ); + }); + + it('should default source to manual_update', async () => { + const event = new ListingPriceChangedEvent('listing-3', 1_000_000n, 2_000_000n); + + await handler.handle(event); + + expect(mockPrisma.priceHistory.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ source: 'manual_update' }), + }), + ); + }); + + it('should log debug message on success', async () => { + const event = new ListingPriceChangedEvent('listing-1', 100n, 200n); + + await handler.handle(event); + + expect(mockLogger.debug).toHaveBeenCalledWith( + expect.stringContaining('listing-1'), + 'RecordPriceHistoryHandler', + ); + }); + + it('should log error and not throw when persistence fails', async () => { + mockPrisma.priceHistory.create.mockRejectedValue(new Error('DB connection lost')); + const event = new ListingPriceChangedEvent('listing-1', 100n, 200n); + + await expect(handler.handle(event)).resolves.toBeUndefined(); + + expect(mockLogger.error).toHaveBeenCalledWith( + expect.stringContaining('DB connection lost'), + expect.any(String), + 'RecordPriceHistoryHandler', + ); + }); +}); diff --git a/apps/api/src/modules/listings/application/event-handlers/record-price-history.handler.ts b/apps/api/src/modules/listings/application/event-handlers/record-price-history.handler.ts index 209f04d..ec89495 100644 --- a/apps/api/src/modules/listings/application/event-handlers/record-price-history.handler.ts +++ b/apps/api/src/modules/listings/application/event-handlers/record-price-history.handler.ts @@ -16,6 +16,7 @@ export class RecordPriceHistoryHandler implements IEventHandler { }); }); + describe('ListingPriceChangedEvent', () => { + it('creates event with correct properties', () => { + const event = new ListingPriceChangedEvent('listing-1', 5_000_000_000n, 6_000_000_000n, 'manual_update'); + + expect(event.eventName).toBe('listing.price_changed'); + expect(event.aggregateId).toBe('listing-1'); + expect(event.oldPrice).toBe(5_000_000_000n); + expect(event.newPrice).toBe(6_000_000_000n); + expect(event.source).toBe('manual_update'); + expect(event.occurredAt).toBeInstanceOf(Date); + }); + + it('defaults source to manual_update', () => { + const event = new ListingPriceChangedEvent('listing-2', 1_000_000n, 2_000_000n); + expect(event.source).toBe('manual_update'); + }); + + it('accepts admin_override source', () => { + const event = new ListingPriceChangedEvent('listing-3', 1n, 2n, 'admin_override'); + expect(event.source).toBe('admin_override'); + }); + + it('accepts market_adjustment source', () => { + const event = new ListingPriceChangedEvent('listing-4', 1n, 2n, 'market_adjustment'); + expect(event.source).toBe('market_adjustment'); + }); + }); + describe('ListingStatusChangedEvent', () => { it('creates event with correct properties', () => { const event = new ListingStatusChangedEvent('listing-1', 'prop-1', 'DRAFT', 'PENDING_REVIEW'); diff --git a/apps/api/src/modules/listings/domain/events/listing-price-changed.event.ts b/apps/api/src/modules/listings/domain/events/listing-price-changed.event.ts index 475d3f5..3f3af34 100644 --- a/apps/api/src/modules/listings/domain/events/listing-price-changed.event.ts +++ b/apps/api/src/modules/listings/domain/events/listing-price-changed.event.ts @@ -1,5 +1,7 @@ import type { DomainEvent } from '@modules/shared'; +export type PriceChangeSource = 'manual_update' | 'admin_override' | 'market_adjustment'; + export class ListingPriceChangedEvent implements DomainEvent { readonly eventName = 'listing.price_changed'; readonly occurredAt = new Date(); @@ -8,5 +10,6 @@ export class ListingPriceChangedEvent implements DomainEvent { public readonly aggregateId: string, public readonly oldPrice: bigint, public readonly newPrice: bigint, + public readonly source: PriceChangeSource = 'manual_update', ) {} } diff --git a/apps/api/src/modules/listings/presentation/__tests__/listings.controller.spec.ts b/apps/api/src/modules/listings/presentation/__tests__/listings.controller.spec.ts index 39cd2b2..1d8a306 100644 --- a/apps/api/src/modules/listings/presentation/__tests__/listings.controller.spec.ts +++ b/apps/api/src/modules/listings/presentation/__tests__/listings.controller.spec.ts @@ -96,6 +96,29 @@ describe('ListingsController', () => { }); }); + describe('getPriceHistory', () => { + it('should execute GetPriceHistoryQuery via query bus', async () => { + const mockHistory = [ + { id: 'ph-1', oldPrice: '5000000000', newPrice: '6000000000', source: 'manual_update', changedAt: '2026-04-16T00:00:00.000Z' }, + ]; + mockQueryBus.execute.mockResolvedValue(mockHistory); + + const result = await controller.getPriceHistory('listing-1'); + + expect(result).toEqual(mockHistory); + expect(mockQueryBus.execute).toHaveBeenCalledTimes(1); + }); + + it('should return empty array when no price history exists', async () => { + mockQueryBus.execute.mockResolvedValue([]); + + const result = await controller.getPriceHistory('listing-no-history'); + + expect(result).toEqual([]); + expect(mockQueryBus.execute).toHaveBeenCalledTimes(1); + }); + }); + describe('updateListing', () => { it('should execute UpdateListingCommand via command bus', async () => { const mockResult = { diff --git a/prisma/migrations/20260416600000_add_source_to_price_history/migration.sql b/prisma/migrations/20260416600000_add_source_to_price_history/migration.sql new file mode 100644 index 0000000..2ca7658 --- /dev/null +++ b/prisma/migrations/20260416600000_add_source_to_price_history/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable: Add source column to PriceHistory +ALTER TABLE "PriceHistory" ADD COLUMN "source" TEXT NOT NULL DEFAULT 'manual_update'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6c29545..8c7c03a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -366,6 +366,7 @@ model PriceHistory { listing Listing @relation(fields: [listingId], references: [id], onDelete: Cascade) oldPrice BigInt newPrice BigInt + source String @default("manual_update") changedAt DateTime @default(now()) @@index([listingId, changedAt(sort: Desc)])