mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
feat(calendar, contacts, todo): add server API tests with vitest
Calendar: 13 tests (RRULE expansion, ICS parsing, health endpoint). Contacts: 11 tests (vCard import, avatar upload, health endpoint). Todo: admin, reminders, and RRULE route tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
293fd7b63b
commit
b684ddeeda
19 changed files with 1553 additions and 353 deletions
|
|
@ -5,7 +5,9 @@
|
|||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun run --watch src/index.ts",
|
||||
"start": "bun run src/index.ts"
|
||||
"start": "bun run src/index.ts",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/shared-hono": "workspace:*",
|
||||
|
|
@ -13,6 +15,7 @@
|
|||
"zod": "^3.25.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3"
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
320
apps/calendar/apps/server/src/index.test.ts
Normal file
320
apps/calendar/apps/server/src/index.test.ts
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { app } from './index';
|
||||
|
||||
function post(path: string, body: unknown) {
|
||||
return app.request(path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function formPost(path: string, formData: FormData) {
|
||||
return app.request(path, { method: 'POST', body: formData });
|
||||
}
|
||||
|
||||
// ─── Health ────────────────────────────────────────────────────
|
||||
|
||||
describe('GET /health', () => {
|
||||
it('returns healthy status', async () => {
|
||||
const res = await app.request('/health');
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── RRULE Expansion ───────────────────────────────────────────
|
||||
|
||||
describe('POST /api/v1/events/expand', () => {
|
||||
it('expands daily RRULE', async () => {
|
||||
const res = await post('/api/v1/events/expand', {
|
||||
rrule: 'FREQ=DAILY',
|
||||
dtstart: '2026-01-01T00:00:00Z',
|
||||
maxOccurrences: 7,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const data = await res.json();
|
||||
expect(data.occurrences).toHaveLength(7);
|
||||
expect(data.count).toBe(7);
|
||||
expect(data.occurrences[0]).toContain('2026-01-01');
|
||||
});
|
||||
|
||||
it('expands weekly RRULE', async () => {
|
||||
const res = await post('/api/v1/events/expand', {
|
||||
rrule: 'FREQ=WEEKLY',
|
||||
dtstart: '2026-01-05T10:00:00Z',
|
||||
maxOccurrences: 4,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const data = await res.json();
|
||||
expect(data.occurrences).toHaveLength(4);
|
||||
// Each occurrence should be 7 days apart
|
||||
const dates = data.occurrences.map((d: string) => new Date(d).getTime());
|
||||
const weekMs = 7 * 24 * 60 * 60 * 1000;
|
||||
expect(dates[1] - dates[0]).toBe(weekMs);
|
||||
});
|
||||
|
||||
it('expands monthly RRULE', async () => {
|
||||
const res = await post('/api/v1/events/expand', {
|
||||
rrule: 'FREQ=MONTHLY',
|
||||
dtstart: '2026-01-15T09:00:00Z',
|
||||
maxOccurrences: 3,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const data = await res.json();
|
||||
expect(data.count).toBe(3);
|
||||
expect(data.occurrences[0]).toContain('2026-01-15');
|
||||
expect(data.occurrences[1]).toContain('2026-02-15');
|
||||
expect(data.occurrences[2]).toContain('2026-03-15');
|
||||
});
|
||||
|
||||
it('expands yearly RRULE', async () => {
|
||||
const res = await post('/api/v1/events/expand', {
|
||||
rrule: 'FREQ=YEARLY',
|
||||
dtstart: '2026-06-01T00:00:00Z',
|
||||
maxOccurrences: 3,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const data = await res.json();
|
||||
expect(data.count).toBe(3);
|
||||
});
|
||||
|
||||
it('respects INTERVAL', async () => {
|
||||
const res = await post('/api/v1/events/expand', {
|
||||
rrule: 'FREQ=DAILY;INTERVAL=3',
|
||||
dtstart: '2026-01-01T00:00:00Z',
|
||||
maxOccurrences: 4,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const data = await res.json();
|
||||
const dates = data.occurrences.map((d: string) => new Date(d).getTime());
|
||||
const threeDaysMs = 3 * 24 * 60 * 60 * 1000;
|
||||
expect(dates[1] - dates[0]).toBe(threeDaysMs);
|
||||
});
|
||||
|
||||
it('stops at until date', async () => {
|
||||
const res = await post('/api/v1/events/expand', {
|
||||
rrule: 'FREQ=DAILY',
|
||||
dtstart: '2026-01-01T00:00:00Z',
|
||||
until: '2026-01-05T00:00:00Z',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const data = await res.json();
|
||||
expect(data.count).toBe(5); // Jan 1-5 inclusive
|
||||
});
|
||||
|
||||
it('enforces max 5000 occurrences', async () => {
|
||||
const res = await post('/api/v1/events/expand', {
|
||||
rrule: 'FREQ=DAILY',
|
||||
dtstart: '2000-01-01T00:00:00Z',
|
||||
maxOccurrences: 10000,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const data = await res.json();
|
||||
expect(data.count).toBeLessThanOrEqual(5000);
|
||||
});
|
||||
|
||||
it('rejects missing rrule', async () => {
|
||||
const res = await post('/api/v1/events/expand', {
|
||||
dtstart: '2026-01-01T00:00:00Z',
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects missing dtstart', async () => {
|
||||
const res = await post('/api/v1/events/expand', {
|
||||
rrule: 'FREQ=DAILY',
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects empty rrule', async () => {
|
||||
const res = await post('/api/v1/events/expand', {
|
||||
rrule: '',
|
||||
dtstart: '2026-01-01T00:00:00Z',
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects rrule exceeding max length', async () => {
|
||||
const res = await post('/api/v1/events/expand', {
|
||||
rrule: 'F'.repeat(501),
|
||||
dtstart: '2026-01-01T00:00:00Z',
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Google Calendar Sync ──────────────────────────────────────
|
||||
|
||||
describe('POST /api/v1/sync/google', () => {
|
||||
it('returns 501 Not Implemented', async () => {
|
||||
const res = await post('/api/v1/sync/google', {});
|
||||
expect(res.status).toBe(501);
|
||||
|
||||
const data = await res.json();
|
||||
expect(data.error).toContain('not yet implemented');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── ICS Import ────────────────────────────────────────────────
|
||||
|
||||
describe('POST /api/v1/import/ics', () => {
|
||||
it('parses a valid ICS file with one event', async () => {
|
||||
const ics = [
|
||||
'BEGIN:VCALENDAR',
|
||||
'BEGIN:VEVENT',
|
||||
'SUMMARY:Team Meeting',
|
||||
'DTSTART:20260615T140000Z',
|
||||
'DTEND:20260615T150000Z',
|
||||
'DESCRIPTION:Weekly sync',
|
||||
'LOCATION:Room 42',
|
||||
'END:VEVENT',
|
||||
'END:VCALENDAR',
|
||||
].join('\r\n');
|
||||
|
||||
const form = new FormData();
|
||||
form.append('file', new File([ics], 'cal.ics', { type: 'text/calendar' }));
|
||||
|
||||
const res = await formPost('/api/v1/import/ics', form);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const data = await res.json();
|
||||
expect(data.count).toBe(1);
|
||||
expect(data.events[0].title).toBe('Team Meeting');
|
||||
expect(data.events[0].start).toBe('20260615T140000Z');
|
||||
expect(data.events[0].end).toBe('20260615T150000Z');
|
||||
expect(data.events[0].description).toBe('Weekly sync');
|
||||
expect(data.events[0].location).toBe('Room 42');
|
||||
});
|
||||
|
||||
it('parses multiple events', async () => {
|
||||
const ics = [
|
||||
'BEGIN:VCALENDAR',
|
||||
'BEGIN:VEVENT',
|
||||
'SUMMARY:Event One',
|
||||
'DTSTART:20260601T090000Z',
|
||||
'DTEND:20260601T100000Z',
|
||||
'END:VEVENT',
|
||||
'BEGIN:VEVENT',
|
||||
'SUMMARY:Event Two',
|
||||
'DTSTART:20260602T110000Z',
|
||||
'DTEND:20260602T120000Z',
|
||||
'END:VEVENT',
|
||||
'BEGIN:VEVENT',
|
||||
'SUMMARY:Event Three',
|
||||
'DTSTART:20260603T130000Z',
|
||||
'DTEND:20260603T140000Z',
|
||||
'END:VEVENT',
|
||||
'END:VCALENDAR',
|
||||
].join('\r\n');
|
||||
|
||||
const form = new FormData();
|
||||
form.append('file', new File([ics], 'multi.ics', { type: 'text/calendar' }));
|
||||
|
||||
const res = await formPost('/api/v1/import/ics', form);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const data = await res.json();
|
||||
expect(data.count).toBe(3);
|
||||
expect(data.events[0].title).toBe('Event One');
|
||||
expect(data.events[2].title).toBe('Event Three');
|
||||
});
|
||||
|
||||
it('parses event with RRULE', async () => {
|
||||
const ics = [
|
||||
'BEGIN:VCALENDAR',
|
||||
'BEGIN:VEVENT',
|
||||
'SUMMARY:Daily Standup',
|
||||
'DTSTART:20260101T090000Z',
|
||||
'RRULE:FREQ=DAILY;COUNT=5',
|
||||
'END:VEVENT',
|
||||
'END:VCALENDAR',
|
||||
].join('\n');
|
||||
|
||||
const form = new FormData();
|
||||
form.append('file', new File([ics], 'recurring.ics'));
|
||||
|
||||
const res = await formPost('/api/v1/import/ics', form);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const data = await res.json();
|
||||
expect(data.events[0].rrule).toBe('FREQ=DAILY;COUNT=5');
|
||||
});
|
||||
|
||||
it('handles ICS with DTSTART parameters', async () => {
|
||||
const ics = [
|
||||
'BEGIN:VCALENDAR',
|
||||
'BEGIN:VEVENT',
|
||||
'SUMMARY:All Day Event',
|
||||
'DTSTART;VALUE=DATE:20260701',
|
||||
'DTEND;VALUE=DATE:20260702',
|
||||
'END:VEVENT',
|
||||
'END:VCALENDAR',
|
||||
].join('\r\n');
|
||||
|
||||
const form = new FormData();
|
||||
form.append('file', new File([ics], 'allday.ics'));
|
||||
|
||||
const res = await formPost('/api/v1/import/ics', form);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const data = await res.json();
|
||||
expect(data.count).toBe(1);
|
||||
// DTSTART;VALUE=DATE:20260701 → split(':').pop() → '20260701'
|
||||
expect(data.events[0].start).toBe('20260701');
|
||||
});
|
||||
|
||||
it('skips events without title and start', async () => {
|
||||
const ics = [
|
||||
'BEGIN:VCALENDAR',
|
||||
'BEGIN:VEVENT',
|
||||
'DESCRIPTION:No title or start',
|
||||
'END:VEVENT',
|
||||
'BEGIN:VEVENT',
|
||||
'SUMMARY:Valid Event',
|
||||
'DTSTART:20260101T090000Z',
|
||||
'END:VEVENT',
|
||||
'END:VCALENDAR',
|
||||
].join('\n');
|
||||
|
||||
const form = new FormData();
|
||||
form.append('file', new File([ics], 'partial.ics'));
|
||||
|
||||
const res = await formPost('/api/v1/import/ics', form);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const data = await res.json();
|
||||
expect(data.count).toBe(1);
|
||||
expect(data.events[0].title).toBe('Valid Event');
|
||||
});
|
||||
|
||||
it('returns empty array for ICS without events', async () => {
|
||||
const ics = 'BEGIN:VCALENDAR\r\nEND:VCALENDAR';
|
||||
|
||||
const form = new FormData();
|
||||
form.append('file', new File([ics], 'empty.ics'));
|
||||
|
||||
const res = await formPost('/api/v1/import/ics', form);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const data = await res.json();
|
||||
expect(data.count).toBe(0);
|
||||
expect(data.events).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns 400 if no file provided', async () => {
|
||||
const form = new FormData();
|
||||
const res = await formPost('/api/v1/import/ics', form);
|
||||
expect(res.status).toBe(400);
|
||||
|
||||
const data = await res.json();
|
||||
expect(data.error).toBe('No file');
|
||||
});
|
||||
});
|
||||
|
|
@ -134,4 +134,5 @@ function parseICS(text: string): Array<Record<string, string>> {
|
|||
return events;
|
||||
}
|
||||
|
||||
export { app };
|
||||
export default { port: PORT, fetch: app.fetch };
|
||||
|
|
|
|||
2
apps/calendar/apps/server/src/test-setup.ts
Normal file
2
apps/calendar/apps/server/src/test-setup.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
process.env.DEV_BYPASS_AUTH = 'true';
|
||||
process.env.NODE_ENV = 'development';
|
||||
|
|
@ -7,5 +7,5 @@
|
|||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
"include": ["src/**/*.ts", "vitest.config.ts"]
|
||||
}
|
||||
|
|
|
|||
13
apps/calendar/apps/server/vitest.config.ts
Normal file
13
apps/calendar/apps/server/vitest.config.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts'],
|
||||
setupFiles: ['./src/test-setup.ts'],
|
||||
clearMocks: true,
|
||||
mockReset: true,
|
||||
restoreMocks: true,
|
||||
},
|
||||
});
|
||||
|
|
@ -5,7 +5,9 @@
|
|||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun run --watch src/index.ts",
|
||||
"start": "bun run src/index.ts"
|
||||
"start": "bun run src/index.ts",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/shared-hono": "workspace:*",
|
||||
|
|
@ -13,6 +15,7 @@
|
|||
"hono": "^4.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3"
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
242
apps/contacts/apps/server/src/index.test.ts
Normal file
242
apps/contacts/apps/server/src/index.test.ts
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { app } from './index';
|
||||
|
||||
function formPost(path: string, formData: FormData) {
|
||||
return app.request(path, { method: 'POST', body: formData });
|
||||
}
|
||||
|
||||
// ─── Health ────────────────────────────────────────────────────
|
||||
|
||||
describe('GET /health', () => {
|
||||
it('returns healthy status', async () => {
|
||||
const res = await app.request('/health');
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── vCard Import ──────────────────────────────────────────────
|
||||
|
||||
describe('POST /api/v1/import/vcard', () => {
|
||||
it('parses a single vCard', async () => {
|
||||
const vcard = [
|
||||
'BEGIN:VCARD',
|
||||
'VERSION:3.0',
|
||||
'FN:Max Mustermann',
|
||||
'EMAIL:max@example.com',
|
||||
'TEL:+49 170 1234567',
|
||||
'ORG:ACME Corp',
|
||||
'TITLE:Engineer',
|
||||
'END:VCARD',
|
||||
].join('\r\n');
|
||||
|
||||
const form = new FormData();
|
||||
form.append('file', new File([vcard], 'contacts.vcf', { type: 'text/vcard' }));
|
||||
|
||||
const res = await formPost('/api/v1/import/vcard', form);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const data = await res.json();
|
||||
expect(data.count).toBe(1);
|
||||
expect(data.contacts[0].name).toBe('Max Mustermann');
|
||||
expect(data.contacts[0].email).toBe('max@example.com');
|
||||
expect(data.contacts[0].phone).toBe('+49 170 1234567');
|
||||
expect(data.contacts[0].company).toBe('ACME Corp');
|
||||
expect(data.contacts[0].title).toBe('Engineer');
|
||||
});
|
||||
|
||||
it('parses multiple vCards', async () => {
|
||||
const vcard = [
|
||||
'BEGIN:VCARD',
|
||||
'FN:Alice',
|
||||
'EMAIL:alice@example.com',
|
||||
'END:VCARD',
|
||||
'BEGIN:VCARD',
|
||||
'FN:Bob',
|
||||
'EMAIL:bob@example.com',
|
||||
'END:VCARD',
|
||||
'BEGIN:VCARD',
|
||||
'FN:Charlie',
|
||||
'TEL:+1 555 0123',
|
||||
'END:VCARD',
|
||||
].join('\n');
|
||||
|
||||
const form = new FormData();
|
||||
form.append('file', new File([vcard], 'multi.vcf'));
|
||||
|
||||
const res = await formPost('/api/v1/import/vcard', form);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const data = await res.json();
|
||||
expect(data.count).toBe(3);
|
||||
expect(data.contacts[0].name).toBe('Alice');
|
||||
expect(data.contacts[1].name).toBe('Bob');
|
||||
expect(data.contacts[2].name).toBe('Charlie');
|
||||
});
|
||||
|
||||
it('handles vCard with EMAIL type parameters', async () => {
|
||||
const vcard = [
|
||||
'BEGIN:VCARD',
|
||||
'FN:Test User',
|
||||
'EMAIL;type=INTERNET;type=WORK:work@example.com',
|
||||
'TEL;type=CELL:+49 170 0000000',
|
||||
'END:VCARD',
|
||||
].join('\r\n');
|
||||
|
||||
const form = new FormData();
|
||||
form.append('file', new File([vcard], 'typed.vcf'));
|
||||
|
||||
const res = await formPost('/api/v1/import/vcard', form);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const data = await res.json();
|
||||
expect(data.count).toBe(1);
|
||||
// EMAIL;type=...:work@example.com → split(':').pop() → 'work@example.com'
|
||||
expect(data.contacts[0].email).toBe('work@example.com');
|
||||
expect(data.contacts[0].phone).toBe('+49 170 0000000');
|
||||
});
|
||||
|
||||
it('skips contacts without name or email', async () => {
|
||||
const vcard = [
|
||||
'BEGIN:VCARD',
|
||||
'TEL:+49 170 0000000',
|
||||
'ORG:Company Only',
|
||||
'END:VCARD',
|
||||
'BEGIN:VCARD',
|
||||
'FN:Valid Contact',
|
||||
'END:VCARD',
|
||||
].join('\n');
|
||||
|
||||
const form = new FormData();
|
||||
form.append('file', new File([vcard], 'partial.vcf'));
|
||||
|
||||
const res = await formPost('/api/v1/import/vcard', form);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const data = await res.json();
|
||||
expect(data.count).toBe(1);
|
||||
expect(data.contacts[0].name).toBe('Valid Contact');
|
||||
});
|
||||
|
||||
it('includes contact with only email (no name)', async () => {
|
||||
const vcard = ['BEGIN:VCARD', 'EMAIL:noreply@example.com', 'END:VCARD'].join('\n');
|
||||
|
||||
const form = new FormData();
|
||||
form.append('file', new File([vcard], 'email-only.vcf'));
|
||||
|
||||
const res = await formPost('/api/v1/import/vcard', form);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const data = await res.json();
|
||||
expect(data.count).toBe(1);
|
||||
expect(data.contacts[0].email).toBe('noreply@example.com');
|
||||
});
|
||||
|
||||
it('returns empty array for empty vCard file', async () => {
|
||||
const form = new FormData();
|
||||
form.append('file', new File([''], 'empty.vcf'));
|
||||
|
||||
const res = await formPost('/api/v1/import/vcard', form);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const data = await res.json();
|
||||
expect(data.count).toBe(0);
|
||||
expect(data.contacts).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns 400 if no file provided', async () => {
|
||||
const form = new FormData();
|
||||
const res = await formPost('/api/v1/import/vcard', form);
|
||||
expect(res.status).toBe(400);
|
||||
|
||||
const data = await res.json();
|
||||
expect(data.error).toBe('No file');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Avatar Upload ─────────────────────────────────────────────
|
||||
|
||||
describe('POST /api/v1/contacts/:id/avatar', () => {
|
||||
it('returns 400 if no file provided', async () => {
|
||||
const form = new FormData();
|
||||
const res = await formPost('/api/v1/contacts/contact-1/avatar', form);
|
||||
expect(res.status).toBe(400);
|
||||
|
||||
const data = await res.json();
|
||||
expect(data.error).toBe('No file');
|
||||
});
|
||||
|
||||
it('returns 400 if file exceeds 5MB', async () => {
|
||||
// Create a file > 5MB
|
||||
const bigContent = new Uint8Array(6 * 1024 * 1024);
|
||||
const form = new FormData();
|
||||
form.append('file', new File([bigContent], 'big.jpg', { type: 'image/jpeg' }));
|
||||
|
||||
const res = await formPost('/api/v1/contacts/contact-1/avatar', form);
|
||||
expect(res.status).toBe(400);
|
||||
|
||||
const data = await res.json();
|
||||
expect(data.error).toBe('Max 5MB');
|
||||
});
|
||||
|
||||
it('returns 400 for invalid file type', async () => {
|
||||
const form = new FormData();
|
||||
form.append('file', new File(['data'], 'doc.pdf', { type: 'application/pdf' }));
|
||||
|
||||
const res = await formPost('/api/v1/contacts/contact-1/avatar', form);
|
||||
expect(res.status).toBe(400);
|
||||
|
||||
const data = await res.json();
|
||||
expect(data.error).toContain('Invalid file type');
|
||||
});
|
||||
|
||||
it('accepts JPEG files', async () => {
|
||||
vi.mock('@manacore/shared-storage', () => ({
|
||||
createContactsStorage: () => ({
|
||||
upload: vi.fn().mockResolvedValue({ url: 'https://s3.example.com/avatar.jpg' }),
|
||||
}),
|
||||
generateUserFileKey: vi.fn().mockReturnValue('users/test/avatar.jpg'),
|
||||
getContentType: vi.fn().mockReturnValue('image/jpeg'),
|
||||
}));
|
||||
|
||||
const form = new FormData();
|
||||
form.append('file', new File(['image-data'], 'photo.jpg', { type: 'image/jpeg' }));
|
||||
|
||||
const res = await formPost('/api/v1/contacts/contact-1/avatar', form);
|
||||
expect(res.status).toBe(201);
|
||||
|
||||
const data = await res.json();
|
||||
expect(data.avatarUrl).toBeDefined();
|
||||
});
|
||||
|
||||
it('accepts PNG files', async () => {
|
||||
vi.mock('@manacore/shared-storage', () => ({
|
||||
createContactsStorage: () => ({
|
||||
upload: vi.fn().mockResolvedValue({ url: 'https://s3.example.com/avatar.png' }),
|
||||
}),
|
||||
generateUserFileKey: vi.fn().mockReturnValue('users/test/avatar.png'),
|
||||
getContentType: vi.fn().mockReturnValue('image/png'),
|
||||
}));
|
||||
|
||||
const form = new FormData();
|
||||
form.append('file', new File(['image-data'], 'photo.png', { type: 'image/png' }));
|
||||
|
||||
const res = await formPost('/api/v1/contacts/contact-1/avatar', form);
|
||||
expect(res.status).toBe(201);
|
||||
});
|
||||
|
||||
it('accepts WebP files', async () => {
|
||||
vi.mock('@manacore/shared-storage', () => ({
|
||||
createContactsStorage: () => ({
|
||||
upload: vi.fn().mockResolvedValue({ url: 'https://s3.example.com/avatar.webp' }),
|
||||
}),
|
||||
generateUserFileKey: vi.fn().mockReturnValue('users/test/avatar.webp'),
|
||||
getContentType: vi.fn().mockReturnValue('image/webp'),
|
||||
}));
|
||||
|
||||
const form = new FormData();
|
||||
form.append('file', new File(['image-data'], 'photo.webp', { type: 'image/webp' }));
|
||||
|
||||
const res = await formPost('/api/v1/contacts/contact-1/avatar', form);
|
||||
expect(res.status).toBe(201);
|
||||
});
|
||||
});
|
||||
|
|
@ -104,4 +104,5 @@ function parseVCard(text: string): Array<Record<string, string>> {
|
|||
return contacts;
|
||||
}
|
||||
|
||||
export { app };
|
||||
export default { port: PORT, fetch: app.fetch };
|
||||
|
|
|
|||
2
apps/contacts/apps/server/src/test-setup.ts
Normal file
2
apps/contacts/apps/server/src/test-setup.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
process.env.DEV_BYPASS_AUTH = 'true';
|
||||
process.env.NODE_ENV = 'development';
|
||||
|
|
@ -7,5 +7,5 @@
|
|||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
"include": ["src/**/*.ts", "vitest.config.ts"]
|
||||
}
|
||||
|
|
|
|||
13
apps/contacts/apps/server/vitest.config.ts
Normal file
13
apps/contacts/apps/server/vitest.config.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts'],
|
||||
setupFiles: ['./src/test-setup.ts'],
|
||||
clearMocks: true,
|
||||
mockReset: true,
|
||||
restoreMocks: true,
|
||||
},
|
||||
});
|
||||
|
|
@ -7,7 +7,9 @@
|
|||
"scripts": {
|
||||
"dev": "bun run --watch src/index.ts",
|
||||
"start": "bun run src/index.ts",
|
||||
"type-check": "bun x tsc --noEmit"
|
||||
"type-check": "bun x tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/shared-hono": "workspace:*",
|
||||
|
|
@ -19,6 +21,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.2.0",
|
||||
"typescript": "^5.9.3"
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
111
apps/todo/apps/server/src/routes/admin.test.ts
Normal file
111
apps/todo/apps/server/src/routes/admin.test.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { Hono } from 'hono';
|
||||
|
||||
// Mock drizzle-orm operators
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
eq: vi.fn((_col, _val) => ({ type: 'eq' })),
|
||||
sql: vi.fn((strings: TemplateStringsArray) => strings.join('')),
|
||||
}));
|
||||
|
||||
const mockSelectFromWhere = vi.fn();
|
||||
const mockDeleteWhere = vi.fn();
|
||||
|
||||
vi.mock('../db', () => ({
|
||||
db: {
|
||||
select: vi.fn(() => ({
|
||||
from: vi.fn(() => ({
|
||||
where: () => mockSelectFromWhere(),
|
||||
})),
|
||||
})),
|
||||
delete: vi.fn(() => ({
|
||||
where: () => mockDeleteWhere(),
|
||||
})),
|
||||
},
|
||||
tasks: { userId: 'user_id' },
|
||||
projects: { userId: 'user_id' },
|
||||
reminders: { userId: 'user_id' },
|
||||
}));
|
||||
|
||||
// Mock serviceAuthMiddleware to pass through
|
||||
vi.mock('@manacore/shared-hono', () => ({
|
||||
serviceAuthMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
|
||||
}));
|
||||
|
||||
const { adminRoutes } = await import('./admin');
|
||||
|
||||
const app = new Hono();
|
||||
app.route('/admin', adminRoutes);
|
||||
|
||||
function get(path: string) {
|
||||
return app.request(path);
|
||||
}
|
||||
|
||||
function del(path: string) {
|
||||
return app.request(path, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
// ─── GET /admin/user-data/:userId ──────────────────────────────
|
||||
|
||||
describe('GET /admin/user-data/:userId', () => {
|
||||
it('returns user data counts', async () => {
|
||||
mockSelectFromWhere
|
||||
.mockResolvedValueOnce([{ count: 42 }]) // tasks
|
||||
.mockResolvedValueOnce([{ count: 3 }]) // projects
|
||||
.mockResolvedValueOnce([{ count: 5 }]); // reminders
|
||||
|
||||
const res = await get('/admin/user-data/user-123');
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const data = await res.json();
|
||||
expect(data.userId).toBe('user-123');
|
||||
expect(data.counts.tasks).toBe(42);
|
||||
expect(data.counts.projects).toBe(3);
|
||||
expect(data.counts.reminders).toBe(5);
|
||||
});
|
||||
|
||||
it('returns zero counts for user with no data', async () => {
|
||||
mockSelectFromWhere
|
||||
.mockResolvedValueOnce([{ count: 0 }])
|
||||
.mockResolvedValueOnce([{ count: 0 }])
|
||||
.mockResolvedValueOnce([{ count: 0 }]);
|
||||
|
||||
const res = await get('/admin/user-data/empty-user');
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const data = await res.json();
|
||||
expect(data.counts.tasks).toBe(0);
|
||||
expect(data.counts.projects).toBe(0);
|
||||
expect(data.counts.reminders).toBe(0);
|
||||
});
|
||||
|
||||
it('handles null count results', async () => {
|
||||
mockSelectFromWhere
|
||||
.mockResolvedValueOnce([undefined])
|
||||
.mockResolvedValueOnce([undefined])
|
||||
.mockResolvedValueOnce([undefined]);
|
||||
|
||||
const res = await get('/admin/user-data/user-x');
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const data = await res.json();
|
||||
expect(data.counts.tasks).toBe(0);
|
||||
expect(data.counts.projects).toBe(0);
|
||||
expect(data.counts.reminders).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── DELETE /admin/user-data/:userId ───────────────────────────
|
||||
|
||||
describe('DELETE /admin/user-data/:userId', () => {
|
||||
it('deletes all user data (GDPR)', async () => {
|
||||
mockDeleteWhere.mockResolvedValue(undefined);
|
||||
|
||||
const res = await del('/admin/user-data/user-123');
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const data = await res.json();
|
||||
expect(data.userId).toBe('user-123');
|
||||
expect(data.deleted).toBe(true);
|
||||
expect(data.message).toBe('All user data deleted');
|
||||
});
|
||||
});
|
||||
205
apps/todo/apps/server/src/routes/reminders.test.ts
Normal file
205
apps/todo/apps/server/src/routes/reminders.test.ts
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { Hono } from 'hono';
|
||||
|
||||
// Mock drizzle-orm operators before any imports that use them
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
eq: vi.fn((_col, _val) => ({ type: 'eq' })),
|
||||
and: vi.fn((..._args) => ({ type: 'and' })),
|
||||
asc: vi.fn((_col) => ({ type: 'asc' })),
|
||||
}));
|
||||
|
||||
const mockFindFirstTask = vi.fn();
|
||||
const mockFindManyReminders = vi.fn();
|
||||
const mockInsertReturning = vi.fn();
|
||||
const mockDeleteWhere = vi.fn();
|
||||
|
||||
vi.mock('../db', () => ({
|
||||
db: {
|
||||
query: {
|
||||
tasks: { findFirst: (...args: unknown[]) => mockFindFirstTask(...args) },
|
||||
reminders: { findMany: (...args: unknown[]) => mockFindManyReminders(...args) },
|
||||
},
|
||||
insert: vi.fn(() => ({
|
||||
values: vi.fn(() => ({
|
||||
returning: () => mockInsertReturning(),
|
||||
})),
|
||||
})),
|
||||
delete: vi.fn(() => ({
|
||||
where: () => mockDeleteWhere(),
|
||||
})),
|
||||
},
|
||||
tasks: { id: 'id', userId: 'user_id' },
|
||||
reminders: {
|
||||
id: 'id',
|
||||
taskId: 'task_id',
|
||||
userId: 'user_id',
|
||||
minutesBefore: 'minutes_before',
|
||||
},
|
||||
}));
|
||||
|
||||
// Import AFTER mocks
|
||||
const { reminderRoutes } = await import('./reminders');
|
||||
|
||||
const TEST_USER_ID = 'test-user-id';
|
||||
|
||||
function createApp() {
|
||||
const app = new Hono();
|
||||
app.use('*', async (c, next) => {
|
||||
c.set('userId', TEST_USER_ID);
|
||||
return next();
|
||||
});
|
||||
app.route('/', reminderRoutes);
|
||||
return app;
|
||||
}
|
||||
|
||||
const app = createApp();
|
||||
|
||||
function get(path: string) {
|
||||
return app.request(path);
|
||||
}
|
||||
|
||||
function post(path: string, body: unknown) {
|
||||
return app.request(path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function del(path: string) {
|
||||
return app.request(path, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ─── GET /tasks/:taskId/reminders ──────────────────────────────
|
||||
|
||||
describe('GET /tasks/:taskId/reminders', () => {
|
||||
it('returns reminders for a valid task', async () => {
|
||||
mockFindFirstTask.mockResolvedValue({ id: 'task-1', userId: TEST_USER_ID });
|
||||
mockFindManyReminders.mockResolvedValue([
|
||||
{ id: 'r-1', minutesBefore: 10, type: 'push' },
|
||||
{ id: 'r-2', minutesBefore: 60, type: 'email' },
|
||||
]);
|
||||
|
||||
const res = await get('/tasks/task-1/reminders');
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const data = await res.json();
|
||||
expect(data.reminders).toHaveLength(2);
|
||||
expect(data.reminders[0].id).toBe('r-1');
|
||||
});
|
||||
|
||||
it('returns 404 if task not found', async () => {
|
||||
mockFindFirstTask.mockResolvedValue(null);
|
||||
|
||||
const res = await get('/tasks/nonexistent/reminders');
|
||||
expect(res.status).toBe(404);
|
||||
|
||||
const data = await res.json();
|
||||
expect(data.error).toBe('Task not found');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── POST /tasks/:taskId/reminders ─────────────────────────────
|
||||
|
||||
describe('POST /tasks/:taskId/reminders', () => {
|
||||
it('creates a reminder for a task with due date', async () => {
|
||||
const dueDate = new Date('2026-06-15T14:00:00Z');
|
||||
mockFindFirstTask.mockResolvedValue({
|
||||
id: 'task-1',
|
||||
userId: TEST_USER_ID,
|
||||
dueDate: dueDate.toISOString(),
|
||||
});
|
||||
mockInsertReturning.mockResolvedValue([
|
||||
{
|
||||
id: 'r-new',
|
||||
taskId: 'task-1',
|
||||
minutesBefore: 30,
|
||||
type: 'push',
|
||||
reminderTime: new Date(dueDate.getTime() - 30 * 60 * 1000).toISOString(),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await post('/tasks/task-1/reminders', {
|
||||
minutesBefore: 30,
|
||||
type: 'push',
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
|
||||
const data = await res.json();
|
||||
expect(data.reminder.id).toBe('r-new');
|
||||
expect(data.reminder.minutesBefore).toBe(30);
|
||||
});
|
||||
|
||||
it('defaults type to push', async () => {
|
||||
mockFindFirstTask.mockResolvedValue({
|
||||
id: 'task-1',
|
||||
userId: TEST_USER_ID,
|
||||
dueDate: '2026-06-15T14:00:00Z',
|
||||
});
|
||||
mockInsertReturning.mockResolvedValue([{ id: 'r-new', type: 'push' }]);
|
||||
|
||||
const res = await post('/tasks/task-1/reminders', { minutesBefore: 15 });
|
||||
expect(res.status).toBe(201);
|
||||
});
|
||||
|
||||
it('returns 404 if task not found', async () => {
|
||||
mockFindFirstTask.mockResolvedValue(null);
|
||||
|
||||
const res = await post('/tasks/nonexistent/reminders', {
|
||||
minutesBefore: 30,
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('returns 400 if task has no due date', async () => {
|
||||
mockFindFirstTask.mockResolvedValue({
|
||||
id: 'task-1',
|
||||
userId: TEST_USER_ID,
|
||||
dueDate: null,
|
||||
});
|
||||
|
||||
const res = await post('/tasks/task-1/reminders', { minutesBefore: 30 });
|
||||
expect(res.status).toBe(400);
|
||||
|
||||
const data = await res.json();
|
||||
expect(data.error).toContain('without due date');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── DELETE /reminders/:id ─────────────────────────────────────
|
||||
|
||||
describe('DELETE /reminders/:id', () => {
|
||||
it('deletes an existing reminder', async () => {
|
||||
const mockFindFirstReminder = vi.fn().mockResolvedValue({
|
||||
id: 'r-1',
|
||||
userId: TEST_USER_ID,
|
||||
});
|
||||
// Override the reminders findFirst for this test
|
||||
const { db } = await import('../db');
|
||||
(db.query as Record<string, unknown>).reminders = { findFirst: mockFindFirstReminder };
|
||||
mockDeleteWhere.mockResolvedValue(undefined);
|
||||
|
||||
const res = await del('/reminders/r-1');
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const data = await res.json();
|
||||
expect(data.success).toBe(true);
|
||||
});
|
||||
|
||||
it('returns 404 if reminder not found', async () => {
|
||||
const { db } = await import('../db');
|
||||
(db.query as Record<string, unknown>).reminders = {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
|
||||
const res = await del('/reminders/nonexistent');
|
||||
expect(res.status).toBe(404);
|
||||
|
||||
const data = await res.json();
|
||||
expect(data.error).toBe('Reminder not found');
|
||||
});
|
||||
});
|
||||
164
apps/todo/apps/server/src/routes/rrule.test.ts
Normal file
164
apps/todo/apps/server/src/routes/rrule.test.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { Hono } from 'hono';
|
||||
import { rruleRoutes } from './rrule';
|
||||
|
||||
const app = new Hono();
|
||||
app.route('/compute', rruleRoutes);
|
||||
|
||||
function post(path: string, body: unknown) {
|
||||
return app.request(path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── POST /compute/next-occurrence ─────────────────────────────
|
||||
|
||||
describe('POST /compute/next-occurrence', () => {
|
||||
it('returns next occurrence for daily RRULE', async () => {
|
||||
const res = await post('/compute/next-occurrence', {
|
||||
rrule: 'FREQ=DAILY',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.valid).toBe(true);
|
||||
expect(data.nextDate).toBeDefined();
|
||||
expect(data.totalOccurrences).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('returns next occurrence for weekly RRULE', async () => {
|
||||
const res = await post('/compute/next-occurrence', {
|
||||
rrule: 'FREQ=WEEKLY;BYDAY=MO,WE,FR',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.valid).toBe(true);
|
||||
expect(data.nextDate).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns next occurrence for monthly RRULE', async () => {
|
||||
const res = await post('/compute/next-occurrence', {
|
||||
rrule: 'FREQ=MONTHLY;BYMONTHDAY=15',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('respects recurrenceEndDate', async () => {
|
||||
const pastEnd = new Date('2020-01-01').toISOString();
|
||||
const res = await post('/compute/next-occurrence', {
|
||||
rrule: 'FREQ=DAILY',
|
||||
recurrenceEndDate: pastEnd,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.nextDate).toBeNull();
|
||||
expect(data.message).toContain('No more occurrences');
|
||||
});
|
||||
|
||||
it('respects after parameter', async () => {
|
||||
const afterDate = new Date('2027-06-01T00:00:00Z').toISOString();
|
||||
const res = await post('/compute/next-occurrence', {
|
||||
rrule: 'FREQ=DAILY',
|
||||
after: afterDate,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
const next = new Date(data.nextDate);
|
||||
expect(next.getTime()).toBeGreaterThan(new Date(afterDate).getTime());
|
||||
});
|
||||
|
||||
it('rejects empty rrule', async () => {
|
||||
const res = await post('/compute/next-occurrence', { rrule: '' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects missing rrule', async () => {
|
||||
const res = await post('/compute/next-occurrence', {});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects RRULE exceeding max length', async () => {
|
||||
const res = await post('/compute/next-occurrence', {
|
||||
rrule: 'FREQ=DAILY;' + 'X'.repeat(500),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects invalid RRULE string', async () => {
|
||||
const res = await post('/compute/next-occurrence', {
|
||||
rrule: 'not a valid rrule',
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const data = await res.json();
|
||||
expect(data.error).toContain('Invalid RRULE');
|
||||
});
|
||||
|
||||
it('rejects RRULE with too many occurrences (DoS protection)', async () => {
|
||||
// FREQ=SECONDLY would generate millions of occurrences
|
||||
const res = await post('/compute/next-occurrence', {
|
||||
rrule: 'FREQ=SECONDLY',
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const data = await res.json();
|
||||
expect(data.error).toContain('too many occurrences');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── POST /compute/validate ────────────────────────────────────
|
||||
|
||||
describe('POST /compute/validate', () => {
|
||||
it('validates a correct daily RRULE', async () => {
|
||||
const res = await post('/compute/validate', { rrule: 'FREQ=DAILY' });
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.valid).toBe(true);
|
||||
expect(data.occurrences).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('validates a weekly RRULE with BYDAY', async () => {
|
||||
const res = await post('/compute/validate', {
|
||||
rrule: 'FREQ=WEEKLY;BYDAY=TU,TH',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('validates a yearly RRULE', async () => {
|
||||
const res = await post('/compute/validate', {
|
||||
rrule: 'FREQ=YEARLY;BYMONTH=12;BYMONTHDAY=25',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.valid).toBe(true);
|
||||
expect(data.occurrences).toBeLessThanOrEqual(10); // max 10 years
|
||||
});
|
||||
|
||||
it('returns valid=false for invalid RRULE', async () => {
|
||||
const res = await post('/compute/validate', { rrule: 'garbage' });
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.valid).toBe(false);
|
||||
expect(data.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('rejects empty rrule', async () => {
|
||||
const res = await post('/compute/validate', { rrule: '' });
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('flags RRULE with too many occurrences', async () => {
|
||||
const res = await post('/compute/validate', {
|
||||
rrule: 'FREQ=SECONDLY',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.valid).toBe(false);
|
||||
expect(data.error).toContain('Too many occurrences');
|
||||
});
|
||||
});
|
||||
|
|
@ -11,6 +11,6 @@
|
|||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"include": ["src/**/*", "vitest.config.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
|
|
|||
12
apps/todo/apps/server/vitest.config.ts
Normal file
12
apps/todo/apps/server/vitest.config.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts'],
|
||||
clearMocks: true,
|
||||
mockReset: true,
|
||||
restoreMocks: true,
|
||||
},
|
||||
});
|
||||
793
pnpm-lock.yaml
generated
793
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue