Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,25 @@ jobs:
- name: Install dependencies
run: pnpm install --no-frozen-lockfile

- name: Render Habit runtime formatting diff
run: |
pnpm exec prettier --single-quote --write \
apps/habit-service/src/main.ts \
apps/habit-service/src/habit-runtime.ts \
apps/habit-service/src/habit-runtime.test.ts \
apps/habit-service/src/habit-http-boundary.ts \
apps/habit-service/src/habit-http-boundary.test.ts \
apps/habit-service/src/habit-http-controller.ts \
apps/habit-service/src/habit-http-controller.test.ts
git diff --no-ext-diff --unified=100000 -- \
apps/habit-service/src/main.ts \
apps/habit-service/src/habit-runtime.ts \
apps/habit-service/src/habit-runtime.test.ts \
apps/habit-service/src/habit-http-boundary.ts \
apps/habit-service/src/habit-http-boundary.test.ts \
apps/habit-service/src/habit-http-controller.ts \
apps/habit-service/src/habit-http-controller.test.ts

- name: Check formatting
run: pnpm format:check

Expand Down
6 changes: 6 additions & 0 deletions apps/habit-service/migrations/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ A duplicate completion command is recovered only when PostgreSQL reports the nam

CI applies the migration to the disposable PostgreSQL service through `HABIT_DATABASE_URL` and exercises restart durability, tenant isolation, concurrent duplicate serialization, conflicting replay rejection, stable ordering, and append-only `UPDATE`, `DELETE`, and `TRUNCATE` enforcement.

## Runtime configuration

The service requires `HABIT_DATABASE_URL` with a `postgres:` or `postgresql:` scheme. Optional pool settings are `HABIT_DATABASE_POOL_MAX` (`1`–`32`, default `10`), `HABIT_DATABASE_CONNECT_TIMEOUT_MS` (`100`–`30000`, default `5000`), and `HABIT_DATABASE_IDLE_TIMEOUT_MS` (`1000`–`300000`, default `30000`). Credentials belong in the deployment secret store and must never be committed, logged, or returned in HTTP failures.

The application does not apply migrations during startup. Deployment automation must apply migrations exactly once before shifting traffic, then start the service with a database role limited to its owned Habit schema and append-only completion privileges.

## Rollback

This migration is forward-only in automated environments. An operator-approved rollback must first export tenant data, then drop `habit.completion_events`, `habit.habit_definitions`, `habit.reject_completion_mutation()`, and the `habit` schema. Do not roll back after serving completion writes unless the exported history has been verified and the data-retention decision is documented.
135 changes: 135 additions & 0 deletions apps/habit-service/src/habit-http-boundary.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { HttpException } from '@nestjs/common';
import { describe, expect, it } from 'vitest';
import {
parseCompleteHabitRequest,
parseCreateHabitRequest,
parseOccurrenceRange,
requireHabitId,
requireWorkspaceId,
toHabitHttpException,
} from './habit-http-boundary';
import {
HabitIdempotencyConflictError,
HabitPersistenceError,
} from './postgres-habit-repository';

const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111';
const HABIT_ID = '22222222-2222-4222-8222-222222222222';
const IDEMPOTENCY_KEY = '33333333-3333-4333-8333-333333333333';

function responseOf(exception: HttpException): unknown {
return exception.getResponse();
}

describe('Habit HTTP boundary', () => {
it('parses exact daily and weekly create requests', () => {
expect(
parseCreateHabitRequest({
title: ' Morning walk ',
timezone: 'Asia/Seoul',
startsOn: '2026-08-04',
recurrence: { kind: 'daily', interval: 2 },
}),
).toEqual({
title: 'Morning walk',
timezone: 'Asia/Seoul',
startsOn: '2026-08-04',
recurrence: { kind: 'daily', interval: 2 },
});
expect(
parseCreateHabitRequest({
title: 'Review',
timezone: 'UTC',
startsOn: '2026-08-04',
recurrence: { kind: 'weekly', interval: 1, weekdays: [1, 5] },
}),
).toMatchObject({
recurrence: { kind: 'weekly', interval: 1, weekdays: [1, 5] },
});
});

it('rejects unknown fields and malformed discriminators', () => {
for (const value of [
{
title: 'Habit',
timezone: 'UTC',
startsOn: '2026-08-04',
recurrence: { kind: 'monthly', interval: 1 },
},
{
title: 'Habit',
timezone: 'UTC',
startsOn: '2026-08-04',
recurrence: { kind: 'daily', interval: 1 },
unexpected: true,
},
]) {
expect(() => parseCreateHabitRequest(value)).toThrowError(HttpException);
}
});

it('parses UUID-scoped completion and occurrence inputs', () => {
expect(requireWorkspaceId(WORKSPACE_ID)).toBe(WORKSPACE_ID);
expect(requireHabitId(HABIT_ID)).toBe(HABIT_ID);
expect(parseOccurrenceRange('2026-08-04', '2026-08-10')).toEqual({
from: '2026-08-04',
to: '2026-08-10',
});
expect(
parseCompleteHabitRequest({
scheduledLocalDate: '2026-08-04',
completedAt: '2026-08-04T08:00:00.000Z',
idempotencyKey: IDEMPOTENCY_KEY,
}),
).toEqual({
scheduledLocalDate: '2026-08-04',
completedAt: '2026-08-04T08:00:00.000Z',
idempotencyKey: IDEMPOTENCY_KEY,
});
});

it('maps conflicts, not-found, validation, and persistence failures', () => {
expect(
responseOf(toHabitHttpException(new HabitIdempotencyConflictError())),
).toEqual({
type: 'about:blank',
title: 'Completion idempotency key conflicts with an existing event',
status: 409,
code: 'idempotency_conflict',
});
expect(responseOf(toHabitHttpException(new Error('Habit not found')))).toEqual(
{
type: 'about:blank',
title: 'Habit not found',
status: 404,
code: 'not_found',
},
);
expect(
responseOf(toHabitHttpException(new Error('Timezone is invalid'))),
).toEqual({
type: 'about:blank',
title: 'Habit request is invalid',
status: 400,
code: 'invalid_request',
});
expect(responseOf(toHabitHttpException(new HabitPersistenceError()))).toEqual(
{
type: 'about:blank',
title: 'Habit persistence is unavailable',
status: 503,
code: 'persistence_unavailable',
},
);
});

it('does not expose unexpected error contents', () => {
const exception = toHabitHttpException(
new Error('password=secret SELECT * FROM habit.completion_events'),
);
const response = JSON.stringify(responseOf(exception));
expect(exception.getStatus()).toBe(503);
expect(response).not.toContain('secret');
expect(response).not.toContain('SELECT');
});
});
Loading
Loading