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