feat: add API unit tests

Add Bun test suite covering:
- Auth: register, login, duplicate email, invalid password
- Events: create, location, validation, unauthorized
- Journals: get, generate, delete, list with pagination
- Settings: get, update, clear prompt, change password
- Export/Import: export data, events with location
- Search: search events, no matches, cross-date
- Days: list days, get day details, empty day
This commit is contained in:
lotherk
2026-03-27 09:50:31 +00:00
parent 5b82455627
commit f414161fd8
11 changed files with 536 additions and 1 deletions

View File

@@ -18,6 +18,7 @@ Self-hosted journaling app where users capture events throughout the day and AI
/src /src
index.ts # Main API routes, journal generation logic index.ts # Main API routes, journal generation logic
/services/ai # AI provider implementations (Groq, OpenAI, Anthropic, Ollama, LMStudio) /services/ai # AI provider implementations (Groq, OpenAI, Anthropic, Ollama, LMStudio)
/__tests__/ # API unit tests (Bun test)
/frontend /frontend
/src /src
@@ -191,6 +192,13 @@ bunx prisma migrate dev --name migration_name
docker compose build && docker compose up -d 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 ## Version History
- 0.1.0: Automatic geolocation capture, Starlight documentation site - 0.1.0: Automatic geolocation capture, Starlight documentation site
- 0.0.6: Automatic geolocation capture on event creation, reverse geocoding to place names - 0.0.6: Automatic geolocation capture on event creation, reverse geocoding to place names

2
backend/bunfig.toml Normal file
View File

@@ -0,0 +1,2 @@
[test]
preload = ["./src/__tests__/helpers.ts"]

View File

@@ -9,7 +9,10 @@
"db:generate": "prisma generate", "db:generate": "prisma generate",
"db:push": "prisma db push", "db:push": "prisma db push",
"db:migrate": "prisma migrate dev", "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": { "dependencies": {
"@prisma/client": "^5.22.0", "@prisma/client": "^5.22.0",

View File

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

View File

@@ -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();
});
});

View File

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

View File

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

View File

@@ -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<string> {
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<string> {
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();
});

View File

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

View File

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

View File

@@ -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();
});
});