import { beforeAll, afterAll, describe, expect, it } from 'vitest'; import fs from 'node:fs'; import http from 'node:http'; import os from 'node:os'; import path from 'node:path'; const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'quiet-habits-api-')); process.env.QUIET_HABITS_DB_PATH = path.join(tempDir, 'quiet-habits.db'); const { createApp } = await import('./app.js'); const { closeDb } = await import('./db.js'); const { todayKey } = await import('../src/habits.js'); let server; let baseUrl; function request(pathname, options = {}) { return new Promise((resolve, reject) => { const url = new URL(pathname, baseUrl); const requestOptions = { method: options.method || 'GET', hostname: url.hostname, port: url.port, path: url.pathname, headers: options.headers, }; const request = http.request(requestOptions, (response) => { const chunks = []; response.on('data', (chunk) => chunks.push(chunk)); response.on('end', () => { const body = Buffer.concat(chunks).toString(); resolve({ status: response.statusCode, headers: response.headers, body, json: () => JSON.parse(body), }); }); }); request.on('error', reject); if (options.body) { request.write(options.body); } request.end(); }); } beforeAll(async () => { server = http.createServer(createApp({ rateLimitMax: 100 })); await new Promise((resolve) => server.listen(0, resolve)); const address = server.address(); baseUrl = `http://127.0.0.1:${address.port}`; }); afterAll(async () => { closeDb(); await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); fs.rmSync(tempDir, { recursive: true, force: true }); }); describe('HTTP API', () => { it('serves health and the built frontend', async () => { const health = await request('/api/health'); const root = await request('/'); expect(health.status).toBe(200); expect(health.json()).toEqual({ ok: true }); expect(root.status).toBe(200); expect(root.body).toContain('Quiet Habits'); }); it('validates habit names before database writes', async () => { const empty = await request('/api/habits', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: ' ' }), }); const tooLong = await request('/api/habits', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'x'.repeat(201) }), }); expect(empty.status).toBe(400); expect(tooLong.status).toBe(400); }); it('supports create, toggle, list, invalid id, and delete operations', async () => { const created = await request('/api/habits', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Read for 10 minutes' }), }); const id = created.json().habit.id; const toggled = await request(`/api/habits/${id}/toggle`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ date: todayKey() }), }); const listed = await request('/api/habits'); const invalid = await request('/api/habits/missing/toggle', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ date: todayKey() }), }); const deleted = await request(`/api/habits/${id}`, { method: 'DELETE' }); expect(created.status).toBe(201); expect(toggled.json().habit.dates).toHaveLength(1); expect(listed.json().habits[0].id).toBe(id); expect(invalid.status).toBe(404); expect(deleted.status).toBe(204); }); it('rejects oversized and malformed JSON bodies', async () => { const oversized = await request('/api/habits', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'x'.repeat(9000) }), }); const malformed = await request('/api/habits', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{"name":', }); expect(oversized.status).toBe(413); expect(malformed.status).toBe(400); }); it('rejects invalid check-in dates', async () => { const created = await request('/api/habits', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Date validation' }), }); const id = created.json().habit.id; const invalid = await request(`/api/habits/${id}/toggle`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ date: '2026-02-30' }), }); expect(invalid.status).toBe(400); }); it('throttles requests when configured', async () => { const limitedServer = http.createServer(createApp({ rateLimitMax: 1 })); await new Promise((resolve) => limitedServer.listen(0, resolve)); const address = limitedServer.address(); const limitedBaseUrl = baseUrl; baseUrl = `http://127.0.0.1:${address.port}`; const first = await request('/api/health'); const second = await request('/api/health'); baseUrl = limitedBaseUrl; await new Promise((resolve) => limitedServer.close(resolve)); expect(first.status).toBe(200); expect(second.status).toBe(429); expect(second.headers['retry-after']).toBeDefined(); }); });