Something went wrong. Try again.
Quiet Habits is a small self-hosted habit tracker. It runs as a local Node application, stores data in SQLite, and does not require an accou
Something went wrong. Try again.
5.6 kB · 165 lines
JavaScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166import { 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('<title>Quiet Habits</title>'); });
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(); });});