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 <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-16 17:43:48 +07:00
parent f3a2a012c4
commit 6cf2c23170
9 changed files with 213 additions and 0 deletions

View File

@@ -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<typeof vi.fn> } };
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');
});
});

View File

@@ -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<typeof vi.fn> } };
let mockLogger: { debug: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn> };
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',
);
});
});

View File

@@ -16,6 +16,7 @@ export class RecordPriceHistoryHandler implements IEventHandler<ListingPriceChan
listingId: event.aggregateId,
oldPrice: event.oldPrice,
newPrice: event.newPrice,
source: event.source,
changedAt: event.occurredAt,
},
});

View File

@@ -6,6 +6,7 @@ export interface PriceHistoryItem {
id: string;
oldPrice: bigint;
newPrice: bigint;
source: string;
changedAt: Date;
}
@@ -21,6 +22,7 @@ export class GetPriceHistoryHandler implements IQueryHandler<GetPriceHistoryQuer
id: true,
oldPrice: true,
newPrice: true,
source: true,
changedAt: true,
},
});

View File

@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest';
import { ListingApprovedEvent } from '../events/listing-approved.event';
import { ListingCreatedEvent } from '../events/listing-created.event';
import { ListingPriceChangedEvent } from '../events/listing-price-changed.event';
import { ListingSoldEvent } from '../events/listing-sold.event';
import { ListingStatusChangedEvent } from '../events/listing-status-changed.event';
@@ -51,6 +52,34 @@ describe('Listings Domain Events', () => {
});
});
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');

View File

@@ -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',
) {}
}

View File

@@ -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 = {

View File

@@ -0,0 +1,2 @@
-- AlterTable: Add source column to PriceHistory
ALTER TABLE "PriceHistory" ADD COLUMN "source" TEXT NOT NULL DEFAULT 'manual_update';

View File

@@ -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)])