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:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable: Add source column to PriceHistory
|
||||
ALTER TABLE "PriceHistory" ADD COLUMN "source" TEXT NOT NULL DEFAULT 'manual_update';
|
||||
@@ -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)])
|
||||
|
||||
Reference in New Issue
Block a user