diff --git a/AGENTS.md b/AGENTS.md index 056c106..f3f2631 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,7 @@ Self-hosted journaling app where users capture events throughout the day and AI /src index.ts # Main API routes, journal generation logic /services/ai # AI provider implementations (Groq, OpenAI, Anthropic, Ollama, LMStudio) + /__tests__/ # API unit tests (Bun test) /frontend /src @@ -191,6 +192,13 @@ bunx prisma migrate dev --name migration_name docker compose build && docker compose up -d ``` +### Running Tests +```bash +cd backend +bun run test:server +``` +Tests require the server running. The test script starts the server, runs tests, then stops it. + ## Version History - 0.1.0: Automatic geolocation capture, Starlight documentation site - 0.0.6: Automatic geolocation capture on event creation, reverse geocoding to place names diff --git a/backend/bunfig.toml b/backend/bunfig.toml new file mode 100644 index 0000000..c05df21 --- /dev/null +++ b/backend/bunfig.toml @@ -0,0 +1,2 @@ +[test] +preload = ["./src/__tests__/helpers.ts"] diff --git a/backend/package.json b/backend/package.json index 12ffa80..403635c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -9,7 +9,10 @@ "db:generate": "prisma generate", "db:push": "prisma db push", "db:migrate": "prisma migrate dev", - "db:studio": "prisma studio" + "db:studio": "prisma studio", + "test": "bun test", + "test:watch": "bun test --watch", + "test:server": "bun run src/index.ts & sleep 2 && bun test && kill $!" }, "dependencies": { "@prisma/client": "^5.22.0", diff --git a/backend/src/__tests__/auth.test.ts b/backend/src/__tests__/auth.test.ts new file mode 100644 index 0000000..3801e88 --- /dev/null +++ b/backend/src/__tests__/auth.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, beforeAll } from 'bun:test'; +import { createTestUser, createEvent } from './helpers'; + +describe('Auth API', () => { + it('should register a new user', async () => { + const res = await fetch('http://localhost:3000/api/v1/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: `newuser_${Date.now()}@test.com`, + password: 'password123', + }), + }); + const json = await res.json(); + expect(res.status).toBe(201); + expect(json.data).toBeDefined(); + expect(json.data.apiKey).toBeDefined(); + }); + + it('should reject duplicate email', async () => { + const email = `duplicate_${Date.now()}@test.com`; + await createTestUser(email, 'password123'); + + const res = await fetch('http://localhost:3000/api/v1/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password: 'password123' }), + }); + expect(res.status).toBe(400); + }); + + it('should login with valid credentials', async () => { + const email = `login_${Date.now()}@test.com`; + await createTestUser(email, 'mypassword'); + + const res = await fetch('http://localhost:3000/api/v1/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password: 'mypassword' }), + }); + const json = await res.json(); + expect(res.status).toBe(200); + expect(json.data.token).toBeDefined(); + }); + + it('should reject invalid password', async () => { + const email = `invalid_${Date.now()}@test.com`; + await createTestUser(email, 'correctpassword'); + + const res = await fetch('http://localhost:3000/api/v1/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password: 'wrongpassword' }), + }); + expect(res.status).toBe(401); + }); +}); diff --git a/backend/src/__tests__/days.test.ts b/backend/src/__tests__/days.test.ts new file mode 100644 index 0000000..b4a8ac7 --- /dev/null +++ b/backend/src/__tests__/days.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect, beforeEach } from 'bun:test'; +import { getApiKey, createEvent } from './helpers'; + +describe('Days API', () => { + let apiKey: string; + + beforeEach(async () => { + apiKey = await getApiKey(); + }); + + it('should list days with events', async () => { + await createEvent(apiKey, '2026-03-25', 'text', 'Day listing test'); + + const res = await fetch('http://localhost:3000/api/v1/days', { + headers: { 'Authorization': `Bearer ${apiKey}` }, + }); + const json = await res.json(); + expect(json.data).toBeDefined(); + expect(Array.isArray(json.data.days)).toBe(true); + }); + + it('should get day with events and journal', async () => { + const date = '2026-03-26'; + await createEvent(apiKey, date, 'text', 'Day details test'); + + const res = await fetch(`http://localhost:3000/api/v1/days/${date}`, { + headers: { 'Authorization': `Bearer ${apiKey}` }, + }); + const json = await res.json(); + expect(json.data).toBeDefined(); + expect(json.data.date).toBe(date); + expect(json.data.events).toBeDefined(); + expect(Array.isArray(json.data.events)).toBe(true); + expect(json.data.events.length).toBeGreaterThan(0); + }); + + it('should return empty events for day with no events', async () => { + const res = await fetch('http://localhost:3000/api/v1/days/2099-12-31', { + headers: { 'Authorization': `Bearer ${apiKey}` }, + }); + const json = await res.json(); + expect(json.data.events.length).toBe(0); + expect(json.data.journal).toBeNull(); + }); +}); diff --git a/backend/src/__tests__/events.test.ts b/backend/src/__tests__/events.test.ts new file mode 100644 index 0000000..e94636a --- /dev/null +++ b/backend/src/__tests__/events.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, beforeEach } from 'bun:test'; +import { getApiKey, createEvent } from './helpers'; + +describe('Events API', () => { + let apiKey: string; + const testDate = '2026-03-27'; + + beforeEach(async () => { + apiKey = await getApiKey(); + }); + + it('should create an event', async () => { + const res = await createEvent(apiKey, testDate, 'text', 'Test event content'); + expect(res.data).toBeDefined(); + expect(res.data.content).toBe('Test event content'); + expect(res.data.type).toBe('text'); + expect(res.data.date).toBe(testDate); + }); + + it('should create event with location', async () => { + const res = await fetch('http://localhost:3000/api/v1/events', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + date: testDate, + type: 'text', + content: 'Event with location', + latitude: 40.7128, + longitude: -74.006, + placeName: 'New York, NY', + }), + }); + const json = await res.json(); + expect(json.data.latitude).toBe(40.7128); + expect(json.data.longitude).toBe(-74.006); + expect(json.data.placeName).toBe('New York, NY'); + }); + + it('should reject event without content', async () => { + const res = await fetch('http://localhost:3000/api/v1/events', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + date: testDate, + type: 'text', + content: '', + }), + }); + expect(res.status).toBe(400); + }); + + it('should reject event with invalid type', async () => { + const res = await fetch('http://localhost:3000/api/v1/events', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + date: testDate, + type: 'invalid', + content: 'Test', + }), + }); + expect(res.status).toBe(400); + }); + + it('should get day events', async () => { + await createEvent(apiKey, testDate, 'text', 'Event for day'); + + const res = await fetch(`http://localhost:3000/api/v1/days/${testDate}`, { + headers: { 'Authorization': `Bearer ${apiKey}` }, + }); + const json = await res.json(); + expect(json.data).toBeDefined(); + expect(json.data.events).toBeDefined(); + expect(Array.isArray(json.data.events)).toBe(true); + }); + + it('should reject unauthorized request', async () => { + const res = await fetch('http://localhost:3000/api/v1/events', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + date: testDate, + type: 'text', + content: 'Test', + }), + }); + expect(res.status).toBe(401); + }); +}); diff --git a/backend/src/__tests__/export-import.test.ts b/backend/src/__tests__/export-import.test.ts new file mode 100644 index 0000000..0a08cfa --- /dev/null +++ b/backend/src/__tests__/export-import.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, beforeEach } from 'bun:test'; +import { getApiKey, createEvent } from './helpers'; + +describe('Export/Import API', () => { + let apiKey: string; + const testDate = '2026-03-29'; + + beforeEach(async () => { + apiKey = await getApiKey(); + await createEvent(apiKey, testDate, 'text', 'Exportable event'); + }); + + it('should export user data', async () => { + const res = await fetch('http://localhost:3000/api/v1/export', { + headers: { 'Authorization': `Bearer ${apiKey}` }, + }); + const json = await res.json(); + expect(json.data).toBeDefined(); + expect(json.data.version).toBeDefined(); + expect(json.data.events).toBeDefined(); + expect(json.data.journals).toBeDefined(); + expect(json.data.settings).toBeDefined(); + expect(Array.isArray(json.data.events)).toBe(true); + expect(Array.isArray(json.data.journals)).toBe(true); + }); + + it('should export events with location', async () => { + await fetch('http://localhost:3000/api/v1/events', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + date: testDate, + type: 'text', + content: 'Event with location for export', + latitude: 51.5074, + longitude: -0.1278, + placeName: 'London, UK', + }), + }); + + const res = await fetch('http://localhost:3000/api/v1/export', { + headers: { 'Authorization': `Bearer ${apiKey}` }, + }); + const json = await res.json(); + + const exportedEvent = json.data.events.find( + (e: any) => e.content === 'Event with location for export' + ); + expect(exportedEvent).toBeDefined(); + expect(exportedEvent.latitude).toBe(51.5074); + expect(exportedEvent.placeName).toBe('London, UK'); + }); + + it('should reject unauthorized export', async () => { + const res = await fetch('http://localhost:3000/api/v1/export'); + expect(res.status).toBe(401); + }); +}); diff --git a/backend/src/__tests__/helpers.ts b/backend/src/__tests__/helpers.ts new file mode 100644 index 0000000..6605843 --- /dev/null +++ b/backend/src/__tests__/helpers.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, beforeAll, afterAll } from 'bun:test'; +import { PrismaClient } from '@prisma/client'; + +export const prisma = new PrismaClient({ + datasourceUrl: 'file:./data/test.db', +}); + +export const TEST_USER = { + email: 'test@example.com', + password: 'testpassword123', +}; + +export const SECOND_USER = { + email: 'second@example.com', + password: 'secondpassword123', +}; + +let testApiKey: string; +let secondApiKey: string; + +export async function createTestUser(email: string, password: string): Promise { + const res = await fetch('http://localhost:3000/api/v1/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + const json = await res.json(); + return json.data.apiKey; +} + +export async function loginUser(email: string, password: string): Promise { + const res = await fetch('http://localhost:3000/api/v1/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + const json = await res.json(); + + const loginRes = await fetch('http://localhost:3000/api/v1/auth/api-key', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${json.data.token}`, + }, + }); + const keyJson = await loginRes.json(); + return keyJson.data.apiKey; +} + +export async function createEvent(apiKey: string, date: string, type: string, content: string) { + const res = await fetch('http://localhost:3000/api/v1/events', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + }, + body: JSON.stringify({ date, type, content }), + }); + return res.json(); +} + +export async function getApiKey() { + if (!testApiKey) { + try { + testApiKey = await createTestUser(TEST_USER.email, TEST_USER.password); + } catch { + testApiKey = await loginUser(TEST_USER.email, TEST_USER.password); + } + } + return testApiKey; +} + +export async function getSecondApiKey() { + if (!secondApiKey) { + try { + secondApiKey = await createTestUser(SECOND_USER.email, SECOND_USER.password); + } catch { + secondApiKey = await loginUser(SECOND_USER.email, SECOND_USER.password); + } + } + return secondApiKey; +} + +beforeAll(async () => { + await prisma.event.deleteMany({ where: { user: { email: TEST_USER.email } } }); + await prisma.journal.deleteMany({ where: { user: { email: TEST_USER.email } } }); + await prisma.task.deleteMany({ where: { user: { email: TEST_USER.email } } }); + await prisma.event.deleteMany({ where: { user: { email: SECOND_USER.email } } }); + await prisma.journal.deleteMany({ where: { user: { email: SECOND_USER.email } } }); + await prisma.task.deleteMany({ where: { user: { email: SECOND_USER.email } } }); +}); + +afterAll(async () => { + await prisma.$disconnect(); +}); diff --git a/backend/src/__tests__/journals.test.ts b/backend/src/__tests__/journals.test.ts new file mode 100644 index 0000000..ed1cf7d --- /dev/null +++ b/backend/src/__tests__/journals.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, beforeEach } from 'bun:test'; +import { getApiKey, createEvent } from './helpers'; + +describe('Journals API', () => { + let apiKey: string; + const testDate = '2026-03-28'; + + beforeEach(async () => { + apiKey = await getApiKey(); + }); + + it('should get empty day for journal', async () => { + const res = await fetch(`http://localhost:3000/api/v1/journal/${testDate}`, { + headers: { 'Authorization': `Bearer ${apiKey}` }, + }); + const json = await res.json(); + expect(res.status).toBe(404); + expect(json.error.code).toBe('NOT_FOUND'); + }); + + it('should reject journal generation with no events', async () => { + const res = await fetch(`http://localhost:3000/api/v1/journal/generate/${testDate}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + }, + }); + const json = await res.json(); + expect(res.status).toBe(400); + expect(json.error.code).toBe('NO_EVENTS'); + }); + + it('should delete journal', async () => { + await createEvent(apiKey, testDate, 'text', 'Test event'); + + const deleteRes = await fetch(`http://localhost:3000/api/v1/journal/${testDate}`, { + method: 'DELETE', + headers: { 'Authorization': `Bearer ${apiKey}` }, + }); + const json = await deleteRes.json(); + expect(json.data.deleted).toBe(true); + }); + + it('should list journals with pagination', async () => { + const res = await fetch('http://localhost:3000/api/v1/journals?page=1&limit=10', { + headers: { 'Authorization': `Bearer ${apiKey}` }, + }); + const json = await res.json(); + expect(json.data.journals).toBeDefined(); + expect(json.data.total).toBeDefined(); + expect(json.data.page).toBe(1); + expect(json.data.limit).toBe(10); + }); +}); diff --git a/backend/src/__tests__/search.test.ts b/backend/src/__tests__/search.test.ts new file mode 100644 index 0000000..f3a0ccd --- /dev/null +++ b/backend/src/__tests__/search.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect, beforeEach } from 'bun:test'; +import { getApiKey, createEvent } from './helpers'; + +describe('Search API', () => { + let apiKey: string; + + beforeEach(async () => { + apiKey = await getApiKey(); + }); + + it('should search events', async () => { + await createEvent(apiKey, '2026-03-30', 'text', 'Unique search term banana apple'); + + const res = await fetch('http://localhost:3000/api/v1/search?q=banana', { + headers: { 'Authorization': `Bearer ${apiKey}` }, + }); + const json = await res.json(); + expect(json.data).toBeDefined(); + expect(json.data.events).toBeDefined(); + }); + + it('should return empty results for no matches', async () => { + const res = await fetch('http://localhost:3000/api/v1/search?q=nonexistentterm12345', { + headers: { 'Authorization': `Bearer ${apiKey}` }, + }); + const json = await res.json(); + expect(json.data.events.length).toBe(0); + }); + + it('should search across all dates', async () => { + await createEvent(apiKey, '2026-03-15', 'text', 'Searchable content from march'); + await createEvent(apiKey, '2026-03-20', 'text', 'Searchable content from march again'); + + const res = await fetch('http://localhost:3000/api/v1/search?q=march', { + headers: { 'Authorization': `Bearer ${apiKey}` }, + }); + const json = await res.json(); + expect(json.data.events.length).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/backend/src/__tests__/settings.test.ts b/backend/src/__tests__/settings.test.ts new file mode 100644 index 0000000..20905d1 --- /dev/null +++ b/backend/src/__tests__/settings.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, beforeEach } from 'bun:test'; +import { getApiKey } from './helpers'; + +describe('Settings API', () => { + let apiKey: string; + + beforeEach(async () => { + apiKey = await getApiKey(); + }); + + it('should get default settings', async () => { + const res = await fetch('http://localhost:3000/api/v1/settings', { + headers: { 'Authorization': `Bearer ${apiKey}` }, + }); + const json = await res.json(); + expect(json.data).toBeDefined(); + expect(json.data.aiProvider).toBeDefined(); + }); + + it('should update settings', async () => { + const res = await fetch('http://localhost:3000/api/v1/settings', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + aiProvider: 'openai', + journalPrompt: 'Write in a poetic style', + }), + }); + const json = await res.json(); + expect(json.data.aiProvider).toBe('openai'); + expect(json.data.journalPrompt).toBe('Write in a poetic style'); + }); + + it('should clear journal prompt with empty string', async () => { + await fetch('http://localhost:3000/api/v1/settings', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + }, + body: JSON.stringify({ journalPrompt: '' }), + }); + + const getRes = await fetch('http://localhost:3000/api/v1/settings', { + headers: { 'Authorization': `Bearer ${apiKey}` }, + }); + const json = await getRes.json(); + expect(json.data.journalPrompt).toBeNull(); + }); + + it('should change password', async () => { + const newPassword = 'newpassword456'; + + const res = await fetch('http://localhost:3000/api/v1/account/password', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + currentPassword: 'testpassword123', + newPassword, + }), + }); + const json = await res.json(); + expect(json.data).toBeDefined(); + }); +});