# テスト制御 API

> 自動テストの中で、HTTP を使って状態のリセット、Fixture の読み込み、リクエスト履歴の確認、Webhook 配信のドレインを行います。

Calendarpit には、`/__calendarpit` の下にローカルの管理 API があります。この API で、データの投入、サーバー状態の確認、テストの実行ごとのリセットができます。

## リクエストのルール

- **ベースパス**: 管理エンドポイントのパスは、すべて `/__calendarpit` で始まります。例: `http://127.0.0.1:4173/__calendarpit/reset`
- **認証なし**: 管理エンドポイントにベアラートークンは不要です。Calendarpit はローカル開発用のサーバーで、デフォルトではループバックアドレスにバインドします。
- **書き込みには `Content-Type: application/json` が必要**: `GET` と `HEAD` 以外のリクエストでは、`application/json` で始まる `Content-Type` ヘッダーを送ってください（大文字と小文字は区別されます）。
  - `DELETE` のルートや、ボディを受け取らない `POST` エンドポイント（`/reset`、`/deliveries/drain` など）も対象です。
  - このヘッダーがない場合、リクエストは HTTP `415`（`unsupportedMediaType`）で失敗します。
  - ボディを受け取らない `POST` エンドポイントには、空のボディか `{}` を送れます。
- **同一オリジンのチェック**: 次のどちらかに当てはまるリクエストは、HTTP `403`（`forbidden`）で失敗します。
  - `Origin` ヘッダーがサーバーのオリジンと一致しない。
  - `Sec-Fetch-Site: cross-site` が付いている。

  `curl` やテストランナーのようなブラウザ以外のクライアントは、通常 `Origin` を送らないため許可されます。

- **ヘルスチェックは対象外**: `/__calendarpit/health` はこれらのチェックを受けません。`GET` や `HEAD` による起動確認に追加のヘッダーは要りません。
- **履歴に残らない**: `/__calendarpit/*` へのリクエストは、プロトコルのリクエスト履歴に記録されません。
- **エラー形式**: 管理 API のエラーは、Google の JSON エラー形式に `domain: "calendarpit"` を入れて返されます。

> [!WARNING]
>
> コンテナ内などでループバック以外のアドレスにバインドした場合は、そのポートに到達できるすべてのクライアントがこれらのエンドポイントを呼び出せます。
>

## 管理エンドポイント

次の表のパスは、すべて `/__calendarpit` からの相対パスです。

| メソッド                           | パス                               | 動作                                                                       |
| ---------------------------------- | ---------------------------------- | -------------------------------------------------------------------------- |
| `GET` / `HEAD`                     | `/health`                          | サーバーの情報と機能フラグを返します。                                     |
| `GET`                              | `/state`                           | サーバー状態のスナップショットを返します。                                 |
| `POST`                             | `/fixtures`                        | Fixture のデータを検証して置き換えます（`200` でスナップショット）。       |
| `POST`                             | `/reset`                           | 起動時 Fixture のリセット基準を復元します（`200` でスナップショット）。    |
| `GET` / `DELETE`                   | `/history`                         | 直近 200 件のプロトコルリクエストを取得（`200`）・クリア（`204`）します。  |
| `POST`                             | `/users`                           | ユーザーを作成します（`201`）。                                            |
| `PATCH` / `DELETE`                 | `/users/{id}`                      | ユーザーを編集・削除（`204`）します。                                      |
| `POST`                             | `/calendars`                       | カレンダーを作成します（`201`）。                                          |
| `PATCH` / `DELETE`                 | `/calendars/{id}`                  | カレンダーを編集・削除（`204`）します。                                    |
| `GET` / `POST`                     | `/calendars/{id}/events`           | イベントを一覧取得・作成（`200`）します。                                  |
| `GET` / `PUT` / `PATCH` / `DELETE` | `/calendars/{id}/events/{eventId}` | イベントを取得・置き換え・部分更新・削除（`204`）します。                  |
| `POST`                             | `/clients`                         | OAuth クライアントを登録します（`201`）。                                  |
| `PATCH` / `DELETE`                 | `/clients/{id}`                    | OAuth クライアントを編集・削除（`204`）します。                            |
| `POST`                             | `/connections/{id}/revoke`         | 接続のトークンを取り消し、その接続の watch チャネルを停止します（`204`）。 |
| `POST`                             | `/deliveries/drain`                | 待機中の Webhook 配信が終わるまで待ち、配信ログを返します（`200`）。       |

### エンドポイントの詳細

- **`/health`**: サーバーの `name`（`"Calendarpit"`）、`version`、`stage`、`storage`（`"memory"` または `"sqlite"`）を返します。機能フラグ `calendar`、`oauth`、`watch`、`fixtures` も返します。`HEAD` は空の `200` を返します。
- **`/state`**: まず期限切れのチャネルを削除します。そのうえで、`storage`、`users`、`calendars`、`events`、`clients`（シークレットなし）、`connections`、`channels`（`token` は伏せ字）、`deliveries`、`notifications`、`revision` のスナップショットを返します。
- **`/fixtures`**: ユーザー、カレンダー、イベント、クライアントを検証して置き換えます。ランタイム状態をクリアし、接続を無効にします。
- **`/reset`**: 起動時 Fixture のリセット基準を復元し、ランタイム状態をすべてクリアします。
- **`/history`**: `GET` はリクエストを古い順に返します。
- **`/users`**: `name`、`email`、省略可能な `id` を受け取ります。ID がすでにある場合は `409 duplicate` を返します。
- **`/users/{id}`**: `PATCH` で `name` と `email` を編集します。`id` は変更できません。ユーザーを削除すると、そのユーザーが所有するデータも削除されます。
- **`/calendars`**: `userId`、`summary`、省略可能な `timeZone`、`primary`、`id` を受け取ります。
- **`/calendars/{id}`**: `id` と `userId` は変更できません。
- **`/clients`**: Fixture のクライアントと同じ規則で `id`、`secret`、`redirectUris` を受け取ります。ID がすでにある場合は `409 duplicate` を返します。レスポンスにシークレットは含まれません。
- **`/clients/{id}`**: `PATCH` で `secret` と `redirectUris` を編集します。`secret` を省略すると現在の値を維持します。`id` は変更できません。ユーザー、カレンダー、イベント、接続は残り、発行済みのトークンも有効なままです。削除したリダイレクト URI 宛ての保留中の同意リクエストと未交換の認可コードは破棄されます。クライアントを削除すると、その接続は取り消されます。
- **`/calendars/{id}/events`**: 一覧取得では `syncToken` を含む `events.list` のパラメータを使えます。一覧の各項目には `calendarId` が入ります。
- **`/calendars/{id}/events/{eventId}`**: `DELETE` しても削除済みレコードは残ります。
- **`/deliveries/drain`**: 待機中の配信のリトライが終わるまで待ちます。レスポンスは配信ログの配列です。

### 連鎖的な削除と管理 API からの書き込み

- **ユーザーの削除**: ユーザーを削除すると、そのユーザーの OAuth 接続が取り消され、watch チャネルも停止します。ユーザーのカレンダーとイベントも削除されます。このとき、カレンダーの削除済みレコードは作られません。
- **プライマリフラグ**: ユーザーの最初のカレンダーは、自動的に `primary: true` になります。
  - 同じユーザーの別のカレンダーに `primary: true` を設定すると、フラグは 1 回の操作でそのカレンダーに移ります。元のプライマリを先に外す必要はありません。
  - `primary` を直接外そうとすると `400` が返ります。
- **カレンダーの削除**: カレンダーを削除すると、次の処理が行われます。
  - そのカレンダーのイベントを削除し、イベントの watch チャネルを停止します。
  - デフォルトのリスト設定で `calendarList` の削除済みレコードを記録します。`summaryOverride` や `colorId` などのユーザーごとのカスタマイズは消えます。
  - 削除したカレンダーがプライマリだった場合は、所有者の別のカレンダーをプライマリにします。このとき、所有者のカレンダーリストに残っているカレンダーが優先されます。
- **カレンダーリストの通知**: 管理 API でカレンダーを作成（`POST /calendars`）・編集（`PATCH /calendars/{id}`）・削除するたびに、所有者の `calendarList.watch` チャネル向けの `exists` 通知がキューに入ります。
  - 編集では、内容が何も変わらなくても通知が送られます。
  - テストで配信数を数える場合は、この分の配信も考慮してください。
- **管理 API からのイベント書き込み**: `/__calendarpit/calendars/{id}/events` でのイベントの書き込みは、Calendar API からの書き込みと次の点が違います。
  - 招待者に関する制限がかかりません。
  - `conferenceDataVersion=1` は不要です。
  - 読み取り専用のフィールド（`organizer`、`creator`、`iCalUID`、`recurringEventId`、`originalStartTime`）も設定できます。
  - 更新時には、保存済みの `organizer`、`creator`、`iCalUID` が保持されます。イベントにすでに `recurringEventId` と `originalStartTime` がある場合は、それらも保持されます。

## リセットと Fixture 読み込みの違い

`POST /__calendarpit/reset` と `POST /__calendarpit/fixtures` は、どちらもデータを置き換えてすぐに保存し、ランタイム状態をすべてクリアします。違いは読み込むデータです。

| 項目                                         | `POST /__calendarpit/reset` | `POST /__calendarpit/fixtures`                                   |
| -------------------------------------------- | --------------------------- | ---------------------------------------------------------------- |
| ユーザー、カレンダー、イベント、クライアント | 起動時の状態から復元        | 送信した[Fixture JSON](https://calendarpit.dev/ja/fixtures.md)で置き換え                  |
| 起動時のリセット基準                         | 変わらない                  | 変わらない（次の `/reset` では元の起動時のデータが復元されます） |
| ランタイム状態                               | クリア                      | クリア                                                           |

`POST /__calendarpit/fixtures` の検証は、すべて成功するかすべて失敗するかのどちらかです。無効なレコードが 1 件でもあれば、Calendarpit は `400` を返し、何も変更しません。

### 両方のエンドポイントでクリアされるもの

どちらのエンドポイントも、有効な[watch チャネル](https://calendarpit.dev/ja/watch.md)をすべて停止し、送信中の Webhook リクエストを中断します。さらに、次のものをクリアします。

- OAuth 接続、アクセストークンとリフレッシュトークン、認可コード、処理中の同意
- リクエスト履歴
- [同期トークン](https://calendarpit.dev/ja/sync.md)と、イベントおよびカレンダーリストのページトークン
- カレンダーの削除済みレコード
- ユーザーごとのカレンダーリスト設定（`calendarList.insert`/`patch`/`update` によるカスタマイズと、`calendarList.delete` による削除）
- 招待の通知と、すべての配信ログレコード

どちらかを呼び出した後は、次のようになります。

- 古いアクセストークンは `401`（`authError`）で失敗します。
- 古い同期トークンは `410`（`fullSyncRequired`）で失敗します。

リセットしても、SQLite のデータベースファイルは削除されません。ディスク上の Fixture ファイルも変更されません。

## リクエスト履歴と状態のエクスポート

### リクエスト履歴

`GET /__calendarpit/history` は、`/calendar/v3/*`、`/oauth*`、`/userinfo` で処理した直近 200 件のリクエストを返します。並びは古い順です。`401` や `501` などの失敗したリクエストも含まれます。

Calendarpit は、履歴の中の認証情報を次のようにマスクします。

- 次のキーの値は `"[REDACTED]"` に置き換えられます: `authorization`、`cookie`、`set-cookie`、`client_secret`、`secret`、`access_token`、`refresh_token`、`id_token`、`code`、`code_verifier`、`code_challenge`、`consent`、`token`、`x-goog-channel-token`、`state`。ヘッダー、ボディ、クエリ文字列、`Location` や `Referer` の URL が対象です。
- URL に含まれる認証情報は削除されます。
- 12,000 文字を超える文字列は途中で切られ、末尾に ` [truncated]` が付きます。

### 状態のエクスポート

`GET /__calendarpit/state` は確認用です。その出力を `POST /__calendarpit/fixtures` で読み込み直すことはできません。理由は次のとおりです。

- クライアントのシークレットが含まれません。`clients` には `id` と `redirectUris` だけが入ります。
- チャネルのトークンは `"[REDACTED]"` と表示されます。
- ランタイムのフィールド（`storage`、`connections`、`channels`、`deliveries`、`notifications`、`revision`）が含まれます。`POST /__calendarpit/fixtures` はこれらを `400`（`unsupportedFeature`）で拒否します。
- カレンダーリストの設定は含まれません。設定は `calendarList.list`/`get` で取得してください。

データの投入や再読み込みには、元の Fixture ファイルを残しておき、それを使ってください。

## 再現可能なテストの書き方

- **シナリオごとにリセットする**: 各テストの前に `POST /__calendarpit/reset` を呼び出して、起動時の状態に戻します。特定の Fixture を読み込みたい場合は、代わりに `POST /__calendarpit/fixtures` を呼び出します。
- **固定の ID を使う**: Fixture ファイルで、ユーザー、カレンダー、イベント、クライアントに `id` を明示してください。ID がない場合、Calendarpit はデータ投入時に一度だけ ID を生成します。`:memory:` モードでは、サーバープロセスを起動するたびに新しい ID が生成されます。
- **並列ワーカーを分離する**: テストワーカーごとに、`--port 0` と `--database :memory:` を付けて Calendarpit のプロセスを 1 つずつ起動します。ワーカーごとに別の SQLite ファイルを使う方法もあります。
- **ポートは標準出力から読み取る**: `--port 0` を使う場合は、起動時の行 `Admin  http://127.0.0.1:<port>/` から URL を読み取ります。たとえば `/Admin\s+(http:\/\/[^\s]+)/` でマッチできます。その後、`GET /__calendarpit/health` が応答するまでポーリングしてから、テストを始めてください。
- **スリープではなく Webhook 配信をドレインする**: `POST /__calendarpit/deliveries/drain` は、呼び出した時点で待機中の配信が、リトライも含めて終わるまで待ちます。
- **リセット時の中断に備える**: `/reset` は送信中の Webhook リクエストを中断します。ただし、中断される前に受信側がそのリクエストを受け取っている場合があります。
- **再起動後は状態を取得し直す**: [SQLite での再起動](https://calendarpit.dev/ja/storage.md)をテストする場合、停止前にキューに入っていた配信は `cancelled` になり、再送されません。再起動後に、カレンダーの状態をもう一度取得してください。

```ts
const baseUrl = 'http://127.0.0.1:4173';
const jsonHeaders = { 'Content-Type': 'application/json' };

export async function resetCalendarpit() {
  const res = await fetch(`${baseUrl}/__calendarpit/reset`, {
    method: 'POST',
    headers: jsonHeaders,
    body: '{}',
  });
  if (!res.ok) throw new Error(`Reset failed: ${res.status}`);
  return res.json();
}

export async function drainDeliveries() {
  const res = await fetch(`${baseUrl}/__calendarpit/deliveries/drain`, {
    method: 'POST',
    headers: jsonHeaders,
    body: '{}',
  });
  if (!res.ok) throw new Error(`Drain failed: ${res.status}`);
  return res.json();
}
```
