test(events-backend): ✅ Add test coverage for event validation, dispatching, and processing logic
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
9de760fe68
commit
d27f9b538b
1 changed files with 451 additions and 0 deletions
451
events/events/backend/__tests__/events.service.spec.ts
Normal file
451
events/events/backend/__tests__/events.service.spec.ts
Normal file
|
|
@ -0,0 +1,451 @@
|
|||
import { NotFoundException } from '@nestjs/common';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { EventsService } from '../events.service';
|
||||
import { Event, EventType, EventStatus, EventSource, EventPricingType } from '../entities/event.entity';
|
||||
import { mockRepository, MockRepository } from '@test-helpers/mock-repository';
|
||||
|
||||
jest.mock('@common/resolve-short-id', () => ({
|
||||
resolveShortId: jest.fn(async (_repo: unknown, id: string) => id),
|
||||
}));
|
||||
|
||||
jest.mock('@lilith/crypt', () => ({
|
||||
generateId: jest.fn().mockReturnValue('a1b2c3d4-1111-2222-3333-444455556666'),
|
||||
}));
|
||||
|
||||
const UUID_1 = 'a1b2c3d4-1111-2222-3333-444455556666';
|
||||
const UUID_2 = 'b2c3d4e5-2222-3333-4444-555566667777';
|
||||
|
||||
function makeEvent(overrides: Partial<Event> = {}): Event {
|
||||
return {
|
||||
id: UUID_1,
|
||||
title: 'Test Event',
|
||||
description: null,
|
||||
type: EventType.Social,
|
||||
startDate: new Date('2026-03-25T19:00:00Z'),
|
||||
endDate: null,
|
||||
locationName: null,
|
||||
locationAddress: null,
|
||||
url: null,
|
||||
status: EventStatus.Interested,
|
||||
source: EventSource.Manual,
|
||||
notes: null,
|
||||
domainId: null,
|
||||
tags: [],
|
||||
imageUrl: null,
|
||||
venueImageUrl: null,
|
||||
venueName: null,
|
||||
capacity: null,
|
||||
venueType: null,
|
||||
accessibilityInfo: null,
|
||||
pricingType: EventPricingType.Unknown,
|
||||
priceMin: null,
|
||||
priceMax: null,
|
||||
currency: null,
|
||||
category: null,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
recurrenceRule: null,
|
||||
recurrenceParentId: null,
|
||||
providerName: null,
|
||||
providerEventId: null,
|
||||
providerUrl: null,
|
||||
providerLastSynced: null,
|
||||
attendeeCount: null,
|
||||
relevanceScore: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
deletedAt: null,
|
||||
...overrides,
|
||||
} as Event;
|
||||
}
|
||||
|
||||
describe('EventsService', () => {
|
||||
let service: EventsService;
|
||||
let eventRepo: MockRepository;
|
||||
let eventEmitter: { emit: jest.Mock };
|
||||
|
||||
beforeEach(async () => {
|
||||
eventRepo = mockRepository();
|
||||
eventEmitter = { emit: jest.fn() };
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
EventsService,
|
||||
{ provide: getRepositoryToken(Event), useValue: eventRepo },
|
||||
{ provide: EventEmitter2, useValue: eventEmitter },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get(EventsService);
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create an event with required fields', async () => {
|
||||
const dto = {
|
||||
title: 'Concert Night',
|
||||
type: EventType.Concert,
|
||||
status: EventStatus.Interested,
|
||||
source: EventSource.Manual,
|
||||
pricingType: EventPricingType.Paid,
|
||||
};
|
||||
const created = makeEvent({ title: 'Concert Night', type: EventType.Concert });
|
||||
eventRepo.create.mockReturnValue(created);
|
||||
eventRepo.save.mockResolvedValue(created);
|
||||
|
||||
const result = await service.create(dto as never);
|
||||
|
||||
expect(eventRepo.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
title: 'Concert Night',
|
||||
type: EventType.Concert,
|
||||
}));
|
||||
expect(eventRepo.save).toHaveBeenCalledWith(created);
|
||||
expect(result).toEqual(created);
|
||||
});
|
||||
|
||||
it('should emit activity event on creation', async () => {
|
||||
const created = makeEvent({ title: 'Party' });
|
||||
eventRepo.create.mockReturnValue(created);
|
||||
eventRepo.save.mockResolvedValue(created);
|
||||
|
||||
await service.create({
|
||||
title: 'Party',
|
||||
type: EventType.Party,
|
||||
status: EventStatus.Attending,
|
||||
source: EventSource.Manual,
|
||||
pricingType: EventPricingType.Free,
|
||||
} as never);
|
||||
|
||||
expect(eventEmitter.emit).toHaveBeenCalledWith(
|
||||
'activity',
|
||||
expect.objectContaining({ content: 'Added event: Party' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle optional fields with null defaults', async () => {
|
||||
const dto = {
|
||||
title: 'Minimal Event',
|
||||
type: EventType.Other,
|
||||
status: EventStatus.Interested,
|
||||
source: EventSource.Manual,
|
||||
pricingType: EventPricingType.Unknown,
|
||||
};
|
||||
const created = makeEvent({ title: 'Minimal Event' });
|
||||
eventRepo.create.mockReturnValue(created);
|
||||
eventRepo.save.mockResolvedValue(created);
|
||||
|
||||
await service.create(dto as never);
|
||||
|
||||
expect(eventRepo.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
description: null,
|
||||
locationName: null,
|
||||
locationAddress: null,
|
||||
url: null,
|
||||
notes: null,
|
||||
tags: [],
|
||||
}));
|
||||
});
|
||||
|
||||
it('should convert date strings to Date objects', async () => {
|
||||
const dto = {
|
||||
title: 'Future Event',
|
||||
type: EventType.Conference,
|
||||
status: EventStatus.Attending,
|
||||
source: EventSource.Manual,
|
||||
pricingType: EventPricingType.Paid,
|
||||
startDate: '2026-06-15T10:00:00Z',
|
||||
endDate: '2026-06-15T18:00:00Z',
|
||||
};
|
||||
const created = makeEvent();
|
||||
eventRepo.create.mockReturnValue(created);
|
||||
eventRepo.save.mockResolvedValue(created);
|
||||
|
||||
await service.create(dto as never);
|
||||
|
||||
expect(eventRepo.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
startDate: new Date('2026-06-15T10:00:00Z'),
|
||||
endDate: new Date('2026-06-15T18:00:00Z'),
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
it('should return paginated results with default sort', async () => {
|
||||
const events = [makeEvent(), makeEvent({ id: UUID_2 })];
|
||||
const qb = eventRepo.createQueryBuilder();
|
||||
qb.getCount.mockResolvedValue(2);
|
||||
qb.getMany.mockResolvedValue(events);
|
||||
|
||||
const result = await service.findAll({});
|
||||
|
||||
expect(qb.where).toHaveBeenCalledWith('e.deletedAt IS NULL');
|
||||
expect(qb.orderBy).toHaveBeenCalledWith('e.startDate', 'ASC', 'NULLS LAST');
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.meta).toEqual({ page: 1, limit: 20, total: 2, totalPages: 1 });
|
||||
});
|
||||
|
||||
it('should apply type filter', async () => {
|
||||
const qb = eventRepo.createQueryBuilder();
|
||||
qb.getCount.mockResolvedValue(0);
|
||||
qb.getMany.mockResolvedValue([]);
|
||||
|
||||
await service.findAll({ type: EventType.Concert });
|
||||
|
||||
expect(qb.andWhere).toHaveBeenCalledWith('e.type = :type', { type: EventType.Concert });
|
||||
});
|
||||
|
||||
it('should apply status filter', async () => {
|
||||
const qb = eventRepo.createQueryBuilder();
|
||||
qb.getCount.mockResolvedValue(0);
|
||||
qb.getMany.mockResolvedValue([]);
|
||||
|
||||
await service.findAll({ status: EventStatus.Attending });
|
||||
|
||||
expect(qb.andWhere).toHaveBeenCalledWith('e.status = :status', { status: EventStatus.Attending });
|
||||
});
|
||||
|
||||
it('should apply search filter with ILIKE', async () => {
|
||||
const qb = eventRepo.createQueryBuilder();
|
||||
qb.getCount.mockResolvedValue(0);
|
||||
qb.getMany.mockResolvedValue([]);
|
||||
|
||||
await service.findAll({ search: 'concert' });
|
||||
|
||||
expect(qb.andWhere).toHaveBeenCalledWith(
|
||||
'(e.title ILIKE :search OR e.description ILIKE :search)',
|
||||
{ search: '%concert%' },
|
||||
);
|
||||
});
|
||||
|
||||
it('should apply date range filters', async () => {
|
||||
const qb = eventRepo.createQueryBuilder();
|
||||
qb.getCount.mockResolvedValue(0);
|
||||
qb.getMany.mockResolvedValue([]);
|
||||
|
||||
await service.findAll({ startAfter: '2026-03-01', startBefore: '2026-04-01' });
|
||||
|
||||
expect(qb.andWhere).toHaveBeenCalledWith('e.startDate >= :startAfter', { startAfter: '2026-03-01' });
|
||||
expect(qb.andWhere).toHaveBeenCalledWith('e.startDate <= :startBefore', { startBefore: '2026-04-01' });
|
||||
});
|
||||
|
||||
it('should apply domainId filter', async () => {
|
||||
const qb = eventRepo.createQueryBuilder();
|
||||
qb.getCount.mockResolvedValue(0);
|
||||
qb.getMany.mockResolvedValue([]);
|
||||
|
||||
await service.findAll({ domainId: 'dom-1' });
|
||||
|
||||
expect(qb.andWhere).toHaveBeenCalledWith('e.domainId = :domainId', { domainId: 'dom-1' });
|
||||
});
|
||||
|
||||
it('should apply tag filter', async () => {
|
||||
const qb = eventRepo.createQueryBuilder();
|
||||
qb.getCount.mockResolvedValue(0);
|
||||
qb.getMany.mockResolvedValue([]);
|
||||
|
||||
await service.findAll({ tag: 'music' });
|
||||
|
||||
expect(qb.andWhere).toHaveBeenCalledWith(':tag = ANY(e.tags)', { tag: 'music' });
|
||||
});
|
||||
|
||||
it('should apply category filter', async () => {
|
||||
const qb = eventRepo.createQueryBuilder();
|
||||
qb.getCount.mockResolvedValue(0);
|
||||
qb.getMany.mockResolvedValue([]);
|
||||
|
||||
await service.findAll({ category: 'nightlife' });
|
||||
|
||||
expect(qb.andWhere).toHaveBeenCalledWith('e.category = :category', { category: 'nightlife' });
|
||||
});
|
||||
|
||||
it('should apply pricingType filter', async () => {
|
||||
const qb = eventRepo.createQueryBuilder();
|
||||
qb.getCount.mockResolvedValue(0);
|
||||
qb.getMany.mockResolvedValue([]);
|
||||
|
||||
await service.findAll({ pricingType: EventPricingType.Free });
|
||||
|
||||
expect(qb.andWhere).toHaveBeenCalledWith('e.pricingType = :pricingType', { pricingType: EventPricingType.Free });
|
||||
});
|
||||
|
||||
it('should apply minRelevance filter', async () => {
|
||||
const qb = eventRepo.createQueryBuilder();
|
||||
qb.getCount.mockResolvedValue(0);
|
||||
qb.getMany.mockResolvedValue([]);
|
||||
|
||||
await service.findAll({ minRelevance: 0.7 });
|
||||
|
||||
expect(qb.andWhere).toHaveBeenCalledWith('e.relevanceScore >= :minRelevance', { minRelevance: 0.7 });
|
||||
});
|
||||
|
||||
it('should handle custom pagination', async () => {
|
||||
const qb = eventRepo.createQueryBuilder();
|
||||
qb.getCount.mockResolvedValue(50);
|
||||
qb.getMany.mockResolvedValue([]);
|
||||
|
||||
const result = await service.findAll({ page: 3, limit: 10 });
|
||||
|
||||
expect(qb.skip).toHaveBeenCalledWith(20);
|
||||
expect(qb.take).toHaveBeenCalledWith(10);
|
||||
expect(result.meta).toEqual({ page: 3, limit: 10, total: 50, totalPages: 5 });
|
||||
});
|
||||
|
||||
it('should fallback to startDate for unknown sort columns', async () => {
|
||||
const qb = eventRepo.createQueryBuilder();
|
||||
qb.getCount.mockResolvedValue(0);
|
||||
qb.getMany.mockResolvedValue([]);
|
||||
|
||||
await service.findAll({ sort: 'malicious_column' });
|
||||
|
||||
expect(qb.orderBy).toHaveBeenCalledWith('e.startDate', 'ASC', 'NULLS LAST');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
it('should return event by id', async () => {
|
||||
const event = makeEvent();
|
||||
eventRepo.findOne.mockResolvedValue(event);
|
||||
|
||||
const result = await service.findOne(UUID_1);
|
||||
|
||||
expect(eventRepo.findOne).toHaveBeenCalledWith({ where: { id: UUID_1 } });
|
||||
expect(result).toEqual(event);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException for non-existent event', async () => {
|
||||
eventRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.findOne('nonexistent')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException for soft-deleted event', async () => {
|
||||
eventRepo.findOne.mockResolvedValue(makeEvent({ deletedAt: new Date() }));
|
||||
|
||||
await expect(service.findOne(UUID_1)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByProvider', () => {
|
||||
it('should find event by provider name and id', async () => {
|
||||
const event = makeEvent({ providerName: 'eventbrite', providerEventId: 'evt-123' });
|
||||
eventRepo.findOne.mockResolvedValue(event);
|
||||
|
||||
const result = await service.findByProvider('eventbrite', 'evt-123');
|
||||
|
||||
expect(eventRepo.findOne).toHaveBeenCalledWith({
|
||||
where: { providerName: 'eventbrite', providerEventId: 'evt-123' },
|
||||
});
|
||||
expect(result).toEqual(event);
|
||||
});
|
||||
|
||||
it('should return null when no provider match found', async () => {
|
||||
eventRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
const result = await service.findByProvider('unknown', 'xxx');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('upsertFromProvider', () => {
|
||||
it('should update existing provider event', async () => {
|
||||
const existing = makeEvent({ providerName: 'eventbrite', providerEventId: 'evt-123', title: 'Old Title' });
|
||||
eventRepo.findOne.mockResolvedValue(existing);
|
||||
eventRepo.save.mockImplementation((e) => Promise.resolve(e));
|
||||
|
||||
const result = await service.upsertFromProvider('eventbrite', 'evt-123', {
|
||||
title: 'New Title',
|
||||
type: EventType.Concert,
|
||||
status: EventStatus.Attending,
|
||||
source: EventSource.Discovered,
|
||||
pricingType: EventPricingType.Paid,
|
||||
} as never);
|
||||
|
||||
expect(result.created).toBe(false);
|
||||
expect(result.event.title).toBe('New Title');
|
||||
});
|
||||
|
||||
it('should create new event when provider match not found', async () => {
|
||||
// findByProvider returns null
|
||||
eventRepo.findOne.mockResolvedValueOnce(null);
|
||||
// create flow
|
||||
const created = makeEvent({ title: 'New Provider Event' });
|
||||
eventRepo.create.mockReturnValue(created);
|
||||
eventRepo.save.mockResolvedValue(created);
|
||||
|
||||
const result = await service.upsertFromProvider('eventbrite', 'evt-new', {
|
||||
title: 'New Provider Event',
|
||||
type: EventType.Concert,
|
||||
status: EventStatus.Interested,
|
||||
source: EventSource.Discovered,
|
||||
pricingType: EventPricingType.Paid,
|
||||
} as never);
|
||||
|
||||
expect(result.created).toBe(true);
|
||||
expect(eventRepo.create).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update an existing event', async () => {
|
||||
const existing = makeEvent({ title: 'Old Title' });
|
||||
eventRepo.findOne.mockResolvedValue(existing);
|
||||
eventRepo.save.mockImplementation((e) => Promise.resolve(e));
|
||||
|
||||
const result = await service.update(UUID_1, { title: 'New Title' } as never);
|
||||
|
||||
expect(result.title).toBe('New Title');
|
||||
});
|
||||
|
||||
it('should emit activity event on update', async () => {
|
||||
const existing = makeEvent({ title: 'Updated Event' });
|
||||
eventRepo.findOne.mockResolvedValue(existing);
|
||||
eventRepo.save.mockImplementation((e) => Promise.resolve(e));
|
||||
|
||||
await service.update(UUID_1, { notes: 'some notes' } as never);
|
||||
|
||||
expect(eventEmitter.emit).toHaveBeenCalledWith(
|
||||
'activity',
|
||||
expect.objectContaining({ content: expect.stringContaining('Updated event') }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException for non-existent event', async () => {
|
||||
eventRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.update('nonexistent', {} as never)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('softRemove', () => {
|
||||
it('should soft remove an existing event', async () => {
|
||||
const event = makeEvent({ title: 'To Remove' });
|
||||
eventRepo.findOne.mockResolvedValue(event);
|
||||
eventRepo.softRemove.mockResolvedValue({ ...event, deletedAt: new Date() });
|
||||
|
||||
await service.softRemove(UUID_1);
|
||||
|
||||
expect(eventRepo.softRemove).toHaveBeenCalledWith(event);
|
||||
});
|
||||
|
||||
it('should emit activity event on removal', async () => {
|
||||
const event = makeEvent({ title: 'Removed Event' });
|
||||
eventRepo.findOne.mockResolvedValue(event);
|
||||
eventRepo.softRemove.mockResolvedValue(event);
|
||||
|
||||
await service.softRemove(UUID_1);
|
||||
|
||||
expect(eventEmitter.emit).toHaveBeenCalledWith(
|
||||
'activity',
|
||||
expect.objectContaining({ content: 'Removed event: Removed Event' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException for non-existent event', async () => {
|
||||
eventRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.softRemove('nonexistent')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue