test: expand test coverage across calendar, contacts, and todo apps

Calendar Web (3 new test files, 29 tests):
- dateNavigation: getOffsetDate for week/month/agenda views
- eventDateHelpers: toDate, getEventStart/End/Times
- eventFiltering: calendar visibility, timed/all-day filtering, hour ranges, overflow events, tag filtering

Contacts Backend (jest setup + 1 test file, 14 tests):
- Add jest config and test scripts
- contact.service.spec.ts: findByUserId, findById, create, update, toggleFavorite, toggleArchive, count

Contacts Web (1 new test file, 6 tests):
- duplicates API: findDuplicates, mergeContacts, dismissDuplicate

Todo Backend (2 new test files, 24 tests):
- project.service.spec.ts: CRUD, archive, reorder, default project protection
- label.service.spec.ts: CRUD with NotFoundException handling

Todo Web (2 new test files, 14 tests):
- projects API: CRUD, archive, reorder
- reminders API: getReminders, createReminder, deleteReminder

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-18 17:41:53 +01:00
parent 8f71ed134d
commit 37a699131c
12 changed files with 2605 additions and 799 deletions

View file

@ -0,0 +1,55 @@
import { describe, it, expect } from 'vitest';
import { getOffsetDate } from './dateNavigation';
describe('getOffsetDate', () => {
// Use local time to avoid timezone offset issues with date-fns
const baseDate = new Date(2026, 2, 18, 12, 0, 0); // March 18, 2026 12:00
describe('week view', () => {
it('should add 1 week for offset +1', () => {
const result = getOffsetDate(baseDate, 'week', 1);
expect(result).toEqual(new Date(2026, 2, 25, 12, 0, 0));
});
it('should subtract 1 week for offset -1', () => {
const result = getOffsetDate(baseDate, 'week', -1);
expect(result).toEqual(new Date(2026, 2, 11, 12, 0, 0));
});
});
describe('month view', () => {
it('should add 1 month for offset +1', () => {
const result = getOffsetDate(baseDate, 'month', 1);
expect(result).toEqual(new Date(2026, 3, 18, 12, 0, 0));
});
it('should subtract 1 month for offset -1', () => {
const result = getOffsetDate(baseDate, 'month', -1);
expect(result).toEqual(new Date(2026, 1, 18, 12, 0, 0));
});
});
describe('agenda view', () => {
it('should add 7 days for offset +1', () => {
const result = getOffsetDate(baseDate, 'agenda', 1);
expect(result).toEqual(new Date(2026, 2, 25, 12, 0, 0));
});
it('should subtract 7 days for offset -1', () => {
const result = getOffsetDate(baseDate, 'agenda', -1);
expect(result).toEqual(new Date(2026, 2, 11, 12, 0, 0));
});
it('should add 14 days for offset +2', () => {
const result = getOffsetDate(baseDate, 'agenda', 2);
expect(result).toEqual(new Date(2026, 3, 1, 12, 0, 0));
});
});
describe('default (unknown view type)', () => {
it('should fall through to week behavior', () => {
const result = getOffsetDate(baseDate, 'unknown' as any, 1);
expect(result).toEqual(new Date(2026, 2, 25, 12, 0, 0));
});
});
});

View file

@ -0,0 +1,62 @@
import { describe, it, expect } from 'vitest';
import { toDate, getEventStart, getEventEnd, getEventTimes } from './eventDateHelpers';
describe('toDate', () => {
it('should parse ISO string to Date', () => {
const result = toDate('2026-03-18T10:30:00Z');
expect(result).toBeInstanceOf(Date);
expect(result.toISOString()).toBe('2026-03-18T10:30:00.000Z');
});
it('should return Date object as-is', () => {
const date = new Date('2026-03-18T10:30:00Z');
const result = toDate(date);
expect(result).toBe(date);
});
});
describe('getEventStart', () => {
it('should extract start time from event with string', () => {
const event = { startTime: '2026-03-18T09:00:00Z' };
const result = getEventStart(event);
expect(result).toBeInstanceOf(Date);
expect(result.toISOString()).toBe('2026-03-18T09:00:00.000Z');
});
it('should handle Date object', () => {
const date = new Date('2026-03-18T09:00:00Z');
const event = { startTime: date };
const result = getEventStart(event);
expect(result).toBe(date);
});
});
describe('getEventEnd', () => {
it('should extract end time from event with string', () => {
const event = { endTime: '2026-03-18T10:00:00Z' };
const result = getEventEnd(event);
expect(result).toBeInstanceOf(Date);
expect(result.toISOString()).toBe('2026-03-18T10:00:00.000Z');
});
it('should handle Date object', () => {
const date = new Date('2026-03-18T10:00:00Z');
const event = { endTime: date };
const result = getEventEnd(event);
expect(result).toBe(date);
});
});
describe('getEventTimes', () => {
it('should return both start and end as Date objects', () => {
const event = {
startTime: '2026-03-18T09:00:00Z',
endTime: '2026-03-18T10:00:00Z',
};
const result = getEventTimes(event);
expect(result.start).toBeInstanceOf(Date);
expect(result.end).toBeInstanceOf(Date);
expect(result.start.toISOString()).toBe('2026-03-18T09:00:00.000Z');
expect(result.end.toISOString()).toBe('2026-03-18T10:00:00.000Z');
});
});

View file

@ -0,0 +1,179 @@
import { describe, it, expect } from 'vitest';
import {
getVisibleCalendarIds,
filterByVisibleCalendars,
filterTimedEvents,
filterAllDayEvents,
getEventMinutes,
eventOverlapsTimeRange,
filterByHourRange,
getOverflowEvents,
filterByTags,
} from './eventFiltering';
// Mock calendars
const calendarA = { id: 'cal-a', name: 'Work', color: '#3B82F6' } as any;
const calendarB = { id: 'cal-b', name: 'Personal', color: '#EF4444' } as any;
// Mock events - use local Date objects to avoid timezone offset issues with getHours()
const timedEventA = {
id: 'evt-1',
calendarId: 'cal-a',
startTime: new Date(2026, 2, 18, 9, 0, 0),
endTime: new Date(2026, 2, 18, 10, 0, 0),
isAllDay: false,
tags: [{ id: 'tag-1', name: 'meeting' }],
} as any;
const timedEventB = {
id: 'evt-2',
calendarId: 'cal-b',
startTime: new Date(2026, 2, 18, 14, 0, 0),
endTime: new Date(2026, 2, 18, 15, 30, 0),
isAllDay: false,
tags: [{ id: 'tag-2', name: 'personal' }],
} as any;
const allDayEvent = {
id: 'evt-3',
calendarId: 'cal-a',
startTime: new Date(2026, 2, 18, 0, 0, 0),
endTime: new Date(2026, 2, 19, 0, 0, 0),
isAllDay: true,
tags: [],
} as any;
const earlyEvent = {
id: 'evt-4',
calendarId: 'cal-a',
startTime: new Date(2026, 2, 18, 5, 0, 0),
endTime: new Date(2026, 2, 18, 6, 0, 0),
isAllDay: false,
tags: null,
} as any;
const lateEvent = {
id: 'evt-5',
calendarId: 'cal-a',
startTime: new Date(2026, 2, 18, 22, 0, 0),
endTime: new Date(2026, 2, 18, 23, 0, 0),
isAllDay: false,
tags: [{ id: 'tag-1', name: 'meeting' }],
} as any;
const allEvents = [timedEventA, timedEventB, allDayEvent, earlyEvent, lateEvent];
describe('getVisibleCalendarIds', () => {
it('should return a Set of calendar IDs', () => {
const result = getVisibleCalendarIds([calendarA, calendarB]);
expect(result).toBeInstanceOf(Set);
expect(result.size).toBe(2);
expect(result.has('cal-a')).toBe(true);
expect(result.has('cal-b')).toBe(true);
});
});
describe('filterByVisibleCalendars', () => {
it('should filter events to only visible calendars', () => {
const result = filterByVisibleCalendars(allEvents, [calendarA]);
expect(result.every((e) => e.calendarId === 'cal-a')).toBe(true);
expect(result).toHaveLength(4);
});
});
describe('filterTimedEvents', () => {
it('should filter out all-day events', () => {
const result = filterTimedEvents(allEvents);
expect(result.every((e) => !e.isAllDay)).toBe(true);
expect(result).toHaveLength(4);
});
});
describe('filterAllDayEvents', () => {
it('should keep only all-day events', () => {
const result = filterAllDayEvents(allEvents);
expect(result.every((e) => e.isAllDay)).toBe(true);
expect(result).toHaveLength(1);
expect(result[0].id).toBe('evt-3');
});
});
describe('getEventMinutes', () => {
it('should convert event times to minutes from midnight', () => {
const result = getEventMinutes(timedEventA);
// 09:00 UTC = 540 minutes
expect(result.start).toBe(9 * 60);
// 10:00 UTC = 600 minutes
expect(result.end).toBe(10 * 60);
});
});
describe('eventOverlapsTimeRange', () => {
it('should return true when event overlaps with range', () => {
// Event is 09:00-10:00, range is 08:00-12:00
const result = eventOverlapsTimeRange(timedEventA, 8 * 60, 12 * 60);
expect(result).toBe(true);
});
it('should return false when event is outside range', () => {
// Event is 09:00-10:00, range is 11:00-12:00
const result = eventOverlapsTimeRange(timedEventA, 11 * 60, 12 * 60);
expect(result).toBe(false);
});
it('should return false when event ends exactly at range start', () => {
// Event is 09:00-10:00, range starts at 10:00
const result = eventOverlapsTimeRange(timedEventA, 10 * 60, 12 * 60);
expect(result).toBe(false);
});
});
describe('filterByHourRange', () => {
it('should filter events within the hour range', () => {
const timedEvents = filterTimedEvents(allEvents);
// Range 8:00-18:00 should include timedEventA (9-10) and timedEventB (14-15:30)
const result = filterByHourRange(timedEvents, 8, 18);
expect(result.some((e) => e.id === 'evt-1')).toBe(true);
expect(result.some((e) => e.id === 'evt-2')).toBe(true);
// earlyEvent (5-6) and lateEvent (22-23) should be excluded
expect(result.some((e) => e.id === 'evt-4')).toBe(false);
expect(result.some((e) => e.id === 'evt-5')).toBe(false);
});
});
describe('getOverflowEvents', () => {
it('should return events before and after visible range', () => {
const timedEvents = filterTimedEvents(allEvents);
const result = getOverflowEvents(timedEvents, 8, 18);
// earlyEvent (5-6) ends before 8:00
expect(result.before.some((e) => e.id === 'evt-4')).toBe(true);
// lateEvent (22-23) starts after 18:00
expect(result.after.some((e) => e.id === 'evt-5')).toBe(true);
// timedEventA (9-10) is within range, should not appear
expect(result.before.some((e) => e.id === 'evt-1')).toBe(false);
expect(result.after.some((e) => e.id === 'evt-1')).toBe(false);
});
});
describe('filterByTags', () => {
it('should return all events when no tags are selected', () => {
const result = filterByTags(allEvents, []);
expect(result).toHaveLength(allEvents.length);
});
it('should filter events by selected tag IDs', () => {
const result = filterByTags(allEvents, ['tag-1']);
// timedEventA and lateEvent have tag-1
expect(result).toHaveLength(2);
expect(result.some((e) => e.id === 'evt-1')).toBe(true);
expect(result.some((e) => e.id === 'evt-5')).toBe(true);
});
it('should exclude events with no tags when filtering', () => {
// allDayEvent has empty tags, earlyEvent has null tags
const result = filterByTags(allEvents, ['tag-2']);
expect(result.some((e) => e.id === 'evt-3')).toBe(false);
expect(result.some((e) => e.id === 'evt-4')).toBe(false);
});
});

View file

@ -0,0 +1,16 @@
/** @type {import('jest').Config} */
module.exports = {
moduleFileExtensions: ['js', 'json', 'ts'],
rootDir: 'src',
testRegex: '.*\\.spec\\.ts$',
transform: {
'^.+\\.(t|j)s$': 'ts-jest',
},
collectCoverageFrom: ['**/*.(t|j)s'],
coverageDirectory: '../coverage',
testEnvironment: 'node',
moduleNameMapper: {
'^@contacts/shared$': '<rootDir>/../../packages/shared/src',
'^@manacore/shared-nestjs-auth$': '<rootDir>/../../../../../packages/shared-nestjs-auth/src',
},
};

View file

@ -15,7 +15,10 @@
"migration:run": "tsx src/db/migrate.ts",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"db:seed": "tsx src/db/seed.ts"
"db:seed": "tsx src/db/seed.ts",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage"
},
"dependencies": {
"@manacore/shared-nestjs-auth": "workspace:*",
@ -44,7 +47,9 @@
"devDependencies": {
"@nestjs/cli": "^10.4.9",
"@nestjs/schematics": "^10.2.3",
"@nestjs/testing": "^11.1.17",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/multer": "^1.4.11",
"@types/node": "^22.10.2",
"@typescript-eslint/eslint-plugin": "^8.18.1",
@ -52,8 +57,10 @@
"eslint": "^9.17.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
"jest": "^30.3.0",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",

View file

@ -0,0 +1,227 @@
import { Test, TestingModule } from '@nestjs/testing';
import { NotFoundException } from '@nestjs/common';
import { ContactService } from '../contact.service';
import { DATABASE_CONNECTION } from '../../db/database.module';
describe('ContactService', () => {
let service: ContactService;
let mockDb: any;
const mockContact = {
id: 'contact-1',
userId: 'user-1',
firstName: 'John',
lastName: 'Doe',
displayName: 'John Doe',
email: 'john@example.com',
phone: '+1234567890',
mobile: null,
nickname: null,
street: null,
city: null,
postalCode: null,
country: null,
company: 'Acme Inc',
jobTitle: null,
department: null,
website: null,
birthday: null,
notes: null,
photoUrl: null,
isFavorite: false,
isArchived: false,
organizationId: null,
teamId: null,
visibility: 'private',
sharedWith: null,
createdAt: new Date('2025-01-01'),
updatedAt: new Date('2025-01-01'),
};
beforeEach(async () => {
mockDb = {
select: jest.fn().mockReturnThis(),
from: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
offset: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
insert: jest.fn().mockReturnThis(),
values: jest.fn().mockReturnThis(),
returning: jest.fn(),
update: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
delete: jest.fn().mockReturnThis(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
ContactService,
{
provide: DATABASE_CONNECTION,
useValue: mockDb,
},
],
}).compile();
service = module.get<ContactService>(ContactService);
});
describe('findByUserId', () => {
it('should return contacts without search filters', async () => {
mockDb.offset.mockResolvedValue([mockContact]);
const result = await service.findByUserId('user-1');
expect(result).toEqual([mockContact]);
expect(mockDb.select).toHaveBeenCalled();
expect(mockDb.from).toHaveBeenCalled();
expect(mockDb.where).toHaveBeenCalled();
});
it('should return contacts with search filter using relevance scoring', async () => {
const searchResult = [{ contact: mockContact, relevance: 100 }];
mockDb.offset.mockResolvedValue(searchResult);
const result = await service.findByUserId('user-1', { search: 'John' });
expect(result).toEqual([mockContact]);
expect(mockDb.select).toHaveBeenCalled();
});
});
describe('findById', () => {
it('should return a contact when found', async () => {
mockDb.where.mockResolvedValue([mockContact]);
const result = await service.findById('contact-1', 'user-1');
expect(result).toEqual(mockContact);
});
it('should return null when contact is not found', async () => {
mockDb.where.mockResolvedValue([]);
const result = await service.findById('nonexistent', 'user-1');
expect(result).toBeNull();
});
});
describe('create', () => {
it('should insert and return a new contact', async () => {
mockDb.returning.mockResolvedValue([mockContact]);
const newContact = {
userId: 'user-1',
firstName: 'John',
lastName: 'Doe',
email: 'john@example.com',
};
const result = await service.create(newContact as any);
expect(result).toEqual(mockContact);
expect(mockDb.insert).toHaveBeenCalled();
expect(mockDb.values).toHaveBeenCalledWith(newContact);
});
});
describe('update', () => {
it('should update and return the contact', async () => {
const updatedContact = { ...mockContact, firstName: 'Jane' };
mockDb.returning.mockResolvedValue([updatedContact]);
const result = await service.update('contact-1', 'user-1', { firstName: 'Jane' });
expect(result).toEqual(updatedContact);
expect(mockDb.update).toHaveBeenCalled();
expect(mockDb.set).toHaveBeenCalled();
});
it('should throw NotFoundException when contact is not found', async () => {
mockDb.returning.mockResolvedValue([]);
await expect(service.update('nonexistent', 'user-1', { firstName: 'Jane' })).rejects.toThrow(
NotFoundException
);
});
});
describe('toggleFavorite', () => {
it('should toggle isFavorite from false to true', async () => {
const toggledContact = { ...mockContact, isFavorite: true };
// findById call
mockDb.where.mockResolvedValueOnce([mockContact]);
// update call
mockDb.returning.mockResolvedValue([toggledContact]);
const result = await service.toggleFavorite('contact-1', 'user-1');
expect(result).toEqual(toggledContact);
expect(result.isFavorite).toBe(true);
});
it('should throw NotFoundException when contact is not found', async () => {
mockDb.where.mockResolvedValue([]);
await expect(service.toggleFavorite('nonexistent', 'user-1')).rejects.toThrow(
NotFoundException
);
});
});
describe('toggleArchive', () => {
it('should toggle isArchived from false to true', async () => {
const archivedContact = { ...mockContact, isArchived: true };
// findById call
mockDb.where.mockResolvedValueOnce([mockContact]);
// update call
mockDb.returning.mockResolvedValue([archivedContact]);
const result = await service.toggleArchive('contact-1', 'user-1');
expect(result).toEqual(archivedContact);
expect(result.isArchived).toBe(true);
});
it('should throw NotFoundException when contact is not found', async () => {
mockDb.where.mockResolvedValue([]);
await expect(service.toggleArchive('nonexistent', 'user-1')).rejects.toThrow(
NotFoundException
);
});
});
describe('count', () => {
it('should return the count of contacts', async () => {
mockDb.where.mockResolvedValue([{ count: 42 }]);
const result = await service.count('user-1');
expect(result).toBe(42);
});
it('should return 0 when no contacts exist', async () => {
mockDb.where.mockResolvedValue([{ count: 0 }]);
const result = await service.count('user-1');
expect(result).toBe(0);
});
});
describe('delete', () => {
it('should delete a contact successfully', async () => {
// delete call
mockDb.where.mockResolvedValueOnce(undefined);
// findById check (contact no longer exists)
mockDb.where.mockResolvedValueOnce([]);
await expect(service.delete('contact-1', 'user-1')).resolves.toBeUndefined();
});
});
});

View file

@ -0,0 +1,139 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { DuplicateGroup, MergeResult } from './duplicates';
vi.mock('$lib/stores/auth.svelte', () => ({
authStore: {
getAccessToken: vi.fn().mockResolvedValue('mock-token'),
},
}));
vi.mock('./config', () => ({
API_BASE: 'http://localhost:3015/api/v1',
}));
const mockFetch = vi.fn();
global.fetch = mockFetch;
// Import after mocks are set up
const { duplicatesApi } = await import('./duplicates');
describe('duplicatesApi', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('findDuplicates', () => {
it('should call GET /duplicates and return duplicates', async () => {
const mockResponse: { duplicates: DuplicateGroup[]; total: number } = {
duplicates: [
{
id: 'group-1',
contacts: [
{ id: 'c1', firstName: 'John', email: 'john@example.com' } as any,
{ id: 'c2', firstName: 'John', email: 'john@example.com' } as any,
],
matchType: 'email',
matchValue: 'john@example.com',
},
],
total: 1,
};
mockFetch.mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue(mockResponse),
});
const result = await duplicatesApi.findDuplicates();
expect(mockFetch).toHaveBeenCalledWith('http://localhost:3015/api/v1/duplicates', {
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer mock-token',
},
});
expect(result).toEqual(mockResponse);
});
it('should throw an error when the request fails', async () => {
mockFetch.mockResolvedValue({
ok: false,
json: vi.fn().mockResolvedValue({ message: 'Unauthorized' }),
});
await expect(duplicatesApi.findDuplicates()).rejects.toThrow('Unauthorized');
});
});
describe('mergeContacts', () => {
it('should call POST /duplicates/merge with correct body', async () => {
const mockResult: MergeResult = {
mergedContact: {
id: 'c1',
firstName: 'John',
email: 'john@example.com',
} as any,
deletedIds: ['c2', 'c3'],
};
mockFetch.mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue(mockResult),
});
const result = await duplicatesApi.mergeContacts('c1', ['c2', 'c3']);
expect(mockFetch).toHaveBeenCalledWith('http://localhost:3015/api/v1/duplicates/merge', {
method: 'POST',
body: JSON.stringify({ primaryId: 'c1', mergeIds: ['c2', 'c3'] }),
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer mock-token',
},
});
expect(result).toEqual(mockResult);
});
it('should throw an error when merge fails', async () => {
mockFetch.mockResolvedValue({
ok: false,
json: vi.fn().mockResolvedValue({ message: 'Merge conflict' }),
});
await expect(duplicatesApi.mergeContacts('c1', ['c2'])).rejects.toThrow('Merge conflict');
});
});
describe('dismissDuplicate', () => {
it('should call DELETE /duplicates/:groupId/dismiss', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue(undefined),
});
await duplicatesApi.dismissDuplicate('group-1');
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:3015/api/v1/duplicates/group-1/dismiss',
{
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer mock-token',
},
}
);
});
it('should throw an error when dismiss fails', async () => {
mockFetch.mockResolvedValue({
ok: false,
json: vi.fn().mockResolvedValue({ message: 'Group not found' }),
});
await expect(duplicatesApi.dismissDuplicate('nonexistent')).rejects.toThrow(
'Group not found'
);
});
});
});

View file

@ -0,0 +1,189 @@
import { Test, TestingModule } from '@nestjs/testing';
import { NotFoundException } from '@nestjs/common';
import { LabelService } from '../label.service';
import { DATABASE_CONNECTION } from '../../db/database.module';
const mockDb = {
query: {
labels: {
findMany: jest.fn(),
findFirst: jest.fn(),
},
},
insert: jest.fn().mockReturnThis(),
update: jest.fn().mockReturnThis(),
delete: jest.fn().mockReturnThis(),
values: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
returning: jest.fn(),
};
describe('LabelService', () => {
let service: LabelService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
LabelService,
{
provide: DATABASE_CONNECTION,
useValue: mockDb,
},
],
}).compile();
service = module.get<LabelService>(LabelService);
jest.clearAllMocks();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('findAll', () => {
it('should return all labels for a user', async () => {
const userId = 'user-123';
const mockLabels = [
{ id: 'label-1', name: 'Important', color: '#ff0000', userId },
{ id: 'label-2', name: 'Work', color: '#0000ff', userId },
];
mockDb.query.labels.findMany.mockResolvedValue(mockLabels);
const result = await service.findAll(userId);
expect(result).toHaveLength(2);
expect(mockDb.query.labels.findMany).toHaveBeenCalled();
});
it('should return empty array when no labels', async () => {
mockDb.query.labels.findMany.mockResolvedValue([]);
const result = await service.findAll('user-123');
expect(result).toEqual([]);
});
});
describe('findById', () => {
it('should return a label when found', async () => {
const userId = 'user-123';
const labelId = 'label-1';
const mockLabel = { id: labelId, name: 'Important', color: '#ff0000', userId };
mockDb.query.labels.findFirst.mockResolvedValue(mockLabel);
const result = await service.findById(labelId, userId);
expect(result).toBeDefined();
expect(result?.id).toBe(labelId);
});
it('should return null when label not found', async () => {
mockDb.query.labels.findFirst.mockResolvedValue(undefined);
const result = await service.findById('non-existent', 'user-123');
expect(result).toBeNull();
});
});
describe('findByIdOrThrow', () => {
it('should return a label when found', async () => {
const userId = 'user-123';
const labelId = 'label-1';
const mockLabel = { id: labelId, name: 'Important', color: '#ff0000', userId };
mockDb.query.labels.findFirst.mockResolvedValue(mockLabel);
const result = await service.findByIdOrThrow(labelId, userId);
expect(result.id).toBe(labelId);
});
it('should throw NotFoundException when label not found', async () => {
mockDb.query.labels.findFirst.mockResolvedValue(undefined);
await expect(service.findByIdOrThrow('non-existent', 'user-123')).rejects.toThrow(
NotFoundException
);
});
});
describe('create', () => {
it('should create a label', async () => {
const userId = 'user-123';
const dto = { name: 'Urgent', color: '#ff0000' };
const createdLabel = { id: 'label-new', ...dto, userId };
mockDb.returning.mockResolvedValue([createdLabel]);
const result = await service.create(userId, dto);
expect(result.name).toBe('Urgent');
expect(result.color).toBe('#ff0000');
expect(mockDb.insert).toHaveBeenCalled();
});
it('should create a label with default color when not provided', async () => {
const userId = 'user-123';
const dto = { name: 'Simple' };
const createdLabel = { id: 'label-new', name: 'Simple', color: '#6B7280', userId };
mockDb.returning.mockResolvedValue([createdLabel]);
const result = await service.create(userId, dto);
expect(result.name).toBe('Simple');
expect(mockDb.insert).toHaveBeenCalled();
});
});
describe('update', () => {
it('should update a label', async () => {
const userId = 'user-123';
const labelId = 'label-1';
const dto = { name: 'Updated Label', color: '#00ff00' };
const existingLabel = { id: labelId, name: 'Original', color: '#ff0000', userId };
const updatedLabel = { id: labelId, ...dto, userId };
mockDb.query.labels.findFirst.mockResolvedValue(existingLabel);
mockDb.returning.mockResolvedValue([updatedLabel]);
const result = await service.update(labelId, userId, dto);
expect(result.name).toBe('Updated Label');
expect(result.color).toBe('#00ff00');
});
it('should throw when label does not exist', async () => {
mockDb.query.labels.findFirst.mockResolvedValue(undefined);
await expect(service.update('non-existent', 'user-123', { name: 'Test' })).rejects.toThrow(
NotFoundException
);
});
});
describe('delete', () => {
it('should delete a label', async () => {
const userId = 'user-123';
const labelId = 'label-1';
const existingLabel = { id: labelId, name: 'Important', userId };
mockDb.query.labels.findFirst.mockResolvedValue(existingLabel);
await service.delete(labelId, userId);
expect(mockDb.delete).toHaveBeenCalled();
});
it('should throw when label does not exist', async () => {
mockDb.query.labels.findFirst.mockResolvedValue(undefined);
await expect(service.delete('non-existent', 'user-123')).rejects.toThrow(NotFoundException);
});
});
});

View file

@ -0,0 +1,246 @@
import { Test, TestingModule } from '@nestjs/testing';
import { NotFoundException } from '@nestjs/common';
import { ProjectService } from '../project.service';
import { DATABASE_CONNECTION } from '../../db/database.module';
const mockDb = {
query: {
projects: {
findMany: jest.fn(),
findFirst: jest.fn(),
},
},
insert: jest.fn().mockReturnThis(),
update: jest.fn().mockReturnThis(),
delete: jest.fn().mockReturnThis(),
values: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
returning: jest.fn(),
};
describe('ProjectService', () => {
let service: ProjectService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ProjectService,
{
provide: DATABASE_CONNECTION,
useValue: mockDb,
},
],
}).compile();
service = module.get<ProjectService>(ProjectService);
jest.clearAllMocks();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('findAll', () => {
it('should return all projects for a user', async () => {
const userId = 'user-123';
const mockProjects = [
{ id: 'proj-1', name: 'Work', userId, order: 0 },
{ id: 'proj-2', name: 'Personal', userId, order: 1 },
];
mockDb.query.projects.findMany.mockResolvedValue(mockProjects);
const result = await service.findAll(userId);
expect(result).toHaveLength(2);
expect(mockDb.query.projects.findMany).toHaveBeenCalled();
});
it('should return empty array when no projects', async () => {
mockDb.query.projects.findMany.mockResolvedValue([]);
const result = await service.findAll('user-123');
expect(result).toEqual([]);
});
});
describe('findById', () => {
it('should return a project when found', async () => {
const userId = 'user-123';
const projectId = 'proj-1';
const mockProject = { id: projectId, name: 'Work', userId };
mockDb.query.projects.findFirst.mockResolvedValue(mockProject);
const result = await service.findById(projectId, userId);
expect(result).toBeDefined();
expect(result?.id).toBe(projectId);
});
it('should return null when project not found', async () => {
mockDb.query.projects.findFirst.mockResolvedValue(undefined);
const result = await service.findById('non-existent', 'user-123');
expect(result).toBeNull();
});
});
describe('findByIdOrThrow', () => {
it('should return a project when found', async () => {
const userId = 'user-123';
const projectId = 'proj-1';
const mockProject = { id: projectId, name: 'Work', userId };
mockDb.query.projects.findFirst.mockResolvedValue(mockProject);
const result = await service.findByIdOrThrow(projectId, userId);
expect(result.id).toBe(projectId);
});
it('should throw NotFoundException when project not found', async () => {
mockDb.query.projects.findFirst.mockResolvedValue(undefined);
await expect(service.findByIdOrThrow('non-existent', 'user-123')).rejects.toThrow(
NotFoundException
);
});
});
describe('create', () => {
it('should create a project with correct order', async () => {
const userId = 'user-123';
const dto = { name: 'New Project', color: '#ff0000', icon: 'star' };
const existingProjects = [
{ id: 'proj-1', name: 'Work', userId, order: 0 },
{ id: 'proj-2', name: 'Personal', userId, order: 1 },
];
const createdProject = { id: 'proj-new', ...dto, userId, order: 2 };
mockDb.query.projects.findMany.mockResolvedValue(existingProjects);
mockDb.returning.mockResolvedValue([createdProject]);
const result = await service.create(userId, dto);
expect(result.order).toBe(2);
expect(result.name).toBe('New Project');
expect(mockDb.insert).toHaveBeenCalled();
});
it('should set order to 0 when no existing projects', async () => {
const userId = 'user-123';
const dto = { name: 'First Project' };
const createdProject = {
id: 'proj-first',
name: 'First Project',
userId,
order: 0,
isDefault: true,
};
mockDb.query.projects.findMany.mockResolvedValue([]);
mockDb.returning.mockResolvedValue([createdProject]);
const result = await service.create(userId, dto);
expect(result.order).toBe(0);
expect(mockDb.insert).toHaveBeenCalled();
});
});
describe('update', () => {
it('should update a project', async () => {
const userId = 'user-123';
const projectId = 'proj-1';
const dto = { name: 'Updated Name' };
const existingProject = { id: projectId, name: 'Original', userId };
const updatedProject = { id: projectId, name: 'Updated Name', userId };
mockDb.query.projects.findFirst.mockResolvedValue(existingProject);
mockDb.returning.mockResolvedValue([updatedProject]);
const result = await service.update(projectId, userId, dto);
expect(result.name).toBe('Updated Name');
});
it('should throw when project does not exist', async () => {
mockDb.query.projects.findFirst.mockResolvedValue(undefined);
await expect(service.update('non-existent', 'user-123', { name: 'Test' })).rejects.toThrow(
NotFoundException
);
});
});
describe('delete', () => {
it('should delete a project', async () => {
const userId = 'user-123';
const projectId = 'proj-1';
const existingProject = { id: projectId, userId, isDefault: false };
mockDb.query.projects.findFirst.mockResolvedValue(existingProject);
await service.delete(projectId, userId);
expect(mockDb.delete).toHaveBeenCalled();
});
it('should throw when project does not exist', async () => {
mockDb.query.projects.findFirst.mockResolvedValue(undefined);
await expect(service.delete('non-existent', 'user-123')).rejects.toThrow(NotFoundException);
});
it('should throw when trying to delete the default project', async () => {
const userId = 'user-123';
const projectId = 'proj-default';
const defaultProject = { id: projectId, userId, isDefault: true };
mockDb.query.projects.findFirst.mockResolvedValue(defaultProject);
await expect(service.delete(projectId, userId)).rejects.toThrow(NotFoundException);
});
});
describe('archive', () => {
it('should archive a project', async () => {
const userId = 'user-123';
const projectId = 'proj-1';
const existingProject = { id: projectId, userId, isArchived: false };
const archivedProject = { ...existingProject, isArchived: true };
mockDb.query.projects.findFirst.mockResolvedValue(existingProject);
mockDb.returning.mockResolvedValue([archivedProject]);
const result = await service.archive(projectId, userId);
expect(result.isArchived).toBe(true);
});
});
describe('reorder', () => {
it('should update order for each project and return all', async () => {
const userId = 'user-123';
const projectIds = ['proj-2', 'proj-1', 'proj-3'];
const reorderedProjects = [
{ id: 'proj-2', order: 0 },
{ id: 'proj-1', order: 1 },
{ id: 'proj-3', order: 2 },
];
// Mock for the final findAll call
mockDb.query.projects.findMany.mockResolvedValue(reorderedProjects);
const result = await service.reorder(userId, projectIds);
expect(mockDb.update).toHaveBeenCalledTimes(3);
expect(result).toEqual(reorderedProjects);
});
});
});

View file

@ -0,0 +1,134 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock the client module
vi.mock('./client', () => ({
apiClient: {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
},
}));
import {
getProjects,
getProject,
createProject,
updateProject,
deleteProject,
archiveProject,
reorderProjects,
} from './projects';
import { apiClient } from './client';
const mockClient = vi.mocked(apiClient);
beforeEach(() => {
vi.clearAllMocks();
});
describe('getProjects', () => {
it('should fetch all projects', async () => {
const projects = [
{ id: 'p1', name: 'Work' },
{ id: 'p2', name: 'Personal' },
];
mockClient.get.mockResolvedValue({ projects });
const result = await getProjects();
expect(mockClient.get).toHaveBeenCalledWith('/api/v1/projects');
expect(result).toEqual(projects);
});
it('should return empty array when no projects', async () => {
mockClient.get.mockResolvedValue({ projects: [] });
const result = await getProjects();
expect(result).toEqual([]);
});
});
describe('getProject', () => {
it('should fetch a single project by id', async () => {
const project = { id: 'p1', name: 'Work' };
mockClient.get.mockResolvedValue({ project });
const result = await getProject('p1');
expect(mockClient.get).toHaveBeenCalledWith('/api/v1/projects/p1');
expect(result).toEqual(project);
});
});
describe('createProject', () => {
it('should POST a new project', async () => {
const data = { name: 'New Project', color: '#ff0000', icon: 'star' };
const project = { id: 'p-new', ...data };
mockClient.post.mockResolvedValue({ project });
const result = await createProject(data);
expect(mockClient.post).toHaveBeenCalledWith('/api/v1/projects', data);
expect(result).toEqual(project);
});
it('should create project with only name', async () => {
const data = { name: 'Minimal Project' };
const project = { id: 'p-min', name: 'Minimal Project' };
mockClient.post.mockResolvedValue({ project });
const result = await createProject(data);
expect(mockClient.post).toHaveBeenCalledWith('/api/v1/projects', data);
expect(result).toEqual(project);
});
});
describe('updateProject', () => {
it('should PUT updated project', async () => {
const data = { name: 'Updated Name', color: '#00ff00' };
const project = { id: 'p1', ...data };
mockClient.put.mockResolvedValue({ project });
const result = await updateProject('p1', data);
expect(mockClient.put).toHaveBeenCalledWith('/api/v1/projects/p1', data);
expect(result).toEqual(project);
});
});
describe('deleteProject', () => {
it('should DELETE project', async () => {
mockClient.delete.mockResolvedValue(undefined);
await deleteProject('p1');
expect(mockClient.delete).toHaveBeenCalledWith('/api/v1/projects/p1');
});
});
describe('archiveProject', () => {
it('should POST to archive endpoint', async () => {
const project = { id: 'p1', name: 'Work', isArchived: true };
mockClient.post.mockResolvedValue({ project });
const result = await archiveProject('p1');
expect(mockClient.post).toHaveBeenCalledWith('/api/v1/projects/p1/archive');
expect(result).toEqual(project);
});
});
describe('reorderProjects', () => {
it('should PUT reorder with project IDs', async () => {
mockClient.put.mockResolvedValue(undefined);
await reorderProjects(['p1', 'p2', 'p3']);
expect(mockClient.put).toHaveBeenCalledWith('/api/v1/projects/reorder', {
projectIds: ['p1', 'p2', 'p3'],
});
});
});

View file

@ -0,0 +1,77 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock the client module
vi.mock('./client', () => ({
apiClient: {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
},
}));
import { getReminders, createReminder, deleteReminder } from './reminders';
import { apiClient } from './client';
const mockClient = vi.mocked(apiClient);
beforeEach(() => {
vi.clearAllMocks();
});
describe('getReminders', () => {
it('should fetch reminders for a task', async () => {
const reminders = [
{ id: 'r1', taskId: 't1', minutesBefore: 15, type: 'push' },
{ id: 'r2', taskId: 't1', minutesBefore: 60, type: 'email' },
];
mockClient.get.mockResolvedValue({ reminders });
const result = await getReminders('t1');
expect(mockClient.get).toHaveBeenCalledWith('/api/v1/tasks/t1/reminders');
expect(result).toEqual(reminders);
});
it('should return empty array when no reminders', async () => {
mockClient.get.mockResolvedValue({ reminders: [] });
const result = await getReminders('t1');
expect(result).toEqual([]);
});
});
describe('createReminder', () => {
it('should POST a new reminder', async () => {
const data = { minutesBefore: 30, type: 'push' as const };
const reminder = { id: 'r-new', taskId: 't1', ...data };
mockClient.post.mockResolvedValue({ reminder });
const result = await createReminder('t1', data);
expect(mockClient.post).toHaveBeenCalledWith('/api/v1/tasks/t1/reminders', data);
expect(result).toEqual(reminder);
});
it('should create reminder with only minutesBefore', async () => {
const data = { minutesBefore: 10 };
const reminder = { id: 'r-new', taskId: 't1', minutesBefore: 10 };
mockClient.post.mockResolvedValue({ reminder });
const result = await createReminder('t1', data);
expect(mockClient.post).toHaveBeenCalledWith('/api/v1/tasks/t1/reminders', data);
expect(result).toEqual(reminder);
});
});
describe('deleteReminder', () => {
it('should DELETE reminder by id', async () => {
mockClient.delete.mockResolvedValue(undefined);
await deleteReminder('r1');
expect(mockClient.delete).toHaveBeenCalledWith('/api/v1/reminders/r1');
});
});

2071
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff