447 lines
30 KiB
Markdown
447 lines
30 KiB
Markdown
<!-- markdownlint-disable MD024 -->
|
|
|
|
# Capability Spec Delta: api-plugin-events (add-api-plugin-events)
|
|
|
|
**Package**: `@suaveplan/api-plugin-events`
|
|
**Status**: PROPOSED
|
|
|
|
> Every requirement below carries a `**Verified by:**` line at authoring time.
|
|
|
|
## ADDED Requirements
|
|
|
|
### Requirement: Plugin factory and lifecycle
|
|
|
|
The package SHALL export `createEventsPlugin(options?: EventsPluginConfigInput)` returning a `PluginDescriptor<EventsPluginConfig>` that mounts event-management routes against `@suaveplan/api`. The factory SHALL Zod-validate input via `EventsPluginOptionsSchema` at the boundary and throw `EventsPluginError(INVALID_CONFIG)` carrying the `ZodError` as `cause` on invalid input.
|
|
|
|
#### Scenario: valid options produce a descriptor
|
|
|
|
- **WHEN** `createEventsPlugin({ basePath: "/v1/events" })` is called
|
|
- **THEN** the returned descriptor has `name === "events"`, `dependencies: ["database"]`, and `defaults.basePath === "/v1/events"`
|
|
|
|
#### Scenario: invalid options fail fast
|
|
|
|
- **WHEN** `createEventsPlugin({ maxInvitesPerRequest: -1 })` is called
|
|
- **THEN** the call throws `EventsPluginError` with `code === EVENTS_PLUGIN_CODES.INVALID_CONFIG` and `cause instanceof ZodError`
|
|
|
|
#### Scenario: onInit registers repositories
|
|
|
|
- **WHEN** the descriptor's `onInit(ctx)` runs with a real `db` service in the registry
|
|
- **THEN** `ctx.getService("eventRepository")` and `ctx.getService("rsvpRepository")` resolve to non-null repository instances
|
|
|
|
#### Scenario: onInit fails without database service
|
|
|
|
- **WHEN** `onInit(ctx)` runs with no `db` service registered
|
|
- **THEN** it throws `EventsPluginError` with `code === EVENTS_PLUGIN_CODES.SERVICE_NOT_AVAILABLE`
|
|
|
|
#### Scenario: onReady mounts routes under basePath
|
|
|
|
- **WHEN** `onReady(ctx)` runs after `onInit`
|
|
- **THEN** `ctx.getService("app").routes` contains entries whose `path` begins with the configured `basePath`
|
|
|
|
#### Scenario: onReady refuses to double-mount
|
|
|
|
- **WHEN** `onReady(ctx)` is invoked twice against the same app instance
|
|
- **THEN** the second call throws `EventsPluginError` with `code === EVENTS_PLUGIN_CODES.ROUTE_PREFIX_COLLISION`
|
|
|
|
#### Scenario: onClose clears repositories
|
|
|
|
- **WHEN** `onClose()` is invoked after a successful boot
|
|
- **THEN** subsequent lifecycle methods on the descriptor's `runtime` see cleared `eventRepository` / `rsvpRepository` state and the registry's slots are no longer accessed by the plugin
|
|
|
|
**Verified by:** `src/plugin/plugin.test.ts::"createEventsPlugin returns descriptor with defaults"` AND `src/plugin/plugin.test.ts::"createEventsPlugin throws INVALID_CONFIG for bad options"` AND `src/plugin/plugin.test.ts::"onInit registers eventRepository and rsvpRepository"` AND `src/plugin/plugin.test.ts::"onInit throws SERVICE_NOT_AVAILABLE without db"` AND `src/plugin/plugin.test.ts::"onReady mounts routes under basePath"` AND `src/plugin/plugin.test.ts::"onReady throws ROUTE_PREFIX_COLLISION on double-mount"` AND `src/plugin/plugin.test.ts::"onClose clears runtime state"`
|
|
|
|
---
|
|
|
|
### Requirement: Configuration schema
|
|
|
|
The package SHALL expose `EventsPluginOptionsSchema` (Zod) producing `EventsPluginConfig` with three fields: `basePath: string` (default `/events`), `enableNotifications: boolean` (default `false`), `maxInvitesPerRequest: integer between 1 and 100` (default `50`).
|
|
|
|
#### Scenario: defaults apply when input is empty
|
|
|
|
- **WHEN** `EventsPluginOptionsSchema.parse({})` is called
|
|
- **THEN** the returned object equals `{ basePath: "/events", enableNotifications: false, maxInvitesPerRequest: 50 }`
|
|
|
|
#### Scenario: out-of-range invite cap rejected
|
|
|
|
- **WHEN** `EventsPluginOptionsSchema.safeParse({ maxInvitesPerRequest: 0 })` is called
|
|
- **THEN** `success === false` and `error.issues[0].path` includes `maxInvitesPerRequest`
|
|
|
|
**Verified by:** `src/schemas/options.test.ts::"parses empty input to defaults"` AND `src/schemas/options.test.ts::"rejects out-of-range maxInvitesPerRequest"`
|
|
|
|
---
|
|
|
|
### Requirement: EventRepository CRUD
|
|
|
|
The package SHALL expose `EventRepository` implementing `IEventRepository` with `findAll(query?)`, `findById(id)`, `create(userId, data)`, `update(id, data)`, `delete(id)`, `isCreator(eventId, userId)`, and `updateAttendeeCount(eventId, status, increment)`. Every method SHALL accept user IDs with or without the `user:` prefix and event IDs with or without the `event:` prefix, normalising both forms internally.
|
|
|
|
#### Scenario: findAll applies status / upcoming / past filters
|
|
|
|
- **WHEN** `findAll({ status: 1, upcoming: true, limit: 10, offset: 5 })` is called
|
|
- **THEN** the underlying adapter receives a SELECT with `WHERE status = $p0 AND end_time > time::now()` and positional params `[1]` plus pagination clauses
|
|
|
|
#### Scenario: findById returns null when missing
|
|
|
|
- **WHEN** `findById("nonexistent")` runs and the adapter returns `[]`
|
|
- **THEN** the method returns `null`
|
|
|
|
#### Scenario: create raises FAILURE on empty response
|
|
|
|
- **WHEN** `create("user:1", data)` runs and the adapter returns `[]`
|
|
- **THEN** the method throws `EventsPluginError` with `code === EVENTS_PLUGIN_CODES.EVENT_CREATE_FAILED`
|
|
|
|
#### Scenario: update emits only provided fields
|
|
|
|
- **WHEN** `update("1", { title: "X" })` is called
|
|
- **THEN** the SET clause includes `title = $p0` and does not include `description`, `start_time`, etc.
|
|
|
|
#### Scenario: isCreator handles RecordId / object / string shapes
|
|
|
|
- **WHEN** the adapter returns `{ creator: { tb: "user", id: "1" } }` and `isCreator("event:1", "user:1")` is called
|
|
- **THEN** the method returns `true`
|
|
|
|
#### Scenario: updateAttendeeCount no-ops for not_going
|
|
|
|
- **WHEN** `updateAttendeeCount("event:1", "not_going", 1)` is called
|
|
- **THEN** no adapter query runs
|
|
|
|
**Verified by:** `src/repositories/event-repository/event-repository.test.ts::"findAll applies status and upcoming filters"` AND `src/repositories/event-repository/event-repository.test.ts::"findById returns null when not found"` AND `src/repositories/event-repository/event-repository.test.ts::"create raises EVENT_CREATE_FAILED on empty response"` AND `src/repositories/event-repository/event-repository.test.ts::"update only emits provided fields"` AND `src/repositories/event-repository/event-repository.test.ts::"isCreator handles RecordId object"` AND `src/repositories/event-repository/event-repository.test.ts::"updateAttendeeCount no-ops for not_going"`
|
|
|
|
---
|
|
|
|
### Requirement: RsvpRepository surface
|
|
|
|
The package SHALL expose `RsvpRepository` implementing `IRsvpRepository` with `findByEventAndUser`, `findAttendees`, `upsert`, `delete`, `createInvitations`, and `countByStatus`. The repository SHALL idempotently skip existing invitations when `createInvitations` is called with a user that already has an RSVP row.
|
|
|
|
#### Scenario: upsert creates when no row exists
|
|
|
|
- **WHEN** `upsert("event:1", "user:1", { status: "going" })` runs against an empty table
|
|
- **THEN** the adapter receives a CREATE statement and the returned RSVP has `status === "going"`
|
|
|
|
#### Scenario: upsert updates when a row exists
|
|
|
|
- **WHEN** `upsert("event:1", "user:1", { status: "maybe" })` runs and a row already exists for the same (event, user)
|
|
- **THEN** the adapter receives an UPDATE statement instead of CREATE
|
|
|
|
#### Scenario: delete returns the prior status
|
|
|
|
- **WHEN** `delete("event:1", "user:1")` runs and the prior row had `status: "going"`
|
|
- **THEN** the method returns `"going"` and the row is deleted
|
|
|
|
#### Scenario: createInvitations skips existing rows
|
|
|
|
- **WHEN** `createInvitations("event:1", ["user:1", "user:2"])` runs and `user:1` already has an RSVP
|
|
- **THEN** the returned array has length 1 (only `user:2`)
|
|
|
|
#### Scenario: countByStatus returns 0 on empty
|
|
|
|
- **WHEN** `countByStatus("event:1", "going")` runs and the adapter returns `[]`
|
|
- **THEN** the method returns `0`
|
|
|
|
**Verified by:** `src/repositories/rsvp-repository/rsvp-repository.test.ts::"upsert creates new row when none exists"` AND `src/repositories/rsvp-repository/rsvp-repository.test.ts::"upsert updates when row exists"` AND `src/repositories/rsvp-repository/rsvp-repository.test.ts::"delete returns prior status"` AND `src/repositories/rsvp-repository/rsvp-repository.test.ts::"createInvitations skips existing rows"` AND `src/repositories/rsvp-repository/rsvp-repository.test.ts::"countByStatus returns 0 on empty result"`
|
|
|
|
---
|
|
|
|
### Requirement: Route surface (9 endpoints)
|
|
|
|
The package SHALL expose `createEventRoutes({ eventRepository, rsvpRepository, emitEvent? })` returning an `OpenAPIHono` instance with these routes (relative to the mount prefix): `GET /`, `GET /{id}`, `POST /`, `PUT /{id}`, `DELETE /{id}`, `POST /{id}/rsvp`, `DELETE /{id}/rsvp`, `GET /{id}/my-rsvp`, `GET /{id}/attendees`, `POST /{id}/invite`. Each route SHALL be wrapped with `@hono/zod-openapi` `createRoute`, validating request bodies / params / queries through the package's Zod schemas. Every route SHALL require an authenticated user (`c.get("user")`) and return `401` with `EVENTS_PLUGIN_CODES.UNAUTHENTICATED` when absent.
|
|
|
|
#### Scenario: unauthenticated request returns 401
|
|
|
|
- **WHEN** a request reaches `GET /` without `user` set in the Hono context
|
|
- **THEN** the response status is `401` and the body is `{ error: "Authentication required" }`
|
|
|
|
#### Scenario: list returns paginated events
|
|
|
|
- **WHEN** `GET /` runs with `user` set and `eventRepository.findAll` returns 2 events
|
|
- **THEN** the response is `200` and the body is `{ data: [event1, event2] }`
|
|
|
|
#### Scenario: get returns 404 when event missing
|
|
|
|
- **WHEN** `GET /missing-id` runs with `user` set and `findById` returns `null`
|
|
- **THEN** the response is `404` with body `{ error: "Event not found" }`
|
|
|
|
#### Scenario: create emits event and returns 201
|
|
|
|
- **WHEN** `POST /` runs with valid body and `user.id = "user:1"`
|
|
- **THEN** the response is `201`, the body is the created event, and `emitEvent` is called with `{ type: "event:created", payload: { eventId, creatorId: "user:1" } }`
|
|
|
|
#### Scenario: update requires creator
|
|
|
|
- **WHEN** `PUT /{id}` runs with a user who is not the event creator
|
|
- **THEN** the response is `403` with body `{ error: "Only event creator can update this event" }`
|
|
|
|
#### Scenario: delete by non-creator forbidden
|
|
|
|
- **WHEN** `DELETE /{id}` runs with a non-creator user
|
|
- **THEN** the response is `403`
|
|
|
|
#### Scenario: rsvp updates attendee counts
|
|
|
|
- **WHEN** `POST /{id}/rsvp` runs with `status: "going"` and no prior RSVP
|
|
- **THEN** `eventRepository.updateAttendeeCount` is called with `(id, "going", 1)` and `emitEvent` fires `rsvp:created`
|
|
|
|
#### Scenario: cancel-rsvp returns 404 when no prior RSVP
|
|
|
|
- **WHEN** `DELETE /{id}/rsvp` runs and the user has no RSVP row
|
|
- **THEN** the response is `404`
|
|
|
|
#### Scenario: attendees returns paginated list
|
|
|
|
- **WHEN** `GET /{id}/attendees` runs
|
|
- **THEN** the response is `200` with body `{ data: [...], total: <count> }`
|
|
|
|
#### Scenario: invite requires creator
|
|
|
|
- **WHEN** `POST /{id}/invite` runs with a non-creator
|
|
- **THEN** the response is `403`
|
|
|
|
#### Scenario: invite enforces maxInvitesPerRequest
|
|
|
|
- **WHEN** `POST /{id}/invite` is called with a `user_ids` array longer than `maxInvitesPerRequest`
|
|
- **THEN** validation fails at the Zod boundary with a `400`
|
|
|
|
**Verified by:** `src/routes/events-routes/events-routes.test.ts::"GET / returns 401 without user"` AND `src/routes/events-routes/events-routes.test.ts::"GET / returns paginated events"` AND `src/routes/events-routes/events-routes.test.ts::"GET /{id} returns 404 when missing"` AND `src/routes/events-routes/events-routes.test.ts::"POST / emits event:created and returns 201"` AND `src/routes/events-routes/events-routes.test.ts::"PUT /{id} returns 403 for non-creator"` AND `src/routes/events-routes/events-routes.test.ts::"DELETE /{id} returns 403 for non-creator"` AND `src/routes/events-routes/events-routes.test.ts::"POST /{id}/rsvp updates attendee count"` AND `src/routes/events-routes/events-routes.test.ts::"DELETE /{id}/rsvp returns 404 when none"` AND `src/routes/events-routes/events-routes.test.ts::"GET /{id}/attendees returns list"` AND `src/routes/events-routes/events-routes.test.ts::"POST /{id}/invite returns 403 for non-creator"` AND `src/schemas/rsvp-schemas.test.ts::"InviteUsersSchema enforces user_ids length"`
|
|
|
|
---
|
|
|
|
### Requirement: Error code registry
|
|
|
|
The package SHALL expose `EVENTS_PLUGIN_CODES = defineCodes("EVENTS_PLUGIN", [...])` covering 12 stable codes: `INVALID_CONFIG`, `SERVICE_NOT_AVAILABLE`, `ROUTE_PREFIX_COLLISION`, `EVENT_CREATE_FAILED`, `EVENT_UPDATE_FAILED`, `RSVP_CREATE_FAILED`, `RSVP_UPDATE_FAILED`, `RSVP_NOT_FOUND`, `EVENT_NOT_FOUND`, `UNAUTHENTICATED`, `FORBIDDEN`, `INVITE_LIMIT_EXCEEDED`. The namespace `"EVENTS_PLUGIN"` (not `"EVENTS"`) prevents collision with `@suaveplan/events`. Every code SHALL be thrown by at least one production path.
|
|
|
|
#### Scenario: every code is reachable
|
|
|
|
- **WHEN** the conformance test reflects over `EVENTS_PLUGIN_CODES` and grep-asserts each value appears in a `new EventsPluginError(<code>, ...)` call site under `src/`
|
|
- **THEN** every code resolves to ≥1 match
|
|
|
|
**Verified by:** `src/error-codes/error-codes.test.ts::"every EVENTS_PLUGIN_CODES value is thrown by src/"`
|
|
|
|
---
|
|
|
|
### Requirement: OTel instruments
|
|
|
|
The package SHALL emit 5 OpenTelemetry instruments via `metrics.getMeter("@suaveplan/api-plugin-events")`: `eventsCreatedTotal` (Counter), `eventsUpdatedTotal` (Counter), `eventsDeletedTotal` (Counter), `rsvpsWrittenTotal` (Counter, labelled by `status`), `invitationsSentTotal` (Counter). Instruments SHALL be lazily built and cached; `resetEventsInstruments()` SHALL clear the cache for test re-binding.
|
|
|
|
#### Scenario: instruments emit on real production paths
|
|
|
|
- **WHEN** a freshly-installed in-memory `MeterProvider` is set, `resetEventsInstruments()` is called, then `POST /` succeeds
|
|
- **THEN** the next `forceFlush` of the in-memory reader contains `events.created_total` with value `1`
|
|
|
|
**Verified by:** `src/otel/instruments.test.ts::"getEventsInstruments returns the same bundle on repeated calls"` AND `src/otel/instruments.test.ts::"emits events.created_total when a create succeeds"` AND `src/otel/instruments.test.ts::"resetEventsInstruments rebinds against a fresh provider"`
|
|
|
|
---
|
|
|
|
### Requirement: Service registry augmentation
|
|
|
|
The package SHALL augment `@suaveplan/api-plugin/service-registry` `ServiceRegistry` with `eventRepository: IEventRepository` and `rsvpRepository: IRsvpRepository` slots so downstream plugins resolve them via `ctx.getService("eventRepository")` with full typing.
|
|
|
|
#### Scenario: registered services resolve with the augmented type
|
|
|
|
- **WHEN** a downstream `PluginContext` calls `ctx.getService("eventRepository")` after the events plugin's `onInit` ran
|
|
- **THEN** the value is an `IEventRepository` (compile-time + runtime)
|
|
|
|
**Verified by:** `src/api-plugin-events.spec-conformance.test.ts::"ServiceRegistry exposes eventRepository and rsvpRepository slots"`
|
|
|
|
---
|
|
|
|
### Requirement: Public surface (subpath exports)
|
|
|
|
The package SHALL expose the following subpath exports in `package.json`: `.`, `./plugin/plugin`, `./schemas/options`, `./schemas/event-schemas`, `./schemas/rsvp-schemas`, `./error-codes/error-codes`, `./otel/instruments`, `./repositories/event-repository/event-repository`, `./repositories/rsvp-repository/rsvp-repository`, `./routes/events-routes/events-routes`, `./types/types`. The root barrel SHALL re-export every public symbol via explicit named exports (rule 10 — no `export *` outside generated forwarders).
|
|
|
|
#### Scenario: every documented symbol is re-exported by the root
|
|
|
|
- **WHEN** the spec-conformance test imports the package root and reflects over expected symbols
|
|
- **THEN** every expected symbol resolves to a non-`undefined` value
|
|
|
|
**Verified by:** `src/api-plugin-events.spec-conformance.test.ts::"re-exports every documented symbol"`
|
|
|
|
---
|
|
|
|
### Requirement: SurrealDB migration payload
|
|
|
|
The package SHALL ship `surql/tables/event.surql` defining the SCHEMAFULL tables `event` and `event_rsvp` plus the supporting indexes (`event_start_idx`, `event_rsvp_unique_idx`). The migration SHALL parse and execute cleanly against a real SurrealDB v3 instance with no cross-plugin function references — the upstream cross-plugin permission rules and helper functions (`fn::bitwise::*`, `fn::event::*`, `fn::group::*`, `fn::notification::*`) and the auxiliary `event_update` table are intentionally scoped out (see proposal `## Out of scope`) because they depend on plugins that do not yet exist on genesis. Permissions are enforced at the route / repository layer, not the database layer.
|
|
|
|
#### Scenario: surql loads cleanly
|
|
|
|
- **WHEN** the full-boot integration test executes the migration against a real SurrealDB v3 container
|
|
- **THEN** subsequent `CREATE event` and `CREATE event_rsvp` statements succeed and round-trip the inserted fields
|
|
|
|
**Verified by:** `tests/integration/full-boot.test.ts::"boots, creates event, records rsvp, increments count, cleans up"`
|
|
|
|
---
|
|
|
|
### Requirement: Integration boot
|
|
|
|
The package SHALL boot end-to-end against a real SurrealDB v3 instance: `createEventsPlugin` → `onInit` → `onReady` → CRUD a real event → RSVP → delete. The integration test SHALL skip cleanly when `isSurrealDBAvailable()` is `false`.
|
|
|
|
#### Scenario: full lifecycle against real Surreal
|
|
|
|
- **WHEN** a SurrealDB v3 testcontainer is running and the integration test invokes the full boot
|
|
- **THEN** an `event` is created, an `rsvp` is recorded, the attendee count rises by 1, the rsvp is cancelled, and the event is deleted — every step round-trips through `db.query` without throwing
|
|
|
|
**Verified by:** `tests/integration/full-boot.test.ts::"boots, creates event, records rsvp, increments count, cleans up"`
|
|
|
|
### Requirement: ICS export endpoint
|
|
|
|
The package SHALL expose a `GET /:id/ics` route mounted by `createEventsPlugin` under the configured `basePath`. The route SHALL return HTTP `200` with `Content-Type: text/calendar; charset=utf-8` and a body containing one RFC 5545 `VCALENDAR` block enclosing a single `VEVENT` for the requested event. The response SHALL carry `Content-Disposition: attachment; filename="event-<id>.ics"` where `<id>` is the URL-safe event id. Authentication SHALL be required: a request without `c.get("user")` SHALL return HTTP `401` with body `{ error: "Authentication required", code: "EVENTS_PLUGIN.UNAUTHENTICATED" }`. A request whose event id does not resolve in `eventRepository.findById` SHALL return HTTP `404` with code `EVENTS_PLUGIN.EVENT_NOT_FOUND`.
|
|
|
|
#### Scenario: unauthenticated request returns 401
|
|
|
|
- **WHEN** `GET /event:1/ics` runs without `user` set in the Hono context
|
|
- **THEN** the response status is `401` and the body contains `code: "EVENTS_PLUGIN.UNAUTHENTICATED"`
|
|
|
|
#### Scenario: 200 success returns VCALENDAR with single VEVENT
|
|
|
|
- **WHEN** `GET /event:1/ics` runs with `user` set and `eventRepository.findById` returns an event
|
|
- **THEN** the response status is `200`, `Content-Type` is `text/calendar; charset=utf-8`, `Content-Disposition` is `attachment; filename="event-event:1.ics"`, and the body matches `/^BEGIN:VCALENDAR\r\n[\s\S]+\r\nEND:VCALENDAR\r\n$/` containing exactly one `BEGIN:VEVENT` / `END:VEVENT` pair
|
|
|
|
#### Scenario: missing event returns 404
|
|
|
|
- **WHEN** `GET /missing/ics` runs and `findById` returns `null`
|
|
- **THEN** the response status is `404` and the body carries `code: "EVENTS_PLUGIN.EVENT_NOT_FOUND"`
|
|
|
|
**Verified by:** `src/routes/ics-export/ics-export.test.ts::"GET /:id/ics returns 401 without user"` AND `src/routes/ics-export/ics-export.test.ts::"GET /:id/ics returns 200 with single VEVENT VCALENDAR"` AND `src/routes/ics-export/ics-export.test.ts::"GET /:id/ics returns 404 when event missing"` AND `src/routes/ics-export/ics-export.test.ts::"GET /:id/ics sets Content-Disposition attachment header"`
|
|
|
|
---
|
|
|
|
### Requirement: VEVENT body content
|
|
|
|
The single `VEVENT` block SHALL emit the following properties, sourced from the event fields shipped on `EventSchema`: `UID` as `${eventId}@suaveplan.events`; `DTSTAMP` from the injected `Clock` formatted as UTC `YYYYMMDDTHHMMSSZ` per §3.3.5; `DTSTART` from `event.start_time` formatted as UTC `YYYYMMDDTHHMMSSZ`; `DTEND` from `event.end_time` formatted the same way; `SUMMARY` from `event.title` with §3.3.11 escaping applied; `DESCRIPTION` from `event.description` if present, also escaped; `LOCATION` from `event.location.name` if present, also escaped; `STATUS` mapped from the event status integer (`0`→`TENTATIVE`, `1`→`CONFIRMED`, `2`→`CANCELLED`) per §3.8.1.11; and `SEQUENCE:0`. `ORGANIZER` SHALL NOT be emitted in v0.1 because the event schema does not carry a host email — the empty slot is documented in the proposal and remains reserved for a future schema field.
|
|
|
|
#### Scenario: every required property is present in order
|
|
|
|
- **WHEN** the builder runs against an event with title `"T"`, description `"d"`, location name `"L"`, start `2026-01-01T10:00:00Z`, end `2026-01-01T12:00:00Z`, status `1`
|
|
- **THEN** the body contains exactly one of each line in order: `BEGIN:VEVENT`, `UID:event:1@suaveplan.events`, `DTSTAMP:...Z`, `DTSTART:20260101T100000Z`, `DTEND:20260101T120000Z`, `SUMMARY:T`, `DESCRIPTION:d`, `LOCATION:L`, `STATUS:CONFIRMED`, `SEQUENCE:0`, `END:VEVENT`
|
|
|
|
#### Scenario: status maps draft → TENTATIVE and cancelled → CANCELLED
|
|
|
|
- **WHEN** the builder runs with `status: 0` and again with `status: 2`
|
|
- **THEN** the first body contains `STATUS:TENTATIVE` and the second contains `STATUS:CANCELLED`
|
|
|
|
#### Scenario: optional fields omit when absent
|
|
|
|
- **WHEN** the builder runs with `description` undefined and `location` undefined
|
|
- **THEN** the body contains no `DESCRIPTION:` or `LOCATION:` line
|
|
|
|
**Verified by:** `src/ics/ics-builder/ics-builder.test.ts::"emits every required VEVENT property in canonical order"` AND `src/ics/ics-builder/ics-builder.test.ts::"maps status integer to RFC 5545 STATUS value"` AND `src/ics/ics-builder/ics-builder.test.ts::"omits DESCRIPTION and LOCATION when absent"`
|
|
|
|
---
|
|
|
|
### Requirement: Text escape rules per §3.3.11
|
|
|
|
Every value written into `SUMMARY`, `DESCRIPTION`, or `LOCATION` SHALL be escaped per RFC 5545 §3.3.11: backslash → `\\`, semicolon → `\;`, comma → `\,`, newline (`\n` and `\r\n`) → `\n` (the literal two characters `\` + `n`). Carriage returns inside text SHALL be normalised away before escaping. Escaping SHALL happen before line folding.
|
|
|
|
#### Scenario: comma semicolon backslash newline are escaped
|
|
|
|
- **WHEN** `escapeText("a,b;c\\d\ne")` runs
|
|
- **THEN** the result is the exact string `a\,b\;c\\d\ne`
|
|
|
|
#### Scenario: CRLF in source becomes a single escaped \n
|
|
|
|
- **WHEN** `escapeText("a\r\nb")` runs
|
|
- **THEN** the result is the exact string `a\nb`
|
|
|
|
**Verified by:** `src/ics/ics-builder/ics-builder.test.ts::"escapeText escapes comma semicolon backslash and newline"` AND `src/ics/ics-builder/ics-builder.test.ts::"escapeText normalises CRLF to a single \\n"`
|
|
|
|
---
|
|
|
|
### Requirement: Line folding per §3.1
|
|
|
|
Every emitted content line longer than 75 octets SHALL be folded: the line is split into 75-octet segments and each subsequent segment is prefixed with a single SPACE on a new CRLF-terminated line, exactly as specified in RFC 5545 §3.1. Folding SHALL operate on the post-escape byte stream so multi-byte UTF-8 sequences are not split mid-codepoint when avoidable (the implementation MAY fold at the nearest safe byte boundary ≤75 octets to preserve UTF-8). Every output line including the final `END:VCALENDAR` SHALL terminate with CRLF (`\r\n`).
|
|
|
|
#### Scenario: a 200-character DESCRIPTION folds into three CRLF-separated segments
|
|
|
|
- **WHEN** the builder runs against an event whose escaped `DESCRIPTION` is 200 ASCII octets long
|
|
- **THEN** the body contains `DESCRIPTION:` followed by the first 63 octets on the first physical line (75 = `DESCRIPTION:` 12 octets + 63 payload), then `\r\n` (CRLF + space) before each subsequent 74-octet payload segment, and every physical line ends in `\r\n`
|
|
|
|
#### Scenario: every output line ends in CRLF
|
|
|
|
- **WHEN** `buildVCalendar` runs against any valid input
|
|
- **THEN** the body contains zero `\n` bytes that are not preceded by `\r`
|
|
|
|
**Verified by:** `src/ics/ics-builder/ics-builder.test.ts::"folds a 200-octet DESCRIPTION into 75-octet continuation segments"` AND `src/ics/ics-builder/ics-builder.test.ts::"every output line ends in CRLF"`
|
|
|
|
---
|
|
|
|
### Requirement: RSVP attendee export (?with=rsvps)
|
|
|
|
When the request includes `?with=rsvps`, the response body SHALL contain one `ATTENDEE` line inside the `VEVENT` per row returned by `rsvpRepository.findAttendees(eventId)`. Each `ATTENDEE` line SHALL carry the `CN` parameter from the attendee's `user_name` if present (escaped per §3.3.11), a `PARTSTAT` parameter mapped from the RSVP status (`going`→`ACCEPTED`, `maybe`→`TENTATIVE`, `not_going`→`DECLINED`, `invited`→`NEEDS-ACTION`), and the CAL-ADDRESS value as `mailto:<user_email>` when `user_email` is present or `mailto:<user_id>@suaveplan.local` as a non-routable fallback when it is not. The `?with=rsvps` variant SHALL require the requester to be the event creator: a non-creator SHALL receive HTTP `403` with code `EVENTS_PLUGIN.FORBIDDEN`. The plain `GET /:id/ics` (no `with` query) SHALL NOT emit any `ATTENDEE` lines and SHALL NOT require creator privileges.
|
|
|
|
#### Scenario: with=rsvps emits one ATTENDEE per RSVP
|
|
|
|
- **WHEN** `GET /event:1/ics?with=rsvps` runs with `user` = creator and `findAttendees` returns 2 RSVP rows
|
|
- **THEN** the body contains exactly 2 `ATTENDEE` lines, each carrying a `PARTSTAT=` parameter and a `mailto:` value
|
|
|
|
#### Scenario: with=rsvps maps PARTSTAT values
|
|
|
|
- **WHEN** the builder runs with attendees of status `going`, `maybe`, `not_going`, `invited`
|
|
- **THEN** the corresponding ATTENDEE lines carry `PARTSTAT=ACCEPTED`, `PARTSTAT=TENTATIVE`, `PARTSTAT=DECLINED`, `PARTSTAT=NEEDS-ACTION`
|
|
|
|
#### Scenario: with=rsvps by non-creator returns 403
|
|
|
|
- **WHEN** `GET /event:1/ics?with=rsvps` runs with a user where `isCreator` returns `false`
|
|
- **THEN** the response status is `403` and the body carries `code: "EVENTS_PLUGIN.FORBIDDEN"`
|
|
|
|
#### Scenario: invalid with value returns 422
|
|
|
|
- **WHEN** `GET /event:1/ics?with=bogus` runs
|
|
- **THEN** the response status is `422` and the body carries `code: "EVENTS_PLUGIN.ICS_INVALID_QUERY"`
|
|
|
|
**Verified by:** `src/routes/ics-export/ics-export.test.ts::"GET /:id/ics?with=rsvps emits one ATTENDEE per RSVP"` AND `src/ics/ics-builder/ics-builder.test.ts::"maps RSVP status to ATTENDEE PARTSTAT"` AND `src/routes/ics-export/ics-export.test.ts::"GET /:id/ics?with=rsvps returns 403 for non-creator"` AND `src/routes/ics-export/ics-export.test.ts::"GET /:id/ics returns 422 for invalid with value"`
|
|
|
|
---
|
|
|
|
### Requirement: New error code
|
|
|
|
The package SHALL extend `EVENTS_PLUGIN_CODES` with `ICS_INVALID_QUERY` (registry size 12 → 13). The code SHALL be raised when the Zod schema for the ICS route query rejects an unknown `with` value, surfaced to the client as HTTP `422`. The code SHALL be thrown by at least one production path under `src/routes/ics-export/`.
|
|
|
|
#### Scenario: ICS_INVALID_QUERY is reachable
|
|
|
|
- **WHEN** the error-codes conformance test reflects over `EVENTS_PLUGIN_CODES` and greps `src/` for a `new EventsPluginError(EVENTS_PLUGIN_CODES.ICS_INVALID_QUERY, ...)` or `code: EVENTS_PLUGIN_CODES.ICS_INVALID_QUERY` call site
|
|
- **THEN** at least one match is found
|
|
|
|
**Verified by:** `src/error-codes/error-codes.test.ts::"every EVENTS_PLUGIN_CODES value is thrown by src/"`
|
|
|
|
---
|
|
|
|
### Requirement: OTel counters
|
|
|
|
The package SHALL extend `EventsInstruments` with two new counters created against `metrics.getMeter("@suaveplan/api-plugin-events")`: `icsServedTotal` (instrument name `events.ics.served_total`, labelled with `with` taking values `"none"` or `"rsvps"`) and `icsFailedTotal` (instrument name `events.ics.failed_total`, labelled with `reason` taking values `"not_found"`, `"forbidden"`, `"unauthenticated"`, or `"invalid_query"`). Both counters SHALL be lazily built, cached, and cleared by `resetEventsInstruments()`. The ICS route SHALL increment `icsServedTotal` on every successful response and `icsFailedTotal` on every error response.
|
|
|
|
#### Scenario: success path increments served_total
|
|
|
|
- **WHEN** a freshly-installed in-memory `MeterProvider` is set, `resetEventsInstruments()` is called, then `GET /event:1/ics` returns 200
|
|
- **THEN** the next `forceFlush` of the in-memory reader contains `events.ics.served_total` with attribute `with=none` and value `1`
|
|
|
|
#### Scenario: 404 path increments failed_total
|
|
|
|
- **WHEN** the same setup runs and `GET /missing/ics` returns 404
|
|
- **THEN** the next `forceFlush` contains `events.ics.failed_total` with attribute `reason=not_found` and value `1`
|
|
|
|
**Verified by:** `src/otel/instruments.test.ts::"exposes ics served and failed counters"` AND `src/routes/ics-export/ics-export.test.ts::"increments events.ics.served_total on success"` AND `src/routes/ics-export/ics-export.test.ts::"increments events.ics.failed_total on 404"`
|
|
|
|
---
|
|
|
|
### Requirement: Public surface (subpath exports + named re-exports)
|
|
|
|
The package SHALL add two new subpath exports in `package.json`: `./ics/ics-builder/ics-builder` and `./routes/ics-export/ics-export`. The root barrel SHALL add explicit named re-exports of `createIcsExportRoutes`, `buildVCalendar`, `BuildVCalendarInput`, `IcsAttendee`, `IcsExportRoutesConfig`, and `IcsEventStatus` (rule 10 — no `export *` outside generated forwarders).
|
|
|
|
#### Scenario: new symbols resolve from the package root
|
|
|
|
- **WHEN** the conformance test imports the package root and asserts the new symbols resolve to non-`undefined`
|
|
- **THEN** every expected symbol matches
|
|
|
|
**Verified by:** `src/api-plugin-events.spec-conformance.test.ts::"re-exports ICS surface (builder + routes)"`
|
|
|
|
---
|
|
|
|
### Requirement: Plugin wires ICS routes alongside CRUD routes
|
|
|
|
`createEventsPlugin`'s `onReady` SHALL mount `createIcsExportRoutes(...)` on the same `OpenAPIHono` instance under the same `basePath` as `createEventRoutes(...)`. Both route modules SHALL share the host-supplied `user` context variable. The plugin SHALL pass `Date.now` as the default `Clock` to the ICS routes; tests MAY override via `createIcsExportRoutes` directly.
|
|
|
|
#### Scenario: plugin onReady mounts an ICS route
|
|
|
|
- **WHEN** `onReady` runs against a fresh registry and app
|
|
- **THEN** the app's `routes` array contains at least one entry whose `path` ends in `/:id/ics`
|
|
|
|
**Verified by:** `src/plugin/plugin.test.ts::"onReady mounts the ICS export route"`
|