Initial Commit
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: slack-client (add-slack-client)
|
||||
|
||||
**Package**: `@suaveplan/slack-client`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: SlackApiClient sends messages via `chat.postMessage`
|
||||
|
||||
The package SHALL expose `SlackApiClient.sendMessage(message)` which POSTs to `${baseUrl}/chat.postMessage` with an `Authorization: Bearer <token>` header and a JSON body containing `channel`, `text`, and the optional `blocks`, `thread_ts`, `unfurl_links`, `unfurl_media`, `metadata` fields. On a successful Slack response carrying `ok: true` and `ts`, the client SHALL record a `SlackDeliveryReport { status: "delivered" }` retrievable via `getStatus(ts)`.
|
||||
|
||||
#### Scenario: successful send populates delivery report
|
||||
|
||||
- **WHEN** the caller invokes `sendMessage({ channel: "C1", text: "hi" })` and the fake fetch resolves `{ ok: true, channel: "C1", ts: "99.99" }`
|
||||
- **THEN** the returned `SlackResponse.ok` is `true`, `getStatus("99.99")` returns a `delivered` report with channel `C1`, and the OTel `slack.requests` counter increments once with `{ method: "chat.postMessage", outcome: "ok" }`
|
||||
|
||||
#### Scenario: blocks + thread + metadata forwarded verbatim
|
||||
|
||||
- **WHEN** `sendMessage` is called with `blocks`, `threadTs`, `unfurlLinks`, `unfurlMedia`, `metadata`
|
||||
- **THEN** the captured request body contains `thread_ts`, `unfurl_links`, `unfurl_media`, `blocks`, `metadata` matching the input
|
||||
|
||||
#### Scenario: default channel used when message channel is empty
|
||||
|
||||
- **WHEN** the client is constructed with `defaultChannel: "C999"` and `sendMessage({ channel: "", text: "hi" })` is invoked
|
||||
- **THEN** the request body posts to channel `C999`
|
||||
|
||||
#### Scenario: no channel throws `SLACK.CHANNEL_NOT_FOUND`
|
||||
|
||||
- **WHEN** `sendMessage({ channel: "", text: "hi" })` is invoked with no `defaultChannel`
|
||||
- **THEN** the call rejects with `SlackChannelNotFoundError` carrying code `SLACK.CHANNEL_NOT_FOUND`
|
||||
|
||||
**Verified by:** `src/client/slack-api-client.test.ts::"sendMessage posts to chat.postMessage and records a delivered report"` AND `src/client/slack-api-client.test.ts::"sendMessage forwards blocks thread metadata and unfurl flags"` AND `src/client/slack-api-client.test.ts::"sendMessage uses defaultChannel when message.channel is empty"` AND `src/client/slack-api-client.test.ts::"sendMessage throws SLACK.CHANNEL_NOT_FOUND when no channel is available"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: SlackApiClient updates and deletes messages
|
||||
|
||||
The package SHALL expose `updateMessage(channel, ts, text, blocks?)` and `deleteMessage(channel, ts)` which POST to `chat.update` and `chat.delete` respectively with the supplied identifiers.
|
||||
|
||||
#### Scenario: updateMessage forwards new text and optional blocks
|
||||
|
||||
- **WHEN** `updateMessage("C1", "1.1", "Updated", blocks)` is invoked
|
||||
- **THEN** the request body contains `channel: "C1"`, `ts: "1.1"`, `text: "Updated"`, and the supplied `blocks`
|
||||
|
||||
#### Scenario: deleteMessage forwards channel and ts
|
||||
|
||||
- **WHEN** `deleteMessage("C1", "1.1")` is invoked
|
||||
- **THEN** the request body contains exactly `{ channel: "C1", ts: "1.1" }`
|
||||
|
||||
**Verified by:** `src/client/slack-api-client.test.ts::"updateMessage posts to chat.update with new text and blocks"` AND `src/client/slack-api-client.test.ts::"deleteMessage posts to chat.delete with channel and ts"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: SlackApiClient lists channels and users with mapping
|
||||
|
||||
The package SHALL expose `listChannels(limit?)`, `getChannelInfo(channelId)`, and `listUsers(limit?)`. The client SHALL map snake_case Slack payload shapes (`is_private`, `topic.value`, `purpose.value`, `num_members`, `real_name`, `profile.email`, `is_bot`) onto camelCase domain types (`SlackChannel`, `SlackUser`). Missing arrays SHALL yield `[]`, not `undefined`.
|
||||
|
||||
#### Scenario: listChannels maps snake_case to domain
|
||||
|
||||
- **WHEN** the fake fetch returns `{ ok: true, channels: [{ id, name, is_private, topic, purpose, num_members }, ...] }`
|
||||
- **THEN** the result is `SlackChannel[]` with camelCase fields populated
|
||||
|
||||
#### Scenario: listChannels returns empty array when key absent
|
||||
|
||||
- **WHEN** the fake fetch returns `{ ok: true }` with no `channels` key
|
||||
- **THEN** the result is `[]`
|
||||
|
||||
#### Scenario: getChannelInfo maps single channel
|
||||
|
||||
- **WHEN** `getChannelInfo("C1")` is invoked and the fake fetch returns a channel object
|
||||
- **THEN** the returned `SlackChannel` matches the mapped shape
|
||||
|
||||
#### Scenario: listUsers maps snake_case to domain
|
||||
|
||||
- **WHEN** the fake fetch returns `{ ok: true, members: [...] }`
|
||||
- **THEN** the result is `SlackUser[]` with `realName`, `email`, `isBot` populated
|
||||
|
||||
#### Scenario: listUsers returns empty array when key absent
|
||||
|
||||
- **WHEN** the fake fetch returns `{ ok: true }` with no `members` key
|
||||
- **THEN** the result is `[]`
|
||||
|
||||
**Verified by:** `src/client/slack-api-client.test.ts::"listChannels maps snake_case payload to SlackChannel"` AND `src/client/slack-api-client.test.ts::"listChannels returns empty array when channels key is absent"` AND `src/client/slack-api-client.test.ts::"getChannelInfo maps a single channel"` AND `src/client/slack-api-client.test.ts::"listUsers maps snake_case payload to SlackUser"` AND `src/client/slack-api-client.test.ts::"listUsers returns empty array when members key is absent"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: SlackApiClient classifies HTTP errors into typed Slack codes
|
||||
|
||||
The package SHALL convert HTTP 401/403 to `SLACK.AUTH_ERROR`, HTTP 404 to `SLACK.CHANNEL_NOT_FOUND`, HTTP 408/504 to `SLACK.TIMEOUT`, HTTP 429 to `SLACK.RATE_LIMITED` (with `retryAfterMs` populated from `Retry-After` or `x-ratelimit-reset`), and other non-2xx to `SLACK.API_ERROR`. A non-`ok: true` JSON body containing `error: "channel_not_found" | "is_archived" | "not_in_channel"` SHALL yield `SLACK.CHANNEL_NOT_FOUND`; `invalid_auth | not_authed | token_revoked | account_inactive` SHALL yield `SLACK.AUTH_ERROR`; `team_not_found | no_workspace` SHALL yield `SLACK.WORKSPACE_NOT_FOUND`; everything else SHALL yield `SLACK.API_ERROR`.
|
||||
|
||||
#### Scenario: HTTP 401 yields SLACK.AUTH_ERROR
|
||||
|
||||
- **WHEN** the fake fetch returns HTTP 401
|
||||
- **THEN** the call rejects with `SlackAuthError` carrying code `SLACK.AUTH_ERROR`
|
||||
|
||||
#### Scenario: HTTP 404 yields SLACK.CHANNEL_NOT_FOUND
|
||||
|
||||
- **WHEN** the fake fetch returns HTTP 404
|
||||
- **THEN** the call rejects with `SlackChannelNotFoundError`
|
||||
|
||||
#### Scenario: HTTP 429 yields SLACK.RATE_LIMITED with parsed retry-after
|
||||
|
||||
- **WHEN** the fake fetch returns HTTP 429 with `Retry-After: "30"`
|
||||
- **THEN** the rejection is `SlackRateLimitedError` whose `context.retryAfterMs` is `30000`
|
||||
|
||||
#### Scenario: HTTP 500 yields SLACK.API_ERROR
|
||||
|
||||
- **WHEN** the fake fetch returns HTTP 500
|
||||
- **THEN** the call rejects with `SlackApiError`
|
||||
|
||||
#### Scenario: Slack error string classifies to typed code
|
||||
|
||||
- **WHEN** the fake fetch returns `{ ok: false, error: "channel_not_found" }`
|
||||
- **THEN** the call rejects with `SlackChannelNotFoundError`
|
||||
|
||||
#### Scenario: invalid_auth string yields SLACK.AUTH_ERROR
|
||||
|
||||
- **WHEN** the fake fetch returns `{ ok: false, error: "invalid_auth" }`
|
||||
- **THEN** the call rejects with `SlackAuthError`
|
||||
|
||||
#### Scenario: team_not_found string yields SLACK.WORKSPACE_NOT_FOUND
|
||||
|
||||
- **WHEN** the fake fetch returns `{ ok: false, error: "team_not_found" }`
|
||||
- **THEN** the call rejects with `SlackWorkspaceNotFoundError`
|
||||
|
||||
#### Scenario: unknown error string yields SLACK.API_ERROR
|
||||
|
||||
- **WHEN** the fake fetch returns `{ ok: false, error: "ratelimited_internal" }`
|
||||
- **THEN** the call rejects with `SlackApiError`
|
||||
|
||||
**Verified by:** `src/client/classify.test.ts::"HTTP 401 classifies to SLACK.AUTH_ERROR"` AND `src/client/classify.test.ts::"HTTP 404 classifies to SLACK.CHANNEL_NOT_FOUND"` AND `src/client/classify.test.ts::"HTTP 500 classifies to SLACK.API_ERROR"` AND `src/client/classify.test.ts::"slack error string channel_not_found maps to SLACK.CHANNEL_NOT_FOUND"` AND `src/client/classify.test.ts::"slack error string invalid_auth maps to SLACK.AUTH_ERROR"` AND `src/client/classify.test.ts::"slack error string team_not_found maps to SLACK.WORKSPACE_NOT_FOUND"` AND `src/client/classify.test.ts::"unknown slack error string falls back to SLACK.API_ERROR"` AND `src/client/slack-api-client.test.ts::"HTTP 429 produces SlackRateLimitedError with parsed retryAfterMs"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Retry-After header parsing supports three forms
|
||||
|
||||
The package SHALL parse the `Retry-After` HTTP header in delay-seconds (`"30"`) and HTTP-date (`"Wed, 21 Oct 2026 07:28:00 GMT"`) forms, and fall back to `x-ratelimit-reset` in epoch-seconds, epoch-milliseconds, or ISO-8601 form. The parser SHALL return `undefined` when no header is present or the value is unparseable, and SHALL clamp negative deltas to `0`.
|
||||
|
||||
#### Scenario: delay-seconds form
|
||||
|
||||
- **WHEN** the response carries `Retry-After: "45"`
|
||||
- **THEN** `parseRetryAfterMs` returns `45000`
|
||||
|
||||
#### Scenario: HTTP-date form
|
||||
|
||||
- **WHEN** the response carries `Retry-After: <ISO date 60s in the future>` and the injected clock is at the present
|
||||
- **THEN** the parser returns a value `>= 0` (≈60000)
|
||||
|
||||
#### Scenario: x-ratelimit-reset epoch seconds fallback
|
||||
|
||||
- **WHEN** no `Retry-After` is present but `x-ratelimit-reset` is epoch seconds in the future
|
||||
- **THEN** the parser returns the difference in milliseconds
|
||||
|
||||
#### Scenario: x-ratelimit-reset ISO fallback
|
||||
|
||||
- **WHEN** no `Retry-After` is present but `x-ratelimit-reset` is an ISO timestamp in the future
|
||||
- **THEN** the parser returns the difference in milliseconds
|
||||
|
||||
#### Scenario: missing headers yields undefined
|
||||
|
||||
- **WHEN** neither header is present
|
||||
- **THEN** the parser returns `undefined`
|
||||
|
||||
#### Scenario: negative delta clamps to 0
|
||||
|
||||
- **WHEN** the header points at a past timestamp
|
||||
- **THEN** the parser returns `0`
|
||||
|
||||
**Verified by:** `src/client/retry-after.test.ts::"parses delay-seconds form"` AND `src/client/retry-after.test.ts::"parses HTTP-date form"` AND `src/client/retry-after.test.ts::"falls back to x-ratelimit-reset epoch seconds"` AND `src/client/retry-after.test.ts::"falls back to x-ratelimit-reset ISO string"` AND `src/client/retry-after.test.ts::"returns undefined when no headers are present"` AND `src/client/retry-after.test.ts::"clamps negative deltas to 0"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: SlackApiClient enforces AbortSignal-driven timeout
|
||||
|
||||
The package SHALL abort an in-flight Slack request after `timeoutMs` (default `10000`) and surface the abort as `SlackTimeoutError` (code `SLACK.TIMEOUT`). Caller-supplied `AbortSignal` SHALL also terminate the request and propagate.
|
||||
|
||||
#### Scenario: internal timeout surfaces SlackTimeoutError
|
||||
|
||||
- **WHEN** the fake fetch never resolves and `timeoutMs: 1` is configured
|
||||
- **THEN** the call rejects with `SlackTimeoutError`
|
||||
|
||||
#### Scenario: caller signal aborts the request
|
||||
|
||||
- **WHEN** the caller passes a pre-aborted `AbortSignal`
|
||||
- **THEN** the call rejects with `SlackTimeoutError` whose `context.url` matches
|
||||
|
||||
**Verified by:** `src/client/slack-api-client.test.ts::"timeoutMs aborts an in-flight request with SlackTimeoutError"` AND `src/client/slack-api-client.test.ts::"caller-supplied AbortSignal cancels the request with SlackTimeoutError"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: SlackWebhook posts to an incoming webhook URL
|
||||
|
||||
The package SHALL expose `SlackWebhook.send(message)` and `SlackWebhook.sendText(text)`. Both SHALL POST a JSON body with `text` and optional `blocks` to the configured `webhookUrl` with `Content-Type: application/json; charset=utf-8`. On 2xx, the returned `SlackDeliveryReport.status` SHALL be `"delivered"`. On non-2xx, the call SHALL reject with `SlackAuthError` (401/403) or `SlackWebhookFailedError` (otherwise). On network failure or timeout, the call SHALL reject with `SlackTimeoutError`.
|
||||
|
||||
#### Scenario: send returns delivered report on 2xx
|
||||
|
||||
- **WHEN** the fake fetch returns HTTP 200
|
||||
- **THEN** the report's status is `"delivered"`, channel `"webhook"`, and `messageId` is non-empty
|
||||
|
||||
#### Scenario: sendText forwards plain text
|
||||
|
||||
- **WHEN** `sendText("hi")` is invoked
|
||||
- **THEN** the request body parses to `{ text: "hi" }` with no `blocks`
|
||||
|
||||
#### Scenario: 401 yields SlackAuthError
|
||||
|
||||
- **WHEN** the fake fetch returns HTTP 401
|
||||
- **THEN** the call rejects with `SlackAuthError`
|
||||
|
||||
#### Scenario: 400 yields SlackWebhookFailedError
|
||||
|
||||
- **WHEN** the fake fetch returns HTTP 400 with body `"invalid_payload"`
|
||||
- **THEN** the call rejects with `SlackWebhookFailedError` carrying the body in `context`
|
||||
|
||||
#### Scenario: message IDs are monotonic
|
||||
|
||||
- **WHEN** two successive sends complete
|
||||
- **THEN** their `messageId`s differ
|
||||
|
||||
**Verified by:** `src/webhook/slack-webhook.test.ts::"send returns a delivered report on HTTP 200"` AND `src/webhook/slack-webhook.test.ts::"sendText posts only the text field"` AND `src/webhook/slack-webhook.test.ts::"HTTP 401 rejects with SlackAuthError"` AND `src/webhook/slack-webhook.test.ts::"HTTP 400 rejects with SlackWebhookFailedError"` AND `src/webhook/slack-webhook.test.ts::"sequential sends yield distinct messageIds"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Constructors validate configuration via Zod
|
||||
|
||||
The package SHALL reject construction of `SlackApiClient` with an empty `token` and `SlackWebhook` with an empty `webhookUrl`, throwing `SlackConfigMissingError` (code `SLACK.CONFIG_MISSING`) at construct time. All public option bundles SHALL be validated through a Zod schema; safeParse SHALL surface a `ZodError` cause on the thrown `SlackConfigMissingError`.
|
||||
|
||||
#### Scenario: empty token rejected
|
||||
|
||||
- **WHEN** `new SlackApiClient({ token: "" })` is invoked
|
||||
- **THEN** the constructor throws `SlackConfigMissingError`
|
||||
|
||||
#### Scenario: empty webhookUrl rejected
|
||||
|
||||
- **WHEN** `new SlackWebhook({ webhookUrl: "" })` is invoked
|
||||
- **THEN** the constructor throws `SlackConfigMissingError`
|
||||
|
||||
#### Scenario: schemas are exposed for boundary parse
|
||||
|
||||
- **WHEN** `SlackMessageSchema.safeParse({ channel: 1, text: "x" })` is invoked
|
||||
- **THEN** `success` is `false`
|
||||
|
||||
**Verified by:** `src/client/slack-api-client.test.ts::"empty token rejects with SlackConfigMissingError"` AND `src/webhook/slack-webhook.test.ts::"empty webhookUrl rejects with SlackConfigMissingError"` AND `src/schemas/schemas.test.ts::"SlackMessageSchema rejects non-string channel"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: OTel instruments emit on every Slack request
|
||||
|
||||
The package SHALL emit four OpenTelemetry instruments on every production path: a `slack.requests` counter, a `slack.errors` counter, a `slack.latency_ms` histogram, and a `slack.webhook_sends` counter. Counters SHALL carry attributes `{ method, outcome }` where `outcome ∈ { "ok", "error" }`.
|
||||
|
||||
#### Scenario: successful sendMessage emits slack.requests + slack.latency_ms
|
||||
|
||||
- **WHEN** `sendMessage` succeeds
|
||||
- **THEN** the in-memory `MeterProvider` records one `slack.requests` increment with `outcome: "ok"` and one `slack.latency_ms` observation
|
||||
|
||||
#### Scenario: failed sendMessage emits slack.requests + slack.errors
|
||||
|
||||
- **WHEN** `sendMessage` rejects with `SlackApiError`
|
||||
- **THEN** the in-memory `MeterProvider` records one `slack.requests` with `outcome: "error"` and one `slack.errors` increment
|
||||
|
||||
#### Scenario: webhook send emits slack.webhook_sends
|
||||
|
||||
- **WHEN** `SlackWebhook.send` succeeds
|
||||
- **THEN** the in-memory `MeterProvider` records one `slack.webhook_sends` increment with `outcome: "ok"`
|
||||
|
||||
**Verified by:** `src/otel/metrics.test.ts::"successful sendMessage emits slack.requests with outcome ok"` AND `src/otel/metrics.test.ts::"failed sendMessage emits slack.requests outcome error and slack.errors"` AND `src/otel/metrics.test.ts::"webhook send emits slack.webhook_sends"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Stable error code registry
|
||||
|
||||
The package SHALL export `SLACK_CODES` (built via `defineCodes("SLACK", [...])`) containing eight stable codes: `API_ERROR`, `AUTH_ERROR`, `RATE_LIMITED`, `CHANNEL_NOT_FOUND`, `WORKSPACE_NOT_FOUND`, `CONFIG_MISSING`, `WEBHOOK_FAILED`, `TIMEOUT`. Each code SHALL have a typed `SuaveplanError` subclass and SHALL be thrown by at least one production path. `src/index.ts` SHALL side-effect import `./error-codes.js` so subpath consumers register codes too.
|
||||
|
||||
#### Scenario: SLACK_CODES contains all eight reasons
|
||||
|
||||
- **WHEN** consumer imports `SLACK_CODES`
|
||||
- **THEN** the record has exactly eight keys mapping reason → `SLACK.<REASON>`
|
||||
|
||||
#### Scenario: each error subclass carries its code
|
||||
|
||||
- **WHEN** an instance of `SlackApiError`, `SlackAuthError`, `SlackRateLimitedError`, `SlackChannelNotFoundError`, `SlackWorkspaceNotFoundError`, `SlackConfigMissingError`, `SlackWebhookFailedError`, or `SlackTimeoutError` is constructed
|
||||
- **THEN** its `.code` equals the corresponding `SLACK.<REASON>` literal
|
||||
|
||||
**Verified by:** `src/errors/errors.test.ts::"SLACK_CODES exposes exactly the eight stable reasons"` AND `src/errors/errors.test.ts::"every error subclass carries its SLACK.<REASON> code"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Deterministic FakeFetch test double
|
||||
|
||||
The package SHALL ship a `FakeFetch` under `src/fakes/fake-fetch.ts` which the test suite uses to drive every transport-level scenario without monkey-patching `globalThis.fetch` and without `vi.mock`. `FakeFetch` SHALL queue scripted responses, record every request, and surface a `FetchLike` instance for direct injection into client constructors.
|
||||
|
||||
#### Scenario: queued response is consumed in FIFO order
|
||||
|
||||
- **WHEN** two responses are queued and two requests are made
|
||||
- **THEN** the first request receives the first queued response
|
||||
|
||||
#### Scenario: pending request abort propagates to AbortError
|
||||
|
||||
- **WHEN** a request is queued as `pending` and the caller aborts the signal
|
||||
- **THEN** the returned promise rejects with `AbortError`
|
||||
|
||||
**Verified by:** `src/fakes/fake-fetch.test.ts::"queued responses are consumed in FIFO order"` AND `src/fakes/fake-fetch.test.ts::"pending request rejects with AbortError when signal aborts"`
|
||||
|
||||
<!-- markdownlint-enable MD024 -->
|
||||
Reference in New Issue
Block a user