test(api): finalize route coverage and document REST contracts

This commit is contained in:
syntaxbullet
2026-09-04 09:19:32 +02:00
parent ff7ec68262
commit 42054a92f2
3 changed files with 227 additions and 6 deletions

View File

@@ -1,7 +1,10 @@
# Minabot
Minimal Bun + Hono backend with Drizzle ORM and local SQLite, and a React Router
client initialized with Bun's shadcn + Tailwind CSS v4 template.
Bun + Hono habit-tracking REST API with Drizzle ORM and local SQLite. The existing
React Router client remains a minimal authentication shell; habit frontend UI is deferred.
See [the REST API reference](docs/API.md) for habits, task recurrence, dated progress,
combined calendars, historical corrections, daily resets, and optional count carryover.
```sh
bun install
@@ -124,8 +127,9 @@ production habit data separate from development data.
- React detects the browser timezone during initial registration. The server
validates it using `Intl.DateTimeFormat`, falling back to UTC. Later sign-ins
update the Discord profile without changing the saved timezone. A timezone
settings editor and habit reset logic are future work. Habit timestamps should
remain UTC; daily boundaries should be calculated using the user's timezone.
settings UI is deferred; `PATCH /api/me` updates the timezone. Habit timestamps
remain UTC and daily boundaries follow the saved timezone with preserved historical
deadlines. See the API reference for travel and DST behavior.
### API
@@ -178,3 +182,20 @@ stubbed Discord HTTP client. They cover registration, profile refresh, timezone
preservation, cookie security, invalid/expired state, provider failures, session
expiry/rotation, logout CSRF checks, and the exact `/api/me` response. A real Discord
consent flow additionally requires your application credentials and browser login.
## Habit API verification
```sh
bun run test:coverage
bun run typecheck
bun run test:smoke
```
The smoke command builds and starts the production server against a disposable
SQLite database, exercises authenticated HTTP requests, then restarts it to verify
persistence. It does not use a live Discord provider or modify application data.
Habit tests cover every route, all PRD acceptance behaviors, ownership and Origin
checks, historical corrections, expiry, timezone boundaries, daily resets, optional
count carryover, Nivo data adaptation, and migration upgrades. Semantic commits
separate the database foundation, REST implementation, carryover, and verification.

199
docs/API.md Normal file
View File

@@ -0,0 +1,199 @@
# Habit REST API
All paths below start with `/api`. The existing Discord session cookie authenticates every habit, task, chart and settings route. Mutations additionally require `Origin` to exactly match `APP_ORIGIN`. JSON bodies require `Content-Type: application/json` and are limited to 64 KiB. Responses use `Cache-Control: no-store`. User IDs are taken from the session, never from input. Foreign and nonexistent resources both return 404.
Run `bun dev` to migrate the local database and serve the API. Run `bun run test:coverage` for the full test suite, and `bun run test:smoke` to build and exercise the production server over HTTP using a disposable database, including a restart.
## Routes
| Method | Path | Result |
| --- | --- | --- |
| GET | `/health` | Public database health |
| GET | `/auth/discord?timezone=Europe%2FBelgrade` | Start Discord sign-in |
| GET | `/auth/discord/callback` | Finish Discord sign-in |
| POST | `/auth/logout` | Revoke current session; 204 |
| GET | `/me` | Current public user |
| PATCH | `/me` | Update saved timezone: `{"timezone":"Europe/Belgrade"}` |
| GET | `/habits` | `{habits: [...]}`; excludes archived by default |
| GET | `/habits?archived=true` | Include archived habits |
| POST | `/habits` | Create habit; 201, resource and Location header |
| GET | `/habits/:id` | Current habit configuration and revision ID |
| PATCH | `/habits/:id` | Change name, method, method settings, schedule or archived flag |
| DELETE | `/habits/:id` | Archive from today, preserving history; 204 |
| GET | `/habits/:id/history` | `{revisions: [...]}` newest first, including superseded same-day revisions |
| GET | `/habits/:id/tasks` | `{tasks: [...]}` for current task method |
| POST | `/habits/:id/tasks` | Add task; 201 |
| GET | `/habits/:id/tasks/:taskId` | Current task definition |
| PATCH | `/habits/:id/tasks/:taskId` | Change task name or recurrence |
| DELETE | `/habits/:id/tasks/:taskId` | Remove task from today's requirements onward; 204 |
| GET | `/today` | Tracking date, timezone, all active habits with progress, due and completed counts |
| GET | `/days/:date` | All owned habits for a date, including historical archived habits |
| GET | `/habits/:id/days/:date` | Requirement snapshot, underlying progress, status and task occurrences |
| PUT | `/habits/:id/days/:date/progress` | Replace count or manual state for that date |
| PUT | `/habits/:id/days/:date/tasks/:taskId` | Replace that task occurrence's boolean completion state |
| GET | `/habits/:id/days/:date/audit` | `{records: [...]}` with all same-day snapshots, occurrences and before/after progress events |
| GET | `/habits/:id/calendar?from=YYYY-MM-DD&to=YYYY-MM-DD` | Individual calendar with every date and its details |
| GET | `/habits/:id/calendar-settings` | Effective color/shade settings |
| PUT | `/habits/:id/calendar-settings` | Replace settings; omitted fields reset to defaults |
| GET | `/charts` | `{charts: [...]}` owned combined charts |
| POST | `/charts` | Create combined chart; 201, resource and Location header |
| GET | `/charts/:id` | Chart name, selected habit IDs and settings |
| PATCH | `/charts/:id` | Change name, selected habits or settings |
| DELETE | `/charts/:id` | Delete the saved chart view; 204; underlying habits remain |
| GET | `/charts/:id/calendar?from=YYYY-MM-DD&to=YYYY-MM-DD` | Combined calendar |
| GET | `/charts/:id/days/:date` | One combined square and its constituent habit details |
Dates must be real `YYYY-MM-DD` dates, 19709998. Calendar ranges are inclusive, ordered, and at most 1,830 days. Future dates are inspectable but cannot be logged. Dates before a habit was created are not due and cannot be logged. Habit creation begins on the current tracking date; there is no implicit historical creation date.
## Create and configure habits
Count habit, with daily resets by default:
```json
{
"name": "Hydration",
"method": "count",
"target": 8,
"unit": "glasses",
"carryPartialProgress": false,
"schedule": { "type": "daily" }
}
```
Manual habit:
```json
{ "name": "Read", "method": "manual" }
```
Task habit with independent task recurrence:
```json
{
"name": "Routine",
"method": "tasks",
"tasks": [
{ "name": "Stretch" },
{
"name": "Clean desk",
"schedule": { "type": "weekly", "every": 2, "weekday": 3, "anchor": "2026-08-31" }
}
]
}
```
A habit has exactly one method. Incompatible settings are rejected. A switch to `count` needs a target. Switching to `tasks` begins with an empty task list; use the task routes to add tasks. Task IDs are generated by the server and remain stable across edits. Restore an archived habit with `PATCH {"archived":false}`. Task edits require restoring an archived habit first.
Names are trimmed, nonempty and at most 200 characters. Count targets are integers from 1 to 10,000. Logged counts are nonnegative integers up to 1,000,000,000 and can exceed the target; completion intensity caps at full. Units default to `steps` and are at most 80 characters. A task habit supports at most 100 task definitions.
## Schedules
Omitted habit or task schedules default to daily. Both rules must match for a task occurrence to exist.
| Rule | JSON | Meaning |
| --- | --- | --- |
| Daily | `{"type":"daily"}` | Every date |
| Day interval | `{"type":"interval","every":3,"anchor":"2026-09-04"}` | Sept 4, 7, 10, ...; never before anchor |
| Weekdays | `{"type":"weekdays","days":[1,3,5]}` | Monday, Wednesday, Friday |
| Week interval | `{"type":"weekly","every":2,"weekday":3,"anchor":"2026-08-31"}` | Wednesday in the anchor week, then every second week |
Weekdays use Sunday=0 through Saturday=6. Weekday lists must be nonempty and unique. Week intervals use Monday-start weeks, with no occurrence before the anchor date. Day intervals support 13,650 days; week intervals support 1520 weeks. Neither lateness nor corrections shift recurrence.
A task habit with no matching tasks is not due, including when its parent schedule and task rules do not intersect. Inspect projected future days or the calendar to see the resulting schedule.
## Daily reset and optional carryover
Each scheduled day starts with zero count, an unchecked manual state, and unchecked task occurrences. Partial progress does not carry by default.
Count habits can opt into `carryPartialProgress: true`. An unfinished count carries to the next **scheduled** date. A completed or over-target count resets the next scheduled occurrence to zero. Manual habits have no partial state; recurring task occurrences retain the PRD's fresh-occurrence and expiry behavior.
Count writes are absolute totals, including any inherited amount. For example, if a date inherits 7 and the user adds one, write `{"count":8}`. Writing `{"count":0}` explicitly clears that date. `loggedCount` identifies explicit entries; `carriedFrom` identifies an inherited count's source date. Historical corrections recalculate inherited downstream values until an explicit entry or reset boundary. They never overwrite explicit entries on later dates. Method switches and archived dates break inheritance. Carryover configuration changes are effective today, preserving earlier behavior.
## Logging and inspecting progress
```text
PUT /api/habits/:id/days/2026-09-04/progress
{"count":7}
PUT /api/habits/:id/days/2026-09-04/progress
{"done":true}
PUT /api/habits/:id/days/2026-09-04/tasks/:taskId
{"done":true}
```
The first two bodies apply to count and manual habits respectively. Task progress is derived, so directly writing habit progress for a task habit returns 409. A task write identifies the recurring task ID plus date, not an occurrence ID from a superseded snapshot.
Every successful write returns the updated individual day. Important response fields:
- `date`, `habitId`, `name`, `method`, `revisionId`, `requirements`: the dated configuration used to evaluate the day.
- `timezone`, `endsAt`: the preserved local-day boundary; `endsAt` is Unix milliseconds, or null for dates without a stored snapshot.
- `due`, `future`, `status`: `not_due`, `future`, `empty`, `partial`, or `complete`.
- `value`, `target`, `unit`, `ratio`, `complete`: exact progress and capped completion ratio. Not-due ratios are null.
- `loggedCount`, `carriedFrom`: explicit and inherited count information.
- `tasks`: due occurrences with `id`, `taskId`, `name`, `done`, `expiredAt` and `updatedAt`.
Backfills use that date's method, target, schedule and task list. They automatically affect every combined chart that selects the habit. Reducing a completed habit below its requirement removes its completed contribution.
## History, expiry and timezones
Revisions and prior day snapshots are retained. Changing a target, method, task list or schedule takes effect on the current tracking date. Same-method edits retain today's explicit count/manual state; surviving task IDs retain their occurrence state across task edits. Changing methods starts a fresh current-day record, retaining the superseded record for audit. Configuration responses include `effectiveDate` and `revisionId` so clients can explain this behavior.
Incomplete task occurrences expire at the preserved local midnight. Expiry is reconciled lazily before authenticated tracking requests, including after downtime, and stores the original deadline rather than the time the API was accessed. No scheduler process is required. A historical correction changes `done` without clearing the original `expiredAt`. Tasks completed at their original deadline have no expiry record. Old occurrences never become overdue tasks on a later date.
Timezone updates first reconcile existing dates under the old timezone. Existing snapshots and deadlines remain fixed; subsequent newly opened dates use the new timezone. The tracking date never moves backward when traveling west across a date boundary. `/today.date` is authoritative during such a transition, and the new timezone catches up naturally. DST boundaries use actual local midnight, including 23-hour and 25-hour days. UTC millisecond timestamps and local date strings serve different purposes and should not be interchanged.
## Combined charts
```json
{
"name": "Daily essentials",
"habitIds": ["owned-habit-uuid-1", "owned-habit-uuid-2"],
"settings": { "mainColor": "#196127", "shadeCount": 4 }
}
```
Select 1100 unique owned habit IDs. Membership edits recompute the entire chart view using the selected habits' historical requirements; they do not alter those requirements. Archived habits remain selectable for historical review.
Each day reports `due`, `completed`, `ratio`, `level`, `shadeCount`, `color`, `status`, `future` and `habits`. Completed habits have equal weight. Partial progress counts as zero completed habits. A zero denominator has a null ratio and neutral color. Full completion is the highest shade, whether the underlying counts are 1/1 or 4/4. Combined shade levels reserve the highest level for full completion.
## Calendar settings and Nivo
The API intentionally stores only product-level appearance preferences:
| Field | Default | Allowed |
| --- | --- | --- |
| `mainColor` | `#196127` | Six-digit hex color |
| `shadeCount` | `"auto"` | `"auto"` or integer 220 |
| `emptyColor` | `#ebedf0` | Due with no progress |
| `notDueColor` | `#f5f5f5` | Nothing scheduled |
| `futureColor` | `#dbeafe` | Upcoming date |
Positive colors are shades of the main color. `auto` uses the dated count target or number of due tasks for individual charts and four positive shades for combined charts. Thus an eight-count habit has eight positive levels plus empty. Explicit shade counts offer a simpler display without changing underlying progress. Manual habits always remain binary. Layout, borders, spacing, typography, callbacks and animation are not persisted settings. PUT replaces individual settings; the `settings` object in a chart PATCH is also a replacement, with defaults for omitted settings fields.
Calendar responses include `kind`, `from`, `to`, `today`, `timezone`, `settings`, `days`, and a numeric `data` summary. Summary values are positive shade levels, 0 for due/empty, -1 for not due, and -2 for future. Exact counts always live in `days`.
The installed `@nivo/calendar` integration contract is in `src/shared/calendar.ts`. It imports:
```ts
import { ResponsiveCalendar } from '@nivo/calendar';
```
`toResponsiveCalendarProps(response)` returns typed data, an exact per-date color scale, and readable value formatting for this component. It uses distinct numeric indices internally because Nivo's color scale receives only a value, and the same shade number under different historical targets can require different colors. Consumers must use the adapter rather than Nivo's automatic min/max scaling on the summary data. Use `days` for date inspection and detailed tooltip content. The adapter renders no component or page; frontend UI remains deferred.
## Errors
Errors are JSON objects with an `error` string:
| Status | Meaning |
| --- | --- |
| 400 | Malformed JSON or missing/wrong JSON content type |
| 401 | Missing, invalid or expired session |
| 403 | Missing or mismatched Origin on mutation |
| 404 | Missing resource, another user's resource or unknown route |
| 409 | Future/unscheduled write, wrong current task method, direct task-habit progress write, or task editing while archived |
| 413 | Body exceeds 64 KiB |
| 422 | Invalid fields, unsupported configuration, invalid dates/range, or a progress body inconsistent with the dated method |
| 500 | Unexpected failure; internal details are not exposed |
Schemas reject unknown body properties. Failed domain transactions roll back their partial writes. Progress audit events record before/after values; they are not overwritten by corrections.

View File

@@ -17,12 +17,13 @@ let app: ReturnType<typeof createApi>;
let timestamp: number;
let calls: { url: string; init: RequestInit }[];
let profile: DiscordProfile;
let failure: "token" | "profile" | "malformed" | "throw" | undefined;
let failure: "token" | "invalid_token" | "profile" | "malformed" | "throw" | undefined;
const request: FetchDiscord = async (url, init) => {
calls.push({ url, init });
if (failure === "throw") throw new Error("provider secret response");
if (url.endsWith("/token")) {
if (failure === "token") return new Response("provider secret response", { status: 400 });
if (failure === "invalid_token") return Response.json({ access_token: "secret-access", token_type: "Bearer", scope: "email" });
return Response.json({ access_token: "secret-access", refresh_token: "secret-refresh", token_type: "Bearer", scope: "identify" });
}
if (failure === "profile") return new Response("provider secret response", { status: 503 });
@@ -149,7 +150,7 @@ describe("Discord sign-in and sessions", () => {
expect(calls).toHaveLength(0); expect(db.select().from(schema.sessions).all()).toHaveLength(0);
});
test.each(["token", "profile", "malformed", "throw"] as const)("provider failure (%s) leaves no account or session", async value => {
test.each(["token", "invalid_token", "profile", "malformed", "throw"] as const)("provider failure (%s) leaves no account or session", async value => {
failure = value;
const result = await login();
expect(result.response.headers.get("Location")).toBe("/?auth_error=discord_unavailable");