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:
Claude Code 2026-03-25 23:19:54 -07:00
parent 9de760fe68
commit d27f9b538b

View 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);
});
});
});