diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0fc1e10 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +# syntax=docker/dockerfile:1 + +FROM golang:1.25 AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/calsync ./cmd/calsync + +# static-debian12 には CA 証明書と tzdata が含まれる(TZ 環境変数がそのまま効く) +FROM gcr.io/distroless/static-debian12 +COPY --from=build /out/calsync /calsync +VOLUME /data +ENTRYPOINT ["/calsync", "run"] +CMD ["--config", "/data/calsync.yaml", "--data", "/data"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..3da3ea8 --- /dev/null +++ b/README.md @@ -0,0 +1,201 @@ +# calsync + +複数の Google カレンダー / Microsoft 365(および個人 Microsoft アカウント)カレンダーを相互監視し、どれかのアカウントに Busy な予定が入ったら他の全アカウントに「予定あり」ブロッカー予定を自動作成する、セルフホスト型の OSS ツールです。Go 製シングルバイナリ、Docker で常駐します。 + +```mermaid +flowchart LR + A["Google アカウント A"] <--> E["calsync (Docker)"] + B["Microsoft アカウント B"] <--> E + C["Google アカウント C"] <--> E + E --> S[("SQLite (/data)")] +``` + +- 予定の中身は同期しません。タイトル固定(既定「予定あり」)・詳細なしのブロッカーだけを作ります +- Google ↔ Microsoft 混在でも相互ブロックが成立します +- ブロッカーへの再同期による無限ループは構造的に防止しています +- 同一人物の複数アカウントが同じ会議に招待されているケースは、既定で重複ブロッカーを抑止します(`dedupe_same_meeting`) + +## v1 の制約 + +- Microsoft アカウントは**プライマリカレンダーのみ**監視・書き込み可能です(Google は複数カレンダー可) +- 過去方向の同期はしません。同期対象は現在〜未来 3 ヶ月(`sync_window` で変更可)です +- 同時刻に複数の元予定があってもブロッカーはマージしません(元予定 1 : ブロッカー 1) +- 単一インスタンス前提です。同じデータディレクトリで 2 プロセス目を起動すると flock により起動エラーになります +- `calsync sync` / `calsync reconcile` / `calsync accounts remove` は同じデータディレクトリの排他ロックを取得するため、デーモン稼働中は実行できません(停止してから実行してください)。一方 `calsync status` / `calsync doctor` は読み取り専用で排他ロックを取得しないため、**デーモン稼働中でも実行できます** +- アカウント間でタイムゾーンが異なる場合、終日予定は「同じ日付」の終日ブロッカーとしてミラーされます(同じ絶対時間帯ではありません) + +## 必要なもの + +- 自分の Google Cloud(GCP)OAuth クライアント、および/または Microsoft Entra ID のアプリ登録(下記手順。calsync は共有クライアントを配布しません) +- Docker / Docker Compose(または Go 1.25 でのローカルビルド) + +## セットアップ 1: Google(GCP) + +1. GCP プロジェクトを作成し、**Google Calendar API** を有効化します +2. OAuth 同意画面を設定します: + - Google Workspace 組織で使うなら **Internal** が第一推奨です(未検証アプリ警告自体が出ません) + - それ以外は **External** を選び、**公開ステータスを必ず「In production」にしてください**。 + **Testing のままだと refresh token が 7 日で失効し、常時同期ツールとして成立しません**(Google の公式仕様です) + - 個人利用(累計 100 ユーザー未満)なら Google の検証審査は不要です。認可時に「Google hasn't verified this app」警告が出ますが、「Advanced」→「Go to ...(unsafe)」でクリックスルーできます +3. 認証情報 → OAuth クライアント ID を **「Desktop app」タイプ**で作成し、**作成直後に JSON をダウンロードして保存してください**(2025 年以降、client secret は作成時にしか表示されません)。JSON をデータディレクトリに置き、`providers.google.credentials_file` にパスを指定します +4. Desktop app の client_secret について: Google 公式ドキュメントは「In this context, the client secret is obviously not treated as a secret.(この文脈では client secret は秘密として扱われない)」と明記しています。JSON がデータディレクトリにあること自体は設計上の問題ではありませんが、他人のアクセスできる場所には置かないでください +5. **6 ヶ月間トークン交換のない OAuth クライアントは Google が自動削除します**。長期停止後に `invalid_client` エラーが出た場合は、クライアントを再作成して JSON を差し替えてください + +## セットアップ 2: Microsoft(Entra ID) + +1. アプリ登録は**無料テナントで可能で、課金は不要**です。個人 Microsoft アカウント(outlook.com 等)しか持っていない場合は、先に無料の Azure アカウントを作成すると Entra テナントが手に入ります +2. [Entra 管理センター](https://entra.microsoft.com) → App registrations → New registration: + - Supported account types: **「Accounts in any organizational directory and personal Microsoft accounts」**を選択(signInAudience=AzureADandPersonalMicrosoftAccount)。calsync は `/common` エンドポイントで認証します +3. Authentication → Add a platform → **「Mobile and desktop applications」**を選び、Redirect URI に **`http://localhost`** を追加します(localhost はポート番号がマッチング時に無視されるため、ポート指定は不要です) +4. 同じ Authentication ページの Advanced settings で **「Allow public client flows」を Yes** にします。**これを忘れると認可時に `AADSTS7000218` エラーになります** +5. API permissions → Add a permission → Microsoft Graph → Delegated permissions で **`Calendars.ReadWrite`** と **`MailboxSettings.Read`**(終日ブロッカー作成に使うメールボックスのタイムゾーン取得に必要)を追加します(`offline_access` は calsync が要求スコープに含めます)。既定では管理者同意は不要ですが、組織でユーザー同意が無効化されている場合は `AADSTS65001` または `AADSTS90094` が出ます。その場合は管理者に API permissions ページの **「Grant admin consent for <テナント名>」** を押してもらってください +6. Overview の **Application (client) ID** を `providers.microsoft.client_id` に設定します + +## 設定ファイル + +`./data/calsync.yaml` を作成します: + +```yaml +poll_interval: 1m # 差分ポーリング間隔 +sync_window: 3mo # 同期ウィンドウ(未来方向)。"90d" のような日数指定も可 +blocker_title: "予定あり" # ブロッカーの固定タイトル +reconcile_at: "04:00" # 日次リコンサイル時刻(コンテナの TZ で解釈) +dedupe_same_meeting: true # 同一会議の重複ブロッカー抑止 +busy_show_as: [busy, oof, tentative] # Microsoft で Busy 扱いにする showAs 値 + +providers: + google: + credentials_file: /data/google-client.json # GCP でダウンロードした JSON + microsoft: + client_id: 00000000-0000-0000-0000-000000000000 # Entra の Application (client) ID + +accounts: + - id: personal + provider: google + email: user@gmail.com + calendars: [primary] # 監視対象(Google は複数指定可) + blocker_calendar: primary # ブロッカー書き込み先(既定 primary) + - id: work-ms + provider: microsoft + email: user@example365.co.jp + # Microsoft は v1 ではプライマリカレンダーのみ +``` + +## 認証(auth add) + +コンテナにはブラウザがないため、**ホストマシンで実行するのが基本**です。トークンはデータディレクトリ(`./data/tokens/`)に保存され、そのままコンテナから使えます。 + +### 推奨: ホストで実行 + +```bash +go build -o calsync ./cmd/calsync +./calsync auth add personal --config ./data/calsync.yaml --data ./data +./calsync auth add work-ms --config ./data/calsync.yaml --data ./data +./calsync auth list --config ./data/calsync.yaml --data ./data +``` + +ブラウザが開き(開かない場合は表示された URL を手動で開く)、認可後にループバックリダイレクトでトークンが保存されます。 + +### 代替: Docker 内で実行(ポート公開) + +イメージの ENTRYPOINT は `calsync run` なので、`--entrypoint` の上書きが必要です: + +```bash +docker compose run --rm --entrypoint /calsync \ + -p 127.0.0.1:8484:8484 calsync \ + auth add personal --config /data/calsync.yaml --data /data --port 8484 +``` + +表示された認可 URL をホストのブラウザで開きます。`--port 8484` はランダムポートを固定し、公開したポート経由でリダイレクトがコンテナに届くようにするためのものです。 + +### Microsoft のみの代替: Device Code Flow + +```bash +docker compose run --rm --entrypoint /calsync calsync \ + auth add work-ms --config /data/calsync.yaml --data /data --device-code +``` + +コードと URL が表示されるだけで完結します(ポート公開不要)。ただし組織テナントでは条件付きアクセスにより Device Code Flow がブロックされている場合があります。 + +## 起動 + +```bash +docker compose up -d --build +docker compose logs -f calsync +``` + +`calsync status` / `calsync doctor` は読み取り専用でデータディレクトリの排他ロックを取得しないため、**デーモン稼働中でもそのまま実行できます**: + +```bash +docker compose run --rm --entrypoint /calsync calsync status --data /data +docker compose run --rm --entrypoint /calsync calsync doctor --config /data/calsync.yaml --data /data +``` + +一方 `calsync sync` / `calsync reconcile` / `calsync accounts remove` はデータディレクトリの排他ロックを取得するため、実行前にデーモンを停止してください: + +```bash +docker compose stop calsync +docker compose run --rm --entrypoint /calsync calsync reconcile --config /data/calsync.yaml --data /data +docker compose start calsync +``` + +## CLI リファレンス + +| コマンド | 説明 | +| --- | --- | +| `calsync run` | デーモン起動(Docker の既定エントリポイント) | +| `calsync sync --once` | 1 回だけ同期して終了 | +| `calsync reconcile` | フルリコンサイル手動実行(ウィンドウスライド・孤児収容・DB 再構築を含む) | +| `calsync status` | 各カレンダーの最終同期時刻・エラー状態(reauth_required 等) | +| `calsync doctor` | 設定・トークン・API 疎通・YAML と DB の不整合診断 | +| `calsync auth add [--port N] [--device-code]` | OAuth フロー(ホスト実行推奨。--device-code は Microsoft のみ) | +| `calsync auth list` | トークン状態一覧 | +| `calsync accounts remove [--force]` | 配布済みブロッカー削除 → 受領ブロッカー削除 → ローカル状態削除 | + +共通フラグ: `--config`(既定 `calsync.yaml`)、`--data`(既定 `./data`) + +## アカウントの削除 + +**必ず `calsync accounts remove ` を実行してから、calsync.yaml からそのアカウントのエントリを削除してください。順序を逆にしないでください。** + +先に calsync.yaml からアカウントを消してしまうと、`accounts remove` はそのアカウント自身の provider を構築できません(provider は calsync.yaml の `accounts` 一覧から組み立てられるため)。その結果、そのアカウントが受け取っていた受領ブロッカーの削除には `--force` が必須になり、`--force` を使った場合、対象アカウントのカレンダーに受領ブロッカーが残ったままになります(手動削除が必要です)。 + +正しい順序: + +```bash +docker compose stop calsync +docker compose run --rm --entrypoint /calsync calsync \ + accounts remove old-account --config /data/calsync.yaml --data /data +``` + +`accounts remove` の完了(配布済みブロッカー削除 → 受領ブロッカー削除 → ローカル状態削除)を確認してから、calsync.yaml から `old-account` のエントリを削除し、`docker compose start calsync` してください。 + +YAML から消しただけの状態は `calsync doctor` と起動時ログが孤児として警告します。認証切れ等でリモートのブロッカーを消せない場合は `--force` でローカル状態だけ削除できます(ブロッカーは各カレンダーに残るため手動削除が必要です)。 + +## トークン失効時(reauth_required) + +パスワード変更・管理者リセット・長期未使用などで refresh token は失効することがあります。失効したアカウントだけ同期が止まり(`status` に reauth_required と表示)、他のアカウントは同期を継続します。ホストで `calsync auth add ` を再実行すれば、次のサイクルとリコンサイルで停止期間中の差分も自動回収されます。 + +## プライバシーについて + +calsync が作成するブロッカー予定には、ループ防止・自己修復のための拡張プロパティ(`calsync-origin` タグ)として**元アカウントの ID と元イベントの ID** が保存されます。カレンダーの UI には表示されませんが、そのカレンダーに API アクセスできる人(組織の管理者を含む)は読み取れます。アカウント ID(YAML の `id`)に見られたくない文字列を使わないでください。タイトル・本文・参加者など予定の中身は一切コピーされません。 + +## データとバックアップ + +- `./data` に SQLite(状態 DB)・OAuth トークン(0600)・設定が入ります。このディレクトリだけバックアップすれば移行できます +- DB を失っても、`calsync reconcile` がカレンダー上の calsync タグからマッピングを再構築します + +## トラブルシューティング + +| 症状 | 原因と対処 | +| --- | --- | +| Google: 7 日ごとに再認証を求められる | OAuth 同意画面が Testing のまま。**In production に変更**(上記セットアップ 1-2) | +| Google: `invalid_client` | 6 ヶ月間未使用でクライアントが自動削除された。クライアント再作成 + JSON 差し替え | +| Microsoft: `AADSTS7000218` | 「Allow public client flows」が No のまま。Yes に変更(セットアップ 2-4) | +| Microsoft: `AADSTS65001` / `AADSTS90094` | 組織でユーザー同意が無効。管理者に「Grant admin consent」を依頼(セットアップ 2-5) | +| `data directory is locked by another calsync process` | 二重起動、または `sync` / `reconcile` / `accounts remove` をデーモン実行中に実行した。デーモンを止めてから実行(`status` / `doctor` は稼働中でも実行可) | +| ブロッカーを手で消したのに復活する | 仕様です。元予定が生きている限りリコンサイルが再作成します | + +## ライセンス + +MIT License([LICENSE](LICENSE) を参照) diff --git a/cmd/calsync/cli_test.go b/cmd/calsync/cli_test.go new file mode 100644 index 0000000..d9c9463 --- /dev/null +++ b/cmd/calsync/cli_test.go @@ -0,0 +1,118 @@ +package main + +import ( + "bytes" + "context" + "errors" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" + + "github.com/cloudnative-co/calsync/internal/auth" + "github.com/cloudnative-co/calsync/internal/config" + "github.com/cloudnative-co/calsync/internal/model" + "github.com/cloudnative-co/calsync/internal/store" +) + +func TestFindOrphanAccounts(t *testing.T) { + tests := []struct { + name string + cfgIDs []string + dbIDs []string + want []string + }{ + {"no orphans", []string{"a", "b"}, []string{"a", "b"}, nil}, + {"one orphan", []string{"a"}, []string{"a", "ghost"}, []string{"ghost"}}, + {"duplicates collapsed and sorted", []string{"a"}, []string{"z", "ghost", "z"}, []string{"ghost", "z"}}, + {"empty db", []string{"a"}, nil, nil}, + {"empty config means all orphans", nil, []string{"a"}, []string{"a"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, findOrphanAccounts(tt.cfgIDs, tt.dbIDs)) + }) + } +} + +func TestRenderStatus(t *testing.T) { + // 一時 store に行を入れ、ListCalendars の実データで表出力を検証する + st, err := store.Open(t.TempDir()) + require.NoError(t, err) + defer st.Close() + + refA := model.CalendarRef{AccountID: "personal", CalendarID: "primary"} + refB := model.CalendarRef{AccountID: "work-ms", CalendarID: "primary"} + require.NoError(t, st.UpsertCalendar(refA)) + require.NoError(t, st.UpsertCalendar(refB)) + require.NoError(t, st.SetCalendarError(refB, "reauth_required")) + + states, err := st.ListCalendars() + require.NoError(t, err) + got := renderStatus(states) + + require.Contains(t, got, "ACCOUNT") + require.Contains(t, got, "CALENDAR") + require.Contains(t, got, "LAST SYNC") + require.Contains(t, got, "STATUS") + // 未同期・エラーなしの行: LAST SYNC は "-"、STATUS は "ok" + require.Regexp(t, `personal\s+primary\s+-\s+ok`, got) + // エラーありの行: STATUS にエラー文字列が出る + require.Regexp(t, `work-ms\s+primary\s+\S+\s+reauth_required`, got) +} + +func TestRunDoctor(t *testing.T) { + dir := t.TempDir() + tokens := &auth.TokenStore{Dir: dir} + require.NoError(t, tokens.Save("a", &oauth2.Token{AccessToken: "at", RefreshToken: "rt"})) + require.NoError(t, tokens.Save("b", &oauth2.Token{AccessToken: "at", RefreshToken: "rt"})) + // アカウント c はトークン未作成のまま + + // DB に YAML に存在しない孤児アカウント ghost の行を作る + st, err := store.Open(dir) + require.NoError(t, err) + require.NoError(t, st.UpsertCalendar(model.CalendarRef{AccountID: "ghost", CalendarID: "primary"})) + require.NoError(t, st.Close()) // 書き込み用ハンドルは閉じる(runDoctor は OpenReadOnly で開く) + + cfg := &config.Config{Accounts: []config.Account{ + {ID: "a", Provider: "google", Calendars: []string{"primary"}}, + {ID: "b", Provider: "microsoft", Calendars: []string{"primary"}}, + {ID: "c", Provider: "google", Calendars: []string{"primary"}}, + }} + // GetCalendarTimezone 疎通の差し替え: a は成功、b は失敗 + probe := func(ctx context.Context, acct config.Account) error { + if acct.ID == "b" { + return errors.New("graph unreachable") + } + return nil + } + + var out bytes.Buffer + err = runDoctor(context.Background(), cfg, dir, probe, &out, "calsync.yaml") + require.Error(t, err) // b の疎通失敗 + c のトークン欠如 + ghost 孤児 + + s := out.String() + require.Contains(t, s, "account a: token ok") + require.Contains(t, s, "account a: API check ok") + require.Contains(t, s, "account b: token ok") + require.Contains(t, s, "account b: API check FAILED") + require.Contains(t, s, "graph unreachable") + require.Contains(t, s, "account c: token MISSING") + require.Contains(t, s, "calsync auth add c") + require.Contains(t, s, "WARNING: account ghost") + require.Contains(t, s, "calsync accounts remove ghost") +} + +func TestRunDoctorAllOK(t *testing.T) { + dir := t.TempDir() + tokens := &auth.TokenStore{Dir: dir} + require.NoError(t, tokens.Save("a", &oauth2.Token{AccessToken: "at", RefreshToken: "rt"})) + cfg := &config.Config{Accounts: []config.Account{ + {ID: "a", Provider: "google", Calendars: []string{"primary"}}, + }} + probe := func(ctx context.Context, acct config.Account) error { return nil } + + var out bytes.Buffer + require.NoError(t, runDoctor(context.Background(), cfg, dir, probe, &out, "calsync.yaml")) + require.Contains(t, out.String(), "all checks passed") +} diff --git a/cmd/calsync/cmd_accounts.go b/cmd/calsync/cmd_accounts.go new file mode 100644 index 0000000..b6dc379 --- /dev/null +++ b/cmd/calsync/cmd_accounts.go @@ -0,0 +1,73 @@ +package main + +import ( + "fmt" + "time" + + "github.com/spf13/cobra" + + "github.com/cloudnative-co/calsync/internal/auth" + "github.com/cloudnative-co/calsync/internal/config" + "github.com/cloudnative-co/calsync/internal/engine" + "github.com/cloudnative-co/calsync/internal/provider" + "github.com/cloudnative-co/calsync/internal/store" +) + +var accountsForce bool + +var accountsCmd = &cobra.Command{ + Use: "accounts", + Short: "Manage synced accounts", +} + +var accountsRemoveCmd = &cobra.Command{ + Use: "remove ", + Short: "Delete distributed blockers, received blockers and local state for an account", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + accountID := args[0] + cfg, err := config.Load(flagConfig) + if err != nil { + return err + } + st, err := store.Open(flagData) + if err != nil { + return err + } + defer st.Close() + + // remove は「認証が切れた/YAML から消したアカウントを片付ける」用途があるため、 + // buildEngine(全アカウント必須)ではなく、構築できないプロバイダは警告して + // スキップする。欠けた provider は RemoveAccount が force に応じて + // エラー/スキップ処理する。 + tokens := &auth.TokenStore{Dir: flagData} + providers := make(map[string]provider.Provider, len(cfg.Accounts)) + for _, acct := range cfg.Accounts { + p, perr := buildProvider(cfg, tokens, acct) + if perr != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "warning: provider for account %s unavailable: %v\n", acct.ID, perr) + continue + } + providers[acct.ID] = p + } + eng := &engine.Engine{Store: st, Providers: providers, Cfg: cfg, Now: time.Now} + + if err := engine.RemoveAccount(cmd.Context(), eng, tokens, accountID, accountsForce); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "account %s removed\n", accountID) + if accountsForce { + fmt.Fprintln(cmd.OutOrStdout(), "note: --force was used; blockers may remain on calendars whose provider was unavailable") + } + if cfg.AccountByID(accountID) != nil { + fmt.Fprintf(cmd.OutOrStdout(), "note: %s is still defined in %s -- remove it from the config, or it will sync again\n", accountID, flagConfig) + } + return nil + }, +} + +func init() { + accountsRemoveCmd.Flags().BoolVar(&accountsForce, "force", false, "skip remote blocker deletion when a provider is unavailable (blockers may remain)") + accountsCmd.AddCommand(accountsRemoveCmd) + rootCmd.AddCommand(accountsCmd) +} diff --git a/cmd/calsync/cmd_auth.go b/cmd/calsync/cmd_auth.go new file mode 100644 index 0000000..22169a5 --- /dev/null +++ b/cmd/calsync/cmd_auth.go @@ -0,0 +1,95 @@ +package main + +import ( + "errors" + "fmt" + "text/tabwriter" + + "github.com/spf13/cobra" + "golang.org/x/oauth2" + + "github.com/cloudnative-co/calsync/internal/auth" + "github.com/cloudnative-co/calsync/internal/config" +) + +var ( + authDeviceCode bool + authPort int +) + +var authCmd = &cobra.Command{ + Use: "auth", + Short: "Manage OAuth tokens", +} + +var authAddCmd = &cobra.Command{ + Use: "add ", + Short: "Authorize an account and store its token (run on the host, not inside the container)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := config.Load(flagConfig) + if err != nil { + return err + } + acct := cfg.AccountByID(args[0]) + if acct == nil { + return fmt.Errorf("account %q is not defined in %s", args[0], flagConfig) + } + ocfg, err := oauthConfigFor(cfg, *acct) + if err != nil { + return err + } + var tok *oauth2.Token + if authDeviceCode { + if acct.Provider != "microsoft" { + return errors.New("--device-code is available only for microsoft accounts") + } + tok, err = auth.RunDeviceFlow(cmd.Context(), ocfg) + } else { + tok, err = auth.RunLoopbackFlow(cmd.Context(), ocfg, authPort) + } + if err != nil { + return err + } + tokens := &auth.TokenStore{Dir: flagData} + if err := tokens.Save(acct.ID, tok); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "token saved for account %s\n", acct.ID) + return nil + }, +} + +var authListCmd = &cobra.Command{ + Use: "list", + Short: "Show token status for each configured account", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := config.Load(flagConfig) + if err != nil { + return err + } + tokens := &auth.TokenStore{Dir: flagData} + w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 4, 2, ' ', 0) + fmt.Fprintln(w, "ACCOUNT\tPROVIDER\tTOKEN") + for _, acct := range cfg.Accounts { + state := "ok" + tok, err := tokens.Load(acct.ID) + switch { + case err != nil: + state = fmt.Sprintf("missing (run: calsync auth add %s)", acct.ID) + case tok.RefreshToken == "": + state = fmt.Sprintf("no refresh token (re-run: calsync auth add %s)", acct.ID) + } + fmt.Fprintf(w, "%s\t%s\t%s\n", acct.ID, acct.Provider, state) + } + return w.Flush() + }, +} + +func init() { + authAddCmd.Flags().BoolVar(&authDeviceCode, "device-code", false, "use the OAuth device code flow (microsoft only)") + authAddCmd.Flags().IntVar(&authPort, "port", 0, "fixed loopback port for the OAuth redirect (0 = random; set when publishing a Docker port)") + authCmd.AddCommand(authAddCmd, authListCmd) + rootCmd.AddCommand(authCmd) +} diff --git a/cmd/calsync/cmd_doctor.go b/cmd/calsync/cmd_doctor.go new file mode 100644 index 0000000..4ee3db6 --- /dev/null +++ b/cmd/calsync/cmd_doctor.go @@ -0,0 +1,111 @@ +package main + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/cloudnative-co/calsync/internal/auth" + "github.com/cloudnative-co/calsync/internal/config" + "github.com/cloudnative-co/calsync/internal/model" + "github.com/cloudnative-co/calsync/internal/store" +) + +// probeFunc は 1 アカウントの API 疎通確認。実体は GetCalendarTimezone 呼び出しで、 +// テストではフェイクに差し替える。 +type probeFunc func(ctx context.Context, acct config.Account) error + +// runDoctor は doctor の本体。probe と出力先を注入可能にしてテストする。 +// 診断項目: (1) 設定ロード済みの確認表示 (2) 各アカウントのトークンファイル有無 +// (3) API 疎通(GetCalendarTimezone) (4) YAML に無い DB アカウントの孤児警告。 +// 問題が 1 つでもあれば error を返す(exit code 非 0)。 +func runDoctor(ctx context.Context, cfg *config.Config, dataDir string, probe probeFunc, out io.Writer, configPath string) error { + fmt.Fprintf(out, "config: ok (%d accounts)\n", len(cfg.Accounts)) + tokens := &auth.TokenStore{Dir: dataDir} + problems := 0 + + for _, acct := range cfg.Accounts { + if _, err := tokens.Load(acct.ID); err != nil { + fmt.Fprintf(out, "account %s: token MISSING -- run: calsync auth add %s\n", acct.ID, acct.ID) + problems++ + continue + } + fmt.Fprintf(out, "account %s: token ok\n", acct.ID) + if err := probe(ctx, acct); err != nil { + fmt.Fprintf(out, "account %s: API check FAILED: %v\n", acct.ID, err) + problems++ + } else { + fmt.Fprintf(out, "account %s: API check ok\n", acct.ID) + } + } + + // 孤児検出(YAML から消しただけで accounts remove していないアカウント)。 + // 読み取り専用オープン: デーモン稼働中でも診断できる。 + // DB ファイルが未作成(初回同期前の正常な状態)の場合は問題としてカウントしない。 + // それ以外の open 失敗(破損・権限等)のみ問題として扱う。 + if _, statErr := os.Stat(filepath.Join(dataDir, "calsync.db")); os.IsNotExist(statErr) { + fmt.Fprintln(out, "store: no local DB yet (run `calsync sync --once` first)") + } else { + st, err := store.OpenReadOnly(dataDir) + if err != nil { + fmt.Fprintf(out, "store: cannot open: %v\n", err) + problems++ + } else { + defer st.Close() + states, lerr := st.ListCalendars() + if lerr != nil { + return lerr + } + cfgIDs := make([]string, 0, len(cfg.Accounts)) + for _, a := range cfg.Accounts { + cfgIDs = append(cfgIDs, a.ID) + } + dbIDs := make([]string, 0, len(states)) + for _, cs := range states { + dbIDs = append(dbIDs, cs.Ref.AccountID) + } + for _, id := range findOrphanAccounts(cfgIDs, dbIDs) { + fmt.Fprintf(out, "WARNING: account %s exists in the local DB but not in %s -- run: calsync accounts remove %s\n", id, configPath, id) + problems++ + } + } + } + + if problems > 0 { + return fmt.Errorf("doctor found %d problem(s)", problems) + } + fmt.Fprintln(out, "all checks passed") + return nil +} + +var doctorCmd = &cobra.Command{ + Use: "doctor", + Short: "Diagnose config, tokens, API connectivity and DB consistency", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := config.Load(flagConfig) + if err != nil { + return fmt.Errorf("config: %w", err) + } + tokens := &auth.TokenStore{Dir: flagData} + probe := func(ctx context.Context, acct config.Account) error { + p, err := buildProvider(cfg, tokens, acct) + if err != nil { + return err + } + cal := "primary" + if len(acct.Calendars) > 0 { + cal = acct.Calendars[0] + } + _, err = p.GetCalendarTimezone(ctx, model.CalendarRef{AccountID: acct.ID, CalendarID: cal}) + return err + } + return runDoctor(cmd.Context(), cfg, flagData, probe, cmd.OutOrStdout(), flagConfig) + }, +} + +func init() { rootCmd.AddCommand(doctorCmd) } diff --git a/cmd/calsync/cmd_reconcile.go b/cmd/calsync/cmd_reconcile.go new file mode 100644 index 0000000..a4fb845 --- /dev/null +++ b/cmd/calsync/cmd_reconcile.go @@ -0,0 +1,27 @@ +package main + +import ( + "github.com/spf13/cobra" + + "github.com/cloudnative-co/calsync/internal/config" +) + +var reconcileCmd = &cobra.Command{ + Use: "reconcile", + Short: "Run a full reconcile (window slide, orphan adoption, self-heal)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := config.Load(flagConfig) + if err != nil { + return err + } + eng, err := buildEngine(cfg, flagData) + if err != nil { + return err + } + defer eng.Store.Close() + return eng.Reconcile(cmd.Context()) + }, +} + +func init() { rootCmd.AddCommand(reconcileCmd) } diff --git a/cmd/calsync/cmd_run.go b/cmd/calsync/cmd_run.go new file mode 100644 index 0000000..466db87 --- /dev/null +++ b/cmd/calsync/cmd_run.go @@ -0,0 +1,32 @@ +package main + +import ( + "os/signal" + "syscall" + + "github.com/spf13/cobra" + + "github.com/cloudnative-co/calsync/internal/config" +) + +var runCmd = &cobra.Command{ + Use: "run", + Short: "Run the sync daemon (polling loop + daily reconcile)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := config.Load(flagConfig) + if err != nil { + return err + } + eng, err := buildEngine(cfg, flagData) + if err != nil { + return err + } + defer eng.Store.Close() + ctx, stop := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + return eng.Run(ctx) + }, +} + +func init() { rootCmd.AddCommand(runCmd) } diff --git a/cmd/calsync/cmd_status.go b/cmd/calsync/cmd_status.go new file mode 100644 index 0000000..35d78a6 --- /dev/null +++ b/cmd/calsync/cmd_status.go @@ -0,0 +1,58 @@ +package main + +import ( + "fmt" + "strings" + "text/tabwriter" + "time" + + "github.com/spf13/cobra" + + "github.com/cloudnative-co/calsync/internal/store" +) + +// renderStatus は ListCalendars の結果を表形式文字列にする(テスト対象の純粋整形)。 +func renderStatus(states []store.CalendarState) string { + var b strings.Builder + w := tabwriter.NewWriter(&b, 0, 4, 2, ' ', 0) + fmt.Fprintln(w, "ACCOUNT\tCALENDAR\tLAST SYNC\tSTATUS") + for _, cs := range states { + last := "-" + if !cs.LastSyncedAt.IsZero() { + last = cs.LastSyncedAt.UTC().Format(time.RFC3339) + } + status := "ok" + if cs.LastError != "" { + status = cs.LastError + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", cs.Ref.AccountID, cs.Ref.CalendarID, last, status) + } + _ = w.Flush() + return b.String() +} + +var statusCmd = &cobra.Command{ + Use: "status", + Short: "Show last sync time and error state for each calendar", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + // 読み取り専用: デーモン稼働中(flock 保持中)でも実行できる + st, err := store.OpenReadOnly(flagData) + if err != nil { + return err + } + defer st.Close() + states, err := st.ListCalendars() + if err != nil { + return err + } + if len(states) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "no calendars in the local state yet (run `calsync sync --once` first)") + return nil + } + fmt.Fprint(cmd.OutOrStdout(), renderStatus(states)) + return nil + }, +} + +func init() { rootCmd.AddCommand(statusCmd) } diff --git a/cmd/calsync/cmd_sync.go b/cmd/calsync/cmd_sync.go new file mode 100644 index 0000000..256a921 --- /dev/null +++ b/cmd/calsync/cmd_sync.go @@ -0,0 +1,57 @@ +package main + +import ( + "errors" + "fmt" + + "github.com/spf13/cobra" + + "github.com/cloudnative-co/calsync/internal/config" + "github.com/cloudnative-co/calsync/internal/model" +) + +var syncOnce bool + +var syncCmd = &cobra.Command{ + Use: "sync", + Short: "Run one sync cycle and exit (requires --once)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if !syncOnce { + return errors.New("continuous sync is `calsync run`; pass --once to sync a single cycle") + } + cfg, err := config.Load(flagConfig) + if err != nil { + return err + } + eng, err := buildEngine(cfg, flagData) + if err != nil { + return err + } + defer eng.Store.Close() + var firstErr error + for _, acct := range cfg.Accounts { + for _, calID := range acct.Calendars { + ref := model.CalendarRef{AccountID: acct.ID, CalendarID: calID} + if err := eng.SyncCalendar(cmd.Context(), ref); err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "sync %s: %v\n", ref, err) + if firstErr == nil { + firstErr = err + } + continue + } + // 成功時は last_error クリア + last_synced_at 更新(scheduler.tick と + // 同じ status 追随。`calsync status` が sync --once の結果も反映する) + if serr := eng.Store.SetCalendarError(ref, ""); serr != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "record sync status %s: %v\n", ref, serr) + } + } + } + return firstErr + }, +} + +func init() { + syncCmd.Flags().BoolVar(&syncOnce, "once", false, "sync one cycle and exit") + rootCmd.AddCommand(syncCmd) +} diff --git a/cmd/calsync/main.go b/cmd/calsync/main.go new file mode 100644 index 0000000..ac8d158 --- /dev/null +++ b/cmd/calsync/main.go @@ -0,0 +1,147 @@ +package main + +import ( + "context" + "errors" + "fmt" + "os" + "sort" + "time" + + "github.com/spf13/cobra" + "golang.org/x/oauth2" + googleoauth "golang.org/x/oauth2/google" + + "github.com/cloudnative-co/calsync/internal/auth" + "github.com/cloudnative-co/calsync/internal/config" + "github.com/cloudnative-co/calsync/internal/engine" + "github.com/cloudnative-co/calsync/internal/provider" + googleprov "github.com/cloudnative-co/calsync/internal/provider/google" + msprov "github.com/cloudnative-co/calsync/internal/provider/microsoft" + "github.com/cloudnative-co/calsync/internal/store" +) + +var ( + flagConfig string + flagData string +) + +var rootCmd = &cobra.Command{ + Use: "calsync", + Short: "Mirror busy slots across Google and Microsoft calendars as blocker events", + SilenceUsage: true, + SilenceErrors: true, +} + +func init() { + rootCmd.PersistentFlags().StringVar(&flagConfig, "config", "calsync.yaml", "path to the calsync YAML config") + rootCmd.PersistentFlags().StringVar(&flagData, "data", "./data", "data directory (SQLite state and OAuth tokens)") +} + +func main() { + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) + } +} + +// oauthConfigFor は各プロバイダの oauth2.Config を組み立てる。 +// auth add(認可フロー)とトークンリフレッシュ(TokenSource)の両方で使う。 +func oauthConfigFor(cfg *config.Config, acct config.Account) (*oauth2.Config, error) { + switch acct.Provider { + case "google": + if cfg.Providers.Google.CredentialsFile == "" { + return nil, errors.New("providers.google.credentials_file is not set in the config") + } + b, err := os.ReadFile(cfg.Providers.Google.CredentialsFile) + if err != nil { + return nil, fmt.Errorf("read google credentials: %w", err) + } + return googleoauth.ConfigFromJSON(b, "https://www.googleapis.com/auth/calendar.events") + case "microsoft": + if cfg.Providers.Microsoft.ClientID == "" { + return nil, errors.New("providers.microsoft.client_id is not set in the config") + } + return &oauth2.Config{ + ClientID: cfg.Providers.Microsoft.ClientID, + Endpoint: oauth2.Endpoint{ + AuthURL: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize", + TokenURL: "https://login.microsoftonline.com/common/oauth2/v2.0/token", + DeviceAuthURL: "https://login.microsoftonline.com/common/oauth2/v2.0/devicecode", + }, + // MailboxSettings.Read は GetCalendarTimezone(/me/mailboxSettings/timeZone) + // に必要(Calendars.ReadWrite だけでは 403。最終ホールブランチレビュー追補 Issue 1) + Scopes: []string{ + "offline_access", + "https://graph.microsoft.com/Calendars.ReadWrite", + "https://graph.microsoft.com/MailboxSettings.Read", + }, + }, nil + default: + return nil, fmt.Errorf("account %s: unknown provider %q", acct.ID, acct.Provider) + } +} + +// buildProvider は 1 アカウント分の Provider を構築する。 +// トークンは PersistingTokenSource で包み、リフレッシュ(MS のローテーション含む) +// のたびにディスクへ書き戻す(仕様 9.3)。 +func buildProvider(cfg *config.Config, tokens *auth.TokenStore, acct config.Account) (provider.Provider, error) { + tok, err := tokens.Load(acct.ID) + if err != nil { + return nil, fmt.Errorf("account %s: no token (run: calsync auth add %s): %w", acct.ID, acct.ID, err) + } + ocfg, err := oauthConfigFor(cfg, acct) + if err != nil { + return nil, err + } + ts := auth.PersistingTokenSource(acct.ID, tokens, ocfg.TokenSource(context.Background(), tok)) + switch acct.Provider { + case "google": + return googleprov.New(ts, acct.ID), nil + case "microsoft": + return msprov.New(ts, acct.ID, cfg.BusyShowAs), nil + default: + return nil, fmt.Errorf("account %s: unknown provider %q", acct.ID, acct.Provider) + } +} + +// buildEngine は store・トークン・全アカウントの Provider を組み立てる。 +// 1 つでも構築できないアカウントがあればエラー(run/sync/reconcile 用の厳格版。 +// doctor と accounts remove はアカウント単位で degrade するため使わない)。 +// 呼び出し側は使用後に eng.Store.Close() すること。 +func buildEngine(cfg *config.Config, dataDir string) (*engine.Engine, error) { + st, err := store.Open(dataDir) + if err != nil { + return nil, err + } + tokens := &auth.TokenStore{Dir: dataDir} + providers := make(map[string]provider.Provider, len(cfg.Accounts)) + for _, acct := range cfg.Accounts { + p, err := buildProvider(cfg, tokens, acct) + if err != nil { + _ = st.Close() + return nil, err + } + providers[acct.ID] = p + } + return &engine.Engine{Store: st, Providers: providers, Cfg: cfg, Now: time.Now}, nil +} + +// findOrphanAccounts は DB に行があるのに設定に存在しないアカウント ID を +// 重複除去・昇順ソートして返す純関数(doctor の孤児警告用。仕様 11 章)。 +func findOrphanAccounts(cfgIDs, dbIDs []string) []string { + known := make(map[string]bool, len(cfgIDs)) + for _, id := range cfgIDs { + known[id] = true + } + seen := make(map[string]bool, len(dbIDs)) + var orphans []string + for _, id := range dbIDs { + if !known[id] && !seen[id] { + seen[id] = true + orphans = append(orphans, id) + } + } + sort.Strings(orphans) + return orphans +} diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..d65bcb8 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,14 @@ +services: + calsync: + build: . + container_name: calsync + restart: unless-stopped + environment: + # reconcile_at("04:00" 等)はこの TZ で解釈される(仕様: コンテナのローカル TZ) + TZ: Asia/Tokyo + volumes: + # calsync.yaml / SQLite / OAuth トークン / GCP クライアント JSON をここに置く + - ./data:/data + # 既定コマンドは Dockerfile の CMD(--config /data/calsync.yaml --data /data)。 + # 変更したい場合の例: + # command: ["--config", "/data/calsync.yaml", "--data", "/data"] diff --git a/docs/superpowers/plans/2026-07-03-calsync-v1.md b/docs/superpowers/plans/2026-07-03-calsync-v1.md index 7890cb3..2dab576 100644 --- a/docs/superpowers/plans/2026-07-03-calsync-v1.md +++ b/docs/superpowers/plans/2026-07-03-calsync-v1.md @@ -6,11 +6,11 @@ **Architecture:** Hub & Spoke 構成。差分ポーリング(Google syncToken / Graph deltaLink)で変更を 1 つの同期エンジンに集約し、SQLite の mappings テーブル(一次)+ 拡張プロパティタグ(二次)でループを遮断しながら、冪等キー付きでブロッカーを配布する。仕様書: `docs/superpowers/specs/2026-07-03-calsync-design.md`(全タスクの正)。 -**Tech Stack:** Go 1.24 / cobra / yaml.v3 / modernc.org/sqlite(CGO 不使用)/ gofrs/flock / golang.org/x/oauth2(両プロバイダ・MSAL 不使用)/ google.golang.org/api/calendar/v3 / Microsoft Graph は net/http 直叩き / testify(require) +**Tech Stack:** Go 1.25 / cobra / yaml.v3 / modernc.org/sqlite(CGO 不使用)/ gofrs/flock / golang.org/x/oauth2(両プロバイダ・MSAL 不使用)/ google.golang.org/api/calendar/v3 / Microsoft Graph は net/http 直叩き / testify(require) ## Global Constraints -- module は `github.com/cloudnative-co/calsync`、Go 1.24、`CGO_ENABLED=0`(distroless 配布のため) +- module は `github.com/cloudnative-co/calsync`、Go 1.25、`CGO_ENABLED=0`(distroless 配布のため) - 依存ライブラリは上記 Tech Stack のみ。追加しない(YAGNI) - 図はすべて Mermaid(このリポジトリは ASCII 図禁止) - コミットは Conventional Commits(feat: / fix: / test: / docs: / chore:、英語)。各タスク末尾で必ずコミット @@ -71,18 +71,18 @@ - [ ] **Step 1: Go モジュールを初期化する** -リポジトリルート(`/Users/okash1n/ghq/github.com/cloudnative-co/calsync`)で実行する。コントラクトの固定値どおり module パスは `github.com/cloudnative-co/calsync`、Go バージョンは 1.24 に固定する(ローカルの toolchain が新しくても `go mod edit -go=1.24` で明示的に揃える)。 +リポジトリルート(`/Users/okash1n/ghq/github.com/cloudnative-co/calsync`)で実行する。コントラクトの固定値どおり module パスは `github.com/cloudnative-co/calsync`、Go バージョンは 1.25 に固定する(ローカルの toolchain が新しくても `go mod edit -go=1.25` で明示的に揃える)。 Run: ```bash go mod init github.com/cloudnative-co/calsync -go mod edit -go=1.24 +go mod edit -go=1.25 ``` Expected: ``` go: creating new go.mod: module github.com/cloudnative-co/calsync ``` -(`go.mod` に `module github.com/cloudnative-co/calsync` と `go 1.24` が入る) +(`go.mod` に `module github.com/cloudnative-co/calsync` と `go 1.25` が入る) - [ ] **Step 2: 依存を追加する(コントラクト固定の7モジュール。これ以外を追加しない)** @@ -976,7 +976,7 @@ git commit -m "feat: add config package with YAML loading, validation, and sync SQLite ステートストアの土台を作る。`Open` はデータディレクトリの flock 取得(二重起動で `ErrLocked`)→ SQLite オープン(WAL)→ スキーマ migrate を行う。スキーマは仕様書 7 章の SQL をベースに、コントラクトの注記どおり **events テーブルに `all_day_start TEXT` / `all_day_end TEXT` を追加**する。あわせて calendars テーブルの全操作を実装する。 -前提: Task 1 で `go.mod`(module `github.com/cloudnative-co/calsync`、Go 1.24)と `internal/model/model.go` が作成済みであること。 +前提: Task 1 で `go.mod`(module `github.com/cloudnative-co/calsync`、Go 1.25)と `internal/model/model.go` が作成済みであること。 補足(コントラクトへの追記): コントラクトの store API 一覧に無い `TouchSynced(ref model.CalendarRef, at time.Time) error` を追加する。`SetCalendarError` はエラー有無にかかわらず `last_synced_at` を現在時刻で更新する(コントラクトのコメント「"" でクリア。last_synced_at も更新」の実装)が、リコンサイル等で「同期成功時刻だけを明示的に記録する」操作が別途必要なため。既存シグネチャの変更ではなくメソッド追加のみ。 @@ -10177,7 +10177,7 @@ git commit -m "feat: add accounts remove with distributed/received blocker clean ```dockerfile # syntax=docker/dockerfile:1 -FROM golang:1.24 AS build +FROM golang:1.25 AS build WORKDIR /src COPY go.mod go.sum ./ RUN go mod download @@ -10252,7 +10252,7 @@ flowchart LR ## 必要なもの - 自分の Google Cloud(GCP)OAuth クライアント、および/または Microsoft Entra ID のアプリ登録(下記手順。calsync は共有クライアントを配布しません) -- Docker / Docker Compose(または Go 1.24 でのローカルビルド) +- Docker / Docker Compose(または Go 1.25 でのローカルビルド) ## セットアップ 1: Google(GCP) diff --git a/docs/superpowers/specs/2026-07-03-calsync-design.md b/docs/superpowers/specs/2026-07-03-calsync-design.md index 5ffefca..477c7d3 100644 --- a/docs/superpowers/specs/2026-07-03-calsync-design.md +++ b/docs/superpowers/specs/2026-07-03-calsync-design.md @@ -55,7 +55,7 @@ flowchart TB | 同一会議の重複抑止 | v1 で実装(既定オン、設定でオフ可) | 同一人物の複数アカウントが同じ会議に招待されるケースは主用途で頻出(6.5) | | 状態管理 | SQLite 1 ファイル + WAL + flock による多重起動防止 | 2 プロセス同時実行は重複ブロッカーを量産するため起動時に排他 | | リコンサイル | 日次(既定 04:00、コンテナのローカル TZ)+ 手動コマンド | カーソル張り直しによるウィンドウスライド・孤児収容・自己修復(8 章) | -| OAuth スコープ | Google: `calendar.events` / Microsoft: `Calendars.ReadWrite` + `offline_access` | 最小権限。freebusy 系スコープでは差分同期に必要な events 読み取りができない | +| OAuth スコープ | Google: `calendar.events` / Microsoft: `Calendars.ReadWrite` + `MailboxSettings.Read` + `offline_access` | 最小権限。freebusy 系スコープでは差分同期に必要な events 読み取りができない。Microsoft の `MailboxSettings.Read` は終日ブロッカー用のメールボックス TZ 取得(`/me/mailboxSettings/timeZone`)に必要。Google のカレンダー TZ は `calendars.get`(要 calendar 系スコープ)ではなく **events.list 応答の `timeZone` フィールド**から取得することで `calendar.events` のみで賄う | | 配布 | 利用者が自分の GCP OAuth クライアント / Entra アプリ登録を用意 | 12 章のセットアップ要件が README の必須記載事項 | ## 4. Provider 抽象化 @@ -218,7 +218,7 @@ CreateBlocker 成功と mappings 登録の間のクラッシュで孤児ブロ - 内部時刻は UTC に統一。Graph は Prefer ヘッダを付けなければ UTC で返る(公式仕様)。Google は dateTime を UTC に変換 - `time_hash` = UTC 正規化した start/end + 終日フラグ(+ 終日は現地日付)のハッシュ - 時刻指定イベントのブロッカーは `timeZone: UTC` で作成(表示はクライアントが変換する) -- 終日イベントは終日ブロッカーとしてミラーする。Google は `start.date`/`end.date`(end は排他的 = 翌日)、Graph は `isAllDay=true` + **ターゲットカレンダーのタイムゾーンでの midnight 境界**(公式要件。UTC midnight だと日付がズレる)。ターゲットの TZ はカレンダー設定から取得してキャッシュする +- 終日イベントは終日ブロッカーとしてミラーする。Google は `start.date`/`end.date`(end は排他的 = 翌日)、Graph は `isAllDay=true` + **ターゲットカレンダーのタイムゾーンでの midnight 境界**(公式要件。UTC midnight だと日付がズレる)。ターゲットの TZ は Google が events.list 応答の `timeZone`、Microsoft が `/me/mailboxSettings/timeZone` から取得してキャッシュする(取得は終日ブロッカー作成時のみの遅延実行 — 時刻指定ブロッカーは TZ 不要) - アカウント間でタイムゾーンが異なる場合、終日→終日ミラーは「同じ日付」を維持する(同じ絶対時間帯ではない)。この仕様は README に明記する ## 7. State Store(SQLite) @@ -413,6 +413,11 @@ YAML からアカウントを消しただけの場合は `doctor` と起動時 4. 終日イベントを transparency/showAs 未指定で API 作成した場合の既定値(calsync 自身は常に明示指定するため参考確認) 5. MSAL Go(microsoft-authentication-library-for-go)の現行ステータスとトークンキャッシュ永続化のサポート状況 6. Google: Device flow が Calendar スコープで使えないことの確認(使えない前提で設計済み) +7. Google: 削除済み(cancelled)ブロッカーを同一 ID で蘇生する際、`events.update` で status が confirmed に戻り再びカレンダーに表示されること(実装は 409 収容パスで蘇生する設計。モックでは証明不能) +8. Graph: ブロッカー削除後に同一 `transactionId` で再作成した場合の挙動(409 が返るなら復元はエラーとして表面化し日次リコンサイルで再試行される — 許容だが実測で確認) +9. Graph: 終日イベントが delta 応答(Prefer なし = UTC 返却)で UTC midnight に変換されて届くか。変換される場合、UTC+ タイムゾーンの終日ブロッカーが前日に立つため、`originalStartTimeZone` からの現地日付復元が必要(`internal/provider/microsoft/delta.go` にマーカーコメントあり) +10. スコープの実疎通: Google `calendar.events` のみで events.list 応答の `timeZone` が読めること / Microsoft `MailboxSettings.Read` で `/me/mailboxSettings/timeZone` が 200 を返すこと(§3 の改訂根拠の実機確認) +11. `docker build` と `docker run --rm calsync:dev --help` の成功(実装時は Docker デーモン不在で `docker compose config -q` と CGO_ENABLED=0 ビルドのみ確認済み) ## 16. リポジトリ構成とロードマップ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..86e01cc --- /dev/null +++ b/go.mod @@ -0,0 +1,55 @@ +module github.com/cloudnative-co/calsync + +go 1.25.8 + +require ( + github.com/gofrs/flock v0.13.0 + github.com/stretchr/testify v1.11.1 + modernc.org/sqlite v1.53.0 +) + +require ( + cloud.google.com/go/auth v0.20.0 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.17 // indirect + github.com/googleapis/gax-go/v2 v2.22.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect + golang.org/x/crypto v0.53.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/text v0.38.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d // indirect + google.golang.org/grpc v1.81.1 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.9 // indirect + golang.org/x/oauth2 v0.36.0 + golang.org/x/sys v0.46.0 // indirect + google.golang.org/api v0.287.0 + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + gopkg.in/yaml.v3 v3.0.1 + modernc.org/libc v1.73.4 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..6197023 --- /dev/null +++ b/go.sum @@ -0,0 +1,101 @@ +cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= +cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= +github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.17 h1:73NfMHdiqo9JFU9+7a5ExpVa10/R29pXfZIaW559nrg= +github.com/googleapis/enterprise-certificate-proxy v0.3.17/go.mod h1:rSEsBUemEBZEexP2y6jPp16LUmUbjmSbcPMQizR0o4k= +github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4= +github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +google.golang.org/api v0.287.0 h1:CQDMqUiqZZ0U/Yge3zyjAhNQ0OSYEH0PaA7l4xtEen4= +google.golang.org/api v0.287.0/go.mod h1:pPW85yt3Iuc3unkpaMhFtMmOqnTdCwCqEOaUlnuxRlQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d h1:mpAgMyM9vQHxycBlDq50y1VHpfSfVwzXvrQKtYbXuUY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA= +modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M= +modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s= diff --git a/internal/auth/flow.go b/internal/auth/flow.go new file mode 100644 index 0000000..640825e --- /dev/null +++ b/internal/auth/flow.go @@ -0,0 +1,183 @@ +package auth + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "os/exec" + "runtime" + "sync" + "time" + + "golang.org/x/oauth2" +) + +// RunLoopbackFlow は認可コード + PKCE + ループバックリダイレクトの OAuth フローを実行する。 +// port==0 ならランダムポート(仕様書9.2 の --port はここに固定値を渡す)。 +// 認可 URL を標準出力に表示し、ブラウザ起動を試みる(失敗しても URL 表示で続行)。 +func RunLoopbackFlow(ctx context.Context, cfg *oauth2.Config, port int) (*oauth2.Token, error) { + return runLoopbackFlow(ctx, cfg, port, os.Stdout, openBrowser, nil) +} + +type flowResult struct { + tok *oauth2.Token + err error +} + +// runLoopbackFlow は実体。authURLCh が非 nil なら認可 URL を送る(テスト用の観測口)。 +func runLoopbackFlow(ctx context.Context, cfg *oauth2.Config, port int, out io.Writer, openURL func(string) error, authURLCh chan<- string) (*oauth2.Token, error) { + if cfg == nil { + return nil, errors.New("auth: oauth2 config is nil") + } + ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + if err != nil { + return nil, fmt.Errorf("listen on loopback port %d: %w", port, err) + } + defer ln.Close() + actualPort := ln.Addr().(*net.TCPAddr).Port + + // リスナーは常に 127.0.0.1。リダイレクト URL のホスト名表記のみ cfg に従う + // (Microsoft は "http://localhost" — localhost はポート無視でマッチする公式仕様。仕様書9.1)。 + host := "127.0.0.1" + if cfg.RedirectURL != "" { + if u, perr := url.Parse(cfg.RedirectURL); perr == nil && u.Hostname() != "" { + host = u.Hostname() + } + } + + conf := *cfg // 呼び出し元の Config は変更しない + conf.RedirectURL = fmt.Sprintf("http://%s:%d/callback", host, actualPort) + + state, err := randomState() + if err != nil { + return nil, fmt.Errorf("generate state: %w", err) + } + verifier := oauth2.GenerateVerifier() + authURL := conf.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(verifier)) + + resultCh := make(chan flowResult, 1) + var once sync.Once + deliver := func(res flowResult) { once.Do(func() { resultCh <- res }) } + + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + // state 不一致(favicon 等のノイズを含む)は 400 で拒否し、フローは継続する + if q.Get("state") != state { + http.Error(w, "invalid state", http.StatusBadRequest) + return + } + if ec := q.Get("error"); ec != "" { + http.Error(w, "authorization failed: "+ec, http.StatusBadRequest) + deliver(flowResult{err: fmt.Errorf("authorization failed: %s (%s)", ec, q.Get("error_description"))}) + return + } + code := q.Get("code") + if code == "" { + http.Error(w, "missing authorization code", http.StatusBadRequest) + return + } + tok, xerr := conf.Exchange(ctx, code, oauth2.VerifierOption(verifier)) + if xerr != nil { + http.Error(w, "token exchange failed", http.StatusInternalServerError) + deliver(flowResult{err: fmt.Errorf("exchange authorization code: %w", xerr)}) + return + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + fmt.Fprintln(w, "calsync: authentication complete. You can close this window.") + // レスポンスを TCP へフラッシュしてから結果を配送する。deliver 後は + // 呼び出し元がすぐ関数を抜けて Shutdown が走り得るため、先にフラッシュしておかないと + // ブラウザ側が書き切っていないレスポンスで接続リセットを見ることがある。 + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + deliver(flowResult{tok: tok}) + }) + + srv := &http.Server{Handler: mux, ReadHeaderTimeout: 10 * time.Second} + go srv.Serve(ln) //nolint:errcheck // Close 由来の ErrServerClosed は無視してよい + // Close ではなく Shutdown を使う: Close は即座に接続を切るため、ハンドラが + // レスポンスを書き終える前に相手へ EOF/接続リセットが返ることがある。 + // Shutdown はアクティブなハンドラの完了を待ってから閉じるため、 + // クライアントは常にレスポンスを読み切れる。ctx キャンセル時に無期限で + // 待たないよう、Shutdown 自体には短いタイムアウト付きの ctx を渡す。 + defer func() { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = srv.Shutdown(shutdownCtx) + }() + + fmt.Fprintf(out, "Open this URL in your browser to authorize calsync:\n\n %s\n\n", authURL) + if openURL != nil { + if berr := openURL(authURL); berr != nil { + // ブラウザ起動失敗は致命ではない(Docker / SSH 環境)。URL 表示で続行する + fmt.Fprintf(out, "could not open browser automatically (%v) - open the URL above manually\n", berr) + } + } + if authURLCh != nil { + authURLCh <- authURL + } + + select { + case res := <-resultCh: + return res.tok, res.err + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +func randomState() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +// openBrowser は OS 既定ブラウザで URL を開く(ベストエフォート)。 +func openBrowser(rawURL string) error { + switch runtime.GOOS { + case "darwin": + return exec.Command("open", rawURL).Start() + case "linux": + return exec.Command("xdg-open", rawURL).Start() + case "windows": + return exec.Command("rundll32", "url.dll,FileProtocolHandler", rawURL).Start() + default: + return fmt.Errorf("unsupported platform %s", runtime.GOOS) + } +} + +// RunDeviceFlow は Device Code Flow を実行する(Microsoft のみ。仕様書9.1)。 +// 呼び出し元は cfg.Endpoint.DeviceAuthURL を設定しておくこと。 +func RunDeviceFlow(ctx context.Context, cfg *oauth2.Config) (*oauth2.Token, error) { + return runDeviceFlow(ctx, cfg, os.Stdout) +} + +func runDeviceFlow(ctx context.Context, cfg *oauth2.Config, out io.Writer) (*oauth2.Token, error) { + if cfg == nil { + return nil, errors.New("auth: oauth2 config is nil") + } + resp, err := cfg.DeviceAuth(ctx) + if err != nil { + return nil, fmt.Errorf("start device authorization: %w", err) + } + fmt.Fprintf(out, "To sign in, open %s in a browser and enter the code: %s\n", resp.VerificationURI, resp.UserCode) + if resp.VerificationURIComplete != "" { + fmt.Fprintf(out, "Or open this URL directly: %s\n", resp.VerificationURIComplete) + } + // DeviceAccessToken はサーバ指定の interval に従い authorization_pending の間 + // ポーリングを続ける。slow_down / expired_token も x/oauth2 側で処理される。 + tok, err := cfg.DeviceAccessToken(ctx, resp) + if err != nil { + return nil, fmt.Errorf("wait for device authorization: %w", err) + } + return tok, nil +} diff --git a/internal/auth/flow_test.go b/internal/auth/flow_test.go new file mode 100644 index 0000000..4fdfeea --- /dev/null +++ b/internal/auth/flow_test.go @@ -0,0 +1,270 @@ +package auth + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" +) + +func TestRunLoopbackFlow(t *testing.T) { + // httptest 側 token エンドポイント。受信内容を記録して後で検証する + type tokenRequest struct { + grantType string + code string + verifier string + redirectURI string + } + var ( + mu sync.Mutex + tokenReqs []tokenRequest + ) + mux := http.NewServeMux() + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + mu.Lock() + tokenReqs = append(tokenReqs, tokenRequest{ + grantType: r.Form.Get("grant_type"), + code: r.Form.Get("code"), + verifier: r.Form.Get("code_verifier"), + redirectURI: r.Form.Get("redirect_uri"), + }) + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"access_token":"atoken","token_type":"Bearer","refresh_token":"rtoken","expires_in":3600}`) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + // oauth2.Config の Endpoint を httptest サーバに向ける + cfg := &oauth2.Config{ + ClientID: "test-client", + Endpoint: oauth2.Endpoint{ + AuthURL: srv.URL + "/authorize", + TokenURL: srv.URL + "/token", + AuthStyle: oauth2.AuthStyleInParams, + }, + Scopes: []string{"https://www.googleapis.com/auth/calendar.events"}, + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + type flowRes struct { + tok *oauth2.Token + err error + } + authURLCh := make(chan string, 1) + resCh := make(chan flowRes, 1) + browserErr := errors.New("no browser in test") + var out bytes.Buffer + go func() { + tok, err := runLoopbackFlow(ctx, cfg, 0, &out, + func(string) error { return browserErr }, // ブラウザ起動は常に失敗させる + authURLCh) + resCh <- flowRes{tok: tok, err: err} + }() + + // 表示された認可 URL をチャネルから取得 + var authURL string + select { + case authURL = <-authURLCh: + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for authorization URL") + } + + u, err := url.Parse(authURL) + require.NoError(t, err) + q := u.Query() + state := q.Get("state") + challenge := q.Get("code_challenge") + redirect := q.Get("redirect_uri") + require.NotEmpty(t, state) + require.NotEmpty(t, challenge) + require.NotEmpty(t, redirect) + require.Equal(t, "S256", q.Get("code_challenge_method")) + require.Equal(t, "test-client", q.Get("client_id")) + + ru, err := url.Parse(redirect) + require.NoError(t, err) + require.Equal(t, "127.0.0.1", ru.Hostname()) + require.NotEmpty(t, ru.Port()) + require.NotEqual(t, "0", ru.Port()) // port==0 指定でも実ポートが割り当てられている + + // 不正コールバックはすべて 400 で拒否され、フローは完了しない + badCallbacks := []struct { + name string + query string + }{ + {name: "wrong state", query: "?state=WRONG&code=evil"}, + {name: "missing state", query: "?code=evil"}, + {name: "missing code", query: "?state=" + url.QueryEscape(state)}, + } + for _, tc := range badCallbacks { + resp, gerr := http.Get(redirect + tc.query) + require.NoError(t, gerr, tc.name) + require.NoError(t, resp.Body.Close(), tc.name) + require.Equal(t, http.StatusBadRequest, resp.StatusCode, tc.name) + } + select { + case r := <-resCh: + t.Fatalf("flow completed after invalid callback: tok=%v err=%v", r.tok, r.err) + case <-time.After(200 * time.Millisecond): + // 完了していない = 期待どおり + } + mu.Lock() + require.Empty(t, tokenReqs, "invalid callback must not reach token exchange") + mu.Unlock() + + // 正しい state + code のコールバックで完了する + resp, err := http.Get(redirect + "?state=" + url.QueryEscape(state) + "&code=good-code") + require.NoError(t, err) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + require.Equal(t, http.StatusOK, resp.StatusCode, string(body)) + + var res flowRes + select { + case res = <-resCh: + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for flow result") + } + require.NoError(t, res.err) + require.Equal(t, "atoken", res.tok.AccessToken) + require.Equal(t, "rtoken", res.tok.RefreshToken) + + // PKCE: token エンドポイントへ code_verifier が送られ、 + // S256(code_verifier) が認可 URL の code_challenge と一致する + mu.Lock() + defer mu.Unlock() + require.Len(t, tokenReqs, 1) + tr := tokenReqs[0] + require.Equal(t, "authorization_code", tr.grantType) + require.Equal(t, "good-code", tr.code) + require.Equal(t, redirect, tr.redirectURI) + require.NotEmpty(t, tr.verifier) + sum := sha256.Sum256([]byte(tr.verifier)) + require.Equal(t, challenge, base64.RawURLEncoding.EncodeToString(sum[:])) + + // 認可 URL は out(標準出力相当)に表示され、ブラウザ起動失敗でも続行している + require.Contains(t, out.String(), authURL) + require.Contains(t, out.String(), browserErr.Error()) +} + +func TestRunLoopbackFlowContextCancel(t *testing.T) { + cfg := &oauth2.Config{ + ClientID: "test-client", + Endpoint: oauth2.Endpoint{ + AuthURL: "http://127.0.0.1:1/authorize", // 実際には接続しない + TokenURL: "http://127.0.0.1:1/token", + }, + } + ctx, cancel := context.WithCancel(context.Background()) + authURLCh := make(chan string, 1) + done := make(chan error, 1) + go func() { + _, err := runLoopbackFlow(ctx, cfg, 0, io.Discard, nil, authURLCh) + done <- err + }() + + select { + case <-authURLCh: // フロー起動(リスナー確立)を確認してからキャンセル + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for authorization URL") + } + cancel() + + select { + case err := <-done: + require.ErrorIs(t, err, context.Canceled) + case <-time.After(5 * time.Second): + t.Fatal("flow did not stop on context cancel") + } +} + +func TestRunDeviceFlow(t *testing.T) { + var ( + mu sync.Mutex + tokenCalls int + grantTypes []string + deviceCodes []string + clientIDs []string + ) + mux := http.NewServeMux() + // device_authorization エンドポイント: user_code / verification_uri / interval を返す + mux.HandleFunc("/devicecode", func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + mu.Lock() + clientIDs = append(clientIDs, r.Form.Get("client_id")) + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"device_code":"dc-1","user_code":"ABCD-1234","verification_uri":"https://microsoft.com/devicelogin","expires_in":900,"interval":1}`) + }) + // token エンドポイント: 1回目 authorization_pending → 2回目成功 + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + mu.Lock() + tokenCalls++ + n := tokenCalls + grantTypes = append(grantTypes, r.Form.Get("grant_type")) + deviceCodes = append(deviceCodes, r.Form.Get("device_code")) + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + if n == 1 { + // RFC 8628: まだユーザーが認可していない + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, `{"error":"authorization_pending"}`) + return + } + fmt.Fprint(w, `{"access_token":"dev-atoken","token_type":"Bearer","refresh_token":"dev-rtoken","expires_in":3600}`) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + cfg := &oauth2.Config{ + ClientID: "test-client", + Endpoint: oauth2.Endpoint{ + AuthURL: srv.URL + "/authorize", + TokenURL: srv.URL + "/token", + DeviceAuthURL: srv.URL + "/devicecode", + AuthStyle: oauth2.AuthStyleInParams, + }, + Scopes: []string{"offline_access", "https://graph.microsoft.com/Calendars.ReadWrite"}, + } + + // interval=1 秒 × 2 回ポーリングのため余裕を持たせる + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + var out bytes.Buffer + tok, err := runDeviceFlow(ctx, cfg, &out) + require.NoError(t, err) + require.Equal(t, "dev-atoken", tok.AccessToken) + require.Equal(t, "dev-rtoken", tok.RefreshToken) + + mu.Lock() + defer mu.Unlock() + require.Equal(t, []string{"test-client"}, clientIDs) + require.Equal(t, 2, tokenCalls, "authorization_pending must trigger exactly one retry") + for i, gt := range grantTypes { + require.Equal(t, "urn:ietf:params:oauth:grant-type:device_code", gt, "call %d", i+1) + require.Equal(t, "dc-1", deviceCodes[i], "call %d", i+1) + } + + // user_code と verification_uri が表示される(仕様書9.2: コードと URL の表示で完結) + require.Contains(t, out.String(), "ABCD-1234") + require.Contains(t, out.String(), "https://microsoft.com/devicelogin") +} diff --git a/internal/auth/tokens.go b/internal/auth/tokens.go new file mode 100644 index 0000000..5fd6cf7 --- /dev/null +++ b/internal/auth/tokens.go @@ -0,0 +1,131 @@ +// Package auth はトークンの永続化と OAuth フロー(ループバック+PKCE / Device Code)を提供する。 +package auth + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + "sync" + + "golang.org/x/oauth2" +) + +// TokenStore は /tokens/.json にトークンを保存する。 +// Dir は SQLite と同じデータディレクトリ(Docker ボリューム。仕様書7章)。 +type TokenStore struct { + Dir string +} + +func (t *TokenStore) path(accountID string) string { + return filepath.Join(t.Dir, "tokens", accountID+".json") +} + +// validateAccountID は設定由来の accountID によるパストラバーサルを防ぐ。 +func validateAccountID(accountID string) error { + if accountID == "" || accountID == "." || accountID == ".." || strings.ContainsAny(accountID, `/\`) { + return fmt.Errorf("auth: invalid account id %q", accountID) + } + return nil +} + +// Save はトークンを JSON で保存する(ディレクトリ 0700・ファイル 0600)。 +// 一時ファイル + rename で、クラッシュしても書きかけの JSON を残さない。 +func (t *TokenStore) Save(accountID string, tok *oauth2.Token) error { + if err := validateAccountID(accountID); err != nil { + return err + } + if tok == nil { + return errors.New("auth: token is nil") + } + dir := filepath.Join(t.Dir, "tokens") + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("create token directory: %w", err) + } + data, err := json.Marshal(tok) + if err != nil { + return fmt.Errorf("marshal token for %s: %w", accountID, err) + } + path := t.path(accountID) + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0o600); err != nil { + return fmt.Errorf("write token file for %s: %w", accountID, err) + } + // 一時ファイルが過去のクラッシュで別権限のまま残っていた場合にも 0600 を保証する + if err := os.Chmod(tmp, 0o600); err != nil { + return fmt.Errorf("chmod token file for %s: %w", accountID, err) + } + if err := os.Rename(tmp, path); err != nil { + return fmt.Errorf("replace token file for %s: %w", accountID, err) + } + return nil +} + +// Load は保存済みトークンを返す。ファイルが無ければエラー(fs.ErrNotExist を包含)。 +func (t *TokenStore) Load(accountID string) (*oauth2.Token, error) { + if err := validateAccountID(accountID); err != nil { + return nil, err + } + data, err := os.ReadFile(t.path(accountID)) + if err != nil { + return nil, fmt.Errorf("load token for %s: %w", accountID, err) + } + var tok oauth2.Token + if err := json.Unmarshal(data, &tok); err != nil { + return nil, fmt.Errorf("parse token file for %s: %w", accountID, err) + } + return &tok, nil +} + +// Delete はトークンファイルを削除する。既に無い場合も成功扱い(冪等)。 +func (t *TokenStore) Delete(accountID string) error { + if err := validateAccountID(accountID); err != nil { + return err + } + if err := os.Remove(t.path(accountID)); err != nil && !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("delete token for %s: %w", accountID, err) + } + return nil +} + +type persistingTokenSource struct { + accountID string + store *TokenStore + base oauth2.TokenSource + + mu sync.Mutex + last *oauth2.Token // 最後に永続化を確認したトークン +} + +// PersistingTokenSource は base が返すトークンが前回から変化していたら +// TokenStore へ保存する TokenSource を返す。Microsoft の refresh token は +// 更新のたびにローテーションするため、毎回の永続化が必須(仕様書9.3)。 +// 比較基準は保存済みトークンで初期化するため、保存済みと同一のトークンを +// 返し続ける base は一度も書き込みを発生させない。 +func PersistingTokenSource(accountID string, ts *TokenStore, base oauth2.TokenSource) oauth2.TokenSource { + p := &persistingTokenSource{accountID: accountID, store: ts, base: base} + if tok, err := ts.Load(accountID); err == nil { + p.last = tok + } + return p +} + +func (p *persistingTokenSource) Token() (*oauth2.Token, error) { + p.mu.Lock() + defer p.mu.Unlock() + tok, err := p.base.Token() + if err != nil { + return nil, err + } + if p.last != nil && p.last.AccessToken == tok.AccessToken && p.last.RefreshToken == tok.RefreshToken { + return tok, nil // 変化なし → 保存しない + } + if err := p.store.Save(p.accountID, tok); err != nil { + return nil, fmt.Errorf("persist refreshed token for %s: %w", p.accountID, err) + } + p.last = tok + return tok, nil +} diff --git a/internal/auth/tokens_test.go b/internal/auth/tokens_test.go new file mode 100644 index 0000000..9c7b0a8 --- /dev/null +++ b/internal/auth/tokens_test.go @@ -0,0 +1,194 @@ +package auth + +import ( + "errors" + "io/fs" + "os" + "path/filepath" + "runtime" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" +) + +func TestTokenStoreSaveLoadRoundTrip(t *testing.T) { + expiry := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC) + tests := []struct { + name string + accountID string + tok *oauth2.Token + }{ + { + name: "full token", + accountID: "work-ms", + tok: &oauth2.Token{ + AccessToken: "at-1", + TokenType: "Bearer", + RefreshToken: "rt-1", + Expiry: expiry, + }, + }, + { + name: "no refresh token", + accountID: "personal", + tok: &oauth2.Token{ + AccessToken: "at-2", + TokenType: "Bearer", + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ts := &TokenStore{Dir: t.TempDir()} + require.NoError(t, ts.Save(tc.accountID, tc.tok)) + + // ファイルは /tokens/.json、権限 0600(仕様書7章) + path := filepath.Join(ts.Dir, "tokens", tc.accountID+".json") + info, err := os.Stat(path) + require.NoError(t, err) + if runtime.GOOS != "windows" { + require.Equal(t, os.FileMode(0o600), info.Mode().Perm()) + } + + got, err := ts.Load(tc.accountID) + require.NoError(t, err) + require.Equal(t, tc.tok.AccessToken, got.AccessToken) + require.Equal(t, tc.tok.TokenType, got.TokenType) + require.Equal(t, tc.tok.RefreshToken, got.RefreshToken) + require.True(t, tc.tok.Expiry.Equal(got.Expiry), + "expiry round-trip: want %v got %v", tc.tok.Expiry, got.Expiry) + }) + } +} + +func TestTokenStoreLoadMissing(t *testing.T) { + ts := &TokenStore{Dir: t.TempDir()} + tok, err := ts.Load("nope") + require.Error(t, err) + require.ErrorIs(t, err, fs.ErrNotExist) + require.Nil(t, tok) +} + +func TestTokenStoreDelete(t *testing.T) { + ts := &TokenStore{Dir: t.TempDir()} + require.NoError(t, ts.Save("acc", &oauth2.Token{AccessToken: "at"})) + + require.NoError(t, ts.Delete("acc")) + _, err := ts.Load("acc") + require.Error(t, err) + + // Delete は冪等: 既に無くてもエラーにしない(accounts remove の再実行安全性) + require.NoError(t, ts.Delete("acc")) +} + +func TestTokenStoreRejectsInvalidAccountID(t *testing.T) { + ts := &TokenStore{Dir: t.TempDir()} + tests := []struct { + name string + id string + }{ + {name: "empty", id: ""}, + {name: "dot", id: "."}, + {name: "dotdot", id: ".."}, + {name: "slash", id: "a/b"}, + {name: "backslash", id: `a\b`}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + require.Error(t, ts.Save(tc.id, &oauth2.Token{AccessToken: "x"})) + _, err := ts.Load(tc.id) + require.Error(t, err) + require.Error(t, ts.Delete(tc.id)) + }) + } +} + +// fakeTokenSource はキューに積んだトークンを順に返す(最後の1件は繰り返す)。 +type fakeTokenSource struct { + mu sync.Mutex + toks []*oauth2.Token + err error // 非 nil なら常にこのエラーを返す + calls int +} + +func (f *fakeTokenSource) Token() (*oauth2.Token, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.calls++ + if f.err != nil { + return nil, f.err + } + if len(f.toks) == 0 { + return nil, errors.New("fakeTokenSource: no tokens queued") + } + tok := f.toks[0] + if len(f.toks) > 1 { + f.toks = f.toks[1:] + } + return tok, nil +} + +func TestPersistingTokenSource(t *testing.T) { + dir := t.TempDir() + store := &TokenStore{Dir: dir} + t1 := &oauth2.Token{AccessToken: "at-1", TokenType: "Bearer", RefreshToken: "rt-1"} + t2 := &oauth2.Token{AccessToken: "at-2", TokenType: "Bearer", RefreshToken: "rt-2"} + + // 事前に t1 を保存(auth add 済みの状態)。コンストラクタがこれを読み込む + require.NoError(t, store.Save("acc", t1)) + base := &fakeTokenSource{toks: []*oauth2.Token{t1, t1, t2}} + src := PersistingTokenSource("acc", store, base) + + // Save が呼ばれたかを「ファイルの再出現」で観測するため、いったん消す + tokenPath := filepath.Join(dir, "tokens", "acc.json") + require.NoError(t, os.Remove(tokenPath)) + + // 1回目: base は保存済みと同一のトークンを返す → Save されない + got, err := src.Token() + require.NoError(t, err) + require.Equal(t, "at-1", got.AccessToken) + _, statErr := os.Stat(tokenPath) + require.ErrorIs(t, statErr, fs.ErrNotExist, "identical token must not be re-saved") + + // 2回目: まだ同一 → Save されない + _, err = src.Token() + require.NoError(t, err) + _, statErr = os.Stat(tokenPath) + require.ErrorIs(t, statErr, fs.ErrNotExist, "identical token must not be re-saved") + + // 3回目: RefreshToken がローテーション → Save される(仕様書9.3) + got, err = src.Token() + require.NoError(t, err) + require.Equal(t, "rt-2", got.RefreshToken) + saved, err := store.Load("acc") + require.NoError(t, err) + require.Equal(t, "at-2", saved.AccessToken) + require.Equal(t, "rt-2", saved.RefreshToken) + require.Equal(t, 3, base.calls) +} + +func TestPersistingTokenSourceSavesFirstTokenWhenNoneStored(t *testing.T) { + store := &TokenStore{Dir: t.TempDir()} + t1 := &oauth2.Token{AccessToken: "at-1", RefreshToken: "rt-1"} + src := PersistingTokenSource("acc", store, &fakeTokenSource{toks: []*oauth2.Token{t1}}) + + _, err := src.Token() + require.NoError(t, err) + + saved, err := store.Load("acc") + require.NoError(t, err) + require.Equal(t, "rt-1", saved.RefreshToken) +} + +func TestPersistingTokenSourcePropagatesBaseError(t *testing.T) { + store := &TokenStore{Dir: t.TempDir()} + baseErr := errors.New("refresh failed") + src := PersistingTokenSource("acc", store, &fakeTokenSource{err: baseErr}) + + tok, err := src.Token() + require.ErrorIs(t, err, baseErr) + require.Nil(t, tok) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..93acb44 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,233 @@ +package config + +import ( + "fmt" + "os" + "regexp" + "strconv" + "strings" + "time" + + "gopkg.in/yaml.v3" + + "github.com/cloudnative-co/calsync/internal/model" +) + +type Config struct { + PollInterval time.Duration // 既定 1m + SyncWindowMonths int // "3mo" → 3。SyncWindowDays と排他 + SyncWindowDays int // "90d" → 90 + BlockerTitle string // 既定 "予定あり" + ReconcileAt string // 既定 "04:00"(コンテナのローカルTZで解釈) + DedupeSameMeeting bool // 既定 true + BusyShowAs []string // 既定 [busy, oof, tentative] + Providers ProvidersConfig + Accounts []Account +} + +type ProvidersConfig struct { + Google struct{ CredentialsFile string } // GCP クライアントJSON のパス + Microsoft struct{ ClientID string } // Entra アプリの client_id +} + +type Account struct { + ID, Provider, Email string // Provider は "google" | "microsoft" + Calendars []string // 既定 ["primary"]。microsoft は ["primary"] 以外エラー(v1制約) + BlockerCalendar string // 既定 "primary" +} + +// rawConfig は YAML の生の形。KnownFields(true) の照合対象になるため、 +// 受理するキーはここに列挙されたものが全て。 +type rawConfig struct { + PollInterval string `yaml:"poll_interval"` + SyncWindow string `yaml:"sync_window"` + BlockerTitle string `yaml:"blocker_title"` + ReconcileAt string `yaml:"reconcile_at"` + DedupeSameMeeting *bool `yaml:"dedupe_same_meeting"` // 未指定(nil)と false を区別する + BusyShowAs []string `yaml:"busy_show_as"` + Providers rawProviders `yaml:"providers"` + Accounts []rawAccount `yaml:"accounts"` +} + +type rawProviders struct { + Google rawGoogleProvider `yaml:"google"` + Microsoft rawMicrosoftProvider `yaml:"microsoft"` +} + +type rawGoogleProvider struct { + CredentialsFile string `yaml:"credentials_file"` +} + +type rawMicrosoftProvider struct { + ClientID string `yaml:"client_id"` +} + +type rawAccount struct { + ID string `yaml:"id"` + Provider string `yaml:"provider"` + Email string `yaml:"email"` + Calendars []string `yaml:"calendars"` + BlockerCalendar string `yaml:"blocker_calendar"` +} + +var syncWindowRe = regexp.MustCompile(`^([0-9]+)(mo|d)$`) + +// validShowAs は Graph の freeBusyStatus が取りうる値(busy_show_as の許容値)。 +// 大文字小文字は Graph の応答表記(camelCase)に合わせて厳密比較する。 +var validShowAs = map[string]bool{ + "free": true, + "tentative": true, + "busy": true, + "oof": true, + "workingElsewhere": true, + "unknown": true, +} + +// Load は YAML 設定を読み込み、検証とデフォルト補完を行う。 +func Load(path string) (*Config, error) { + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("config: %w", err) + } + defer f.Close() + + var raw rawConfig + dec := yaml.NewDecoder(f) + dec.KnownFields(true) // 未知キーはエラー(タイポの黙殺を防ぐ) + if err := dec.Decode(&raw); err != nil { + return nil, fmt.Errorf("config: parse %s: %w", path, err) + } + + cfg := &Config{ + PollInterval: time.Minute, + SyncWindowMonths: 3, + BlockerTitle: "予定あり", + ReconcileAt: "04:00", + DedupeSameMeeting: true, + BusyShowAs: []string{"busy", "oof", "tentative"}, + } + cfg.Providers.Google.CredentialsFile = raw.Providers.Google.CredentialsFile + cfg.Providers.Microsoft.ClientID = raw.Providers.Microsoft.ClientID + + if raw.PollInterval != "" { + d, err := time.ParseDuration(raw.PollInterval) + if err != nil || d <= 0 { + return nil, fmt.Errorf("config: invalid poll_interval %q (want a positive Go duration such as \"1m\")", raw.PollInterval) + } + cfg.PollInterval = d + } + + if raw.SyncWindow != "" { + m := syncWindowRe.FindStringSubmatch(raw.SyncWindow) + if m == nil { + return nil, fmt.Errorf("config: invalid sync_window %q (want \"mo\" or \"d\", e.g. \"3mo\", \"90d\")", raw.SyncWindow) + } + n, err := strconv.Atoi(m[1]) + if err != nil || n <= 0 { + return nil, fmt.Errorf("config: invalid sync_window %q (value must be a positive integer)", raw.SyncWindow) + } + if m[2] == "mo" { + cfg.SyncWindowMonths, cfg.SyncWindowDays = n, 0 + } else { + cfg.SyncWindowMonths, cfg.SyncWindowDays = 0, n + } + } + + if raw.BlockerTitle != "" { + cfg.BlockerTitle = raw.BlockerTitle + } + if raw.ReconcileAt != "" { + cfg.ReconcileAt = raw.ReconcileAt + } + if _, err := time.Parse("15:04", cfg.ReconcileAt); err != nil { + return nil, fmt.Errorf("config: invalid reconcile_at %q (want \"HH:MM\", e.g. \"04:00\")", cfg.ReconcileAt) + } + if raw.DedupeSameMeeting != nil { + cfg.DedupeSameMeeting = *raw.DedupeSameMeeting + } + if len(raw.BusyShowAs) > 0 { + for _, v := range raw.BusyShowAs { + if !validShowAs[v] { + return nil, fmt.Errorf("config: invalid busy_show_as value %q (want one of free, tentative, busy, oof, workingElsewhere, unknown)", v) + } + } + cfg.BusyShowAs = raw.BusyShowAs + } + + seen := make(map[string]bool, len(raw.Accounts)) + for i, ra := range raw.Accounts { + a := Account{ + ID: ra.ID, + Provider: ra.Provider, + Email: ra.Email, + Calendars: ra.Calendars, + BlockerCalendar: ra.BlockerCalendar, + } + if a.ID == "" { + return nil, fmt.Errorf("config: accounts[%d]: id is required", i) + } + if strings.Contains(a.ID, ":") { + // OriginTag は ":" 形式で、parseOriginTag は最初の + // ":" で切る(engine/reconcile.go)。account id に ":" を許すと adoption が + // タグを誤パースし、正規ブロッカーを孤児と誤認して削除しうる。 + return nil, fmt.Errorf("config: account %q: id must not contain %q (reserved as the origin tag separator)", a.ID, ":") + } + if seen[a.ID] { + return nil, fmt.Errorf("config: duplicate account id %q", a.ID) + } + seen[a.ID] = true + if a.Provider != "google" && a.Provider != "microsoft" { + return nil, fmt.Errorf("config: account %q: unsupported provider %q (want \"google\" or \"microsoft\")", a.ID, a.Provider) + } + if len(a.Calendars) == 0 { + a.Calendars = []string{"primary"} + } + if a.BlockerCalendar == "" { + a.BlockerCalendar = "primary" + } + if a.Provider == "microsoft" { + for _, cal := range a.Calendars { + if cal != "primary" { + return nil, fmt.Errorf("config: account %q: microsoft supports only the primary calendar in v1 (got calendar %q)", a.ID, cal) + } + } + if a.BlockerCalendar != "primary" { + return nil, fmt.Errorf("config: account %q: microsoft supports only the primary calendar in v1 (got blocker_calendar %q)", a.ID, a.BlockerCalendar) + } + } + cfg.Accounts = append(cfg.Accounts, a) + } + + return cfg, nil +} + +// WindowFrom は now を起点とする同期ウィンドウを返す。 +// months は AddDate(0, mo, 0)、days は AddDate(0, 0, d) で終端を計算する。 +func (c *Config) WindowFrom(now time.Time) model.Window { + end := now.AddDate(0, c.SyncWindowMonths, 0) + if c.SyncWindowDays > 0 { + end = now.AddDate(0, 0, c.SyncWindowDays) + } + return model.Window{Start: now, End: end} +} + +// TargetsOf は origin 以外の全アカウント(= ブロッカー配布先)を返す。 +func (c *Config) TargetsOf(originAccountID string) []Account { + var out []Account + for _, a := range c.Accounts { + if a.ID != originAccountID { + out = append(out, a) + } + } + return out +} + +// AccountByID は該当アカウントへのポインタを返す。無ければ nil。 +func (c *Config) AccountByID(id string) *Account { + for i := range c.Accounts { + if c.Accounts[i].ID == id { + return &c.Accounts[i] + } + } + return nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..54aef0c --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,294 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func writeConfig(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "calsync.yaml") + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + return path +} + +const minimalYAML = ` +accounts: + - id: personal + provider: google + email: user@gmail.com +` + +func TestLoad(t *testing.T) { + tests := []struct { + name string + yaml string + wantErr string // 空なら成功を期待。非空なら err.Error() に含まれる文字列 + check func(t *testing.T, c *Config) // 成功時の追加検証 + }{ + { + name: "defaults are applied for a minimal config", + yaml: minimalYAML, + check: func(t *testing.T, c *Config) { + require.Equal(t, time.Minute, c.PollInterval) + require.Equal(t, 3, c.SyncWindowMonths) + require.Equal(t, 0, c.SyncWindowDays) + require.Equal(t, "予定あり", c.BlockerTitle) + require.Equal(t, "04:00", c.ReconcileAt) + require.True(t, c.DedupeSameMeeting) + require.Equal(t, []string{"busy", "oof", "tentative"}, c.BusyShowAs) + require.Len(t, c.Accounts, 1) + require.Equal(t, "personal", c.Accounts[0].ID) + require.Equal(t, "google", c.Accounts[0].Provider) + require.Equal(t, "user@gmail.com", c.Accounts[0].Email) + require.Equal(t, []string{"primary"}, c.Accounts[0].Calendars) + require.Equal(t, "primary", c.Accounts[0].BlockerCalendar) + }, + }, + { + name: "explicit values override defaults", + yaml: ` +poll_interval: 5m +sync_window: 90d +blocker_title: Busy +reconcile_at: "03:30" +dedupe_same_meeting: false +busy_show_as: [busy, oof] +providers: + google: + credentials_file: /data/google-client.json + microsoft: + client_id: 00000000-1111-2222-3333-444444444444 +accounts: + - id: personal + provider: google + email: user@gmail.com + calendars: [primary, team@group.calendar.google.com] + blocker_calendar: blockers@group.calendar.google.com + - id: work-ms + provider: microsoft + email: user@example365.co.jp +`, + check: func(t *testing.T, c *Config) { + require.Equal(t, 5*time.Minute, c.PollInterval) + require.Equal(t, 0, c.SyncWindowMonths) + require.Equal(t, 90, c.SyncWindowDays) + require.Equal(t, "Busy", c.BlockerTitle) + require.Equal(t, "03:30", c.ReconcileAt) + require.False(t, c.DedupeSameMeeting) + require.Equal(t, []string{"busy", "oof"}, c.BusyShowAs) + require.Equal(t, "/data/google-client.json", c.Providers.Google.CredentialsFile) + require.Equal(t, "00000000-1111-2222-3333-444444444444", c.Providers.Microsoft.ClientID) + require.Len(t, c.Accounts, 2) + require.Equal(t, []string{"primary", "team@group.calendar.google.com"}, c.Accounts[0].Calendars) + require.Equal(t, "blockers@group.calendar.google.com", c.Accounts[0].BlockerCalendar) + require.Equal(t, []string{"primary"}, c.Accounts[1].Calendars) + require.Equal(t, "primary", c.Accounts[1].BlockerCalendar) + }, + }, + { + name: "sync_window in months", + yaml: "sync_window: 6mo\n" + minimalYAML, + check: func(t *testing.T, c *Config) { + require.Equal(t, 6, c.SyncWindowMonths) + require.Equal(t, 0, c.SyncWindowDays) + }, + }, + { + name: "invalid sync_window unit is rejected", + yaml: "sync_window: 3w\n" + minimalYAML, + wantErr: "invalid sync_window", + }, + { + name: "invalid poll_interval is rejected", + yaml: "poll_interval: fast\n" + minimalYAML, + wantErr: "invalid poll_interval", + }, + { + name: "invalid reconcile_at is rejected", + yaml: "reconcile_at: \"25:99\"\n" + minimalYAML, + wantErr: "invalid reconcile_at", + }, + { + name: "duplicate account id is rejected", + yaml: ` +accounts: + - id: personal + provider: google + email: a@gmail.com + - id: personal + provider: microsoft + email: b@example.com +`, + wantErr: `duplicate account id "personal"`, + }, + { + name: "missing account id is rejected", + yaml: ` +accounts: + - provider: google + email: a@gmail.com +`, + wantErr: "id is required", + }, + { + // OriginTag は ":" 形式で、parseOriginTag は最初の + // ":" で切る(engine/reconcile.go)。account id に ":" を許すと adoption が + // タグを誤パースし、正規ブロッカーを孤児と誤認して削除しうる + // (最終ホールブランチレビュー所見3)。 + name: "account id containing a colon is rejected", + yaml: ` +accounts: + - id: "work:primary" + provider: google + email: a@gmail.com +`, + wantErr: `id must not contain ":"`, + }, + { + // busy_show_as は Graph の freeBusyStatus 列挙値のみ許容する + // (タイポを黙って「busy 扱いされない値」として吸収しない) + name: "invalid busy_show_as value is rejected", + yaml: "busy_show_as: [busy, ooof]\n" + minimalYAML, + wantErr: "invalid busy_show_as", + }, + { + // 大文字小文字は Graph の camelCase 表記に厳密一致(WorkingElsewhere は不可) + name: "busy_show_as is case-sensitive", + yaml: "busy_show_as: [Busy]\n" + minimalYAML, + wantErr: "invalid busy_show_as", + }, + { + name: "all valid busy_show_as values are accepted", + yaml: "busy_show_as: [free, tentative, busy, oof, workingElsewhere, unknown]\n" + minimalYAML, + check: func(t *testing.T, c *Config) { + require.Equal(t, []string{"free", "tentative", "busy", "oof", "workingElsewhere", "unknown"}, c.BusyShowAs) + }, + }, + { + name: "unsupported provider is rejected", + yaml: ` +accounts: + - id: icloud + provider: apple + email: a@icloud.com +`, + wantErr: `unsupported provider "apple"`, + }, + { + name: "microsoft non-primary calendars are rejected (v1 constraint)", + yaml: ` +accounts: + - id: work-ms + provider: microsoft + email: a@example.com + calendars: [primary, second] +`, + wantErr: "microsoft supports only the primary calendar", + }, + { + name: "microsoft non-primary blocker_calendar is rejected (v1 constraint)", + yaml: ` +accounts: + - id: work-ms + provider: microsoft + email: a@example.com + blocker_calendar: second +`, + wantErr: "microsoft supports only the primary calendar", + }, + { + name: "unknown top-level key is rejected by KnownFields", + yaml: "pol_interval: 1m\n" + minimalYAML, + wantErr: "field pol_interval not found", + }, + { + name: "unknown account key is rejected by KnownFields", + yaml: ` +accounts: + - id: personal + provider: google + email: a@gmail.com + callendars: [primary] +`, + wantErr: "field callendars not found", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg, err := Load(writeConfig(t, tt.yaml)) + if tt.wantErr != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + tt.check(t, cfg) + }) + } +} + +func TestLoadMissingFile(t *testing.T) { + _, err := Load(filepath.Join(t.TempDir(), "nope.yaml")) + require.Error(t, err) +} + +func TestWindowFrom(t *testing.T) { + now := time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC) + tests := []struct { + name string + cfg Config + wantEnd time.Time + }{ + { + name: "months uses AddDate(0, mo, 0)", + cfg: Config{SyncWindowMonths: 3}, + wantEnd: time.Date(2026, 10, 3, 10, 0, 0, 0, time.UTC), + }, + { + name: "days uses AddDate(0, 0, d)", + cfg: Config{SyncWindowDays: 90}, + wantEnd: time.Date(2026, 10, 1, 10, 0, 0, 0, time.UTC), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := tt.cfg.WindowFrom(now) + require.Equal(t, now, w.Start) + require.Equal(t, tt.wantEnd, w.End) + }) + } +} + +func TestTargetsOfAndAccountByID(t *testing.T) { + src := ` +accounts: + - id: a + provider: google + email: a@gmail.com + - id: b + provider: microsoft + email: b@example.com + - id: c + provider: google + email: c@gmail.com +` + cfg, err := Load(writeConfig(t, src)) + require.NoError(t, err) + + targets := cfg.TargetsOf("b") + ids := make([]string, 0, len(targets)) + for _, a := range targets { + ids = append(ids, a.ID) + } + require.Equal(t, []string{"a", "c"}, ids, "TargetsOf must return all accounts except the origin") + + acct := cfg.AccountByID("b") + require.NotNil(t, acct) + require.Equal(t, "microsoft", acct.Provider) + require.Nil(t, cfg.AccountByID("missing")) +} diff --git a/internal/engine/classify.go b/internal/engine/classify.go new file mode 100644 index 0000000..74ab5a8 --- /dev/null +++ b/internal/engine/classify.go @@ -0,0 +1,16 @@ +package engine + +import "github.com/cloudnative-co/calsync/internal/model" + +// ShouldBlock は「busy かつ未辞退かつ削除通知でない」イベントだけを +// ブロッカー配布の対象とする(仕様6.2。未返信・仮承諾は IsBusy=true として +// 正規化されるためブロック対象になる)。 +func ShouldBlock(ev model.NormalizedEvent) bool { + return ev.IsBusy && !ev.IsDeclined && !ev.Deleted +} + +// InWindow は同期ウィンドウ判定。クライアント側フィルタが正であり(仕様5.3)、 +// 判定本体は model.Window.Contains(end > Start && start < End)に委譲する。 +func InWindow(w model.Window, ev model.NormalizedEvent) bool { + return w.Contains(ev) +} diff --git a/internal/engine/classify_test.go b/internal/engine/classify_test.go new file mode 100644 index 0000000..aef4b99 --- /dev/null +++ b/internal/engine/classify_test.go @@ -0,0 +1,94 @@ +package engine + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/cloudnative-co/calsync/internal/model" +) + +// ShouldBlock の真理値表: IsBusy / IsDeclined / Deleted の全 8 組合せ。 +// true になるのは「busy かつ未辞退かつ削除通知でない」の 1 組合せだけ(仕様6.2)。 +func TestShouldBlock_TruthTable(t *testing.T) { + cases := []struct { + busy bool + declined bool + deleted bool + want bool + }{ + {false, false, false, false}, + {false, false, true, false}, + {false, true, false, false}, + {false, true, true, false}, + {true, false, false, true}, + {true, false, true, false}, + {true, true, false, false}, + {true, true, true, false}, + } + for _, tc := range cases { + name := fmt.Sprintf("busy=%v_declined=%v_deleted=%v", tc.busy, tc.declined, tc.deleted) + t.Run(name, func(t *testing.T) { + ev := model.NormalizedEvent{ + ID: "e1", + IsBusy: tc.busy, + IsDeclined: tc.declined, + Deleted: tc.deleted, + } + require.Equal(t, tc.want, ShouldBlock(ev)) + }) + } +} + +// InWindow / Window.Contains の境界: end > w.Start && start < w.End +// (Google timeMin/timeMax と同じ境界セマンティクス。仕様5.3)。 +func TestInWindow_Boundaries(t *testing.T) { + w := model.Window{ + Start: time.Date(2026, 7, 3, 9, 0, 0, 0, time.UTC), + End: time.Date(2026, 10, 3, 9, 0, 0, 0, time.UTC), + } + timed := func(start, end time.Time) model.NormalizedEvent { + return model.NormalizedEvent{ID: "e1", StartUTC: start, EndUTC: end} + } + allDay := func(startDate, endDate string) model.NormalizedEvent { + return model.NormalizedEvent{ID: "e1", IsAllDay: true, AllDayStart: startDate, AllDayEnd: endDate} + } + + cases := []struct { + name string + ev model.NormalizedEvent + want bool + }{ + {"完全に内側", timed( + time.Date(2026, 7, 10, 10, 0, 0, 0, time.UTC), + time.Date(2026, 7, 10, 11, 0, 0, 0, time.UTC)), true}, + {"終了がウィンドウ開始と一致(end > Start は排他)", timed( + w.Start.Add(-time.Hour), w.Start), false}, + {"終了がウィンドウ開始の1秒後(境界を跨ぐ重なり)", timed( + w.Start.Add(-time.Hour), w.Start.Add(time.Second)), true}, + {"開始がウィンドウ終了と一致(start < End は排他)", timed( + w.End, w.End.Add(time.Hour)), false}, + {"開始がウィンドウ終了の1秒前(境界を跨ぐ重なり)", timed( + w.End.Add(-time.Second), w.End.Add(time.Hour)), true}, + {"ウィンドウ全体を包含する長期イベント", timed( + w.Start.Add(-24*time.Hour), w.End.Add(24*time.Hour)), true}, + {"完全に過去", timed( + w.Start.Add(-2*time.Hour), w.Start.Add(-time.Hour)), false}, + {"完全に未来", timed( + w.End.Add(time.Hour), w.End.Add(2*time.Hour)), false}, + {"終日: ウィンドウ内", allDay("2026-07-10", "2026-07-11"), true}, + {"終日: 排他的終了日がウィンドウ開始日(00:00 < 09:00 で対象外)", allDay("2026-07-02", "2026-07-03"), false}, + {"終日: ウィンドウ終了日に開始(00:00 < 09:00 で重なりあり)", allDay("2026-10-03", "2026-10-04"), true}, + {"終日: 開始日が不正な文字列", allDay("not-a-date", "2026-07-11"), false}, + {"終日: 終了日が不正な文字列", allDay("2026-07-10", ""), false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, InWindow(w, tc.ev)) + // InWindow は Window.Contains への委譲であることも固定する + require.Equal(t, tc.want, w.Contains(tc.ev)) + }) + } +} diff --git a/internal/engine/dedupe.go b/internal/engine/dedupe.go new file mode 100644 index 0000000..9565ae5 --- /dev/null +++ b/internal/engine/dedupe.go @@ -0,0 +1,102 @@ +package engine + +import ( + "context" + + "github.com/cloudnative-co/calsync/internal/config" + "github.com/cloudnative-co/calsync/internal/model" + "github.com/cloudnative-co/calsync/internal/store" +) + +// isDuplicateOnTarget は「ターゲットの events キャッシュに同一 iCalUID・同一開始の +// busy 実予定が存在するか」を判定する(仕様6.5)。真の場合、upsertBlockers は +// ブロッカーを作らず status=suppressed の mapping を記録する。 +// 判定単位: 時刻指定は iCalUID + StartUTC の完全一致、終日は iCalUID + AllDayStart の一致。 +// Google は繰り返し全回が同一 iCalUID、Graph は回ごとに異なるため、単発は確実・ +// 繰り返しはベストエフォート(抑止漏れはブロッカーが重なるだけで安全側)。 +// Cfg.DedupeSameMeeting=false なら常に false(抑止しない)。 +func (e *Engine) isDuplicateOnTarget(target config.Account, ev model.NormalizedEvent) (bool, error) { + if !e.Cfg.DedupeSameMeeting || ev.ICalUID == "" { + return false, nil + } + allDayStart := "" + if ev.IsAllDay { + allDayStart = ev.AllDayStart + } + return e.Store.HasBusyEventByICalUID(target.ID, ev.ICalUID, ev.StartUTC, allDayStart) +} + +// promoteSuppressed はターゲット上の実予定(icalUID)が消えたときに呼ばれ、 +// その実予定を理由に抑止されていた suppressed mapping をブロッカー作成に昇格する +// (仕様6.5「削除処理時に iCalUID で suppressed を逆引き」)。 +// 呼び出し元: processEvent の削除通知処理・非 busy 化処理(Task 8 で配線済み)、 +// FullResync の set-difference(Task 10)。 +func (e *Engine) promoteSuppressed(ctx context.Context, targetAccountID, icalUID string) error { + if icalUID == "" { + return nil + } + maps, err := e.Store.ListSuppressedByOriginICalUID(targetAccountID, icalUID) + if err != nil { + return err + } + for _, m := range maps { + originRef := model.CalendarRef{AccountID: m.OriginAccount, CalendarID: m.OriginCalendar} + ev, err := e.Store.GetEvent(originRef, m.OriginEventID) + if err != nil { + return err + } + if ev == nil { + // ListSuppressedByOriginICalUID は events キャッシュと JOIN 済みのため + // 通常到達しない(防御)。掃除はリコンサイルの責務 + continue + } + target := e.Cfg.AccountByID(m.TargetAccount) + if target == nil { + continue // 設定から消えたアカウント。掃除は accounts remove の責務 + } + // 同一会議の別の実予定がまだ残っていれば昇格しない + dup, err := e.isDuplicateOnTarget(*target, *ev) + if err != nil { + return err + } + if dup { + continue + } + if err := e.createFromMapping(ctx, m, *ev); err != nil { + return err + } + } + return nil +} + +// createFromMapping は既存 mapping(suppressed / pending)に記録済みの冪等キーを +// 使ってブロッカーを作成し、active へ遷移させる(intent-first。仕様6.4)。 +// 同一キーの再実行は既存 ID が返るため二重作成にならない。 +// Task 11 の pending 解決・suppressed 再評価もこのヘルパを使う。 +// ターゲットの認証失効は TargetAuthError に包んで返す(仕様9.3。呼び出し元が +// 失効アカウントに帰属させられるようにする)。 +func (e *Engine) createFromMapping(ctx context.Context, m store.Mapping, ev model.NormalizedEvent) error { + p, err := e.providerFor(m.TargetAccount) + if err != nil { + return err + } + targetCal := model.CalendarRef{AccountID: m.TargetAccount, CalendarID: m.TargetCalendar} + originTag := model.OriginTagOf(m.OriginAccount, m.OriginEventID) + b, err := e.blockerFor(ctx, ev, originTag, targetCal, p) + if err != nil { + return wrapTargetAuth(m.TargetAccount, err) + } + m.TimeHash = model.TimeHash(ev) + m.Status = store.StatusPending + m.BlockerEventID = "" + if err := e.Store.PutMapping(m); err != nil { + return err + } + eventID, err := p.CreateBlocker(ctx, targetCal, b, m.IdempotencyKey) + if err != nil { + return wrapTargetAuth(m.TargetAccount, err) + } + m.BlockerEventID = eventID + m.Status = store.StatusActive + return e.Store.PutMapping(m) +} diff --git a/internal/engine/dedupe_test.go b/internal/engine/dedupe_test.go new file mode 100644 index 0000000..ca1026e --- /dev/null +++ b/internal/engine/dedupe_test.go @@ -0,0 +1,225 @@ +package engine + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/cloudnative-co/calsync/internal/model" + "github.com/cloudnative-co/calsync/internal/store" +) + +// dupPair は「a の origin 予定」と「b 上の同一会議の実予定」の組を返す。 +// 同一 iCalUID で、時刻指定なら同一開始時刻、終日なら同一開始日(仕様6.5の判定単位)。 +// Task 11 の suppressed 再評価テストからも再利用される。 +func dupPair(allDay bool) (origin, real model.NormalizedEvent) { + if allDay { + origin = model.NormalizedEvent{ + ID: "a-ev", ICalUID: "meet-1@example.com", IsAllDay: true, + AllDayStart: "2026-07-15", AllDayEnd: "2026-07-16", IsBusy: true, + } + real = model.NormalizedEvent{ + ID: "b-ev", ICalUID: "meet-1@example.com", IsAllDay: true, + AllDayStart: "2026-07-15", AllDayEnd: "2026-07-16", IsBusy: true, + } + return origin, real + } + start := time.Date(2026, 7, 10, 1, 0, 0, 0, time.UTC) + origin = model.NormalizedEvent{ + ID: "a-ev", ICalUID: "meet-1@example.com", + StartUTC: start, EndUTC: start.Add(time.Hour), IsBusy: true, + } + real = model.NormalizedEvent{ + ID: "b-ev", ICalUID: "meet-1@example.com", + // 終了時刻は判定に使わない(開始一致のみ)ことを検証するため意図的にズラす + StartUTC: start, EndUTC: start.Add(30 * time.Minute), IsBusy: true, + } + return origin, real +} + +// 抑止: ターゲット b のキャッシュに同一会議の busy 実予定がある場合、 +// b にはブロッカーを作らず status=suppressed の mapping を記録する。 +// 時刻指定と終日の両方を検証する。 +func TestDedupe_SuppressesSameMeetingOnTarget(t *testing.T) { + cases := []struct { + name string + allDay bool + }{ + {"時刻指定: 同一 iCalUID + 同一開始時刻", false}, + {"終日: 同一 iCalUID + 同一開始日", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + e, f := newTestEngine(t) + ctx := context.Background() + origin, real := dupPair(tc.allDay) + + // b のイベントキャッシュに同一会議の busy 実予定を置く + require.NoError(t, e.Store.UpsertEvent(calBv, real)) + + require.NoError(t, e.processEvent(ctx, refA, origin)) + + // b にはブロッカーを作らない + require.Empty(t, f.Blockers(calBv)) + m, err := e.Store.GetMapping("a", "primary", "a-ev", "b") + require.NoError(t, err) + require.NotNil(t, m) + require.Equal(t, store.StatusSuppressed, m.Status) + require.Empty(t, m.BlockerEventID) + require.Equal(t, model.TimeHash(origin), m.TimeHash) + // 冪等キーは suppressed の時点で確定している(昇格時にそのまま使う) + require.Equal(t, model.MSTransactionID(model.OriginTagOf("a", "a-ev"), "b"), m.IdempotencyKey) + + // 重複のない c には通常どおり作成される + require.Len(t, f.Blockers(calCv), 1) + mc, err := e.Store.GetMapping("a", "primary", "a-ev", "c") + require.NoError(t, err) + require.NotNil(t, mc) + require.Equal(t, store.StatusActive, mc.Status) + }) + } +} + +// 「同一会議」でなければ抑止しない(iCalUID 不一致・開始不一致・終日/時刻指定の食い違い) +func TestDedupe_NoSuppressionWhenNotSameMeeting(t *testing.T) { + start := time.Date(2026, 7, 10, 1, 0, 0, 0, time.UTC) + cases := []struct { + name string + real model.NormalizedEvent + }{ + { + name: "iCalUID が異なる", + real: model.NormalizedEvent{ID: "b-ev", ICalUID: "other@example.com", + StartUTC: start, EndUTC: start.Add(time.Hour), IsBusy: true}, + }, + { + name: "開始時刻が異なる", + real: model.NormalizedEvent{ID: "b-ev", ICalUID: "meet-1@example.com", + StartUTC: start.Add(time.Hour), EndUTC: start.Add(2 * time.Hour), IsBusy: true}, + }, + { + name: "origin は時刻指定・ターゲットは終日", + real: model.NormalizedEvent{ID: "b-ev", ICalUID: "meet-1@example.com", IsAllDay: true, + AllDayStart: "2026-07-10", AllDayEnd: "2026-07-11", IsBusy: true}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + e, f := newTestEngine(t) + ctx := context.Background() + origin, _ := dupPair(false) + require.NoError(t, e.Store.UpsertEvent(calBv, tc.real)) + + require.NoError(t, e.processEvent(ctx, refA, origin)) + + require.Len(t, f.Blockers(calBv), 1) // 抑止されず通常作成 + m, err := e.Store.GetMapping("a", "primary", "a-ev", "b") + require.NoError(t, err) + require.NotNil(t, m) + require.Equal(t, store.StatusActive, m.Status) + }) + } +} + +// dedupe_same_meeting: false なら重複していても抑止しない(設定でオフ可。仕様3章) +func TestDedupe_DisabledByConfig(t *testing.T) { + e, f := newTestEngine(t) + e.Cfg.DedupeSameMeeting = false + ctx := context.Background() + origin, real := dupPair(false) + require.NoError(t, e.Store.UpsertEvent(calBv, real)) + + require.NoError(t, e.processEvent(ctx, refA, origin)) + + require.Len(t, f.Blockers(calBv), 1) + m, err := e.Store.GetMapping("a", "primary", "a-ev", "b") + require.NoError(t, err) + require.NotNil(t, m) + require.Equal(t, store.StatusActive, m.Status) +} + +// 昇格: ターゲット b の実予定の削除通知を processEvent が処理すると +// promoteSuppressed(b, iCalUID) が呼ばれ、suppressed が作成実行され active になる。 +// 時刻指定と終日の両方を検証する。 +func TestPromoteSuppressed_OnTargetRealEventDeletion(t *testing.T) { + cases := []struct { + name string + allDay bool + }{ + {"時刻指定", false}, + {"終日", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + e, f := newTestEngine(t) + ctx := context.Background() + origin, real := dupPair(tc.allDay) + tag := model.OriginTagOf("a", "a-ev") + + // b の実予定を b 自身の origin として通常処理(キャッシュ登録 + a/c へ配布) + require.NoError(t, e.processEvent(ctx, calBv, real)) + require.Len(t, f.Blockers(refA), 1) // b:b-ev のブロッカー + require.Len(t, f.Blockers(calCv), 1) // b:b-ev のブロッカー + + // a の origin は b では抑止される + require.NoError(t, e.processEvent(ctx, refA, origin)) + require.Empty(t, f.Blockers(calBv)) + require.Len(t, f.Blockers(calCv), 2) // b:b-ev + a:a-ev + + // b の実予定の削除通知(id のみ。仕様6.1) + require.NoError(t, e.processEvent(ctx, calBv, model.NormalizedEvent{ID: "b-ev", Deleted: true})) + + // suppressed が昇格し、b にブロッカーが立つ + blks := f.Blockers(calBv) + require.Len(t, blks, 1) + require.Equal(t, tag, blks[0].OriginTag) + require.Equal(t, model.TimeHash(origin), blks[0].TimeHash) + m, err := e.Store.GetMapping("a", "primary", "a-ev", "b") + require.NoError(t, err) + require.NotNil(t, m) + require.Equal(t, store.StatusActive, m.Status) + require.Equal(t, blks[0].EventID, m.BlockerEventID) + require.Equal(t, model.MSTransactionID(tag, "b"), m.IdempotencyKey) + + // 終日は終日ブロッカーとして昇格される + if tc.allDay { + body, ok := f.StoredBlocker(calBv, blks[0].EventID) + require.True(t, ok) + require.True(t, body.IsAllDay) + require.Equal(t, "2026-07-15", body.AllDayStart) + require.Equal(t, "2026-07-16", body.AllDayEnd) + } + + // b:b-ev が a/c に配っていたブロッカーは削除通知処理で消えている + require.Empty(t, f.Blockers(refA)) + require.Len(t, f.Blockers(calCv), 1) + require.Equal(t, tag, f.Blockers(calCv)[0].OriginTag) + }) + } +} + +// 昇格の再評価: 同一会議の実予定がまだ別に残っている場合は昇格しない +func TestPromoteSuppressed_KeepsSuppressedWhileAnotherDuplicateAlive(t *testing.T) { + e, f := newTestEngine(t) + ctx := context.Background() + origin, real := dupPair(false) + + // b のキャッシュに同一会議の実予定が 2 本ある(例: 主催者コピーと招待コピー) + real2 := real + real2.ID = "b-ev2" + require.NoError(t, e.processEvent(ctx, calBv, real)) + require.NoError(t, e.Store.UpsertEvent(calBv, real2)) // 2本目はキャッシュのみ + require.NoError(t, e.processEvent(ctx, refA, origin)) + require.Empty(t, f.Blockers(calBv)) // 抑止中 + + // 1本目の削除通知 → 2本目が残っているので昇格しない + require.NoError(t, e.processEvent(ctx, calBv, model.NormalizedEvent{ID: "b-ev", Deleted: true})) + + require.Empty(t, f.Blockers(calBv)) + m, err := e.Store.GetMapping("a", "primary", "a-ev", "b") + require.NoError(t, err) + require.NotNil(t, m) + require.Equal(t, store.StatusSuppressed, m.Status) +} diff --git a/internal/engine/engine.go b/internal/engine/engine.go new file mode 100644 index 0000000..d3699cc --- /dev/null +++ b/internal/engine/engine.go @@ -0,0 +1,430 @@ +package engine + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/cloudnative-co/calsync/internal/config" + "github.com/cloudnative-co/calsync/internal/model" + "github.com/cloudnative-co/calsync/internal/provider" + "github.com/cloudnative-co/calsync/internal/store" +) + +// Engine は同期エンジン本体。プロバイダ非依存(仕様2章)。 +type Engine struct { + Store *store.Store + Providers map[string]provider.Provider // key: account ID + Cfg *config.Config + Now func() time.Time // テストで固定する。nil なら time.Now +} + +func (e *Engine) now() time.Time { + if e.Now != nil { + return e.Now() + } + return time.Now() +} + +func (e *Engine) currentWindow() model.Window { return e.Cfg.WindowFrom(e.now()) } + +func (e *Engine) providerFor(accountID string) (provider.Provider, error) { + p, ok := e.Providers[accountID] + if !ok { + return nil, fmt.Errorf("no provider registered for account %q", accountID) + } + return p, nil +} + +// idemKeyFor はターゲットの provider 種別から決定的な冪等キーを導出する(仕様6.4)。 +// Google はクライアント生成イベントID、Microsoft は transactionId に使う。 +func idemKeyFor(providerName, originTag, targetAccountID string) string { + if providerName == "microsoft" { + return model.MSTransactionID(originTag, targetAccountID) + } + return model.GoogleBlockerID(originTag, targetAccountID) +} + +// SyncCalendar は 1 カレンダー分の差分を取得し、各イベントを processEvent へ流す。 +// カーソルは Changes が完走した(newCursor 非空)場合のみ永続化する(仕様5.1)。 +// 途中失敗時は旧カーソルのまま再実行される(全処理が冪等なので安全)。 +// カーソル失効(Google 410 / Graph 410・syncStateNotFound 系)は provider が +// ErrCursorInvalid に正規化して返し、ここで即時 FullResync に切り替えて自己修復する(仕様8章)。 +func (e *Engine) SyncCalendar(ctx context.Context, ref model.CalendarRef) error { + if err := e.Store.UpsertCalendar(ref); err != nil { + return err + } + st, err := e.Store.GetCalendar(ref) + if err != nil { + return err + } + cursor := "" + if st != nil { + cursor = st.Cursor + } + w := e.currentWindow() + p, err := e.providerFor(ref.AccountID) + if err != nil { + return err + } + events, newCursor, err := p.Changes(ctx, ref, cursor, w) + if errors.Is(err, provider.ErrCursorInvalid) { + return e.FullResync(ctx, ref) + } + if err != nil { + return err + } + var errs []error + for _, ev := range events { + if err := e.processEvent(ctx, ref, ev); err != nil { + errs = append(errs, err) + if onlyTargetAuthErrors(err) { + // ターゲットの認証失効だけなら origin 側の同期は継続・完走させる + // (仕様9.3: 失効はターゲットに帰属し、origin の同期は止めない) + continue + } + return errors.Join(errs...) + } + } + if newCursor != "" { + // TargetAuthError のみの場合もカーソルは前進させる。処理は冪等で、 + // 失効ターゲットへの配布は mapping(pending)とリコンサイルが復帰後に + // 回収するため、origin の差分を再取得し直す必要はない + if err := e.Store.SetCursor(ref, newCursor, w); err != nil { + errs = append(errs, err) + return errors.Join(errs...) + } + } + return errors.Join(errs...) +} + +// processEvent は仕様6.1のフローチャート(コントラクトの決定則1〜5)を実装する。 +func (e *Engine) processEvent(ctx context.Context, ref model.CalendarRef, ev model.NormalizedEvent) error { + // 決定則1: 削除通知。id しか含まれない前提のため、判定はタグではなく mappings で行う + if ev.Deleted { + isBlocker, err := e.Store.IsBlocker(ref.AccountID, ev.ID) + if err != nil { + return err + } + if isBlocker { + return nil // 自作ブロッカーの削除通知。復元はリコンサイルの責務 + } + maps, err := e.Store.ListMappingsForOrigin(ref.AccountID, ref.CalendarID, ev.ID) + if err != nil { + return err + } + if len(maps) == 0 { + return nil // 未知ID(Graph の @removed ウィンドウ外ノイズ含む) + } + // 削除通知には iCalUID が無いので、suppressed 昇格用にキャッシュから引く + cached, err := e.Store.GetEvent(ref, ev.ID) + if err != nil { + return err + } + if err := e.deleteBlockersForOrigin(ctx, ref, ev.ID); err != nil { + return err + } + if err := e.Store.DeleteEvent(ref, ev.ID); err != nil { + return err + } + if cached != nil && cached.ICalUID != "" { + return e.promoteSuppressed(ctx, ref.AccountID, cached.ICalUID) + } + return nil + } + + // 決定則2: ループ遮断(mappings 一次判定 / タグ二次判定) + isBlocker, err := e.Store.IsBlocker(ref.AccountID, ev.ID) + if err != nil { + return err + } + if isBlocker || ev.OriginTag != "" { + return nil + } + + // 決定則3: ウィンドウ外(クライアント側判定が正。仕様5.3) + if !InWindow(e.currentWindow(), ev) { + return e.deleteBlockersForOrigin(ctx, ref, ev.ID) + } + + // 決定則4: busy でない・辞退済み + if !ShouldBlock(ev) { + if err := e.deleteBlockersForOrigin(ctx, ref, ev.ID); err != nil { + return err + } + if err := e.Store.DeleteEvent(ref, ev.ID); err != nil { + return err + } + if ev.ICalUID != "" { + return e.promoteSuppressed(ctx, ref.AccountID, ev.ICalUID) + } + return nil + } + + // 決定則5: busy かつウィンドウ内 → イベントキャッシュ更新 + 全ターゲットへ upsert + if err := e.Store.UpsertEvent(ref, ev); err != nil { + return err + } + return e.upsertBlockers(ctx, ref, ev) +} + +// upsertBlockers は origin イベントを全ターゲットへ配布する。 +// 1 ターゲットの認証失効(provider.ErrAuthExpired)は TargetAuthError に包み、 +// そのターゲットだけスキップして他ターゲットへの配布は継続する(仕様9.3)。 +// それ以外のエラーは従来どおり即時中断する。エラーは errors.Join で集約して返す。 +func (e *Engine) upsertBlockers(ctx context.Context, ref model.CalendarRef, ev model.NormalizedEvent) error { + originTag := model.OriginTagOf(ref.AccountID, ev.ID) + timeHash := model.TimeHash(ev) + var errs []error + for _, target := range e.Cfg.TargetsOf(ref.AccountID) { + if err := e.upsertBlockerOnTarget(ctx, ref, ev, target, originTag, timeHash); err != nil { + err = wrapTargetAuth(target.ID, err) + errs = append(errs, err) + var tae *TargetAuthError + if errors.As(err, &tae) { + continue // 失効ターゲットのみスキップし、残りのターゲットへ配布を続ける + } + return errors.Join(errs...) + } + } + return errors.Join(errs...) +} + +// upsertBlockerOnTarget は 1 ターゲット分の配布を行う。 +// mapping なし → 重複抑止判定の上、作成(intent-first + 決定的冪等キー) +// mapping あり・time_hash 不一致 → UpdateBlocker(patch)。404 は再作成にフォールバック +// mapping あり・一致 → 何もしない +func (e *Engine) upsertBlockerOnTarget(ctx context.Context, ref model.CalendarRef, ev model.NormalizedEvent, target config.Account, originTag, timeHash string) error { + m, err := e.Store.GetMapping(ref.AccountID, ref.CalendarID, ev.ID, target.ID) + if err != nil { + return err + } + targetCal := model.CalendarRef{AccountID: target.ID, CalendarID: target.BlockerCalendar} + p, err := e.providerFor(target.ID) + if err != nil { + return err + } + switch { + case m == nil || (m.Status == store.StatusPending && m.BlockerEventID == ""): + // 新規作成、または pending のまま残った行(クラッシュ跡)の再実行。 + // 冪等キーは決定的なので再送しても二重作成にならない(仕様6.4) + dup, err := e.isDuplicateOnTarget(target, ev) + if err != nil { + return err + } + pm := store.Mapping{ + OriginAccount: ref.AccountID, + OriginCalendar: ref.CalendarID, + OriginEventID: ev.ID, + TargetAccount: target.ID, + TargetCalendar: target.BlockerCalendar, + IdempotencyKey: idemKeyFor(target.Provider, originTag, target.ID), + TimeHash: timeHash, + Status: store.StatusSuppressed, + } + if dup { + // 同一会議の実予定がターゲットに存在 → 作成せず suppressed 記録(仕様6.5) + return e.Store.PutMapping(pm) + } + pm.Status = store.StatusPending + if err := e.Store.PutMapping(pm); err != nil { + return err + } + b, err := e.blockerFor(ctx, ev, originTag, targetCal, p) + if err != nil { + return err + } + eventID, err := p.CreateBlocker(ctx, targetCal, b, pm.IdempotencyKey) + if err != nil { + return err + } + pm.BlockerEventID = eventID + pm.Status = store.StatusActive + return e.Store.PutMapping(pm) + case m.Status == store.StatusSuppressed: + // 昇格は Task 9(promoteSuppressed / 再評価)の責務。時刻変更のみ追従する + if m.TimeHash != timeHash { + m.TimeHash = timeHash + return e.Store.PutMapping(*m) + } + return nil + case m.TimeHash != timeHash: + b, err := e.blockerFor(ctx, ev, originTag, targetCal, p) + if err != nil { + return err + } + if err := p.UpdateBlocker(ctx, targetCal, m.BlockerEventID, b); err != nil { + if errors.Is(err, provider.ErrNotFound) { + // ブロッカーが消えている(手動削除等)→ pending 化して再作成する + // (createFromMapping が pending → CreateBlocker → active を行う。仕様8章4) + return e.createFromMapping(ctx, *m, ev) + } + return err + } + m.TimeHash = timeHash + return e.Store.PutMapping(*m) + default: + // time_hash 一致 → プロバイダ呼び出しなし + return nil + } +} + +// deleteBlockersForOrigin は origin イベントに紐づく全ターゲットのブロッカーと +// mappings を削除する。mapping が無ければ何もしない。 +// suppressed / pending(BlockerEventID 空)の行はプロバイダ呼び出しなしで mapping だけ消す。 +// ターゲットの認証失効は TargetAuthError に包んでそのターゲットだけスキップし +// (mapping は残す — 復帰後のリコンサイル/再実行で削除される)、他ターゲットの +// 削除は継続する(仕様9.3)。 +func (e *Engine) deleteBlockersForOrigin(ctx context.Context, ref model.CalendarRef, originEventID string) error { + maps, err := e.Store.ListMappingsForOrigin(ref.AccountID, ref.CalendarID, originEventID) + if err != nil { + return err + } + var errs []error + for _, m := range maps { + if m.BlockerEventID != "" { + p, err := e.providerFor(m.TargetAccount) + if err != nil { + errs = append(errs, err) + return errors.Join(errs...) + } + targetCal := model.CalendarRef{AccountID: m.TargetAccount, CalendarID: m.TargetCalendar} + if err := p.DeleteBlocker(ctx, targetCal, m.BlockerEventID); err != nil { + if errors.Is(err, provider.ErrAuthExpired) { + errs = append(errs, wrapTargetAuth(m.TargetAccount, err)) + continue + } + errs = append(errs, err) + return errors.Join(errs...) + } + } + if err := e.Store.DeleteMapping(m.OriginAccount, m.OriginCalendar, m.OriginEventID, m.TargetAccount); err != nil { + errs = append(errs, err) + return errors.Join(errs...) + } + } + return errors.Join(errs...) +} + +// blockerFor は origin イベントからターゲット用の Blocker を組み立てる。 +// TargetTimezone は終日ブロッカーの現地日付境界の決定にしか使われない(仕様6.6。 +// 時刻指定は両プロバイダとも UTC 固定で送る)ため、IsAllDay のときだけ取得する。 +// 取得時は calendars.timezone のキャッシュを優先し、無ければ provider から +// 取得して calendars テーブルへキャッシュする。 +func (e *Engine) blockerFor(ctx context.Context, ev model.NormalizedEvent, originTag string, targetCal model.CalendarRef, p provider.Provider) (model.Blocker, error) { + tz := "" + if ev.IsAllDay { + var err error + tz, err = e.targetTimezone(ctx, targetCal, p) + if err != nil { + return model.Blocker{}, err + } + } + return model.Blocker{ + Title: e.Cfg.BlockerTitle, + StartUTC: ev.StartUTC, + EndUTC: ev.EndUTC, + IsAllDay: ev.IsAllDay, + AllDayStart: ev.AllDayStart, + AllDayEnd: ev.AllDayEnd, + TargetTimezone: tz, + OriginTag: originTag, + }, nil +} + +func (e *Engine) targetTimezone(ctx context.Context, cal model.CalendarRef, p provider.Provider) (string, error) { + st, err := e.Store.GetCalendar(cal) + if err != nil { + return "", err + } + if st != nil && st.Timezone != "" { + return st.Timezone, nil + } + tz, err := p.GetCalendarTimezone(ctx, cal) + if err != nil { + return "", err + } + if err := e.Store.UpsertCalendar(cal); err != nil { + return "", err + } + if err := e.Store.SetCalendarTimezone(cal, tz); err != nil { + return "", err + } + return tz, nil +} + +// TokenDeleter は RemoveAccount がトークンファイルを消すための最小インター +// フェース。auth.TokenStore が満たす(engine→auth の依存を作らないためにここで定義)。 +type TokenDeleter interface { + Delete(accountID string) error +} + +// RemoveAccount はアカウントを完全に削除する(仕様 11 章 accounts remove)。処理順: +// 1. このアカウント発のブロッカーを全ターゲットのカレンダーから削除(+mappings 行削除) +// 2. このアカウントに置かれた受領ブロッカーを削除(+mappings 行削除) +// 3. events / calendars / トークンのローカル状態を削除 +// +// force=true の場合、プロバイダ不在(認証切れで構築不能)や DeleteBlocker の失敗を +// スキップして続行する(リモートにブロッカーが残りうることは呼び出し側が警告する)。 +// force=false でエラー中断しても、mappings 行は API 削除成功後にのみ消しているため +// 再実行すれば続きから冪等に完了できる。 +func RemoveAccount(ctx context.Context, e *Engine, tokens TokenDeleter, accountID string, force bool) error { + deleteRemote := func(targetAccount, targetCalendar, eventID string) error { + if eventID == "" { + return nil // pending / suppressed はリモートに実体がない + } + p, ok := e.Providers[targetAccount] + if !ok { + if force { + return nil + } + return fmt.Errorf("account %s: provider unavailable; re-authenticate it or pass --force to skip remote deletion", targetAccount) + } + cal := model.CalendarRef{AccountID: targetAccount, CalendarID: targetCalendar} + if err := p.DeleteBlocker(ctx, cal, eventID); err != nil { + if force { + return nil + } + return fmt.Errorf("delete blocker %s on %s (pass --force to skip): %w", eventID, cal, err) + } + return nil + } + + // (1) 配布済みブロッカー(このアカウントが origin) + origins, err := e.Store.ListMappingsWhereOriginAccount(accountID) + if err != nil { + return err + } + for _, m := range origins { + if err := deleteRemote(m.TargetAccount, m.TargetCalendar, m.BlockerEventID); err != nil { + return err + } + if err := e.Store.DeleteMapping(m.OriginAccount, m.OriginCalendar, m.OriginEventID, m.TargetAccount); err != nil { + return err + } + } + + // (2) 受領ブロッカー(このアカウントが target。m.TargetAccount == accountID) + received, err := e.Store.ListMappingsWhereTargetAccount(accountID) + if err != nil { + return err + } + for _, m := range received { + if err := deleteRemote(m.TargetAccount, m.TargetCalendar, m.BlockerEventID); err != nil { + return err + } + if err := e.Store.DeleteMapping(m.OriginAccount, m.OriginCalendar, m.OriginEventID, m.TargetAccount); err != nil { + return err + } + } + + // (3) ローカル状態 + if err := e.Store.DeleteEventsForAccount(accountID); err != nil { + return err + } + if err := e.Store.DeleteCalendarsForAccount(accountID); err != nil { + return err + } + return tokens.Delete(accountID) +} diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go new file mode 100644 index 0000000..20a90d4 --- /dev/null +++ b/internal/engine/engine_test.go @@ -0,0 +1,548 @@ +package engine + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/cloudnative-co/calsync/internal/config" + "github.com/cloudnative-co/calsync/internal/model" + "github.com/cloudnative-co/calsync/internal/provider" + "github.com/cloudnative-co/calsync/internal/provider/fake" + "github.com/cloudnative-co/calsync/internal/store" +) + +var ( + testNow = time.Date(2026, 7, 3, 9, 0, 0, 0, time.UTC) + refA = model.CalendarRef{AccountID: "a", CalendarID: "primary"} // origin(google) + calBv = model.CalendarRef{AccountID: "b", CalendarID: "primary"} // target(microsoft) + calCv = model.CalendarRef{AccountID: "c", CalendarID: "primary"} // target(google) +) + +func testConfig() *config.Config { + return &config.Config{ + PollInterval: time.Minute, + SyncWindowMonths: 3, + BlockerTitle: "予定あり", + DedupeSameMeeting: true, + BusyShowAs: []string{"busy", "oof", "tentative"}, + Accounts: []config.Account{ + {ID: "a", Provider: "google", Email: "a@example.com", Calendars: []string{"primary"}, BlockerCalendar: "primary"}, + {ID: "b", Provider: "microsoft", Email: "b@example.com", Calendars: []string{"primary"}, BlockerCalendar: "primary"}, + {ID: "c", Provider: "google", Email: "c@example.com", Calendars: []string{"primary"}, BlockerCalendar: "primary"}, + }, + } +} + +// newTestEngine は 実SQLite(t.TempDir)+ fake プロバイダのエンジンを組み立てる。 +// ターゲット b/c のタイムゾーンは calendars テーブルに事前キャッシュしておく。 +func newTestEngine(t *testing.T) (*Engine, *fake.Fake) { + t.Helper() + st, err := store.Open(t.TempDir()) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, st.Close()) }) + + f := fake.New() + e := &Engine{ + Store: st, + Providers: map[string]provider.Provider{"a": f, "b": f, "c": f}, + Cfg: testConfig(), + Now: func() time.Time { return testNow }, + } + for acct, tz := range map[string]string{"b": "Asia/Tokyo", "c": "America/New_York"} { + ref := model.CalendarRef{AccountID: acct, CalendarID: "primary"} + require.NoError(t, st.UpsertCalendar(ref)) + require.NoError(t, st.SetCalendarTimezone(ref, tz)) + } + return e, f +} + +// busyEvent はウィンドウ内(2026-07-10)の busy イベントを返す。 +func busyEvent(id string) model.NormalizedEvent { + return model.NormalizedEvent{ + ID: id, + ICalUID: id + "@example.com", + StartUTC: time.Date(2026, 7, 10, 1, 0, 0, 0, time.UTC), + EndUTC: time.Date(2026, 7, 10, 2, 0, 0, 0, time.UTC), + IsBusy: true, + } +} + +// ---- SyncCalendar ---- + +func TestSyncCalendar_SetsCursorOnlyOnCompletion(t *testing.T) { + e, f := newTestEngine(t) + ctx := context.Background() + f.SetFullState(refA, []model.NormalizedEvent{busyEvent("ev1")}) + + // フル同期(cursor=="")完走 → "c1" を永続化し、イベントも処理される + require.NoError(t, e.SyncCalendar(ctx, refA)) + st, err := e.Store.GetCalendar(refA) + require.NoError(t, err) + require.NotNil(t, st) + require.Equal(t, "c1", st.Cursor) + require.Len(t, f.Blockers(calBv), 1) + require.Len(t, f.Blockers(calCv), 1) + + // 増分完走 → "c2" に進む + f.QueueChanges(refA, []model.NormalizedEvent{busyEvent("ev2")}) + require.NoError(t, e.SyncCalendar(ctx, refA)) + st, err = e.Store.GetCalendar(refA) + require.NoError(t, err) + require.Equal(t, "c2", st.Cursor) + require.Len(t, f.Blockers(calBv), 2) +} + +func TestSyncCalendar_AuthErrorPropagatesAndKeepsCursor(t *testing.T) { + e, f := newTestEngine(t) + ctx := context.Background() + f.SetFullState(refA, nil) + require.NoError(t, e.SyncCalendar(ctx, refA)) // cursor "c1" を確立 + + f.FailNext(refA, provider.ErrAuthExpired) + err := e.SyncCalendar(ctx, refA) + require.ErrorIs(t, err, provider.ErrAuthExpired) // そのまま返る + + // 失敗時はカーソルを進めない(Changes 完走時のみ SetCursor) + st, gerr := e.Store.GetCalendar(refA) + require.NoError(t, gerr) + require.Equal(t, "c1", st.Cursor) +} + +// ---- processEvent (g): 新規 busy → 全ターゲットに作成 ---- + +func TestProcessEvent_CreateOnAllTargets(t *testing.T) { + e, f := newTestEngine(t) + ctx := context.Background() + ev := busyEvent("ev1") + require.NoError(t, e.processEvent(ctx, refA, ev)) + + wantTag := model.OriginTagOf("a", "ev1") + wantHash := model.TimeHash(ev) + + cases := []struct { + cal model.CalendarRef + acct string + idemKey string + }{ + // b は microsoft → transactionId / c は google → クライアント生成イベントID + {calBv, "b", model.MSTransactionID(wantTag, "b")}, + {calCv, "c", model.GoogleBlockerID(wantTag, "c")}, + } + for _, tc := range cases { + blks := f.Blockers(tc.cal) + require.Len(t, blks, 1, "target %s", tc.acct) + require.Equal(t, wantTag, blks[0].OriginTag) + require.Equal(t, wantHash, blks[0].TimeHash) + + body, ok := f.StoredBlocker(tc.cal, blks[0].EventID) + require.True(t, ok) + require.Equal(t, "予定あり", body.Title) // Cfg.BlockerTitle + require.Empty(t, body.TargetTimezone) // 時刻指定は UTC 固定で送るため TZ 不要(仕様6.6) + require.Equal(t, wantTag, body.OriginTag) + + m, err := e.Store.GetMapping("a", "primary", "ev1", tc.acct) + require.NoError(t, err) + require.NotNil(t, m) + require.Equal(t, store.StatusActive, m.Status) + require.Equal(t, blks[0].EventID, m.BlockerEventID) + require.Equal(t, tc.idemKey, m.IdempotencyKey) // provider 種別ごとの決定的冪等キー + require.Equal(t, wantHash, m.TimeHash) + require.Equal(t, "primary", m.TargetCalendar) + } + + // origin 側の events キャッシュにも登録される + cached, err := e.Store.GetEvent(refA, "ev1") + require.NoError(t, err) + require.NotNil(t, cached) + require.Equal(t, wantHash, model.TimeHash(*cached)) + + // origin 自身にはブロッカーを作らない + require.Empty(t, f.Blockers(refA)) +} + +// (g) intent-first の回復性 1: pending 登録後・作成前にクラッシュした状態からの再処理 +func TestProcessEvent_PendingRecovery(t *testing.T) { + e, f := newTestEngine(t) + ctx := context.Background() + ev := busyEvent("ev1") + tag := model.OriginTagOf("a", "ev1") + require.NoError(t, e.Store.PutMapping(store.Mapping{ + OriginAccount: "a", OriginCalendar: "primary", OriginEventID: "ev1", + TargetAccount: "b", TargetCalendar: "primary", + IdempotencyKey: model.MSTransactionID(tag, "b"), + TimeHash: model.TimeHash(ev), + Status: store.StatusPending, // BlockerEventID は空のまま + })) + + require.NoError(t, e.processEvent(ctx, refA, ev)) + + m, err := e.Store.GetMapping("a", "primary", "ev1", "b") + require.NoError(t, err) + require.NotNil(t, m) + require.Equal(t, store.StatusActive, m.Status) // pending → active に解決 + require.NotEmpty(t, m.BlockerEventID) + require.Len(t, f.Blockers(calBv), 1) + require.Len(t, f.Blockers(calCv), 1) // 他ターゲットは通常作成 +} + +// (g) intent-first の回復性 2: CreateBlocker 成功後・active 更新前にクラッシュ。 +// 同一冪等キーの再作成は既存 ID が返るため二重作成にならない(仕様6.4) +func TestProcessEvent_CreateAfterCrashBeforeActive(t *testing.T) { + e, f := newTestEngine(t) + ctx := context.Background() + ev := busyEvent("ev1") + tag := model.OriginTagOf("a", "ev1") + idem := model.MSTransactionID(tag, "b") + + preID, err := f.CreateBlocker(ctx, calBv, model.Blocker{ + Title: "予定あり", StartUTC: ev.StartUTC, EndUTC: ev.EndUTC, + TargetTimezone: "Asia/Tokyo", OriginTag: tag, + }, idem) + require.NoError(t, err) + require.NoError(t, e.Store.PutMapping(store.Mapping{ + OriginAccount: "a", OriginCalendar: "primary", OriginEventID: "ev1", + TargetAccount: "b", TargetCalendar: "primary", + IdempotencyKey: idem, TimeHash: model.TimeHash(ev), + Status: store.StatusPending, + })) + + require.NoError(t, e.processEvent(ctx, refA, ev)) + + require.Len(t, f.Blockers(calBv), 1) // 二重作成なし + m, err := e.Store.GetMapping("a", "primary", "ev1", "b") + require.NoError(t, err) + require.Equal(t, store.StatusActive, m.Status) + require.Equal(t, preID, m.BlockerEventID) // 既存 ID で収容 +} + +// (g) 終日イベントは現地日付のまま配布され、TargetTimezone が付く +func TestProcessEvent_AllDayBlockerKeepsLocalDates(t *testing.T) { + e, f := newTestEngine(t) + ctx := context.Background() + ev := model.NormalizedEvent{ + ID: "allday1", + ICalUID: "allday1@example.com", + IsAllDay: true, + AllDayStart: "2026-07-15", + AllDayEnd: "2026-07-16", + IsBusy: true, + } + require.NoError(t, e.processEvent(ctx, refA, ev)) + + blks := f.Blockers(calBv) + require.Len(t, blks, 1) + body, ok := f.StoredBlocker(calBv, blks[0].EventID) + require.True(t, ok) + require.True(t, body.IsAllDay) + require.Equal(t, "2026-07-15", body.AllDayStart) + require.Equal(t, "2026-07-16", body.AllDayEnd) + require.Equal(t, "Asia/Tokyo", body.TargetTimezone) // Graph はこの TZ の midnight 境界で作る +} + +// calendars.timezone が未キャッシュなら(終日イベントの配布時に)provider から +// 取得して保存する +func TestUpsertBlockers_FetchesTimezoneWhenNotCached(t *testing.T) { + e, f := newTestEngine(t) + ctx := context.Background() + require.NoError(t, e.Store.DeleteCalendarsForAccount("c")) // c のキャッシュを消す + f.SetTimezone(calCv, "Europe/Berlin") + + allDay := model.NormalizedEvent{ + ID: "allday1", ICalUID: "allday1@example.com", IsBusy: true, + IsAllDay: true, AllDayStart: "2026-07-15", AllDayEnd: "2026-07-16", + } + require.NoError(t, e.processEvent(ctx, refA, allDay)) + + blks := f.Blockers(calCv) + require.Len(t, blks, 1) + body, ok := f.StoredBlocker(calCv, blks[0].EventID) + require.True(t, ok) + require.Equal(t, "Europe/Berlin", body.TargetTimezone) + + st, err := e.Store.GetCalendar(calCv) + require.NoError(t, err) + require.NotNil(t, st) + require.Equal(t, "Europe/Berlin", st.Timezone) // calendars.timezone にキャッシュされる +} + +// tzCountingProvider は GetCalendarTimezone の呼び出し回数を数えるラッパー +// (タイムゾーン取得の遅延化=時刻指定イベントでは呼ばれないことの検証用)。 +type tzCountingProvider struct { + provider.Provider + mu sync.Mutex + calls int +} + +func (p *tzCountingProvider) GetCalendarTimezone(ctx context.Context, cal model.CalendarRef) (string, error) { + p.mu.Lock() + p.calls++ + p.mu.Unlock() + return p.Provider.GetCalendarTimezone(ctx, cal) +} + +func (p *tzCountingProvider) count() int { + p.mu.Lock() + defer p.mu.Unlock() + return p.calls +} + +// 時刻指定イベントの配布では GetCalendarTimezone を呼ばない(仕様6.6: +// タイムゾーンは終日ブロッカーにしか使われない。毎イベントの TZ 取得は +// スコープ要求と API コールの両面で無駄。最終ホールブランチレビュー追補 Issue 1)。 +func TestUpsertBlockers_TimedEventDoesNotFetchTimezone(t *testing.T) { + e, f := newTestEngine(t) + ctx := context.Background() + // TZ キャッシュを消し、「呼ばれるなら必ず provider に到達する」状態にする + require.NoError(t, e.Store.DeleteCalendarsForAccount("b")) + require.NoError(t, e.Store.DeleteCalendarsForAccount("c")) + cp := &tzCountingProvider{Provider: f} + e.Providers["b"] = cp + e.Providers["c"] = cp + + require.NoError(t, e.processEvent(ctx, refA, busyEvent("ev1"))) + + require.Len(t, f.Blockers(calBv), 1) + require.Len(t, f.Blockers(calCv), 1) + require.Zero(t, cp.count(), "時刻指定イベントでは GetCalendarTimezone を呼ばない") + + // 終日イベントでは取得される(カウンタが機能していることの対照) + allDay := model.NormalizedEvent{ + ID: "allday1", ICalUID: "allday1@example.com", IsBusy: true, + IsAllDay: true, AllDayStart: "2026-07-15", AllDayEnd: "2026-07-16", + } + require.NoError(t, e.processEvent(ctx, refA, allDay)) + require.Positive(t, cp.count(), "終日イベントでは GetCalendarTimezone が呼ばれる") +} + +// ---- (a) mappings の blocker_event_id 一致で無視(ループ遮断・一次判定) ---- + +func TestProcessEvent_IgnoreOwnBlocker(t *testing.T) { + e, f := newTestEngine(t) + ctx := context.Background() + // b 発のブロッカー blk1 が a 上に置かれている状態 + require.NoError(t, e.Store.PutMapping(store.Mapping{ + OriginAccount: "b", OriginCalendar: "primary", OriginEventID: "ev-b", + TargetAccount: "a", TargetCalendar: "primary", + BlockerEventID: "blk1", IdempotencyKey: "k", TimeHash: "h", + Status: store.StatusActive, + })) + + ev := busyEvent("blk1") // a のフィードに自作ブロッカーが busy として現れた + require.NoError(t, e.processEvent(ctx, refA, ev)) + + require.Empty(t, f.Blockers(calBv)) + require.Empty(t, f.Blockers(calCv)) + cached, err := e.Store.GetEvent(refA, "blk1") + require.NoError(t, err) + require.Nil(t, cached) // キャッシュにも入れない + m, err := e.Store.GetMapping("a", "primary", "blk1", "b") + require.NoError(t, err) + require.Nil(t, m) // 新たな mapping も作らない +} + +// ---- (b) OriginTag 非空で無視(タグ二次判定) ---- + +func TestProcessEvent_IgnoreTaggedEvent(t *testing.T) { + e, f := newTestEngine(t) + ev := busyEvent("ev1") + ev.OriginTag = "b:ev-b" // mappings に無くてもタグが読めれば遮断(DB 消失後の孤児等) + require.NoError(t, e.processEvent(context.Background(), refA, ev)) + + require.Empty(t, f.Blockers(calBv)) + require.Empty(t, f.Blockers(calCv)) + cached, err := e.Store.GetEvent(refA, "ev1") + require.NoError(t, err) + require.Nil(t, cached) +} + +// ---- (c) 削除通知 + origin 登録あり ---- + +func TestProcessEvent_DeleteNotification(t *testing.T) { + e, f := newTestEngine(t) + ctx := context.Background() + require.NoError(t, e.processEvent(ctx, refA, busyEvent("ev1"))) + require.Len(t, f.Blockers(calBv), 1) + require.Len(t, f.Blockers(calCv), 1) + + // 削除通知は id しか含まれない前提(仕様6.1) + del := model.NormalizedEvent{ID: "ev1", Deleted: true} + require.NoError(t, e.processEvent(ctx, refA, del)) + + require.Empty(t, f.Blockers(calBv)) // 全ターゲットのブロッカー削除 + require.Empty(t, f.Blockers(calCv)) + for _, acct := range []string{"b", "c"} { + m, err := e.Store.GetMapping("a", "primary", "ev1", acct) + require.NoError(t, err) + require.Nil(t, m) // mappings 削除 + } + cached, err := e.Store.GetEvent(refA, "ev1") + require.NoError(t, err) + require.Nil(t, cached) // events キャッシュ削除 +} + +// (c) 自作ブロッカーの削除通知は無視(復元はリコンサイルの責務) +func TestProcessEvent_DeleteOwnBlockerIsIgnored(t *testing.T) { + e, f := newTestEngine(t) + ctx := context.Background() + require.NoError(t, e.Store.PutMapping(store.Mapping{ + OriginAccount: "b", OriginCalendar: "primary", OriginEventID: "ev-b", + TargetAccount: "a", TargetCalendar: "primary", + BlockerEventID: "blk1", IdempotencyKey: "k", TimeHash: "h", + Status: store.StatusActive, + })) + + del := model.NormalizedEvent{ID: "blk1", Deleted: true} + require.NoError(t, e.processEvent(ctx, refA, del)) + + // mapping は残る(リコンサイルが再作成できるように消さない) + m, err := e.Store.GetMapping("b", "primary", "ev-b", "a") + require.NoError(t, err) + require.NotNil(t, m) + require.Empty(t, f.Blockers(calBv)) + require.Empty(t, f.Blockers(calCv)) +} + +// ---- (d) 未知 ID の削除通知 → 無視(Graph の @removed ウィンドウ外ノイズ耐性) ---- + +func TestProcessEvent_DeleteUnknownIDIsNoop(t *testing.T) { + e, f := newTestEngine(t) + del := model.NormalizedEvent{ID: "never-seen", Deleted: true} + require.NoError(t, e.processEvent(context.Background(), refA, del)) + require.Empty(t, f.Blockers(calBv)) + require.Empty(t, f.Blockers(calCv)) +} + +// ---- (e) ウィンドウ外 → 既存 mapping の削除 ---- + +func TestProcessEvent_OutOfWindowDeletesExistingMapping(t *testing.T) { + e, f := newTestEngine(t) + ctx := context.Background() + ev := busyEvent("ev1") + require.NoError(t, e.processEvent(ctx, refA, ev)) + require.Len(t, f.Blockers(calBv), 1) + + // 予定がウィンドウ外(now+4ヶ月)へ移動した + moved := ev + moved.StartUTC = testNow.AddDate(0, 4, 0) + moved.EndUTC = moved.StartUTC.Add(time.Hour) + require.NoError(t, e.processEvent(ctx, refA, moved)) + + require.Empty(t, f.Blockers(calBv)) + require.Empty(t, f.Blockers(calCv)) + for _, acct := range []string{"b", "c"} { + m, err := e.Store.GetMapping("a", "primary", "ev1", acct) + require.NoError(t, err) + require.Nil(t, m) + } + // events キャッシュの掃除はリコンサイルの set-difference の責務(決定則3ではブロッカーのみ) +} + +func TestProcessEvent_OutOfWindowWithoutMappingIsNoop(t *testing.T) { + e, f := newTestEngine(t) + ev := busyEvent("ev1") + ev.StartUTC = testNow.AddDate(0, 4, 0) + ev.EndUTC = ev.StartUTC.Add(time.Hour) + require.NoError(t, e.processEvent(context.Background(), refA, ev)) + + require.Empty(t, f.Blockers(calBv)) + require.Empty(t, f.Blockers(calCv)) + cached, err := e.Store.GetEvent(refA, "ev1") + require.NoError(t, err) + require.Nil(t, cached) // ウィンドウ外はキャッシュにも入れない +} + +// ---- (f) 非 busy / 辞退 → 既存ブロッカー削除 + キャッシュ削除 ---- + +func TestProcessEvent_NotBlockableDeletesExisting(t *testing.T) { + cases := []struct { + name string + mutate func(ev *model.NormalizedEvent) + }{ + {"busyでなくなった", func(ev *model.NormalizedEvent) { ev.IsBusy = false }}, + {"辞退した", func(ev *model.NormalizedEvent) { ev.IsDeclined = true }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + e, f := newTestEngine(t) + ctx := context.Background() + ev := busyEvent("ev1") + require.NoError(t, e.processEvent(ctx, refA, ev)) + require.Len(t, f.Blockers(calBv), 1) + + changed := ev + tc.mutate(&changed) + require.NoError(t, e.processEvent(ctx, refA, changed)) + + require.Empty(t, f.Blockers(calBv)) + require.Empty(t, f.Blockers(calCv)) + for _, acct := range []string{"b", "c"} { + m, err := e.Store.GetMapping("a", "primary", "ev1", acct) + require.NoError(t, err) + require.Nil(t, m) + } + cached, err := e.Store.GetEvent(refA, "ev1") + require.NoError(t, err) + require.Nil(t, cached) // 決定則4はキャッシュも削除する + }) + } +} + +// ---- (h) time_hash 変化 → UpdateBlocker(patch であって再作成ではない) ---- + +func TestProcessEvent_TimeChangeUpdatesBlocker(t *testing.T) { + e, f := newTestEngine(t) + ctx := context.Background() + ev := busyEvent("ev1") + require.NoError(t, e.processEvent(ctx, refA, ev)) + origID := f.Blockers(calBv)[0].EventID + + moved := ev + moved.EndUTC = ev.EndUTC.Add(30 * time.Minute) + require.NoError(t, e.processEvent(ctx, refA, moved)) + + wantHash := model.TimeHash(moved) + blks := f.Blockers(calBv) + require.Len(t, blks, 1) + require.Equal(t, origID, blks[0].EventID) // 同一イベントの patch + require.Equal(t, wantHash, blks[0].TimeHash) + require.Len(t, f.Blockers(calCv), 1) + require.Equal(t, wantHash, f.Blockers(calCv)[0].TimeHash) + + m, err := e.Store.GetMapping("a", "primary", "ev1", "b") + require.NoError(t, err) + require.Equal(t, wantHash, m.TimeHash) // mapping の time_hash も更新 + cached, err := e.Store.GetEvent(refA, "ev1") + require.NoError(t, err) + require.Equal(t, wantHash, model.TimeHash(*cached)) // events キャッシュも更新 +} + +// ---- (i) time_hash 一致 → プロバイダ呼び出しなし ---- + +func TestProcessEvent_UnchangedMakesNoProviderCalls(t *testing.T) { + e, f := newTestEngine(t) + ctx := context.Background() + ev := busyEvent("ev1") + require.NoError(t, e.processEvent(ctx, refA, ev)) + blks := f.Blockers(calBv) + require.Len(t, blks, 1) + + // fake 上の記録を改竄しておく(SeedBlocker は EventID で upsert)。 + // エンジンが Update/Delete を呼べば改竄値は上書き・消滅するので、 + // 残っていれば「プロバイダ呼び出しなし」を証明できる。 + f.SeedBlocker(calBv, model.BlockerRecord{ + EventID: blks[0].EventID, OriginTag: blks[0].OriginTag, TimeHash: "tampered", + }) + + require.NoError(t, e.processEvent(ctx, refA, ev)) // 同一内容の再受信 + + after := f.Blockers(calBv) + require.Len(t, after, 1) + require.Equal(t, "tampered", after[0].TimeHash) // 改竄が残っている = 未呼び出し + m, err := e.Store.GetMapping("a", "primary", "ev1", "b") + require.NoError(t, err) + require.Equal(t, store.StatusActive, m.Status) +} diff --git a/internal/engine/errors.go b/internal/engine/errors.go new file mode 100644 index 0000000..d109b71 --- /dev/null +++ b/internal/engine/errors.go @@ -0,0 +1,133 @@ +package engine + +import ( + "errors" + "fmt" + + "github.com/cloudnative-co/calsync/internal/provider" +) + +// TargetAuthError は「ターゲットアカウントへのブロッカー書き込みが認証失効で +// 失敗した」ことを表す型付きエラー(仕様9.3)。origin アカウントの同期中に +// ターゲット側の provider.ErrAuthExpired をそのまま返すと、scheduler が +// errors.Is(err, ErrAuthExpired) で origin に誤帰属して origin の同期まで +// 止めてしまうため、失効したアカウント ID を保持してラップする。 +type TargetAuthError struct { + AccountID string // 失効したターゲットアカウントの ID + Err error // 元の provider.ErrAuthExpired チェーン +} + +func (e *TargetAuthError) Error() string { + return fmt.Sprintf("target account %s: authentication expired: %v", e.AccountID, e.Err) +} + +func (e *TargetAuthError) Unwrap() error { return e.Err } + +// wrapTargetAuth は err が provider.ErrAuthExpired を含む場合に TargetAuthError +// へ包む(既に TargetAuthError ならそのまま)。それ以外の err は素通しする。 +func wrapTargetAuth(targetAccountID string, err error) error { + if err == nil { + return nil + } + var tae *TargetAuthError + if errors.As(err, &tae) { + return err // 二重ラップしない(createFromMapping 経由等) + } + if errors.Is(err, provider.ErrAuthExpired) { + return &TargetAuthError{AccountID: targetAccountID, Err: err} + } + return err +} + +// collectTargetAuthErrors は err のエラーツリー(errors.Join / fmt.Errorf の +// %w 連鎖)を走査し、含まれる全 *TargetAuthError を返す。TargetAuthError の +// 配下(ラップされた ErrAuthExpired)へは降りない。 +func collectTargetAuthErrors(err error) []*TargetAuthError { + var out []*TargetAuthError + var walk func(error) + walk = func(e error) { + if e == nil { + return + } + if tae, ok := e.(*TargetAuthError); ok { + out = append(out, tae) + return + } + switch x := e.(type) { + case interface{ Unwrap() []error }: + for _, c := range x.Unwrap() { + walk(c) + } + case interface{ Unwrap() error }: + walk(x.Unwrap()) + } + } + walk(err) + return out +} + +// originAuthExpired は err のエラーツリーに「TargetAuthError の配下ではない」 +// provider.ErrAuthExpired が含まれるか(= origin アカウント自身の認証失効か)を +// 返す。TargetAuthError のサブツリーには降りないため、ターゲット失効由来の +// ErrAuthExpired は origin に誤帰属しない(仕様9.3)。 +func originAuthExpired(err error) bool { + var walk func(error) bool + walk = func(e error) bool { + if e == nil { + return false + } + if _, ok := e.(*TargetAuthError); ok { + return false // ターゲット失効の配下は origin の失効ではない + } + if e == provider.ErrAuthExpired { + return true + } + switch x := e.(type) { + case interface{ Unwrap() []error }: + for _, c := range x.Unwrap() { + if walk(c) { + return true + } + } + case interface{ Unwrap() error }: + return walk(x.Unwrap()) + } + return false + } + return walk(err) +} + +// onlyTargetAuthErrors は err が TargetAuthError のみで構成されるか +// (= origin 自身の同期は成功しており、ターゲットの失効だけが原因か)を返す。 +// err==nil は false(「エラーがあり、かつ全部ターゲット失効」の判定専用)。 +func onlyTargetAuthErrors(err error) bool { + if err == nil { + return false + } + var all func(error) bool + all = func(e error) bool { + if e == nil { + return false + } + if _, ok := e.(*TargetAuthError); ok { + return true + } + switch x := e.(type) { + case interface{ Unwrap() []error }: + children := x.Unwrap() + if len(children) == 0 { + return false + } + for _, c := range children { + if !all(c) { + return false + } + } + return true + case interface{ Unwrap() error }: + return all(x.Unwrap()) + } + return false + } + return all(err) +} diff --git a/internal/engine/reconcile.go b/internal/engine/reconcile.go new file mode 100644 index 0000000..3d43dba --- /dev/null +++ b/internal/engine/reconcile.go @@ -0,0 +1,394 @@ +package engine + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/cloudnative-co/calsync/internal/config" + "github.com/cloudnative-co/calsync/internal/model" + "github.com/cloudnative-co/calsync/internal/provider" + "github.com/cloudnative-co/calsync/internal/store" +) + +// FullResync は 1 カレンダーに対するカーソル張り直し + set-difference リコンサイル +// (仕様8章 1〜2)。cursor="" のウィンドウ付きフル同期で新カーソルを確立し +// (= ウィンドウのスライド)、フル同期結果と events キャッシュを突合する。 +// +// mappings は全ワイプしない: Google の「410 時は全ワイプ」公式手順を mappings に +// 適用するとループ防止(一次判定)と origin→blocker の対応関係が失われるため、 +// 破棄するのはカーソルと、フル結果に現れなくなったキャッシュ行のみ(仕様8章の確定判断)。 +// カーソル失効時(SyncCalendar からの委譲)と日次リコンサイル(Task 11)の両方で使う。 +func (e *Engine) FullResync(ctx context.Context, ref model.CalendarRef) error { + if err := e.Store.UpsertCalendar(ref); err != nil { + return err + } + p, err := e.providerFor(ref.AccountID) + if err != nil { + return err + } + w := e.currentWindow() + events, newCursor, err := p.Changes(ctx, ref, "", w) + if err != nil { + return err + } + + // フル結果のうち「キャッシュに残るべきイベント」(busy・未辞退・ウィンドウ内)の ID 集合。 + // ウィンドウ外・非 busy 化・削除済みはここに入らず、下の消滅処理で掃除される。 + alive := make(map[string]bool, len(events)) + for _, ev := range events { + if !ev.Deleted && ShouldBlock(ev) && InWindow(w, ev) { + alive[ev.ID] = true + } + } + + // set-difference: キャッシュにあるがフル結果で生きていない → 消滅扱い。 + // ブロッカー削除 + mapping 削除 + キャッシュ削除 + suppressed 昇格 + // (processEvent の削除通知処理と同じ手順。キャッシュ由来の ID なので未知IDガードは不要) + // ターゲットの認証失効(TargetAuthError のみのエラー)はそのイベントの後続処理を + // スキップして続行する(仕様9.3: 失効ターゲットが origin の同期を止めない) + var errs []error + cachedIDs, err := e.Store.ListEventIDs(ref) + if err != nil { + return err + } + for _, id := range cachedIDs { + if alive[id] { + continue + } + cached, err := e.Store.GetEvent(ref, id) + if err != nil { + return err + } + if err := e.deleteBlockersForOrigin(ctx, ref, id); err != nil { + errs = append(errs, err) + if onlyTargetAuthErrors(err) { + continue // mapping ごと残る。復帰後のリコンサイルで再削除される + } + return errors.Join(errs...) + } + if err := e.Store.DeleteEvent(ref, id); err != nil { + errs = append(errs, err) + return errors.Join(errs...) + } + if cached != nil && cached.ICalUID != "" { + if err := e.promoteSuppressed(ctx, ref.AccountID, cached.ICalUID); err != nil { + errs = append(errs, err) + if onlyTargetAuthErrors(err) { + continue + } + return errors.Join(errs...) + } + } + } + + // フル結果を通常の決定則で処理する(新規範囲入りの作成・time_hash 不一致の patch・ + // pending 行の解決は processEvent / upsertBlockers 側の既存ロジックが行う) + for _, ev := range events { + if err := e.processEvent(ctx, ref, ev); err != nil { + errs = append(errs, err) + if onlyTargetAuthErrors(err) { + continue + } + return errors.Join(errs...) + } + } + + // 完走した時だけ新カーソルと新ウィンドウを永続化する(仕様5.1のカーソル規律と同じ)。 + // TargetAuthError のみの場合は SyncCalendar と同じ理由でカーソルを前進させる + if newCursor != "" { + if err := e.Store.SetCursor(ref, newCursor, w); err != nil { + errs = append(errs, err) + return errors.Join(errs...) + } + } + return errors.Join(errs...) +} + +// Reconcile はフルリコンサイル(仕様8章)。日次スケジュール(Task 18)と +// `calsync reconcile`(Task 19)から呼ばれる。 +// フェーズ: (1) 全監視カレンダーの FullResync(カーソル張り直し + set-difference) +// (2) pending 解決 (3) adoption(孤児ブロッカーの収容/掃除) +// (4) 手で消されたブロッカーの再作成 (5) suppressed 再評価。 +// pending 解決を adoption より先に行うのは、pending 行に紐づく作成済みブロッカーを +// adoption が孤児と誤認して掃除しないため。restoreMissingBlockers を adoption の +// 後に置くのは、adoption が同じ回で active 化した行(遅れて実在確認できた分)も +// 復元対象の判定に含めるため。 +// 1 カレンダーの障害で全体を止めず、エラーは集約して返す(仕様10章)。 +func (e *Engine) Reconcile(ctx context.Context) error { + var errs []error + for _, acct := range e.Cfg.Accounts { + for _, calID := range acct.Calendars { + ref := model.CalendarRef{AccountID: acct.ID, CalendarID: calID} + if err := e.FullResync(ctx, ref); err != nil { + errs = append(errs, fmt.Errorf("full resync %s: %w", ref, err)) + } + } + } + if err := e.resolvePending(ctx); err != nil { + errs = append(errs, fmt.Errorf("resolve pending: %w", err)) + } + if err := e.adoptOrphans(ctx); err != nil { + errs = append(errs, fmt.Errorf("adopt orphans: %w", err)) + } + if err := e.restoreMissingBlockers(ctx); err != nil { + errs = append(errs, fmt.Errorf("restore missing blockers: %w", err)) + } + if err := e.reevaluateSuppressed(ctx); err != nil { + errs = append(errs, fmt.Errorf("reevaluate suppressed: %w", err)) + } + return errors.Join(errs...) +} + +// resolvePending は pending のまま残った mappings(作成実行と active 更新の間の +// クラッシュ跡)を解決する(仕様6.4)。origin が events キャッシュに生きていれば +// 同一冪等キーで CreateBlocker を再実行する — 既に作成済みなら既存 ID が返るため、 +// 実在確認と収容を兼ねる。origin が消えていれば intent ごと破棄する +// (ブロッカーが作られてしまっていた場合は直後の adoption が掃除する)。 +// 1 行の失敗で残りを止めず、エラーは行単位で集約して返す(仕様10章。 +// 最終ホールブランチレビュー追補 Issue 6)。 +func (e *Engine) resolvePending(ctx context.Context) error { + pend, err := e.Store.ListPendingMappings() + if err != nil { + return err + } + var errs []error + for _, m := range pend { + originRef := model.CalendarRef{AccountID: m.OriginAccount, CalendarID: m.OriginCalendar} + ev, err := e.Store.GetEvent(originRef, m.OriginEventID) + if err != nil { + errs = append(errs, err) + continue + } + if ev == nil { + if err := e.Store.DeleteMapping(m.OriginAccount, m.OriginCalendar, m.OriginEventID, m.TargetAccount); err != nil { + errs = append(errs, err) + } + continue + } + if err := e.createFromMapping(ctx, m, *ev); err != nil { + errs = append(errs, err) + } + } + return errors.Join(errs...) +} + +// adoptOrphans は各アカウントのブロッカー書き込み先を ListBlockers(タグ検索)で +// 列挙し、mappings 未登録のタグ付きブロッカー(クラッシュ起因・DB 消失起因の孤児)を +// origin の実在に応じて収容 or 削除する(仕様8章 3)。DB 全損時の mappings 再構築も +// この経路で行われる(仕様8章 5)。origin の実在は直前の FullResync で最新化された +// events キャッシュで判定する。 +// 1 アカウント/1 ブロッカーの失敗で残りを止めず、エラーは集約して返す(仕様10章。 +// 最終ホールブランチレビュー追補 Issue 6)。 +func (e *Engine) adoptOrphans(ctx context.Context) error { + w := e.currentWindow() + var errs []error + for _, acct := range e.Cfg.Accounts { + ref := model.CalendarRef{AccountID: acct.ID, CalendarID: acct.BlockerCalendar} + p, err := e.providerFor(acct.ID) + if err != nil { + errs = append(errs, fmt.Errorf("account %s: %w", acct.ID, err)) + continue + } + recs, err := p.ListBlockers(ctx, ref, w) + if err != nil { + errs = append(errs, fmt.Errorf("list blockers %s: %w", ref, err)) + continue + } + for _, rec := range recs { + if err := e.adoptOrphan(ctx, p, acct, ref, rec); err != nil { + errs = append(errs, fmt.Errorf("adopt %s on %s: %w", rec.EventID, ref, err)) + } + } + } + return errors.Join(errs...) +} + +// adoptOrphan は 1 ブロッカー分の孤児判定・収容・掃除を行う(adoptOrphans の 1 反復)。 +func (e *Engine) adoptOrphan(ctx context.Context, p provider.Provider, acct config.Account, ref model.CalendarRef, rec model.BlockerRecord) error { + known, err := e.Store.IsBlocker(acct.ID, rec.EventID) + if err != nil { + return err + } + if known { + return nil // mappings 登録済みの正規ブロッカー + } + originAcct, originEventID, ok := parseOriginTag(rec.OriginTag) + if !ok { + return nil // タグ形式不正。calsync 製と断定できないため触らない + } + ev, originCal, err := e.findCachedOriginEvent(originAcct, originEventID) + if err != nil { + return err + } + if ev == nil { + // origin 消滅(またはアカウントが監視対象外)→ 掃除 + return p.DeleteBlocker(ctx, ref, rec.EventID) + } + existing, err := e.Store.GetMapping(originAcct, originCal, originEventID, acct.ID) + if err != nil { + return err + } + if existing != nil && existing.BlockerEventID != rec.EventID { + // 同じ origin/target 対に別のブロッカー(または suppressed/pending の意図)が + // 既に紐づいている → この孤児は重複。掃除 + return p.DeleteBlocker(ctx, ref, rec.EventID) + } + // origin 生存 → active として収容(削除・再作成はしない) + m := store.Mapping{ + OriginAccount: originAcct, + OriginCalendar: originCal, + OriginEventID: originEventID, + TargetAccount: acct.ID, + TargetCalendar: acct.BlockerCalendar, + BlockerEventID: rec.EventID, + IdempotencyKey: idemKeyFor(acct.Provider, rec.OriginTag, acct.ID), + TimeHash: rec.TimeHash, + Status: store.StatusActive, + } + return e.Store.PutMapping(m) +} + +// restoreMissingBlockers は「手で消されたブロッカーの再作成」を行う(仕様8章4: +// 元予定が生きている限りブロッカーは維持する、確定仕様)。processEvent の +// time_hash 一致判定(upsertBlockers の default ケース)はプロバイダを一切 +// 呼ばないため、ブロッカー本体だけが手動削除されても通常の同期では検知できない +// (最終ホールブランチレビュー所見2)。 +// +// 各アカウントのブロッカー書き込み先を ListBlockers で列挙して実在 ID 集合を作り、 +// そのアカウントを target とする active mapping のうち BlockerEventID がその集合に +// 無い行を、origin イベントがキャッシュに残っていれば createFromMapping(既存の +// pending→CreateBlocker→active の流れ)で再作成する。origin がキャッシュに無ければ +// スキップする(次回 FullResync が整合を回復する)。 +// 実プロバイダでは決定的な冪等キーでの再送が 409 → cancelled 蘇生の経路を通るため +// (修正1)、fake のようにキーを解放して新規作成になる意味論・実プロバイダのように +// 蘇生になる意味論のどちらでも成立する。 +// 1 アカウント/1 行の失敗で残りを止めず、エラーは集約して返す(仕様10章)。 +func (e *Engine) restoreMissingBlockers(ctx context.Context) error { + w := e.currentWindow() + var errs []error + for _, acct := range e.Cfg.Accounts { + ref := model.CalendarRef{AccountID: acct.ID, CalendarID: acct.BlockerCalendar} + p, err := e.providerFor(acct.ID) + if err != nil { + errs = append(errs, fmt.Errorf("account %s: %w", acct.ID, err)) + continue + } + recs, err := p.ListBlockers(ctx, ref, w) + if err != nil { + errs = append(errs, fmt.Errorf("list blockers %s: %w", ref, err)) + continue + } + existing := make(map[string]bool, len(recs)) + for _, rec := range recs { + existing[rec.EventID] = true + } + + maps, err := e.Store.ListMappingsWhereTargetAccount(acct.ID) + if err != nil { + errs = append(errs, err) + continue + } + for _, m := range maps { + if m.Status != store.StatusActive || existing[m.BlockerEventID] { + continue + } + originRef := model.CalendarRef{AccountID: m.OriginAccount, CalendarID: m.OriginCalendar} + ev, err := e.Store.GetEvent(originRef, m.OriginEventID) + if err != nil { + errs = append(errs, err) + continue + } + if ev == nil { + continue // origin がキャッシュに無い → 次回 FullResync が整合を回復する + } + if err := e.createFromMapping(ctx, m, *ev); err != nil { + errs = append(errs, err) + } + } + } + return errors.Join(errs...) +} + +// reevaluateSuppressed は suppressed mappings を再評価する(仕様6.5)。 +// 抑止理由だった重複実予定が消えていれば昇格し、origin 自体が消えていれば行を掃除する。 +// 削除通知経由の promoteSuppressed(Task 9)を取り逃がした場合のセーフティネット。 +// 1 アカウント/1 行の失敗で残りを止めず、エラーは集約して返す(仕様10章)。 +func (e *Engine) reevaluateSuppressed(ctx context.Context) error { + var errs []error + for _, acct := range e.Cfg.Accounts { + maps, err := e.Store.ListMappingsWhereTargetAccount(acct.ID) + if err != nil { + errs = append(errs, err) + continue + } + for _, m := range maps { + if m.Status != store.StatusSuppressed { + continue + } + originRef := model.CalendarRef{AccountID: m.OriginAccount, CalendarID: m.OriginCalendar} + ev, err := e.Store.GetEvent(originRef, m.OriginEventID) + if err != nil { + errs = append(errs, err) + continue + } + if ev == nil { + // origin 消滅 → suppressed 行を掃除(ブロッカーは元々存在しない) + if err := e.Store.DeleteMapping(m.OriginAccount, m.OriginCalendar, m.OriginEventID, m.TargetAccount); err != nil { + errs = append(errs, err) + } + continue + } + target := e.Cfg.AccountByID(m.TargetAccount) + if target == nil { + continue + } + dup, err := e.isDuplicateOnTarget(*target, *ev) + if err != nil { + errs = append(errs, err) + continue + } + if dup { + continue // 重複実予定が健在 → 抑止継続 + } + if err := e.createFromMapping(ctx, m, *ev); err != nil { + errs = append(errs, err) + } + } + } + return errors.Join(errs...) +} + +// parseOriginTag は ":" を分解する。 +// イベント ID には ":" が含まれうるため最初の ":" で切る +// (アカウント ID に ":" を含めない前提。model.OriginTagOf と対)。 +func parseOriginTag(tag string) (accountID, eventID string, ok bool) { + accountID, eventID, ok = strings.Cut(tag, ":") + if !ok || accountID == "" || eventID == "" { + return "", "", false + } + return accountID, eventID, true +} + +// findCachedOriginEvent は origin アカウントの監視カレンダーの events キャッシュから +// origin イベントを探す(タグにはカレンダー ID が含まれないため全監視カレンダーを見る)。 +// 見つかればイベントとカレンダー ID を、アカウントが設定に無い/キャッシュに無ければ +// (nil, "", nil) を返す。 +func (e *Engine) findCachedOriginEvent(originAccountID, originEventID string) (*model.NormalizedEvent, string, error) { + acct := e.Cfg.AccountByID(originAccountID) + if acct == nil { + return nil, "", nil + } + for _, calID := range acct.Calendars { + ref := model.CalendarRef{AccountID: originAccountID, CalendarID: calID} + ev, err := e.Store.GetEvent(ref, originEventID) + if err != nil { + return nil, "", err + } + if ev != nil { + return ev, calID, nil + } + } + return nil, "", nil +} diff --git a/internal/engine/reconcile_test.go b/internal/engine/reconcile_test.go new file mode 100644 index 0000000..2fcb26f --- /dev/null +++ b/internal/engine/reconcile_test.go @@ -0,0 +1,647 @@ +package engine + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/cloudnative-co/calsync/internal/model" + "github.com/cloudnative-co/calsync/internal/provider" + "github.com/cloudnative-co/calsync/internal/store" +) + +// FullResync の set-difference: 1件消滅・1件時刻変更・1件新規・1件生存。 +// 生存分の mappings が保持される(全ワイプしない)ことを明示的にアサートする。 +func TestFullResync_SetDifference(t *testing.T) { + e, f := newTestEngine(t) + ctx := context.Background() + + evKeep := busyEvent("ev-keep") + evGone := busyEvent("ev-gone") + evMoved := busyEvent("ev-moved") + + // 事前状態: 3件を通常同期済み(events キャッシュ + active mappings + ブロッカー) + f.SetFullState(refA, []model.NormalizedEvent{evKeep, evGone, evMoved}) + require.NoError(t, e.SyncCalendar(ctx, refA)) // cursor "c1" + require.Len(t, f.Blockers(calBv), 3) + require.Len(t, f.Blockers(calCv), 3) + + keepBefore, err := e.Store.GetMapping("a", "primary", "ev-keep", "b") + require.NoError(t, err) + require.NotNil(t, keepBefore) + movedBefore, err := e.Store.GetMapping("a", "primary", "ev-moved", "b") + require.NoError(t, err) + require.NotNil(t, movedBefore) + + // 実カレンダーの現状: ev-gone 消滅・ev-moved 時刻変更・ev-new 新規 + moved := evMoved + moved.EndUTC = evMoved.EndUTC.Add(30 * time.Minute) + evNew := busyEvent("ev-new") + f.SetFullState(refA, []model.NormalizedEvent{evKeep, moved, evNew}) + + // リコンサイル時点の now は 1 日進んでいる(ウィンドウのスライドを検証) + now2 := testNow.AddDate(0, 0, 1) + e.Now = func() time.Time { return now2 } + + require.NoError(t, e.FullResync(ctx, refA)) + + // 消滅分: ブロッカー削除 + mapping 削除 + キャッシュ削除 + for _, acct := range []string{"b", "c"} { + m, err := e.Store.GetMapping("a", "primary", "ev-gone", acct) + require.NoError(t, err) + require.Nil(t, m) + } + cached, err := e.Store.GetEvent(refA, "ev-gone") + require.NoError(t, err) + require.Nil(t, cached) + + // 生存分の mapping は保持される(全ワイプしない): resync 前と同じ行・同じブロッカーID + keepAfter, err := e.Store.GetMapping("a", "primary", "ev-keep", "b") + require.NoError(t, err) + require.NotNil(t, keepAfter) + require.Equal(t, keepBefore.BlockerEventID, keepAfter.BlockerEventID) + require.Equal(t, store.StatusActive, keepAfter.Status) + + // 変更分: UpdateBlocker(同一イベントIDのまま time_hash 更新。再作成ではない) + movedAfter, err := e.Store.GetMapping("a", "primary", "ev-moved", "b") + require.NoError(t, err) + require.NotNil(t, movedAfter) + require.Equal(t, movedBefore.BlockerEventID, movedAfter.BlockerEventID) + require.Equal(t, model.TimeHash(moved), movedAfter.TimeHash) + + // 新規分: 作成 + newMap, err := e.Store.GetMapping("a", "primary", "ev-new", "b") + require.NoError(t, err) + require.NotNil(t, newMap) + require.Equal(t, store.StatusActive, newMap.Status) + newCached, err := e.Store.GetEvent(refA, "ev-new") + require.NoError(t, err) + require.NotNil(t, newCached) + + // ブロッカーの最終状態: keep / moved(更新済) / new の3本 + for _, cal := range []model.CalendarRef{calBv, calCv} { + blks := f.Blockers(cal) + require.Len(t, blks, 3, "calendar %s", cal) + byTag := map[string]model.BlockerRecord{} + for _, b := range blks { + byTag[b.OriginTag] = b + } + require.Contains(t, byTag, "a:ev-keep") + require.Contains(t, byTag, "a:ev-new") + require.Equal(t, model.TimeHash(moved), byTag["a:ev-moved"].TimeHash) + } + + // カーソルは張り直され、新しい Window(now2 起点)が保存される + st, err := e.Store.GetCalendar(refA) + require.NoError(t, err) + require.NotNil(t, st) + require.Equal(t, "c2", st.Cursor) // SyncCalendar で c1 → FullResync 完走で c2 + w := e.Cfg.WindowFrom(now2) + require.True(t, st.Window.Start.Equal(w.Start), "window start: got %v want %v", st.Window.Start, w.Start) + require.True(t, st.Window.End.Equal(w.End), "window end: got %v want %v", st.Window.End, w.End) +} + +// ウィンドウ外へ移動した元予定: ブロッカー・mapping・キャッシュがすべて掃除される +// (processEvent の決定則3はキャッシュを消さないため、set-difference 側の責務) +func TestFullResync_RemovesOutOfWindowEventFromCache(t *testing.T) { + e, f := newTestEngine(t) + ctx := context.Background() + ev := busyEvent("ev1") + f.SetFullState(refA, []model.NormalizedEvent{ev}) + require.NoError(t, e.SyncCalendar(ctx, refA)) + require.Len(t, f.Blockers(calBv), 1) + + // 実カレンダー上でウィンドウ外(now+4ヶ月)へ移動した + movedOut := ev + movedOut.StartUTC = testNow.AddDate(0, 4, 0) + movedOut.EndUTC = movedOut.StartUTC.Add(time.Hour) + f.SetFullState(refA, []model.NormalizedEvent{movedOut}) + + require.NoError(t, e.FullResync(ctx, refA)) + + require.Empty(t, f.Blockers(calBv)) + require.Empty(t, f.Blockers(calCv)) + m, err := e.Store.GetMapping("a", "primary", "ev1", "b") + require.NoError(t, err) + require.Nil(t, m) + cached, err := e.Store.GetEvent(refA, "ev1") + require.NoError(t, err) + require.Nil(t, cached) +} + +// FullResync が途中失敗しても旧カーソル・ブロッカー・mappings は無傷 +// (完走時のみ SetCursor。冪等なので再実行で復旧する) +func TestFullResync_FailureKeepsCursorAndState(t *testing.T) { + e, f := newTestEngine(t) + ctx := context.Background() + f.SetFullState(refA, []model.NormalizedEvent{busyEvent("ev1")}) + require.NoError(t, e.SyncCalendar(ctx, refA)) // cursor "c1" + + f.FailNext(refA, provider.ErrAuthExpired) + err := e.FullResync(ctx, refA) + require.ErrorIs(t, err, provider.ErrAuthExpired) + + st, gerr := e.Store.GetCalendar(refA) + require.NoError(t, gerr) + require.Equal(t, "c1", st.Cursor) + require.Len(t, f.Blockers(calBv), 1) + m, gerr := e.Store.GetMapping("a", "primary", "ev1", "b") + require.NoError(t, gerr) + require.NotNil(t, m) +} + +// SyncCalendar が provider から ErrCursorInvalid を受けたとき、 +// 自動で FullResync に切り替わり、呼び出し元にはエラーを返さない(仕様8章) +func TestSyncCalendar_CursorInvalidTriggersFullResync(t *testing.T) { + e, f := newTestEngine(t) + ctx := context.Background() + f.SetFullState(refA, []model.NormalizedEvent{busyEvent("ev1")}) + require.NoError(t, e.SyncCalendar(ctx, refA)) // cursor "c1" + + // カーソル失効。実カレンダーは ev1 消滅・ev2 出現に変わっている + f.SetFullState(refA, []model.NormalizedEvent{busyEvent("ev2")}) + f.FailNext(refA, provider.ErrCursorInvalid) + + require.NoError(t, e.SyncCalendar(ctx, refA)) // エラーにならず自己修復 + + // set-difference が適用されている: ev1 のブロッカーは消え、ev2 が立つ + for _, cal := range []model.CalendarRef{calBv, calCv} { + blks := f.Blockers(cal) + require.Len(t, blks, 1, "calendar %s", cal) + require.Equal(t, "a:ev2", blks[0].OriginTag) + } + m1, err := e.Store.GetMapping("a", "primary", "ev1", "b") + require.NoError(t, err) + require.Nil(t, m1) + m2, err := e.Store.GetMapping("a", "primary", "ev2", "b") + require.NoError(t, err) + require.NotNil(t, m2) + require.Equal(t, store.StatusActive, m2.Status) + + // FailNext はカーソル連番を消費しないため、FullResync 完走で "c2" + st, err := e.Store.GetCalendar(refA) + require.NoError(t, err) + require.Equal(t, "c2", st.Cursor) +} + +// 複数カレンダーで新ウィンドウのカーソルが前進する +func TestReconcile_AdvancesCursorsForAllCalendars(t *testing.T) { + e, f := newTestEngine(t) + ctx := context.Background() + f.SetFullState(refA, []model.NormalizedEvent{busyEvent("ev1")}) + // b・c のフル状態は空 + + require.NoError(t, e.Reconcile(ctx)) + + w := e.Cfg.WindowFrom(testNow) + for _, ref := range []model.CalendarRef{refA, calBv, calCv} { + st, err := e.Store.GetCalendar(ref) + require.NoError(t, err) + require.NotNil(t, st, "calendar %s", ref) + require.Equal(t, "c1", st.Cursor, "calendar %s(fake はカレンダーごとに独立の連番)", ref) + require.True(t, st.Window.Start.Equal(w.Start), "calendar %s", ref) + require.True(t, st.Window.End.Equal(w.End), "calendar %s", ref) + } + // 通常配布も行われている + require.Len(t, f.Blockers(calBv), 1) + require.Len(t, f.Blockers(calCv), 1) +} + +// adoption: mappings 未登録のタグ付きブロッカー(DB 消失起因の孤児)は、 +// origin イベントが生存していれば PutMapping で active として収容される。 +// origin アカウントの同期障害(reauth_required 相当)が他の処理を止めないことも兼ねる。 +func TestReconcile_AdoptsOrphanWhenOriginAlive(t *testing.T) { + e, f := newTestEngine(t) + ctx := context.Background() + ev := busyEvent("ev1") + tag := model.OriginTagOf("a", "ev1") + + // origin アカウント a は今回の同期に失敗するが、 + // 過去の同期で events キャッシュには ev1 が残っている + require.NoError(t, e.Store.UpsertCalendar(refA)) + require.NoError(t, e.Store.UpsertEvent(refA, ev)) + f.FailNext(refA, provider.ErrAuthExpired) + + // b 上に mappings 未登録のタグ付きブロッカーを事前配置 + f.SeedBlocker(calBv, model.BlockerRecord{ + EventID: "orphan-b1", + OriginTag: tag, + TimeHash: model.TimeHash(ev), + }) + + err := e.Reconcile(ctx) + require.ErrorIs(t, err, provider.ErrAuthExpired) // a の失敗は集約されて返る + + // 孤児は削除・再作成ではなく、そのまま active として収容される + m, gerr := e.Store.GetMapping("a", "primary", "ev1", "b") + require.NoError(t, gerr) + require.NotNil(t, m) + require.Equal(t, store.StatusActive, m.Status) + require.Equal(t, "orphan-b1", m.BlockerEventID) + require.Equal(t, model.TimeHash(ev), m.TimeHash) + require.Equal(t, model.MSTransactionID(tag, "b"), m.IdempotencyKey) + blks := f.Blockers(calBv) + require.Len(t, blks, 1) + require.Equal(t, "orphan-b1", blks[0].EventID) + + // a の障害は b・c のリコンサイルを止めない + stB, gerr := e.Store.GetCalendar(calBv) + require.NoError(t, gerr) + require.Equal(t, "c1", stB.Cursor) + stC, gerr := e.Store.GetCalendar(calCv) + require.NoError(t, gerr) + require.Equal(t, "c1", stC.Cursor) +} + +// adoption: origin イベントが消滅している孤児は DeleteBlocker で掃除される +func TestReconcile_CleansOrphanWhenOriginGone(t *testing.T) { + e, f := newTestEngine(t) + ctx := context.Background() + + f.SeedBlocker(calBv, model.BlockerRecord{ + EventID: "orphan-b2", + OriginTag: model.OriginTagOf("a", "ghost"), + TimeHash: "deadbeef00000000", + }) + + require.NoError(t, e.Reconcile(ctx)) + + require.Empty(t, f.Blockers(calBv)) + m, err := e.Store.GetMapping("a", "primary", "ghost", "b") + require.NoError(t, err) + require.Nil(t, m) // mapping も作られない +} + +// adoption: タグ形式が不正なイベントは calsync 製と断定できないため触らない +func TestReconcile_IgnoresBlockerWithMalformedTag(t *testing.T) { + e, f := newTestEngine(t) + f.SeedBlocker(calBv, model.BlockerRecord{EventID: "weird", OriginTag: "no-separator", TimeHash: "h"}) + + require.NoError(t, e.Reconcile(context.Background())) + + require.Len(t, f.Blockers(calBv), 1) // 残る +} + +// pending 解決: CreateBlocker 成功と active 更新の間のクラッシュ跡。 +// fake に同一 idemKey のブロッカーが既存 → CreateBlocker 再実行で既存 ID が返り active 化。 +func TestReconcile_ResolvesPendingWithExistingBlocker(t *testing.T) { + e, f := newTestEngine(t) + ctx := context.Background() + ev := busyEvent("ev1") + tag := model.OriginTagOf("a", "ev1") + idem := model.MSTransactionID(tag, "b") + + // クラッシュ跡の再現: ブロッカー作成は成功済みだが mapping は pending のまま + preID, err := f.CreateBlocker(ctx, calBv, model.Blocker{ + Title: "予定あり", StartUTC: ev.StartUTC, EndUTC: ev.EndUTC, + TargetTimezone: "Asia/Tokyo", OriginTag: tag, + }, idem) + require.NoError(t, err) + require.NoError(t, e.Store.PutMapping(store.Mapping{ + OriginAccount: "a", OriginCalendar: "primary", OriginEventID: "ev1", + TargetAccount: "b", TargetCalendar: "primary", + IdempotencyKey: idem, TimeHash: model.TimeHash(ev), + Status: store.StatusPending, // BlockerEventID は空のまま + })) + // origin は生きている(今回のフル同期にも現れる) + f.SetFullState(refA, []model.NormalizedEvent{ev}) + + require.NoError(t, e.Reconcile(ctx)) + + m, err := e.Store.GetMapping("a", "primary", "ev1", "b") + require.NoError(t, err) + require.NotNil(t, m) + require.Equal(t, store.StatusActive, m.Status) + require.Equal(t, preID, m.BlockerEventID) // 既存 ID で収容 + require.Len(t, f.Blockers(calBv), 1) // 二重作成なし・adoption にも掃除されない +} + +// pending 解決: origin が消滅している pending 行は intent ごと破棄される +func TestReconcile_DropsPendingWhenOriginGone(t *testing.T) { + e, f := newTestEngine(t) + ctx := context.Background() + require.NoError(t, e.Store.PutMapping(store.Mapping{ + OriginAccount: "a", OriginCalendar: "primary", OriginEventID: "ghost", + TargetAccount: "b", TargetCalendar: "primary", + IdempotencyKey: "k", TimeHash: "h", + Status: store.StatusPending, + })) + + require.NoError(t, e.Reconcile(ctx)) + + m, err := e.Store.GetMapping("a", "primary", "ghost", "b") + require.NoError(t, err) + require.Nil(t, m) + require.Empty(t, f.Blockers(calBv)) // 作成もされない +} + +// suppressed 再評価: 抑止理由だった重複実予定が(削除通知を取り逃がすなどで) +// いつの間にか消えていた場合、リコンサイルが昇格する +func TestReconcile_PromotesSuppressedWhenDuplicateGone(t *testing.T) { + e, f := newTestEngine(t) + ctx := context.Background() + origin, _ := dupPair(false) + tag := model.OriginTagOf("a", origin.ID) + + // 過去に重複抑止された suppressed 行。重複実予定は b のどこにも存在しない + require.NoError(t, e.Store.PutMapping(store.Mapping{ + OriginAccount: "a", OriginCalendar: "primary", OriginEventID: origin.ID, + TargetAccount: "b", TargetCalendar: "primary", + IdempotencyKey: model.MSTransactionID(tag, "b"), + TimeHash: model.TimeHash(origin), + Status: store.StatusSuppressed, + })) + // origin は生きている + f.SetFullState(refA, []model.NormalizedEvent{origin}) + + require.NoError(t, e.Reconcile(ctx)) + + m, err := e.Store.GetMapping("a", "primary", origin.ID, "b") + require.NoError(t, err) + require.NotNil(t, m) + require.Equal(t, store.StatusActive, m.Status) + require.NotEmpty(t, m.BlockerEventID) + blks := f.Blockers(calBv) + require.Len(t, blks, 1) + require.Equal(t, tag, blks[0].OriginTag) +} + +// suppressed 再評価: 重複実予定がまだ生きていれば suppressed のまま +func TestReconcile_KeepsSuppressedWhileDuplicateAlive(t *testing.T) { + e, f := newTestEngine(t) + ctx := context.Background() + origin, real := dupPair(false) + tag := model.OriginTagOf("a", origin.ID) + + require.NoError(t, e.Store.PutMapping(store.Mapping{ + OriginAccount: "a", OriginCalendar: "primary", OriginEventID: origin.ID, + TargetAccount: "b", TargetCalendar: "primary", + IdempotencyKey: model.MSTransactionID(tag, "b"), + TimeHash: model.TimeHash(origin), + Status: store.StatusSuppressed, + })) + f.SetFullState(refA, []model.NormalizedEvent{origin}) + f.SetFullState(calBv, []model.NormalizedEvent{real}) // 重複実予定は健在 + + require.NoError(t, e.Reconcile(ctx)) + + m, err := e.Store.GetMapping("a", "primary", origin.ID, "b") + require.NoError(t, err) + require.NotNil(t, m) + require.Equal(t, store.StatusSuppressed, m.Status) + require.Empty(t, m.BlockerEventID) + require.Empty(t, f.Blockers(calBv)) // b 自身にはブロッカーが立たない +} + +// suppressed 再評価: origin が消滅した suppressed 行は掃除される +func TestReconcile_DropsSuppressedWhenOriginGone(t *testing.T) { + e, f := newTestEngine(t) + ctx := context.Background() + require.NoError(t, e.Store.PutMapping(store.Mapping{ + OriginAccount: "a", OriginCalendar: "primary", OriginEventID: "ghost", + TargetAccount: "b", TargetCalendar: "primary", + IdempotencyKey: "k", TimeHash: "h", + Status: store.StatusSuppressed, + })) + + require.NoError(t, e.Reconcile(ctx)) + + m, err := e.Store.GetMapping("a", "primary", "ghost", "b") + require.NoError(t, err) + require.Nil(t, m) + require.Empty(t, f.Blockers(calBv)) +} + +// 手で消されたブロッカーの再作成(仕様8章4: 元予定が生きている限りブロッカーは +// 維持する、確定仕様)。active mapping はそのままに、ブロッカー本体だけを +// DeleteBlocker で直接消す(手動削除の再現)。processEvent の time_hash 一致判定は +// プロバイダを一切呼ばないため(engine.go upsertBlockers の default ケース)、この +// 消失は restoreMissingBlockers でしか検知できない(最終ホールブランチレビュー所見2)。 +func TestReconcile_RestoresManuallyDeletedBlocker(t *testing.T) { + e, f := newTestEngine(t) + ctx := context.Background() + ev := busyEvent("ev1") + + f.SetFullState(refA, []model.NormalizedEvent{ev}) + require.NoError(t, e.SyncCalendar(ctx, refA)) + + before, err := e.Store.GetMapping("a", "primary", "ev1", "b") + require.NoError(t, err) + require.NotNil(t, before) + require.Equal(t, store.StatusActive, before.Status) + require.NotEmpty(t, before.BlockerEventID) + + // 手動削除の再現: mapping には触れず、ブロッカー本体だけを消す + require.NoError(t, f.DeleteBlocker(ctx, calBv, before.BlockerEventID)) + require.Empty(t, f.Blockers(calBv)) + + require.NoError(t, e.Reconcile(ctx)) + + after, err := e.Store.GetMapping("a", "primary", "ev1", "b") + require.NoError(t, err) + require.NotNil(t, after) + require.Equal(t, store.StatusActive, after.Status, "再作成後も active") + require.NotEmpty(t, after.BlockerEventID, "新しいブロッカー ID が記録される") + + blks := f.Blockers(calBv) + require.Len(t, blks, 1) + require.Equal(t, "a:ev1", blks[0].OriginTag) + require.Equal(t, after.BlockerEventID, blks[0].EventID) +} + +// UpdateBlocker が provider.ErrNotFound を返した場合(ブロッカー手動削除後に +// 元予定の時刻が変わったケース)、upsertBlockers は mapping を pending 化して +// createFromMapping で再作成する(リコンサイルを待たない即時フォールバック)。 +// fake の UpdateBlocker は未存在 ID に対して自然に ErrNotFound を返す。 +func TestUpsertBlockers_UpdateNotFoundRecreatesBlocker(t *testing.T) { + e, f := newTestEngine(t) + ctx := context.Background() + ev := busyEvent("ev1") + + f.SetFullState(refA, []model.NormalizedEvent{ev}) + require.NoError(t, e.SyncCalendar(ctx, refA)) + + before, err := e.Store.GetMapping("a", "primary", "ev1", "b") + require.NoError(t, err) + require.NotNil(t, before) + + // 手動削除の再現: mapping には触れず、b 上のブロッカー本体だけを消す + require.NoError(t, f.DeleteBlocker(ctx, calBv, before.BlockerEventID)) + require.Empty(t, f.Blockers(calBv)) + + // 元予定の時刻変更 → time_hash 不一致で UpdateBlocker 分岐に入る + moved := ev + moved.EndUTC = ev.EndUTC.Add(30 * time.Minute) + require.NoError(t, e.processEvent(ctx, refA, moved)) + + after, err := e.Store.GetMapping("a", "primary", "ev1", "b") + require.NoError(t, err) + require.NotNil(t, after) + require.Equal(t, store.StatusActive, after.Status) + require.NotEmpty(t, after.BlockerEventID) + require.Equal(t, model.TimeHash(moved), after.TimeHash) + + blks := f.Blockers(calBv) + require.Len(t, blks, 1, "ブロッカーが再作成される") + require.Equal(t, after.BlockerEventID, blks[0].EventID) + require.Equal(t, model.TimeHash(moved), blks[0].TimeHash, "再作成は変更後の時刻で行われる") + + // c 側のブロッカーは通常の Update 経路で更新されている(巻き添えなし) + blksC := f.Blockers(calCv) + require.Len(t, blksC, 1) + require.Equal(t, model.TimeHash(moved), blksC[0].TimeHash) +} + +// 実在するブロッカーは再作成の対象にならない(余計な CreateBlocker が起きない)。 +func TestReconcile_DoesNotRecreateBlockerThatStillExists(t *testing.T) { + e, f := newTestEngine(t) + ctx := context.Background() + ev := busyEvent("ev1") + + f.SetFullState(refA, []model.NormalizedEvent{ev}) + require.NoError(t, e.SyncCalendar(ctx, refA)) + + before, err := e.Store.GetMapping("a", "primary", "ev1", "b") + require.NoError(t, err) + require.NotNil(t, before) + require.Len(t, f.Blockers(calBv), 1) + + require.NoError(t, e.Reconcile(ctx)) + + after, err := e.Store.GetMapping("a", "primary", "ev1", "b") + require.NoError(t, err) + require.NotNil(t, after) + require.Equal(t, before.BlockerEventID, after.BlockerEventID, "既存ブロッカーの ID は変わらない") + require.Len(t, f.Blockers(calBv), 1, "余計な CreateBlocker は起きない") +} + +// errListBlockersBoom は failingListProvider が注入する ListBlockers の失敗。 +var errListBlockersBoom = errors.New("boom: list blockers failed") + +// failingListProvider は ListBlockers のみを常に失敗させるラッパー +// (リコンサイル後半フェーズのエラー集約・続行の検証用)。 +type failingListProvider struct { + provider.Provider +} + +func (p *failingListProvider) ListBlockers(ctx context.Context, cal model.CalendarRef, window model.Window) ([]model.BlockerRecord, error) { + return nil, errListBlockersBoom +} + +// Reconcile 後半フェーズは 1 アカウントの障害で全体を中断しない(仕様10章。 +// 最終ホールブランチレビュー追補 Issue 6)。アカウント a の ListBlockers が +// 失敗しても、b 上の孤児 adoption は実行され、エラーは集約されて返る。 +// 修正前は adoptOrphans が a のエラーで即 return し、b の孤児が収容されなかった。 +func TestReconcile_ListBlockersFailureDoesNotStopOtherAdoptions(t *testing.T) { + e, f := newTestEngine(t) + ctx := context.Background() + ev := busyEvent("ev1") + tag := model.OriginTagOf("a", "ev1") + + // origin イベントは(過去の同期で)キャッシュに生存。a の FullResync 自体は + // 失敗させ、upsertBlockers が孤児より先に正規ブロッカーを作らないようにする + require.NoError(t, e.Store.UpsertCalendar(refA)) + require.NoError(t, e.Store.UpsertEvent(refA, ev)) + f.FailNext(refA, provider.ErrAuthExpired) + + // b 上に mappings 未登録の孤児ブロッカー + f.SeedBlocker(calBv, model.BlockerRecord{ + EventID: "orphan-b1", + OriginTag: tag, + TimeHash: model.TimeHash(ev), + }) + + // アカウント a(Cfg.Accounts の先頭)の ListBlockers を失敗させる + e.Providers["a"] = &failingListProvider{Provider: f} + + err := e.Reconcile(ctx) + require.ErrorIs(t, err, errListBlockersBoom, "a の adoption 障害は集約されて返る") + require.ErrorIs(t, err, provider.ErrAuthExpired, "a の resync 失敗も併せて集約される") + + // b の adoption は実行されている + m, gerr := e.Store.GetMapping("a", "primary", "ev1", "b") + require.NoError(t, gerr) + require.NotNil(t, m, "b の孤児は a の障害に関係なく収容される") + require.Equal(t, store.StatusActive, m.Status) + require.Equal(t, "orphan-b1", m.BlockerEventID) +} + +// errCreateBlockerBoom は onceFailingCreateProvider が注入する CreateBlocker の失敗。 +var errCreateBlockerBoom = errors.New("boom: create blocker failed") + +// onceFailingCreateProvider は provider.Provider をラップし、CreateBlocker のみを +// fail フラグが立っている間失敗させる(他メソッドは埋め込みの fake にそのまま委譲)。 +// fake パッケージ自体には CreateBlocker の失敗注入手段が無く、それを追加すると +// 他のテストへの影響範囲が広がるため、このテストファイル内だけで完結するラッパーで代替する。 +type onceFailingCreateProvider struct { + provider.Provider + fail bool +} + +func (p *onceFailingCreateProvider) CreateBlocker(ctx context.Context, cal model.CalendarRef, b model.Blocker, idemKey string) (string, error) { + if p.fail { + return "", errCreateBlockerBoom + } + return p.Provider.CreateBlocker(ctx, cal, b, idemKey) +} + +// pending 解決: CreateBlocker が失敗した場合、mapping は pending のまま残り +// (active に遷移しない)、ブロッカーも作られない。障害が解消すれば次回 Reconcile の +// resolvePending で再解決される(intent-first の再実行安全性。仕様6.4)。 +// a の FullResync は両呼び出しとも FailNext で失敗させ、upsertBlockers 側が +// 同じ mapping を先取りして処理しないようにする(origin イベントは直接キャッシュに +// 置く)ことで、CreateBlocker の成否が resolvePending 単独の効果として観測できるようにする。 +func TestReconcile_PendingStaysPendingWhenCreateBlockerFails(t *testing.T) { + e, f := newTestEngine(t) + ctx := context.Background() + ev := busyEvent("ev1") + tag := model.OriginTagOf("a", "ev1") + idem := model.MSTransactionID(tag, "b") + + // クラッシュ跡の再現: pending 行のみ存在し、ブロッカーはまだ作られていない。 + // origin イベントは(過去の同期で)events キャッシュに残っている + require.NoError(t, e.Store.UpsertCalendar(refA)) + require.NoError(t, e.Store.UpsertEvent(refA, ev)) + require.NoError(t, e.Store.PutMapping(store.Mapping{ + OriginAccount: "a", OriginCalendar: "primary", OriginEventID: "ev1", + TargetAccount: "b", TargetCalendar: "primary", + IdempotencyKey: idem, TimeHash: model.TimeHash(ev), + Status: store.StatusPending, // BlockerEventID は空のまま + })) + + // b への CreateBlocker だけを失敗させる + fp := &onceFailingCreateProvider{Provider: f, fail: true} + e.Providers["b"] = fp + // a の FullResync 自体は失敗させ、upsertBlockers がこの pending 行を + // 先取り解決しないようにする + f.FailNext(refA, provider.ErrAuthExpired) + + err := e.Reconcile(ctx) + require.Error(t, err) + require.ErrorIs(t, err, provider.ErrAuthExpired) + require.ErrorIs(t, err, errCreateBlockerBoom) + + m, gerr := e.Store.GetMapping("a", "primary", "ev1", "b") + require.NoError(t, gerr) + require.NotNil(t, m) + require.Equal(t, store.StatusPending, m.Status) // active に遷移しない + require.Empty(t, m.BlockerEventID) + require.Empty(t, f.Blockers(calBv)) + + // 障害解消後: 次回 Reconcile の resolvePending が同一冪等キーで再解決する + // (a の FullResync 自体は今回も失敗させ、resolvePending 単独の効果を見る) + fp.fail = false + f.FailNext(refA, provider.ErrAuthExpired) + err = e.Reconcile(ctx) + require.ErrorIs(t, err, provider.ErrAuthExpired) // a の resync 失敗は残る + + m2, gerr := e.Store.GetMapping("a", "primary", "ev1", "b") + require.NoError(t, gerr) + require.NotNil(t, m2) + require.Equal(t, store.StatusActive, m2.Status) // resolvePending は解決している + require.NotEmpty(t, m2.BlockerEventID) + require.Len(t, f.Blockers(calBv), 1) +} diff --git a/internal/engine/remove_test.go b/internal/engine/remove_test.go new file mode 100644 index 0000000..ee31ee4 --- /dev/null +++ b/internal/engine/remove_test.go @@ -0,0 +1,166 @@ +package engine + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/cloudnative-co/calsync/internal/config" + "github.com/cloudnative-co/calsync/internal/model" + "github.com/cloudnative-co/calsync/internal/provider" + "github.com/cloudnative-co/calsync/internal/provider/fake" + "github.com/cloudnative-co/calsync/internal/store" +) + +// recordingTokenDeleter は TokenDeleter のテストダブル。 +type recordingTokenDeleter struct{ deleted []string } + +func (r *recordingTokenDeleter) Delete(accountID string) error { + r.deleted = append(r.deleted, accountID) + return nil +} + +// setupRemoveFixture は +// - a 発の予定 evA -> b 上のブロッカー blk-on-b(mapping active) +// - b 発の予定 evB -> a 上の受領ブロッカー blk-on-a(mapping active) +// - a のイベントキャッシュ・カレンダー行 +// +// を持つエンジンを作る。 +func setupRemoveFixture(t *testing.T) (*Engine, *fake.Fake, *store.Store) { + t.Helper() + st, err := store.Open(t.TempDir()) + require.NoError(t, err) + t.Cleanup(func() { _ = st.Close() }) + f := fake.New() + cfg := &config.Config{ + BlockerTitle: "予定あり", + Accounts: []config.Account{ + {ID: "a", Provider: "google", Calendars: []string{"primary"}, BlockerCalendar: "primary"}, + {ID: "b", Provider: "google", Calendars: []string{"primary"}, BlockerCalendar: "primary"}, + }, + } + e := &Engine{ + Store: st, + Providers: map[string]provider.Provider{"a": f, "b": f}, + Cfg: cfg, + Now: time.Now, + } + + calA := model.CalendarRef{AccountID: "a", CalendarID: "primary"} + calB := model.CalendarRef{AccountID: "b", CalendarID: "primary"} + + // (1) 用: a 発の配布済みブロッカー(b 上) + f.SeedBlocker(calB, model.BlockerRecord{EventID: "blk-on-b", OriginTag: model.OriginTagOf("a", "evA"), TimeHash: "h1"}) + require.NoError(t, st.PutMapping(store.Mapping{ + OriginAccount: "a", OriginCalendar: "primary", OriginEventID: "evA", + TargetAccount: "b", TargetCalendar: "primary", + BlockerEventID: "blk-on-b", IdempotencyKey: "k1", TimeHash: "h1", Status: store.StatusActive, + })) + + // (2) 用: a が受領しているブロッカー(a 上、origin は b) + f.SeedBlocker(calA, model.BlockerRecord{EventID: "blk-on-a", OriginTag: model.OriginTagOf("b", "evB"), TimeHash: "h2"}) + require.NoError(t, st.PutMapping(store.Mapping{ + OriginAccount: "b", OriginCalendar: "primary", OriginEventID: "evB", + TargetAccount: "a", TargetCalendar: "primary", + BlockerEventID: "blk-on-a", IdempotencyKey: "k2", TimeHash: "h2", Status: store.StatusActive, + })) + + // (3) 用: a のローカル状態 + require.NoError(t, st.UpsertCalendar(calA)) + require.NoError(t, st.UpsertCalendar(calB)) + require.NoError(t, st.UpsertEvent(calA, model.NormalizedEvent{ + ID: "evA", ICalUID: "evA@example.com", + StartUTC: time.Now().UTC(), EndUTC: time.Now().Add(time.Hour).UTC(), + IsBusy: true, + })) + return e, f, st +} + +func TestRemoveAccountDeletesDistributedReceivedAndLocalState(t *testing.T) { + e, f, st := setupRemoveFixture(t) + tokens := &recordingTokenDeleter{} + + require.NoError(t, RemoveAccount(context.Background(), e, tokens, "a", false)) + + calA := model.CalendarRef{AccountID: "a", CalendarID: "primary"} + calB := model.CalendarRef{AccountID: "b", CalendarID: "primary"} + + // (1) b 上の配布済みブロッカーと mapping が消えている + require.Empty(t, f.Blockers(calB)) + m, err := st.GetMapping("a", "primary", "evA", "b") + require.NoError(t, err) + require.Nil(t, m) + + // (2) a 上の受領ブロッカーと mapping が消えている + require.Empty(t, f.Blockers(calA)) + m, err = st.GetMapping("b", "primary", "evB", "a") + require.NoError(t, err) + require.Nil(t, m) + + // (3) イベントキャッシュ・カレンダー行・トークンが消えている + ids, err := st.ListEventIDs(calA) + require.NoError(t, err) + require.Empty(t, ids) + cs, err := st.GetCalendar(calA) + require.NoError(t, err) + require.Nil(t, cs) + require.Equal(t, []string{"a"}, tokens.deleted) + + // b のカレンダー行は残る + csB, err := st.GetCalendar(calB) + require.NoError(t, err) + require.NotNil(t, csB) +} + +func TestRemoveAccountProviderUnavailable(t *testing.T) { + t.Run("force=false はエラーで中断し受領側を残す", func(t *testing.T) { + e, _, st := setupRemoveFixture(t) + delete(e.Providers, "a") // a の provider 構築不能(認証切れ等)を再現 + tokens := &recordingTokenDeleter{} + + err := RemoveAccount(context.Background(), e, tokens, "a", false) + require.Error(t, err) + require.Contains(t, err.Error(), "--force") + + // (1) は b の provider が生きているので完了済み(部分進行。再実行で冪等に続行できる) + m1, gerr := st.GetMapping("a", "primary", "evA", "b") + require.NoError(t, gerr) + require.Nil(t, m1) + + // (2) の受領 mapping は残り、ローカル状態・トークンも消えていない + m2, gerr := st.GetMapping("b", "primary", "evB", "a") + require.NoError(t, gerr) + require.NotNil(t, m2) + cs, gerr := st.GetCalendar(model.CalendarRef{AccountID: "a", CalendarID: "primary"}) + require.NoError(t, gerr) + require.NotNil(t, cs) + require.Empty(t, tokens.deleted) + }) + + t.Run("force=true はリモート削除をスキップして完走する", func(t *testing.T) { + e, f, st := setupRemoveFixture(t) + delete(e.Providers, "a") + tokens := &recordingTokenDeleter{} + + require.NoError(t, RemoveAccount(context.Background(), e, tokens, "a", true)) + + calA := model.CalendarRef{AccountID: "a", CalendarID: "primary"} + calB := model.CalendarRef{AccountID: "b", CalendarID: "primary"} + + // b 上の配布済みブロッカーは b の provider が生きているので消える + require.Empty(t, f.Blockers(calB)) + // a 上の受領ブロッカーはリモートに残る(スキップされた) + require.Len(t, f.Blockers(calA), 1) + + // ローカル状態は全て消える + m, err := st.GetMapping("b", "primary", "evB", "a") + require.NoError(t, err) + require.Nil(t, m) + cs, err := st.GetCalendar(calA) + require.NoError(t, err) + require.Nil(t, cs) + require.Equal(t, []string{"a"}, tokens.deleted) + }) +} diff --git a/internal/engine/scheduler.go b/internal/engine/scheduler.go new file mode 100644 index 0000000..d14de09 --- /dev/null +++ b/internal/engine/scheduler.go @@ -0,0 +1,124 @@ +package engine + +import ( + "context" + "log" + "time" + + "github.com/cloudnative-co/calsync/internal/model" +) + +// nextReconcileAt は now と同じロケーションで、hhmm("15:04" 形式)が指す直近の +// 未来時刻を返す。当日の hhmm が now より未来ならその日、now 以前なら翌日。 +// hhmm が不正な場合は既定の "04:00" にフォールバックする(config 側の検証が正、 +// ここは防御的フォールバック)。 +func nextReconcileAt(now time.Time, hhmm string) time.Time { + parsed, err := time.Parse("15:04", hhmm) + if err != nil { + parsed, _ = time.Parse("15:04", "04:00") + } + candidate := time.Date(now.Year(), now.Month(), now.Day(), + parsed.Hour(), parsed.Minute(), 0, 0, now.Location()) + if candidate.After(now) { + return candidate + } + return candidate.AddDate(0, 0, 1) +} + +// Run はデーモン本体。 +// - Cfg.PollInterval ごとに全アカウント・全カレンダーを SyncCalendar する +// - SyncCalendar が provider.ErrAuthExpired を返したアカウントは reauth_required +// として以後のティックでスキップし、SetCalendarError に記録する(仕様 9.3)。 +// 他アカウントの同期は継続する +// - Cfg.ReconcileAt(コンテナのローカル TZ)を過ぎたら Reconcile を実行する。 +// Reconcile 後は reauth スキップを解除して再試行の機会を与える +// - ctx キャンセルで nil を返して終了する +func (e *Engine) Run(ctx context.Context) error { + reauth := make(map[string]bool) // account ID -> reauth_required + next := nextReconcileAt(e.now(), e.Cfg.ReconcileAt) + ticker := time.NewTicker(e.Cfg.PollInterval) + defer ticker.Stop() + + // 起動直後に 1 ティック実行(初回同期をポーリング間隔まで待たせない) + e.tick(ctx, reauth) + + for { + select { + case <-ctx.Done(): + return nil + case <-ticker.C: + } + if !e.now().Before(next) { + if err := e.Reconcile(ctx); err != nil { + log.Printf("reconcile: %v", err) + } + next = nextReconcileAt(e.now(), e.Cfg.ReconcileAt) + // 日次リコンサイルのタイミングで reauth スキップを解除し再試行する + // (再認証済みなら次のティックから自動バックフィルされる) + reauth = make(map[string]bool) + } + e.tick(ctx, reauth) + } +} + +// tick は 1 ポーリングサイクル分の同期を行う。 +func (e *Engine) tick(ctx context.Context, reauth map[string]bool) { + for _, acct := range e.Cfg.Accounts { + if reauth[acct.ID] { + continue + } + for _, calID := range acct.Calendars { + if ctx.Err() != nil { + return + } + ref := model.CalendarRef{AccountID: acct.ID, CalendarID: calID} + err := e.SyncCalendar(ctx, ref) + + // ターゲットアカウントの認証失効(TargetAuthError)は origin ではなく + // 該当ターゲットに帰属させる(仕様9.3)。origin の同期は継続する + for _, tae := range collectTargetAuthErrors(err) { + if reauth[tae.AccountID] { + continue + } + reauth[tae.AccountID] = true + msg := "reauth_required: run `calsync auth add " + tae.AccountID + "`" + if target := e.Cfg.AccountByID(tae.AccountID); target != nil { + for _, tcalID := range target.Calendars { + tref := model.CalendarRef{AccountID: tae.AccountID, CalendarID: tcalID} + if serr := e.Store.SetCalendarError(tref, msg); serr != nil { + log.Printf("set calendar error %s: %v", tref, serr) + } + } + } + log.Printf("account %s: authentication expired (detected while writing blockers); pausing sync until reauth (%s)", tae.AccountID, msg) + } + + if err == nil || onlyTargetAuthErrors(err) { + // 成功した同期を記録する: 過去の last_error(reauth_required 等)を + // クリアし、last_synced_at を更新する(仕様11章の calsync status が + // 参照する)。SetCalendarError は "" 指定でエラークリア + 時刻更新の + // 両方を担う(store 契約)。TargetAuthError のみの場合、origin の + // 同期自体は完走している(カーソルも前進済み)ため成功扱いにする + if serr := e.Store.SetCalendarError(ref, ""); serr != nil { + log.Printf("clear calendar error %s: %v", ref, serr) + } + continue + } + // origin 自身の失効のみ reauth 扱いにする(errors.Is だと TargetAuthError + // 配下の ErrAuthExpired まで origin に誤帰属するため使わない) + if originAuthExpired(err) { + reauth[acct.ID] = true + msg := "reauth_required: run `calsync auth add " + acct.ID + "`" + if serr := e.Store.SetCalendarError(ref, msg); serr != nil { + log.Printf("set calendar error %s: %v", ref, serr) + } + log.Printf("account %s: authentication expired; pausing sync until reauth (%s)", acct.ID, msg) + break // 同一アカウントの残りカレンダーもスキップ + } + log.Printf("sync %s: %v", ref, err) + if serr := e.Store.SetCalendarError(ref, err.Error()); serr != nil { + log.Printf("set calendar error %s: %v", ref, serr) + } + } + } +} diff --git a/internal/engine/scheduler_test.go b/internal/engine/scheduler_test.go new file mode 100644 index 0000000..9dc7eff --- /dev/null +++ b/internal/engine/scheduler_test.go @@ -0,0 +1,356 @@ +package engine + +import ( + "context" + "fmt" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/cloudnative-co/calsync/internal/config" + "github.com/cloudnative-co/calsync/internal/model" + "github.com/cloudnative-co/calsync/internal/provider" + "github.com/cloudnative-co/calsync/internal/provider/fake" + "github.com/cloudnative-co/calsync/internal/store" +) + +func TestNextReconcileAt(t *testing.T) { + jst := time.FixedZone("JST", 9*3600) + tests := []struct { + name string + now time.Time + hhmm string + want time.Time + }{ + { + name: "future same day", + now: time.Date(2026, 7, 3, 10, 0, 0, 0, jst), + hhmm: "14:30", + want: time.Date(2026, 7, 3, 14, 30, 0, 0, jst), + }, + { + name: "past time rolls to next day", + now: time.Date(2026, 7, 3, 10, 0, 0, 0, jst), + hhmm: "04:00", + want: time.Date(2026, 7, 4, 4, 0, 0, 0, jst), + }, + { + name: "exactly now rolls to next day", + now: time.Date(2026, 7, 3, 4, 0, 0, 0, jst), + hhmm: "04:00", + want: time.Date(2026, 7, 4, 4, 0, 0, 0, jst), + }, + { + name: "one minute before stays same day", + now: time.Date(2026, 7, 3, 3, 59, 0, 0, jst), + hhmm: "04:00", + want: time.Date(2026, 7, 3, 4, 0, 0, 0, jst), + }, + { + name: "invalid hhmm falls back to 04:00", + now: time.Date(2026, 7, 3, 10, 0, 0, 0, jst), + hhmm: "bogus", + want: time.Date(2026, 7, 4, 4, 0, 0, 0, jst), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := nextReconcileAt(tt.now, tt.hhmm) + require.True(t, got.Equal(tt.want), "got %v want %v", got, tt.want) + }) + } +} + +// schedEvent は scheduler テスト専用の busy イベント生成ヘルパー +// (engine_test.go 等の既存ヘルパーと名前が衝突しないよう sched プレフィクスを付ける)。 +func schedEvent(id string, start time.Time) model.NormalizedEvent { + return model.NormalizedEvent{ + ID: id, + ICalUID: id + "@test", + StartUTC: start, + EndUTC: start.Add(time.Hour), + IsBusy: true, + } +} + +func TestRunSkipsReauthAccountAndContinuesOthers(t *testing.T) { + st, err := store.Open(t.TempDir()) + require.NoError(t, err) + defer st.Close() + + refA := model.CalendarRef{AccountID: "a", CalendarID: "primary"} + refB := model.CalendarRef{AccountID: "b", CalendarID: "primary"} + require.NoError(t, st.UpsertCalendar(refA)) + require.NoError(t, st.UpsertCalendar(refB)) + + f := fake.New() + f.SetTimezone(refA, "UTC") + f.SetTimezone(refB, "UTC") + + now := time.Now().UTC() + // a: 初回 Changes が ErrAuthExpired を返す。a は以後スキップされるため、 + // この full state(evA)が b へ配布されてはならない。 + f.SetFullState(refA, []model.NormalizedEvent{schedEvent("evA", now.Add(time.Hour))}) + f.FailNext(refA, provider.ErrAuthExpired) + // b: busy 予定 evB → ターゲット a にブロッカーが立つはず(b の同期は継続する証拠)。 + f.SetFullState(refB, []model.NormalizedEvent{schedEvent("evB", now.Add(2*time.Hour))}) + + cfg := &config.Config{ + PollInterval: 10 * time.Millisecond, + SyncWindowMonths: 3, + BlockerTitle: "予定あり", + ReconcileAt: time.Now().Add(12 * time.Hour).Format("15:04"), // テスト中に発火しない + DedupeSameMeeting: true, + Accounts: []config.Account{ + {ID: "a", Provider: "google", Calendars: []string{"primary"}, BlockerCalendar: "primary"}, + {ID: "b", Provider: "google", Calendars: []string{"primary"}, BlockerCalendar: "primary"}, + }, + } + e := &Engine{ + Store: st, + Providers: map[string]provider.Provider{"a": f, "b": f}, + Cfg: cfg, + Now: time.Now, + } + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- e.Run(ctx) }() + + // b の予定 evB が a にブロッカーとして配布されるまで待つ + require.Eventually(t, func() bool { + return len(f.Blockers(refA)) == 1 + }, 3*time.Second, 10*time.Millisecond) + + // 数ティック余分に回し、a がスキップされ続けることを確認する猶予を与える + time.Sleep(100 * time.Millisecond) + cancel() + select { + case err := <-done: + require.NoError(t, err) // ctx キャンセルは nil 終了 + case <-time.After(2 * time.Second): + t.Fatal("Run did not return after context cancel") + } + + // a は reauth_required でスキップされた: もしスキップされていなければ + // 2 ティック目以降で a の full state(evA)が同期され b にブロッカーが立つ + require.Empty(t, f.Blockers(refB), "account a must be skipped after ErrAuthExpired") + + // b → a のブロッカーは 1 件で OriginTag は b:evB + blockers := f.Blockers(refA) + require.Len(t, blockers, 1) + require.Equal(t, "b:evB", blockers[0].OriginTag) + + // SetCalendarError で reauth_required が記録されている + calA, err := st.GetCalendar(refA) + require.NoError(t, err) + require.NotNil(t, calA) + require.Contains(t, calA.LastError, "reauth_required") +} + +// authFailingProvider はブロッカー書き込み系呼び出しを fail フラグが立っている間 +// provider.ErrAuthExpired で失敗させるラッパー(「ターゲットとしての認証失効」を +// 再現する。Changes は素通しなので、失効検出が origin の同期経由であることを分離できる)。 +type authFailingProvider struct { + provider.Provider + mu sync.Mutex + fail bool +} + +func (p *authFailingProvider) setFail(v bool) { + p.mu.Lock() + defer p.mu.Unlock() + p.fail = v +} + +func (p *authFailingProvider) failing() bool { + p.mu.Lock() + defer p.mu.Unlock() + return p.fail +} + +func (p *authFailingProvider) authErr() error { + return fmt.Errorf("status 401: %w", provider.ErrAuthExpired) +} + +func (p *authFailingProvider) CreateBlocker(ctx context.Context, cal model.CalendarRef, b model.Blocker, idemKey string) (string, error) { + if p.failing() { + return "", p.authErr() + } + return p.Provider.CreateBlocker(ctx, cal, b, idemKey) +} + +func (p *authFailingProvider) UpdateBlocker(ctx context.Context, cal model.CalendarRef, eventID string, b model.Blocker) error { + if p.failing() { + return p.authErr() + } + return p.Provider.UpdateBlocker(ctx, cal, eventID, b) +} + +func (p *authFailingProvider) DeleteBlocker(ctx context.Context, cal model.CalendarRef, eventID string) error { + if p.failing() { + return p.authErr() + } + return p.Provider.DeleteBlocker(ctx, cal, eventID) +} + +func (p *authFailingProvider) GetCalendarTimezone(ctx context.Context, cal model.CalendarRef) (string, error) { + if p.failing() { + return "", p.authErr() + } + return p.Provider.GetCalendarTimezone(ctx, cal) +} + +// TestTickAttributesTargetAuthExpiryToTargetAccount は仕様9.3の帰属を検証する: +// origin A の同期中にターゲット B への書き込みが認証失効で失敗しても、 +// (a) 他ターゲット C への配布は継続し、(b) reauth_required は B に帰属して +// A には帰属せず、(c) A のカーソルは前進し(同期成功扱い)、 +// (d) B の復帰後は Reconcile でバックフィルされる。 +// 修正前は B 由来の ErrAuthExpired が errors.Is で A に誤帰属し、A の同期が +// 全体停止していた(最終ホールブランチレビュー追補 Issue 3)。 +func TestTickAttributesTargetAuthExpiryToTargetAccount(t *testing.T) { + e, f := newTestEngine(t) // a(origin)/ b・c(target)。TZ はキャッシュ済み + ctx := context.Background() + + bp := &authFailingProvider{Provider: f, fail: true} + e.Providers["b"] = bp + + f.SetFullState(refA, []model.NormalizedEvent{busyEvent("ev1")}) + + reauth := map[string]bool{} + e.tick(ctx, reauth) + + // (a) 正常ターゲット C にはブロッカーが作成される(B のみスキップ) + require.Len(t, f.Blockers(calCv), 1, "healthy target c must still receive the blocker") + require.Empty(t, f.Blockers(calBv)) + + // (b) reauth_required は B に帰属し、A には帰属しない + require.True(t, reauth["b"], "expired target b must enter the reauth set") + require.False(t, reauth["a"], "origin a must not be blamed for b's expiry") + stB, err := e.Store.GetCalendar(calBv) + require.NoError(t, err) + require.Contains(t, stB.LastError, "reauth_required") + require.Contains(t, stB.LastError, "calsync auth add b") + stA, err := e.Store.GetCalendar(refA) + require.NoError(t, err) + require.Empty(t, stA.LastError, "origin a counts as a successful sync") + + // (c) A のカーソルは前進する(ターゲット失効だけでは同期を失敗扱いにしない) + require.Equal(t, "c1", stA.Cursor) + require.False(t, stA.LastSyncedAt.IsZero()) + + // B への intent は pending として残る(復帰後のバックフィルの足がかり) + mB, err := e.Store.GetMapping("a", "primary", "ev1", "b") + require.NoError(t, err) + require.NotNil(t, mB) + require.Equal(t, store.StatusPending, mB.Status) + + // (d) B の復帰後、Reconcile がバックフィルする + bp.setFail(false) + require.NoError(t, e.Reconcile(ctx)) + + blks := f.Blockers(calBv) + require.Len(t, blks, 1, "b must be backfilled after recovery") + require.Equal(t, "a:ev1", blks[0].OriginTag) + mB, err = e.Store.GetMapping("a", "primary", "ev1", "b") + require.NoError(t, err) + require.NotNil(t, mB) + require.Equal(t, store.StatusActive, mB.Status) + require.Equal(t, blks[0].EventID, mB.BlockerEventID) +} + +// TestTickPersistsSuccessOnSyncCalendar は成功ティック後に last_error が空で +// last_synced_at が更新されていることを確認する(`calsync status` が参照する状態)。 +func TestTickPersistsSuccessOnSyncCalendar(t *testing.T) { + st, err := store.Open(t.TempDir()) + require.NoError(t, err) + defer st.Close() + + ref := model.CalendarRef{AccountID: "a", CalendarID: "primary"} + require.NoError(t, st.UpsertCalendar(ref)) + + f := fake.New() + f.SetTimezone(ref, "UTC") + f.SetFullState(ref, nil) // イベントなし → 同期は成功で完走する + + cfg := &config.Config{ + SyncWindowMonths: 3, + BlockerTitle: "予定あり", + Accounts: []config.Account{ + {ID: "a", Provider: "google", Calendars: []string{"primary"}, BlockerCalendar: "primary"}, + }, + } + e := &Engine{ + Store: st, + Providers: map[string]provider.Provider{"a": f}, + Cfg: cfg, + Now: time.Now, + } + + e.tick(context.Background(), map[string]bool{}) + + got, err := st.GetCalendar(ref) + require.NoError(t, err) + require.NotNil(t, got) + require.Empty(t, got.LastError) + require.False(t, got.LastSyncedAt.IsZero()) +} + +// TestTickClearsPriorReauthErrorOnSuccess は reauth_required で汚れた last_error が +// 再認証成功後の成功ティックでクリアされることを確認する(復旧後も +// `calsync status` が古いエラーを表示し続ける問題の回帰テスト)。 +func TestTickClearsPriorReauthErrorOnSuccess(t *testing.T) { + st, err := store.Open(t.TempDir()) + require.NoError(t, err) + defer st.Close() + + ref := model.CalendarRef{AccountID: "a", CalendarID: "primary"} + require.NoError(t, st.UpsertCalendar(ref)) + require.NoError(t, st.SetCalendarError(ref, "reauth_required: run `calsync auth add a`")) + + f := fake.New() + f.SetTimezone(ref, "UTC") + f.SetFullState(ref, nil) + + cfg := &config.Config{ + SyncWindowMonths: 3, + BlockerTitle: "予定あり", + Accounts: []config.Account{ + {ID: "a", Provider: "google", Calendars: []string{"primary"}, BlockerCalendar: "primary"}, + }, + } + e := &Engine{ + Store: st, + Providers: map[string]provider.Provider{"a": f}, + Cfg: cfg, + Now: time.Now, + } + + e.tick(context.Background(), map[string]bool{}) + + got, err := st.GetCalendar(ref) + require.NoError(t, err) + require.NotNil(t, got) + require.Empty(t, got.LastError, "reauth_required は再認証成功後にクリアされるべき") +} + +// TestRunWithoutNowDoesNotPanic は Engine.Now を設定しないまま Run を実行しても +// nil パニックしないことを確認する(nil セーフな e.now() 経由であることの回帰テスト)。 +func TestRunWithoutNowDoesNotPanic(t *testing.T) { + cfg := &config.Config{ + PollInterval: 10 * time.Millisecond, + ReconcileAt: "04:00", + } + e := &Engine{Cfg: cfg} // Now は意図的に未設定(nil) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // 即キャンセルして短時間で Run を終了させる + + var runErr error + require.NotPanics(t, func() { + runErr = e.Run(ctx) + }) + require.NoError(t, runErr) +} diff --git a/internal/model/model.go b/internal/model/model.go new file mode 100644 index 0000000..5a02fb1 --- /dev/null +++ b/internal/model/model.go @@ -0,0 +1,94 @@ +package model + +import ( + "crypto/sha256" + "encoding/base32" + "encoding/hex" + "fmt" + "time" +) + +type Window struct { + Start time.Time // 含む(end > Start のイベントが対象) + End time.Time // 含まない(start < End のイベントが対象) +} + +type CalendarRef struct { + AccountID string + CalendarID string // Google: "primary" 等 / Microsoft: 常に "primary"(v1) +} + +type NormalizedEvent struct { + ID string // プロバイダのイベントID(opaque。パース禁止) + ICalUID string + StartUTC time.Time + EndUTC time.Time + IsAllDay bool + AllDayStart string // "2006-01-02"(IsAllDay時のみ。現地日付) + AllDayEnd string // 排他的終了日 + IsBusy bool + IsDeclined bool + Deleted bool // cancelled / @removed / isCancelled + OriginTag string // calsync タグが読めた場合のみ(Graph delta では常に "") +} + +type Blocker struct { + Title string + StartUTC time.Time + EndUTC time.Time + IsAllDay bool + AllDayStart string + AllDayEnd string + TargetTimezone string // 終日ブロッカー作成用(Graph はこのTZの midnight 境界で作る) + OriginTag string +} + +type BlockerRecord struct { + EventID string + OriginTag string + TimeHash string +} + +// OriginTag は ":" +func OriginTagOf(accountID, eventID string) string { return accountID + ":" + eventID } + +// TimeHash: 予定時刻の変更検出用。16桁hex。 +func TimeHash(ev NormalizedEvent) string { + var s string + if ev.IsAllDay { + s = "allday|" + ev.AllDayStart + "|" + ev.AllDayEnd + } else { + s = ev.StartUTC.UTC().Format(time.RFC3339) + "|" + ev.EndUTC.UTC().Format(time.RFC3339) + } + sum := sha256.Sum256([]byte(s)) + return hex.EncodeToString(sum[:])[:16] +} + +var b32 = base32.NewEncoding("abcdefghijklmnopqrstuv0123456789").WithPadding(base32.NoPadding) // 疑似base32hex(Google許容文字 a-v0-9) + +// GoogleBlockerID: Google events.insert の id に指定するクライアント生成ID(冪等キー)。 +func GoogleBlockerID(originTag, targetAccount string) string { + sum := sha256.Sum256([]byte("gcal|" + originTag + "|" + targetAccount)) + return "cs" + b32.EncodeToString(sum[:20]) +} + +// MSTransactionID: Graph イベント作成の transactionId(冪等キー)。 +func MSTransactionID(originTag, targetAccount string) string { + sum := sha256.Sum256([]byte("msgraph|" + originTag + "|" + targetAccount)) + return "calsync-" + hex.EncodeToString(sum[:16]) +} + +func (w Window) Contains(ev NormalizedEvent) bool { + if ev.IsAllDay { + // 終日は現地日付だが、境界判定は日付をUTC日付として近似してよい(仕様5.3) + start, err1 := time.Parse("2006-01-02", ev.AllDayStart) + end, err2 := time.Parse("2006-01-02", ev.AllDayEnd) + if err1 != nil || err2 != nil { + return false + } + return end.After(w.Start) && start.Before(w.End) + } + return ev.EndUTC.After(w.Start) && ev.StartUTC.Before(w.End) +} + +func (r CalendarRef) String() string { return fmt.Sprintf("%s/%s", r.AccountID, r.CalendarID) } diff --git a/internal/model/model_test.go b/internal/model/model_test.go new file mode 100644 index 0000000..a628b2f --- /dev/null +++ b/internal/model/model_test.go @@ -0,0 +1,163 @@ +package model + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestTimeHash(t *testing.T) { + jst := time.FixedZone("JST", 9*60*60) + timed := func(startHour, endHour int) NormalizedEvent { + return NormalizedEvent{ + StartUTC: time.Date(2026, 7, 10, startHour, 0, 0, 0, time.UTC), + EndUTC: time.Date(2026, 7, 10, endHour, 0, 0, 0, time.UTC), + } + } + allDay := func(start, end string) NormalizedEvent { + return NormalizedEvent{IsAllDay: true, AllDayStart: start, AllDayEnd: end} + } + + tests := []struct { + name string + a, b NormalizedEvent + wantEqual bool + }{ + { + name: "same timed input is stable", + a: timed(1, 2), + b: timed(1, 2), + wantEqual: true, + }, + { + name: "non-UTC location is normalized before hashing", + a: timed(1, 2), + b: NormalizedEvent{ + StartUTC: time.Date(2026, 7, 10, 10, 0, 0, 0, jst), // == 2026-07-10T01:00Z + EndUTC: time.Date(2026, 7, 10, 11, 0, 0, 0, jst), // == 2026-07-10T02:00Z + }, + wantEqual: true, + }, + { + name: "different end time differs", + a: timed(1, 2), + b: timed(1, 3), + wantEqual: false, + }, + { + name: "same all-day input is stable", + a: allDay("2026-07-10", "2026-07-11"), + b: allDay("2026-07-10", "2026-07-11"), + wantEqual: true, + }, + { + name: "all-day differs from timed event covering the same instant range", + a: allDay("2026-07-10", "2026-07-11"), + b: NormalizedEvent{ + StartUTC: time.Date(2026, 7, 10, 0, 0, 0, 0, time.UTC), + EndUTC: time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC), + }, + wantEqual: false, + }, + { + name: "different all-day dates differ", + a: allDay("2026-07-10", "2026-07-11"), + b: allDay("2026-07-10", "2026-07-12"), + wantEqual: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ha, hb := TimeHash(tt.a), TimeHash(tt.b) + require.Regexp(t, `^[0-9a-f]{16}$`, ha, "TimeHash must be 16 hex chars") + require.Regexp(t, `^[0-9a-f]{16}$`, hb, "TimeHash must be 16 hex chars") + if tt.wantEqual { + require.Equal(t, ha, hb) + } else { + require.NotEqual(t, ha, hb) + } + }) + } +} + +func TestIdempotencyKeys(t *testing.T) { + tests := []struct { + name string + originTag string + targetAccount string + }{ + {name: "basic", originTag: "acct-a:ev123", targetAccount: "acct-b"}, + {name: "different target yields different keys", originTag: "acct-a:ev123", targetAccount: "acct-c"}, + {name: "different origin event yields different keys", originTag: "acct-a:ev999", targetAccount: "acct-b"}, + } + seenGoogle := map[string]bool{} + seenMS := map[string]bool{} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := GoogleBlockerID(tt.originTag, tt.targetAccount) + // 接頭辞 cs + sha256 先頭20バイトの base32(5bit×32文字)。Google の + // クライアント生成 ID 許容文字 a-v0-9(base32hex 小文字)のみで構成される。 + require.Regexp(t, `^cs[a-v0-9]{32}$`, g) + require.Equal(t, g, GoogleBlockerID(tt.originTag, tt.targetAccount), "must be deterministic") + require.False(t, seenGoogle[g], "GoogleBlockerID must be unique per (originTag, targetAccount)") + seenGoogle[g] = true + + m := MSTransactionID(tt.originTag, tt.targetAccount) + // calsync- + sha256 先頭16バイトの hex(32桁)。 + require.Regexp(t, `^calsync-[0-9a-f]{32}$`, m) + require.Equal(t, m, MSTransactionID(tt.originTag, tt.targetAccount), "must be deterministic") + require.False(t, seenMS[m], "MSTransactionID must be unique per (originTag, targetAccount)") + seenMS[m] = true + }) + } +} + +func TestOriginTagOf(t *testing.T) { + require.Equal(t, "acct-a:ev123", OriginTagOf("acct-a", "ev123")) +} + +func TestCalendarRefString(t *testing.T) { + require.Equal(t, "acct-a/primary", CalendarRef{AccountID: "acct-a", CalendarID: "primary"}.String()) +} + +func TestWindowContains(t *testing.T) { + w := Window{ + Start: time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC), + End: time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC), + } + d := func(month time.Month, day, hour int) time.Time { + return time.Date(2026, month, day, hour, 0, 0, 0, time.UTC) + } + timed := func(start, end time.Time) NormalizedEvent { + return NormalizedEvent{StartUTC: start, EndUTC: end} + } + allDay := func(start, end string) NormalizedEvent { + return NormalizedEvent{IsAllDay: true, AllDayStart: start, AllDayEnd: end} + } + + tests := []struct { + name string + ev NormalizedEvent + want bool + }{ + {"fully inside is included", timed(d(7, 10, 10), d(7, 10, 11)), true}, + {"end exactly at window start is excluded", timed(d(6, 30, 23), d(7, 1, 0)), false}, + {"start exactly at window end is excluded", timed(d(10, 1, 0), d(10, 1, 1)), false}, + {"partial overlap across window start is included", timed(d(6, 30, 23), d(7, 1, 1)), true}, + {"partial overlap across window end is included", timed(d(9, 30, 23), d(10, 1, 1)), true}, + {"event spanning the entire window is included", timed(d(6, 1, 0), d(11, 1, 0)), true}, + {"entirely before window is excluded", timed(d(6, 1, 0), d(6, 2, 0)), false}, + {"entirely after window is excluded", timed(d(10, 2, 0), d(10, 3, 0)), false}, + {"all-day inside window is included", allDay("2026-07-10", "2026-07-11"), true}, + {"all-day ending on window start date is excluded", allDay("2026-06-30", "2026-07-01"), false}, + {"all-day overlapping window start is included", allDay("2026-06-30", "2026-07-02"), true}, + {"all-day starting at window end is excluded", allDay("2026-10-01", "2026-10-02"), false}, + {"all-day with unparsable dates is excluded", NormalizedEvent{IsAllDay: true}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, w.Contains(tt.ev)) + }) + } +} diff --git a/internal/provider/autherr.go b/internal/provider/autherr.go new file mode 100644 index 0000000..497dc81 --- /dev/null +++ b/internal/provider/autherr.go @@ -0,0 +1,26 @@ +package provider + +import ( + "errors" + "fmt" + + "golang.org/x/oauth2" +) + +// NormalizeAuthErr は OAuth2 のトークン更新失敗のうち、リフレッシュトークンの +// 失効・再同意要求を示すもの(invalid_grant / interaction_required)を +// ErrAuthExpired にラップして返す(仕様書9.3)。該当しない場合は err をそのまま返す。 +// +// TokenSource の失敗は http.Client(oauth2.Transport 経由)によって *url.Error に +// 包まれて届くため、型スイッチではなく errors.As でエラーチェーンを掘り下げて +// *oauth2.RetrieveError を探す。google/microsoft 双方のプロバイダから共用する。 +func NormalizeAuthErr(err error) error { + var rerr *oauth2.RetrieveError + if errors.As(err, &rerr) { + switch rerr.ErrorCode { + case "invalid_grant", "interaction_required": + return fmt.Errorf("%w: %w", ErrAuthExpired, err) + } + } + return err +} diff --git a/internal/provider/autherr_test.go b/internal/provider/autherr_test.go new file mode 100644 index 0000000..14b158a --- /dev/null +++ b/internal/provider/autherr_test.go @@ -0,0 +1,52 @@ +package provider + +import ( + "errors" + "net/url" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" +) + +func TestNormalizeAuthErr(t *testing.T) { + t.Run("invalid_grant は *url.Error にネストしていても ErrAuthExpired になる", func(t *testing.T) { + rerr := &oauth2.RetrieveError{ErrorCode: "invalid_grant", ErrorDescription: "Token has been expired or revoked"} + wrapped := &url.Error{Op: "Post", URL: "https://oauth2.googleapis.com/token", Err: rerr} + + got := NormalizeAuthErr(wrapped) + + require.ErrorIs(t, got, ErrAuthExpired) + // 元エラーは %w で保持されるため、RetrieveError までたどり着ける。 + var got2 *oauth2.RetrieveError + require.ErrorAs(t, got, &got2) + require.Equal(t, "invalid_grant", got2.ErrorCode) + }) + + t.Run("interaction_required も ErrAuthExpired になる", func(t *testing.T) { + rerr := &oauth2.RetrieveError{ErrorCode: "interaction_required"} + wrapped := &url.Error{Op: "Post", URL: "https://login.microsoftonline.com/token", Err: rerr} + + got := NormalizeAuthErr(wrapped) + + require.ErrorIs(t, got, ErrAuthExpired) + }) + + t.Run("無関係なエラーはそのまま返す", func(t *testing.T) { + orig := errors.New("boom") + + got := NormalizeAuthErr(orig) + + require.Same(t, orig, got) + require.False(t, errors.Is(got, ErrAuthExpired)) + }) + + t.Run("invalid_grant/interaction_required 以外の RetrieveError はそのまま返す", func(t *testing.T) { + rerr := &oauth2.RetrieveError{ErrorCode: "invalid_client"} + + got := NormalizeAuthErr(rerr) + + require.Same(t, error(rerr), got) + require.False(t, errors.Is(got, ErrAuthExpired)) + }) +} diff --git a/internal/provider/fake/fake.go b/internal/provider/fake/fake.go new file mode 100644 index 0000000..3d6e375 --- /dev/null +++ b/internal/provider/fake/fake.go @@ -0,0 +1,212 @@ +// Package fake はエンジンテスト用のインメモリ Provider 実装。 +// 実 HTTP を使わず、実プロバイダと同じ契約(冪等作成・404削除成功扱い・ +// 完走時のみ非空カーソル)を再現する。 +package fake + +import ( + "context" + "fmt" + "sort" + "sync" + + "github.com/cloudnative-co/calsync/internal/model" + "github.com/cloudnative-co/calsync/internal/provider" +) + +// コンパイル時に provider.Provider を満たすことを保証する。 +var _ provider.Provider = (*Fake)(nil) + +type storedBlocker struct { + rec model.BlockerRecord + body model.Blocker +} + +type calState struct { + full []model.NormalizedEvent + queue [][]model.NormalizedEvent + failNext error + blockers map[string]*storedBlocker // key: eventID + byIdemKey map[string]string // idemKey → eventID + timezone string + cursorSeq int + idSeq int +} + +type Fake struct { + mu sync.Mutex + cals map[model.CalendarRef]*calState +} + +func New() *Fake { + return &Fake{cals: make(map[model.CalendarRef]*calState)} +} + +// state は cal の状態を返す(なければ初期化)。呼び出し側が f.mu を保持していること。 +func (f *Fake) state(cal model.CalendarRef) *calState { + st, ok := f.cals[cal] + if !ok { + st = &calState{ + blockers: make(map[string]*storedBlocker), + byIdemKey: make(map[string]string), + timezone: "UTC", + } + f.cals[cal] = st + } + return st +} + +// SetFullState は cursor=="" の Changes が返す全量を設定する(フル同期シミュレーション)。 +func (f *Fake) SetFullState(cal model.CalendarRef, evs []model.NormalizedEvent) { + f.mu.Lock() + defer f.mu.Unlock() + f.state(cal).full = append([]model.NormalizedEvent(nil), evs...) +} + +// QueueChanges は次の増分 Changes(cursor!="")が返すイベント列を積む。 +// 呼ばれるたびにキューを1要素消費し、newCursor は "c1","c2",... と増加する。 +func (f *Fake) QueueChanges(cal model.CalendarRef, evs []model.NormalizedEvent) { + f.mu.Lock() + defer f.mu.Unlock() + st := f.state(cal) + st.queue = append(st.queue, append([]model.NormalizedEvent(nil), evs...)) +} + +// FailNext は次の Changes 呼び出しに err を返させる(1回で消費)。 +// ErrCursorInvalid / ErrAuthExpired のテスト用。 +func (f *Fake) FailNext(cal model.CalendarRef, err error) { + f.mu.Lock() + defer f.mu.Unlock() + f.state(cal).failNext = err +} + +// Blockers は cal 上のブロッカーを EventID 昇順で返す(観測用)。 +func (f *Fake) Blockers(cal model.CalendarRef) []model.BlockerRecord { + f.mu.Lock() + defer f.mu.Unlock() + st := f.state(cal) + recs := make([]model.BlockerRecord, 0, len(st.blockers)) + for _, b := range st.blockers { + recs = append(recs, b.rec) + } + sort.Slice(recs, func(i, j int) bool { return recs[i].EventID < recs[j].EventID }) + return recs +} + +// StoredBlocker は eventID のブロッカー本体(Title / TargetTimezone 等)を返す(観測用)。 +// NOTE: コントラクトの公開 API への追加。Task 8 が Blocker の内容を検証するために使う。 +func (f *Fake) StoredBlocker(cal model.CalendarRef, eventID string) (model.Blocker, bool) { + f.mu.Lock() + defer f.mu.Unlock() + b, ok := f.state(cal).blockers[eventID] + if !ok { + return model.Blocker{}, false + } + return b.body, true +} + +// SeedBlocker はタグ付きブロッカーを事前配置する(adoption テスト用)。 +// EventID で upsert する(同じ EventID への再 Seed は上書き)。 +func (f *Fake) SeedBlocker(cal model.CalendarRef, rec model.BlockerRecord) { + f.mu.Lock() + defer f.mu.Unlock() + f.state(cal).blockers[rec.EventID] = &storedBlocker{rec: rec} +} + +// SetTimezone は GetCalendarTimezone が返す値を設定する(既定 "UTC")。 +func (f *Fake) SetTimezone(cal model.CalendarRef, tz string) { + f.mu.Lock() + defer f.mu.Unlock() + f.state(cal).timezone = tz +} + +// --- provider.Provider 実装 --- + +func (f *Fake) Changes(ctx context.Context, cal model.CalendarRef, cursor string, window model.Window) ([]model.NormalizedEvent, string, error) { + f.mu.Lock() + defer f.mu.Unlock() + st := f.state(cal) + if st.failNext != nil { + err := st.failNext + st.failNext = nil + return nil, "", err + } + var evs []model.NormalizedEvent + if cursor == "" { + evs = append([]model.NormalizedEvent(nil), st.full...) + } else if len(st.queue) > 0 { + evs = st.queue[0] + st.queue = st.queue[1:] + } + // ウィンドウではフィルタしない(実プロバイダ同様、ウィンドウ外も返りうる契約) + st.cursorSeq++ + return evs, fmt.Sprintf("c%d", st.cursorSeq), nil +} + +// blockerTimeHash は Blocker の時刻から BlockerRecord.TimeHash を導出する +// (model.TimeHash と同一のハッシュ規則)。 +func blockerTimeHash(b model.Blocker) string { + return model.TimeHash(model.NormalizedEvent{ + StartUTC: b.StartUTC, + EndUTC: b.EndUTC, + IsAllDay: b.IsAllDay, + AllDayStart: b.AllDayStart, + AllDayEnd: b.AllDayEnd, + }) +} + +func (f *Fake) CreateBlocker(ctx context.Context, cal model.CalendarRef, b model.Blocker, idemKey string) (string, error) { + f.mu.Lock() + defer f.mu.Unlock() + st := f.state(cal) + if id, ok := st.byIdemKey[idemKey]; ok { + return id, nil // 同一冪等キーの再作成は既存 ID を返す(実プロバイダと同じ契約) + } + st.idSeq++ + id := fmt.Sprintf("fake-%s-%d", cal.AccountID, st.idSeq) + st.blockers[id] = &storedBlocker{ + rec: model.BlockerRecord{EventID: id, OriginTag: b.OriginTag, TimeHash: blockerTimeHash(b)}, + body: b, + } + st.byIdemKey[idemKey] = id + return id, nil +} + +func (f *Fake) UpdateBlocker(ctx context.Context, cal model.CalendarRef, eventID string, b model.Blocker) error { + f.mu.Lock() + defer f.mu.Unlock() + sb, ok := f.state(cal).blockers[eventID] + if !ok { + return fmt.Errorf("update blocker %s on %s: %w", eventID, cal, provider.ErrNotFound) + } + sb.rec.OriginTag = b.OriginTag + sb.rec.TimeHash = blockerTimeHash(b) + sb.body = b + return nil +} + +func (f *Fake) DeleteBlocker(ctx context.Context, cal model.CalendarRef, eventID string) error { + f.mu.Lock() + defer f.mu.Unlock() + st := f.state(cal) + delete(st.blockers, eventID) // 未存在(404 相当)でも成功扱い + + // byIdemKey から該当するエントリを削除(削除後の同キー再作成は新規扱い) + for key, id := range st.byIdemKey { + if id == eventID { + delete(st.byIdemKey, key) + break + } + } + return nil +} + +func (f *Fake) ListBlockers(ctx context.Context, cal model.CalendarRef, window model.Window) ([]model.BlockerRecord, error) { + // fake はウィンドウでフィルタしない(リコンサイルテストを単純に保つ) + return f.Blockers(cal), nil +} + +func (f *Fake) GetCalendarTimezone(ctx context.Context, cal model.CalendarRef) (string, error) { + f.mu.Lock() + defer f.mu.Unlock() + return f.state(cal).timezone, nil +} diff --git a/internal/provider/fake/fake_test.go b/internal/provider/fake/fake_test.go new file mode 100644 index 0000000..38ee77d --- /dev/null +++ b/internal/provider/fake/fake_test.go @@ -0,0 +1,298 @@ +package fake_test + +import ( + "context" + "sort" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/cloudnative-co/calsync/internal/model" + "github.com/cloudnative-co/calsync/internal/provider" + "github.com/cloudnative-co/calsync/internal/provider/fake" +) + +var ( + calA = model.CalendarRef{AccountID: "a", CalendarID: "primary"} + calB = model.CalendarRef{AccountID: "b", CalendarID: "primary"} +) + +func ev(id string, start time.Time) model.NormalizedEvent { + return model.NormalizedEvent{ + ID: id, + ICalUID: id + "@test", + StartUTC: start, + EndUTC: start.Add(time.Hour), + IsBusy: true, + } +} + +func blocker(tag string, start time.Time) model.Blocker { + return model.Blocker{ + Title: "予定あり", + StartUTC: start, + EndUTC: start.Add(time.Hour), + TargetTimezone: "UTC", + OriginTag: tag, + } +} + +func TestChanges_FullThenIncrementalCursorSequence(t *testing.T) { + f := fake.New() + ctx := context.Background() + base := time.Date(2026, 7, 10, 1, 0, 0, 0, time.UTC) + w := model.Window{Start: base.AddDate(0, 0, -7), End: base.AddDate(0, 3, 0)} + + f.SetFullState(calA, []model.NormalizedEvent{ev("e1", base), ev("e2", base.Add(2*time.Hour))}) + f.QueueChanges(calA, []model.NormalizedEvent{ev("e3", base.Add(4*time.Hour))}) + f.QueueChanges(calA, nil) + + // cursor=="" → フル同期(SetFullState の全量) + evs, cur, err := f.Changes(ctx, calA, "", w) + require.NoError(t, err) + require.Len(t, evs, 2) + require.Equal(t, "e1", evs[0].ID) + require.Equal(t, "e2", evs[1].ID) + require.Equal(t, "c1", cur) + + // 増分1回目: キュー先頭を消費 + evs, cur, err = f.Changes(ctx, calA, cur, w) + require.NoError(t, err) + require.Len(t, evs, 1) + require.Equal(t, "e3", evs[0].ID) + require.Equal(t, "c2", cur) + + // 増分2回目: 空のキュー要素も1要素として消費される + evs, cur, err = f.Changes(ctx, calA, cur, w) + require.NoError(t, err) + require.Empty(t, evs) + require.Equal(t, "c3", cur) + + // キュー枯渇後は空イベント + 新カーソル + evs, cur, err = f.Changes(ctx, calA, cur, w) + require.NoError(t, err) + require.Empty(t, evs) + require.Equal(t, "c4", cur) +} + +func TestChanges_FailNext(t *testing.T) { + cases := []struct { + name string + err error + }{ + {"cursor invalid", provider.ErrCursorInvalid}, + {"auth expired", provider.ErrAuthExpired}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f := fake.New() + ctx := context.Background() + f.FailNext(calA, tc.err) + + _, cur, err := f.Changes(ctx, calA, "", model.Window{}) + require.ErrorIs(t, err, tc.err) + require.Empty(t, cur) // 失敗時は newCursor を返さない(完走時のみ非空の契約) + + // エラーは1回で消費され、次の呼び出しは成功する + _, cur, err = f.Changes(ctx, calA, "", model.Window{}) + require.NoError(t, err) + require.Equal(t, "c1", cur) + }) + } +} + +func TestChanges_CalendarsAreIsolated(t *testing.T) { + f := fake.New() + ctx := context.Background() + base := time.Date(2026, 7, 10, 1, 0, 0, 0, time.UTC) + + f.SetFullState(calA, []model.NormalizedEvent{ev("e1", base)}) + + // calB には何も設定していない → 空 + calB 自身の連番カーソル + evs, cur, err := f.Changes(ctx, calB, "", model.Window{}) + require.NoError(t, err) + require.Empty(t, evs) + require.Equal(t, "c1", cur) + + // calA は独立に自分の全量を返す + evs, cur, err = f.Changes(ctx, calA, "", model.Window{}) + require.NoError(t, err) + require.Len(t, evs, 1) + require.Equal(t, "c1", cur) +} + +func TestCreateBlocker_IdempotentOnSameKey(t *testing.T) { + f := fake.New() + ctx := context.Background() + start := time.Date(2026, 7, 10, 1, 0, 0, 0, time.UTC) + + id1, err := f.CreateBlocker(ctx, calB, blocker("a:e1", start), "key-1") + require.NoError(t, err) + require.NotEmpty(t, id1) + + // 同一 idemKey の再作成 → 既存 ID を返し、二重作成しない(実プロバイダと同じ契約) + id2, err := f.CreateBlocker(ctx, calB, blocker("a:e1", start), "key-1") + require.NoError(t, err) + require.Equal(t, id1, id2) + require.Len(t, f.Blockers(calB), 1) + + // 別キーは別ブロッカー + id3, err := f.CreateBlocker(ctx, calB, blocker("a:e2", start), "key-2") + require.NoError(t, err) + require.NotEqual(t, id1, id3) + require.Len(t, f.Blockers(calB), 2) +} + +func TestCreateBlocker_RecordsTagTimeHashAndBody(t *testing.T) { + f := fake.New() + ctx := context.Background() + start := time.Date(2026, 7, 10, 1, 0, 0, 0, time.UTC) + b := blocker("a:e1", start) + + id, err := f.CreateBlocker(ctx, calB, b, "key-1") + require.NoError(t, err) + + recs := f.Blockers(calB) + require.Len(t, recs, 1) + require.Equal(t, id, recs[0].EventID) + require.Equal(t, "a:e1", recs[0].OriginTag) + wantHash := model.TimeHash(model.NormalizedEvent{StartUTC: b.StartUTC, EndUTC: b.EndUTC}) + require.Equal(t, wantHash, recs[0].TimeHash) + + body, ok := f.StoredBlocker(calB, id) + require.True(t, ok) + require.Equal(t, "予定あり", body.Title) + require.Equal(t, "UTC", body.TargetTimezone) + require.Equal(t, "a:e1", body.OriginTag) + + _, ok = f.StoredBlocker(calB, "no-such-id") + require.False(t, ok) +} + +func TestUpdateBlocker(t *testing.T) { + f := fake.New() + ctx := context.Background() + start := time.Date(2026, 7, 10, 1, 0, 0, 0, time.UTC) + id, err := f.CreateBlocker(ctx, calB, blocker("a:e1", start), "key-1") + require.NoError(t, err) + + updated := blocker("a:e1", start.Add(30*time.Minute)) + require.NoError(t, f.UpdateBlocker(ctx, calB, id, updated)) + + recs := f.Blockers(calB) + require.Len(t, recs, 1) + wantHash := model.TimeHash(model.NormalizedEvent{StartUTC: updated.StartUTC, EndUTC: updated.EndUTC}) + require.Equal(t, wantHash, recs[0].TimeHash) + body, ok := f.StoredBlocker(calB, id) + require.True(t, ok) + require.Equal(t, updated.StartUTC, body.StartUTC) + + // 未存在 ID は ErrNotFound + err = f.UpdateBlocker(ctx, calB, "no-such-id", updated) + require.ErrorIs(t, err, provider.ErrNotFound) +} + +func TestDeleteBlocker(t *testing.T) { + f := fake.New() + ctx := context.Background() + start := time.Date(2026, 7, 10, 1, 0, 0, 0, time.UTC) + id, err := f.CreateBlocker(ctx, calB, blocker("a:e1", start), "key-1") + require.NoError(t, err) + + require.NoError(t, f.DeleteBlocker(ctx, calB, id)) + require.Empty(t, f.Blockers(calB)) + + // 未存在(404 相当)でも nil(コントラクト: 404 は成功扱い) + require.NoError(t, f.DeleteBlocker(ctx, calB, id)) + require.NoError(t, f.DeleteBlocker(ctx, calB, "never-existed")) +} + +func TestDeleteBlocker_ReleasesIdemKey(t *testing.T) { + f := fake.New() + ctx := context.Background() + start := time.Date(2026, 7, 10, 1, 0, 0, 0, time.UTC) + + // Create first blocker with idemKey "k1" + id1, err := f.CreateBlocker(ctx, calB, blocker("a:e1", start), "k1") + require.NoError(t, err) + require.NotEmpty(t, id1) + + // Delete the blocker + require.NoError(t, f.DeleteBlocker(ctx, calB, id1)) + require.Empty(t, f.Blockers(calB)) + + // Create a new blocker with the same idemKey but different content + id2, err := f.CreateBlocker(ctx, calB, blocker("a:e2", start.Add(time.Hour)), "k1") + require.NoError(t, err) + + // Should get a new ID (not the old deleted one) + require.NotEqual(t, id1, id2) + + // Should have exactly one blocker with the new content + recs := f.Blockers(calB) + require.Len(t, recs, 1) + require.Equal(t, id2, recs[0].EventID) + require.Equal(t, "a:e2", recs[0].OriginTag) + + // StoredBlocker should return the new content + body, ok := f.StoredBlocker(calB, id2) + require.True(t, ok) + require.Equal(t, "a:e2", body.OriginTag) + require.Equal(t, start.Add(time.Hour), body.StartUTC) +} + +func TestSeedBlockerAndListBlockers(t *testing.T) { + f := fake.New() + ctx := context.Background() + seed := model.BlockerRecord{EventID: "orphan-1", OriginTag: "x:gone", TimeHash: "h1"} + f.SeedBlocker(calB, seed) + + require.Equal(t, []model.BlockerRecord{seed}, f.Blockers(calB)) + listed, err := f.ListBlockers(ctx, calB, model.Window{}) + require.NoError(t, err) + require.Equal(t, []model.BlockerRecord{seed}, listed) + + // SeedBlocker は EventID で upsert(2度目は上書き)。 + // Task 8 の「プロバイダ呼び出しなし」検証(改竄トリック)がこの性質に依存する + f.SeedBlocker(calB, model.BlockerRecord{EventID: "orphan-1", OriginTag: "x:gone", TimeHash: "h2"}) + recs := f.Blockers(calB) + require.Len(t, recs, 1) + require.Equal(t, "h2", recs[0].TimeHash) +} + +func TestBlockers_SortedByEventID(t *testing.T) { + f := fake.New() + ctx := context.Background() + start := time.Date(2026, 7, 10, 1, 0, 0, 0, time.UTC) + + f.SeedBlocker(calB, model.BlockerRecord{EventID: "zzz", OriginTag: "x:1", TimeHash: "h"}) + _, err := f.CreateBlocker(ctx, calB, blocker("a:e1", start), "key-1") + require.NoError(t, err) + f.SeedBlocker(calB, model.BlockerRecord{EventID: "aaa", OriginTag: "x:2", TimeHash: "h"}) + + recs := f.Blockers(calB) + require.Len(t, recs, 3) + require.True(t, sort.SliceIsSorted(recs, func(i, j int) bool { + return recs[i].EventID < recs[j].EventID + })) +} + +func TestGetCalendarTimezone(t *testing.T) { + f := fake.New() + ctx := context.Background() + + tz, err := f.GetCalendarTimezone(ctx, calB) + require.NoError(t, err) + require.Equal(t, "UTC", tz) // 既定値 + + f.SetTimezone(calB, "Asia/Tokyo") + tz, err = f.GetCalendarTimezone(ctx, calB) + require.NoError(t, err) + require.Equal(t, "Asia/Tokyo", tz) +} + +func TestFakeImplementsProvider(t *testing.T) { + var p provider.Provider = fake.New() + require.NotNil(t, p) +} diff --git a/internal/provider/google/blockers.go b/internal/provider/google/blockers.go new file mode 100644 index 0000000..a29a95d --- /dev/null +++ b/internal/provider/google/blockers.go @@ -0,0 +1,217 @@ +package google + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + calendar "google.golang.org/api/calendar/v3" + "google.golang.org/api/googleapi" + + "github.com/cloudnative-co/calsync/internal/model" + "github.com/cloudnative-co/calsync/internal/provider" +) + +// Provider の全メソッドが揃う本ファイルで、コンパイル時に実装を保証する。 +var _ provider.Provider = (*Client)(nil) + +// blockerStart / blockerEnd: 時刻指定は dateTime(UTC 固定)、 +// 終日は date に現地日付をそのまま入れる(end は排他的。仕様書6.6)。 +func blockerStart(b model.Blocker) *calendar.EventDateTime { + if b.IsAllDay { + return &calendar.EventDateTime{Date: b.AllDayStart} + } + return &calendar.EventDateTime{ + DateTime: b.StartUTC.UTC().Format(time.RFC3339), + TimeZone: "UTC", + } +} + +func blockerEnd(b model.Blocker) *calendar.EventDateTime { + if b.IsAllDay { + return &calendar.EventDateTime{Date: b.AllDayEnd} + } + return &calendar.EventDateTime{ + DateTime: b.EndUTC.UTC().Format(time.RFC3339), + TimeZone: "UTC", + } +} + +// blockerEventBody はブロッカーの本体(summary / transparency / visibility / +// reminders / extendedProperties / start / end)を組み立てる。events.insert +// (呼び出し元が Id を追加設定する)と、409 収容が cancelled イベントを蘇生させる +// events.update の両方で共用する(仕様書6.4。最終ホールブランチレビュー修正1)。 +func blockerEventBody(b model.Blocker) *calendar.Event { + return &calendar.Event{ + Summary: b.Title, + Transparency: "opaque", + Visibility: "private", + Start: blockerStart(b), + End: blockerEnd(b), + Reminders: &calendar.EventReminders{ + UseDefault: false, + // ゼロ値の false は omitempty で消えるため明示送信する + ForceSendFields: []string{"UseDefault"}, + }, + ExtendedProperties: &calendar.EventExtendedProperties{ + Private: map[string]string{ + "calsync": "v1", + "calsync-origin": b.OriginTag, + }, + }, + } +} + +// CreateBlocker は idemKey をクライアント生成イベント ID として events.insert する +// (冪等作成。仕様書6.4)。409(ID 衝突)は「同一冪等キーで作成済み」を意味するため、 +// events.get で実在確認する。 +// +// ただし Google は削除済みイベントを cancelled 状態のまま保持し、同一 ID の +// 再 insert も 409 を返す。cancelled の ID をそのまま返すと、そのブロッカーは +// カレンダー上に見えないにもかかわらず active mapping に収容されてしまい、 +// busy→free→busy のような削除→再作成シナリオでブロッカーが二度と出現しなくなる +// (最終ホールブランチレビュー所見1)。そのため cancelled の場合は events.update で +// 本来 insert するはずだったボディを送って蘇生し、その ID を返す。 +func (c *Client) CreateBlocker(ctx context.Context, cal model.CalendarRef, b model.Blocker, idemKey string) (string, error) { + svc, err := c.service(ctx) + if err != nil { + return "", err + } + body := blockerEventBody(b) + body.Id = idemKey + insert := svc.Events.Insert(cal.CalendarID, body).Context(ctx) + var created *calendar.Event + err = c.doWithRetry(ctx, func() error { + var e error + created, e = insert.Do() + return e + }) + if err == nil { + return created.Id, nil + } + var gerr *googleapi.Error + if errors.As(err, &gerr) && gerr.Code == http.StatusConflict { + get := svc.Events.Get(cal.CalendarID, idemKey).Context(ctx) + var existing *calendar.Event + if err := c.doWithRetry(ctx, func() error { + var e error + existing, e = get.Do() + return e + }); err != nil { + return "", fmt.Errorf("google[%s]: confirm existing blocker %s: %w", c.accountID, idemKey, normalizeAuthErr(err)) + } + if existing.Status != "cancelled" { + return existing.Id, nil + } + // Status を明示的に confirmed へ戻す(update ボディの status 省略時に + // cancelled のままになる実装差異に依存しない。仕様書15章 実測確認7と対) + body := blockerEventBody(b) + body.Status = "confirmed" + update := svc.Events.Update(cal.CalendarID, idemKey, body).Context(ctx) + var revived *calendar.Event + if err := c.doWithRetry(ctx, func() error { + var e error + revived, e = update.Do() + return e + }); err != nil { + return "", fmt.Errorf("google[%s]: resurrect cancelled blocker %s: %w", c.accountID, idemKey, normalizeAuthErr(err)) + } + return revived.Id, nil + } + return "", fmt.Errorf("google[%s]: events.insert %s: %w", c.accountID, cal, normalizeAuthErr(err)) +} + +// UpdateBlocker は events.patch で start/end のみ更新する(タイトル等は送らない)。 +// 404(ブロッカーが手動削除等で消えている)は provider.ErrNotFound に写像し、 +// エンジン側が「pending 化して再作成」にフォールバックできるようにする(仕様8章4)。 +func (c *Client) UpdateBlocker(ctx context.Context, cal model.CalendarRef, eventID string, b model.Blocker) error { + svc, err := c.service(ctx) + if err != nil { + return err + } + patch := &calendar.Event{ + Start: blockerStart(b), + End: blockerEnd(b), + } + call := svc.Events.Patch(cal.CalendarID, eventID, patch).Context(ctx) + err = c.doWithRetry(ctx, func() error { + _, e := call.Do() + return e + }) + if err != nil { + var gerr *googleapi.Error + if errors.As(err, &gerr) && gerr.Code == http.StatusNotFound { + return fmt.Errorf("google[%s]: events.patch %s/%s: %w", c.accountID, cal, eventID, provider.ErrNotFound) + } + return fmt.Errorf("google[%s]: events.patch %s/%s: %w", c.accountID, cal, eventID, normalizeAuthErr(err)) + } + return nil +} + +// DeleteBlocker は events.delete を実行する。404(存在しない)・410(削除済み)は +// 成功扱いで nil を返す(冪等削除。コントラクト: 404 は成功扱い)。 +func (c *Client) DeleteBlocker(ctx context.Context, cal model.CalendarRef, eventID string) error { + svc, err := c.service(ctx) + if err != nil { + return err + } + call := svc.Events.Delete(cal.CalendarID, eventID).Context(ctx) + err = c.doWithRetry(ctx, func() error { return call.Do() }) + if err != nil { + var gerr *googleapi.Error + if errors.As(err, &gerr) && (gerr.Code == http.StatusNotFound || gerr.Code == http.StatusGone) { + return nil + } + return fmt.Errorf("google[%s]: events.delete %s/%s: %w", c.accountID, cal, eventID, normalizeAuthErr(err)) + } + return nil +} + +// ListBlockers は calsync タグ付きイベントを privateExtendedProperty で列挙する。 +// privateExtendedProperty は syncToken と併用できないためリコンサイル専用(仕様書4章)。 +// TimeHash は normalizeEvent の結果(終日/時刻指定を吸収済み)に model.TimeHash を適用する。 +func (c *Client) ListBlockers(ctx context.Context, cal model.CalendarRef, window model.Window) ([]model.BlockerRecord, error) { + svc, err := c.service(ctx) + if err != nil { + return nil, err + } + var ( + records []model.BlockerRecord + pageToken string + ) + for { + call := svc.Events.List(cal.CalendarID).Context(ctx). + PrivateExtendedProperty("calsync=v1"). + TimeMin(window.Start.UTC().Format(time.RFC3339)). + TimeMax(window.End.UTC().Format(time.RFC3339)) + if pageToken != "" { + call.PageToken(pageToken) + } + var resp *calendar.Events + err := c.doWithRetry(ctx, func() error { + var e error + resp, e = call.Do() + return e + }) + if err != nil { + return nil, fmt.Errorf("google[%s]: list blockers %s: %w", c.accountID, cal, normalizeAuthErr(err)) + } + for _, item := range resp.Items { + if item.Status == "cancelled" { + continue + } + nev := normalizeEvent(item) + records = append(records, model.BlockerRecord{ + EventID: nev.ID, + OriginTag: nev.OriginTag, + TimeHash: model.TimeHash(nev), + }) + } + if resp.NextPageToken == "" { + return records, nil + } + pageToken = resp.NextPageToken + } +} diff --git a/internal/provider/google/blockers_test.go b/internal/provider/google/blockers_test.go new file mode 100644 index 0000000..285905b --- /dev/null +++ b/internal/provider/google/blockers_test.go @@ -0,0 +1,395 @@ +package google + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/cloudnative-co/calsync/internal/model" + "github.com/cloudnative-co/calsync/internal/provider" +) + +func decodeBody(t *testing.T, body []byte) map[string]any { + t.Helper() + var m map[string]any + require.NoError(t, json.Unmarshal(body, &m)) + return m +} + +func mapKeys(m map[string]any) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + return keys +} + +func TestCreateBlockerTimedBody(t *testing.T) { + b := model.Blocker{ + Title: "予定あり", + StartUTC: time.Date(2026, 7, 10, 1, 0, 0, 0, time.UTC), + EndUTC: time.Date(2026, 7, 10, 2, 0, 0, 0, time.UTC), + OriginTag: model.OriginTagOf("work", "ev-origin-1"), + } + idem := model.GoogleBlockerID(b.OriginTag, "test-account") + + rec := &recorder{} + handler := rec.wrap(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"id": %q}`, idem) + }) + c := newTestClient(t, handler) + + eventID, err := c.CreateBlocker(context.Background(), testRef, b, idem) + require.NoError(t, err) + require.Equal(t, idem, eventID) + + reqs := rec.all() + require.Len(t, reqs, 1) + require.Equal(t, http.MethodPost, reqs[0].Method) + require.Equal(t, "/calendars/primary/events", reqs[0].Path) + + m := decodeBody(t, reqs[0].Body) + require.Equal(t, idem, m["id"], "id はクライアント生成の冪等キー") + require.Equal(t, "予定あり", m["summary"]) + require.Equal(t, "opaque", m["transparency"]) + require.Equal(t, "private", m["visibility"]) + + rem, ok := m["reminders"].(map[string]any) + require.True(t, ok, "reminders がボディに無い") + require.Equal(t, false, rem["useDefault"], "useDefault=false を明示送信する(ゼロ値でも省略しない)") + + ext, ok := m["extendedProperties"].(map[string]any) + require.True(t, ok, "extendedProperties がボディに無い") + priv, ok := ext["private"].(map[string]any) + require.True(t, ok, "extendedProperties.private がボディに無い") + require.Equal(t, "v1", priv["calsync"]) + require.Equal(t, "work:ev-origin-1", priv["calsync-origin"]) + + start, ok := m["start"].(map[string]any) + require.True(t, ok) + require.Equal(t, "2026-07-10T01:00:00Z", start["dateTime"]) + require.Equal(t, "UTC", start["timeZone"]) + end, ok := m["end"].(map[string]any) + require.True(t, ok) + require.Equal(t, "2026-07-10T02:00:00Z", end["dateTime"]) + require.Equal(t, "UTC", end["timeZone"]) +} + +func TestCreateBlockerAllDayBody(t *testing.T) { + b := model.Blocker{ + Title: "予定あり", + IsAllDay: true, + AllDayStart: "2026-07-15", + AllDayEnd: "2026-07-16", + OriginTag: model.OriginTagOf("work", "ev-origin-2"), + } + idem := model.GoogleBlockerID(b.OriginTag, "test-account") + + rec := &recorder{} + handler := rec.wrap(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"id": %q}`, idem) + }) + c := newTestClient(t, handler) + + eventID, err := c.CreateBlocker(context.Background(), testRef, b, idem) + require.NoError(t, err) + require.Equal(t, idem, eventID) + + m := decodeBody(t, rec.all()[0].Body) + start, ok := m["start"].(map[string]any) + require.True(t, ok) + require.Equal(t, "2026-07-15", start["date"], "終日は AllDayStart をそのまま date に") + require.NotContains(t, start, "dateTime") + end, ok := m["end"].(map[string]any) + require.True(t, ok) + require.Equal(t, "2026-07-16", end["date"], "終日 end は排他的日付をそのまま") + require.NotContains(t, end, "dateTime") +} + +func TestCreateBlockerConflictAdoptsExisting(t *testing.T) { + b := model.Blocker{ + Title: "予定あり", + StartUTC: time.Date(2026, 7, 10, 1, 0, 0, 0, time.UTC), + EndUTC: time.Date(2026, 7, 10, 2, 0, 0, 0, time.UTC), + OriginTag: model.OriginTagOf("work", "ev-origin-1"), + } + idem := model.GoogleBlockerID(b.OriginTag, "test-account") + + rec := &recorder{} + handler := rec.wrap(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodPost { + w.WriteHeader(http.StatusConflict) + fmt.Fprint(w, `{"error": {"errors": [{"domain": "global", "reason": "duplicate", + "message": "The requested identifier already exists."}], + "code": 409, "message": "The requested identifier already exists."}}`) + return + } + fmt.Fprintf(w, `{"id": %q, "status": "confirmed"}`, idem) + }) + c := newTestClient(t, handler) + + eventID, err := c.CreateBlocker(context.Background(), testRef, b, idem) + require.NoError(t, err, "409 は作成済みとして収容しエラーにしない(仕様書6.4)") + require.Equal(t, idem, eventID) + + reqs := rec.all() + require.Len(t, reqs, 2) + require.Equal(t, http.MethodPost, reqs[0].Method) + require.Equal(t, http.MethodGet, reqs[1].Method, "409 後は events.get で実在確認する") + require.Equal(t, "/calendars/primary/events/"+idem, reqs[1].Path) +} + +// 409 収容が cancelled(削除済み)イベントの ID を無条件に返すと、busy→free→busy の +// 削除→再作成シナリオでカレンダーに見えないイベントを active mapping に収容してしまい、 +// ブロッカーが二度と出現しなくなる(最終ホールブランチレビュー所見1)。 +// events.get が status=cancelled を返した場合は events.update で本来の insert ボディ +// (ID を除く)を送って蘇生し、その ID を返さねばならない。 +func TestCreateBlockerConflictResurrectsCancelledEvent(t *testing.T) { + b := model.Blocker{ + Title: "予定あり", + StartUTC: time.Date(2026, 7, 10, 1, 0, 0, 0, time.UTC), + EndUTC: time.Date(2026, 7, 10, 2, 0, 0, 0, time.UTC), + OriginTag: model.OriginTagOf("work", "ev-origin-1"), + } + idem := model.GoogleBlockerID(b.OriginTag, "test-account") + + rec := &recorder{} + handler := rec.wrap(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.Method { + case http.MethodPost: + w.WriteHeader(http.StatusConflict) + fmt.Fprint(w, `{"error": {"errors": [{"domain": "global", "reason": "duplicate", + "message": "The requested identifier already exists."}], + "code": 409, "message": "The requested identifier already exists."}}`) + case http.MethodGet: + fmt.Fprintf(w, `{"id": %q, "status": "cancelled"}`, idem) + case http.MethodPut: + fmt.Fprintf(w, `{"id": %q, "status": "confirmed"}`, idem) + default: + t.Fatalf("unexpected method %s", r.Method) + } + }) + c := newTestClient(t, handler) + + eventID, err := c.CreateBlocker(context.Background(), testRef, b, idem) + require.NoError(t, err, "cancelled イベントの蘇生はエラーにしない") + require.Equal(t, idem, eventID) + + reqs := rec.all() + require.Len(t, reqs, 3) + require.Equal(t, http.MethodPost, reqs[0].Method) + require.Equal(t, http.MethodGet, reqs[1].Method, "409 後は events.get で状態確認する") + require.Equal(t, http.MethodPut, reqs[2].Method, "cancelled なら events.update で蘇生する") + require.Equal(t, "/calendars/primary/events/"+idem, reqs[2].Path) + + m := decodeBody(t, reqs[2].Body) + require.Equal(t, "confirmed", m["status"], "蘇生ボディは status=confirmed を明示送信する") + require.Equal(t, "予定あり", m["summary"]) + require.Equal(t, "opaque", m["transparency"]) + require.Equal(t, "private", m["visibility"]) + + rem, ok := m["reminders"].(map[string]any) + require.True(t, ok, "蘇生ボディにも reminders を含める") + require.Equal(t, false, rem["useDefault"]) + + ext, ok := m["extendedProperties"].(map[string]any) + require.True(t, ok, "蘇生ボディにも extendedProperties を含める") + priv, ok := ext["private"].(map[string]any) + require.True(t, ok) + require.Equal(t, "v1", priv["calsync"]) + require.Equal(t, "work:ev-origin-1", priv["calsync-origin"]) + + start, ok := m["start"].(map[string]any) + require.True(t, ok) + require.Equal(t, "2026-07-10T01:00:00Z", start["dateTime"]) + end, ok := m["end"].(map[string]any) + require.True(t, ok) + require.Equal(t, "2026-07-10T02:00:00Z", end["dateTime"]) +} + +func TestUpdateBlockerPatchesTimesOnly(t *testing.T) { + rec := &recorder{} + handler := rec.wrap(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"id": "ev-blk-1"}`) + }) + c := newTestClient(t, handler) + + b := model.Blocker{ + Title: "予定あり", // patch には含めない(時刻変更のみ) + StartUTC: time.Date(2026, 7, 11, 3, 0, 0, 0, time.UTC), + EndUTC: time.Date(2026, 7, 11, 4, 0, 0, 0, time.UTC), + } + err := c.UpdateBlocker(context.Background(), testRef, "ev-blk-1", b) + require.NoError(t, err) + + reqs := rec.all() + require.Len(t, reqs, 1) + require.Equal(t, http.MethodPatch, reqs[0].Method) + require.Equal(t, "/calendars/primary/events/ev-blk-1", reqs[0].Path) + + m := decodeBody(t, reqs[0].Body) + require.ElementsMatch(t, []string{"start", "end"}, mapKeys(m), "patch は start/end のみ") + start, ok := m["start"].(map[string]any) + require.True(t, ok) + require.Equal(t, "2026-07-11T03:00:00Z", start["dateTime"]) + end, ok := m["end"].(map[string]any) + require.True(t, ok) + require.Equal(t, "2026-07-11T04:00:00Z", end["dateTime"]) +} + +// 404(手動削除等でブロッカーが消えている)は provider.ErrNotFound に写像され、 +// エンジンの「pending 化して再作成」フォールバックを発動させる(仕様8章4)。 +func TestUpdateBlockerNotFoundMapsToErrNotFound(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + fmt.Fprint(w, `{"error": {"code": 404, "message": "Not Found"}}`) + }) + c := newTestClient(t, handler) + + b := model.Blocker{ + StartUTC: time.Date(2026, 7, 11, 3, 0, 0, 0, time.UTC), + EndUTC: time.Date(2026, 7, 11, 4, 0, 0, 0, time.UTC), + } + err := c.UpdateBlocker(context.Background(), testRef, "ev-gone", b) + require.ErrorIs(t, err, provider.ErrNotFound) +} + +func TestDeleteBlocker(t *testing.T) { + cases := []struct { + name string + status int + body string + wantErr bool + }{ + {name: "204 は成功", status: http.StatusNoContent, body: "", wantErr: false}, + {name: "404 は成功扱い(既に無い)", status: http.StatusNotFound, + body: `{"error": {"code": 404, "message": "Not Found"}}`, wantErr: false}, + {name: "410 は成功扱い(既に削除済み)", status: http.StatusGone, + body: `{"error": {"code": 410, "message": "Resource has been deleted"}}`, wantErr: false}, + {name: "500 はエラー", status: http.StatusInternalServerError, + body: `{"error": {"code": 500, "message": "Backend Error"}}`, wantErr: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tc.status) + if tc.body != "" { + fmt.Fprint(w, tc.body) + } + }) + c := newTestClient(t, handler) + + err := c.DeleteBlocker(context.Background(), testRef, "ev-blk-1") + if tc.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + }) + } +} + +func TestListBlockers(t *testing.T) { + rec := &recorder{} + handler := rec.wrap(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Query().Get("pageToken") == "" { + fmt.Fprint(w, `{ + "items": [{"id": "blk-1", "status": "confirmed", + "extendedProperties": {"private": {"calsync": "v1", "calsync-origin": "work:ev-9"}}, + "start": {"dateTime": "2026-07-10T01:00:00Z"}, + "end": {"dateTime": "2026-07-10T02:00:00Z"}}], + "nextPageToken": "page-2" + }`) + return + } + fmt.Fprint(w, `{ + "items": [{"id": "blk-2", "status": "confirmed", + "extendedProperties": {"private": {"calsync": "v1", "calsync-origin": "work:ev-10"}}, + "start": {"date": "2026-07-15"}, + "end": {"date": "2026-07-16"}}] + }`) + }) + c := newTestClient(t, handler) + + records, err := c.ListBlockers(context.Background(), testRef, testWindow) + require.NoError(t, err) + + want := []model.BlockerRecord{ + { + EventID: "blk-1", + OriginTag: "work:ev-9", + TimeHash: model.TimeHash(model.NormalizedEvent{ + StartUTC: time.Date(2026, 7, 10, 1, 0, 0, 0, time.UTC), + EndUTC: time.Date(2026, 7, 10, 2, 0, 0, 0, time.UTC), + }), + }, + { + EventID: "blk-2", + OriginTag: "work:ev-10", + TimeHash: model.TimeHash(model.NormalizedEvent{ + IsAllDay: true, + AllDayStart: "2026-07-15", + AllDayEnd: "2026-07-16", + }), + }, + } + require.Equal(t, want, records, "TimeHash は終日/時刻指定に応じて model.TimeHash を適用") + + reqs := rec.all() + require.Len(t, reqs, 2) + q := reqs[0].Query + require.Equal(t, "calsync=v1", q.Get("privateExtendedProperty")) + require.Equal(t, "2026-07-03T00:00:00Z", q.Get("timeMin")) + require.Equal(t, "2026-10-03T00:00:00Z", q.Get("timeMax")) + require.False(t, q.Has("syncToken"), "privateExtendedProperty は syncToken と併用不可(リコンサイル専用)") + require.Equal(t, "page-2", reqs[1].Query.Get("pageToken")) +} + +// GetCalendarTimezone は calendars.get ではなく events.list(maxResults=1)の +// 応答エンベロープの timeZone を使う。calendars.get は calendar.readonly 系 +// スコープが必要で、calsync の calendar.events スコープでは実環境 403 になる +// (最終ホールブランチレビュー追補 Issue 1)。 +func TestGetCalendarTimezone(t *testing.T) { + rec := &recorder{} + handler := rec.wrap(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"timeZone": "Asia/Tokyo", "items": [], "nextSyncToken": "s"}`) + }) + c := newTestClient(t, handler) + + tz, err := c.GetCalendarTimezone(context.Background(), testRef) + require.NoError(t, err) + require.Equal(t, "Asia/Tokyo", tz) + + reqs := rec.all() + require.Len(t, reqs, 1) + require.Equal(t, http.MethodGet, reqs[0].Method) + require.Equal(t, "/calendars/primary/events", reqs[0].Path, "events.list を使う(calendars.get はスコープ不足)") + require.Equal(t, "1", reqs[0].Query.Get("maxResults"), "maxResults=1 で軽量に取得する") +} + +// events.list が timeZone を返さない場合はエラー(空 TZ をキャッシュしない)。 +func TestGetCalendarTimezoneMissingIsError(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"items": []}`) + }) + c := newTestClient(t, handler) + + _, err := c.GetCalendarTimezone(context.Background(), testRef) + require.Error(t, err) + require.Contains(t, err.Error(), "no timeZone") +} diff --git a/internal/provider/google/changes.go b/internal/provider/google/changes.go new file mode 100644 index 0000000..3d30b87 --- /dev/null +++ b/internal/provider/google/changes.go @@ -0,0 +1,110 @@ +package google + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + calendar "google.golang.org/api/calendar/v3" + "google.golang.org/api/googleapi" + + "github.com/cloudnative-co/calsync/internal/model" + "github.com/cloudnative-co/calsync/internal/provider" +) + +// Changes は events.list による差分取得を実装する(仕様書5.1)。 +// - cursor=="" のフル同期: timeMin/timeMax/singleEvents=true +// - 増分: syncToken + singleEvents=true のみ(timeMin 等との併用は 400) +// - nextSyncToken は最終ページにのみ返るため、全ページ完走時だけ newCursor を返す。 +// 途中で失敗した場合は newCursor="" のままエラーを返す(呼び出し側は旧カーソルで再実行) +// - 410 GONE は provider.ErrCursorInvalid に写像する +// - 401 および invalid_grant/interaction_required 系のトークン失効は +// provider.ErrAuthExpired に写像する(仕様書9.3) +// - showDeleted は指定しない(増分での明示指定は 400。仕様書5.1-4) +func (c *Client) Changes(ctx context.Context, cal model.CalendarRef, cursor string, window model.Window) ([]model.NormalizedEvent, string, error) { + svc, err := c.service(ctx) + if err != nil { + return nil, "", err + } + var ( + events []model.NormalizedEvent + pageToken string + ) + for { + call := svc.Events.List(cal.CalendarID).Context(ctx).SingleEvents(true) + if cursor != "" { + call.SyncToken(cursor) + } else { + call.TimeMin(window.Start.UTC().Format(time.RFC3339)) + call.TimeMax(window.End.UTC().Format(time.RFC3339)) + } + if pageToken != "" { + call.PageToken(pageToken) + } + var resp *calendar.Events + err := c.doWithRetry(ctx, func() error { + var e error + resp, e = call.Do() + return e + }) + if err != nil { + var gerr *googleapi.Error + if errors.As(err, &gerr) && gerr.Code == http.StatusGone { + return nil, "", provider.ErrCursorInvalid + } + return nil, "", fmt.Errorf("google[%s]: events.list %s: %w", c.accountID, cal, normalizeAuthErr(err)) + } + for _, item := range resp.Items { + events = append(events, normalizeEvent(item)) + } + if resp.NextPageToken == "" { + return events, resp.NextSyncToken, nil + } + pageToken = resp.NextPageToken + } +} + +// normalizeEvent は Google のイベントを NormalizedEvent に正規化する。 +// - status=cancelled: 削除通知。id 以外は保証されないため ID のみ設定(仕様書6.1) +// - transparency: 省略時 opaque = busy(仕様書6.2) +// - 辞退: attendees の self==true エントリの responseStatus==declined(仕様書6.2) +// - タグ: extendedProperties.private の calsync-origin(仕様書6.3) +// - 終日: date を現地日付のまま保持(end は排他的)。時刻指定は UTC に正規化(仕様書6.6) +func normalizeEvent(item *calendar.Event) model.NormalizedEvent { + ev := model.NormalizedEvent{ID: item.Id} + if item.Status == "cancelled" { + ev.Deleted = true + return ev + } + ev.ICalUID = item.ICalUID + ev.IsBusy = item.Transparency != "transparent" + for _, a := range item.Attendees { + if a != nil && a.Self && a.ResponseStatus == "declined" { + ev.IsDeclined = true + } + } + if item.ExtendedProperties != nil && item.ExtendedProperties.Private != nil { + ev.OriginTag = item.ExtendedProperties.Private["calsync-origin"] + } + if item.Start != nil && item.Start.Date != "" { + ev.IsAllDay = true + ev.AllDayStart = item.Start.Date + if item.End != nil { + ev.AllDayEnd = item.End.Date + } + return ev + } + if item.Start != nil && item.Start.DateTime != "" { + if t, err := time.Parse(time.RFC3339, item.Start.DateTime); err == nil { + ev.StartUTC = t.UTC() + } + } + if item.End != nil && item.End.DateTime != "" { + if t, err := time.Parse(time.RFC3339, item.End.DateTime); err == nil { + ev.EndUTC = t.UTC() + } + } + return ev +} diff --git a/internal/provider/google/changes_test.go b/internal/provider/google/changes_test.go new file mode 100644 index 0000000..ab34ab0 --- /dev/null +++ b/internal/provider/google/changes_test.go @@ -0,0 +1,316 @@ +package google + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/cloudnative-co/calsync/internal/model" + "github.com/cloudnative-co/calsync/internal/provider" +) + +var ( + testRef = model.CalendarRef{AccountID: "test-account", CalendarID: "primary"} + testWindow = model.Window{ + Start: time.Date(2026, 7, 3, 0, 0, 0, 0, time.UTC), + End: time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC), + } +) + +// recorded は httptest ハンドラが受けた 1 リクエストの記録。 +type recorded struct { + Method string + Path string + Query url.Values + Body []byte +} + +// recorder はリクエストを記録する。ハンドラはテスト本体と別ゴルーチンで +// 動くため mutex で保護し、アサーションは必ずテスト本体側(all() の戻り値)で行う。 +type recorder struct { + mu sync.Mutex + reqs []recorded +} + +func (rec *recorder) wrap(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + rec.mu.Lock() + rec.reqs = append(rec.reqs, recorded{ + Method: r.Method, + Path: r.URL.Path, + Query: r.URL.Query(), + Body: body, + }) + rec.mu.Unlock() + next(w, r) + } +} + +func (rec *recorder) all() []recorded { + rec.mu.Lock() + defer rec.mu.Unlock() + return append([]recorded(nil), rec.reqs...) +} + +// newTestClient は httptest.Server を立て、baseURL を差し替えた Client を返す。 +// TokenSource は nil でよい(oauth2.NewClient(ctx, nil) は素の http.Client を返す)。 +func newTestClient(t *testing.T, handler http.Handler) *Client { + t.Helper() + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + c := New(nil, "test-account") + c.baseURL = srv.URL + c.retryBase = time.Millisecond // バックオフをテスト用に短縮 + return c +} + +func TestChangesFullSyncPaging(t *testing.T) { + rec := &recorder{} + handler := rec.wrap(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Query().Get("pageToken") == "" { + // 1 ページ目: nextSyncToken は返さない(最終ページのみ返る仕様) + fmt.Fprint(w, `{ + "items": [{"id": "ev-1", "iCalUID": "uid-1@google.com", "status": "confirmed", + "start": {"dateTime": "2026-07-10T10:00:00+09:00"}, + "end": {"dateTime": "2026-07-10T11:00:00+09:00"}}], + "nextPageToken": "page-2" + }`) + return + } + fmt.Fprint(w, `{ + "items": [{"id": "ev-2", "iCalUID": "uid-2@google.com", "status": "confirmed", + "start": {"dateTime": "2026-07-11T09:00:00Z"}, + "end": {"dateTime": "2026-07-11T10:00:00Z"}}], + "nextSyncToken": "sync-final" + }`) + }) + c := newTestClient(t, handler) + + events, cursor, err := c.Changes(context.Background(), testRef, "", testWindow) + require.NoError(t, err) + require.Equal(t, "sync-final", cursor, "最終ページの nextSyncToken だけが newCursor になる") + require.Len(t, events, 2) + require.Equal(t, "ev-1", events[0].ID) + require.Equal(t, time.Date(2026, 7, 10, 1, 0, 0, 0, time.UTC), events[0].StartUTC, "+09:00 は UTC に正規化される") + require.Equal(t, "ev-2", events[1].ID) + + reqs := rec.all() + require.Len(t, reqs, 2) + // 1 ページ目: フル同期パラメータ(仕様書 5.1-1) + require.Equal(t, "/calendars/primary/events", reqs[0].Path) + q1 := reqs[0].Query + require.Equal(t, "2026-07-03T00:00:00Z", q1.Get("timeMin")) + require.Equal(t, "2026-10-03T00:00:00Z", q1.Get("timeMax")) + require.Equal(t, "true", q1.Get("singleEvents")) + require.False(t, q1.Has("syncToken")) + require.False(t, q1.Has("pageToken")) + // 2 ページ目: pageToken 付きで同一パラメータ + q2 := reqs[1].Query + require.Equal(t, "page-2", q2.Get("pageToken")) + require.Equal(t, "true", q2.Get("singleEvents")) + require.Equal(t, "2026-07-03T00:00:00Z", q2.Get("timeMin")) +} + +func TestChangesIncremental(t *testing.T) { + rec := &recorder{} + handler := rec.wrap(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"items": [], "nextSyncToken": "sync-2"}`) + }) + c := newTestClient(t, handler) + + events, cursor, err := c.Changes(context.Background(), testRef, "sync-1", testWindow) + require.NoError(t, err) + require.Empty(t, events) + require.Equal(t, "sync-2", cursor) + + reqs := rec.all() + require.Len(t, reqs, 1) + q := reqs[0].Query + require.Equal(t, "sync-1", q.Get("syncToken")) + require.Equal(t, "true", q.Get("singleEvents")) + // syncToken と timeMin/timeMax の併用は 400 になる(仕様書 5.1)ため付けない + require.False(t, q.Has("timeMin")) + require.False(t, q.Has("timeMax")) +} + +func TestChangesCursorInvalid(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusGone) + fmt.Fprint(w, `{"error": {"errors": [{"domain": "global", "reason": "fullSyncRequired", + "message": "Sync token is no longer valid, a full sync is required."}], + "code": 410, "message": "Sync token is no longer valid, a full sync is required."}}`) + }) + c := newTestClient(t, handler) + + _, _, err := c.Changes(context.Background(), testRef, "sync-stale", testWindow) + require.ErrorIs(t, err, provider.ErrCursorInvalid) +} + +func TestChangesAuthExpired(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprint(w, `{"error": {"errors": [{"domain": "global", "reason": "authError", + "message": "Invalid Credentials"}], "code": 401, "message": "Invalid Credentials"}}`) + }) + c := newTestClient(t, handler) + + _, _, err := c.Changes(context.Background(), testRef, "sync-1", testWindow) + require.ErrorIs(t, err, provider.ErrAuthExpired, "401 はトークン失効として ErrAuthExpired に写像される(仕様書9.3)") +} + +func TestChangesRetriesRateLimit(t *testing.T) { + var mu sync.Mutex + calls := 0 + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + calls++ + n := calls + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + if n == 1 { + w.WriteHeader(http.StatusForbidden) + fmt.Fprint(w, `{"error": {"errors": [{"domain": "usageLimits", "reason": "rateLimitExceeded", + "message": "Rate Limit Exceeded"}], "code": 403, "message": "Rate Limit Exceeded"}}`) + return + } + fmt.Fprint(w, `{"items": [{"id": "ev-1", "status": "confirmed", + "start": {"dateTime": "2026-07-10T10:00:00Z"}, + "end": {"dateTime": "2026-07-10T11:00:00Z"}}], + "nextSyncToken": "sync-after-retry"}`) + }) + c := newTestClient(t, handler) + + events, cursor, err := c.Changes(context.Background(), testRef, "sync-1", testWindow) + require.NoError(t, err, "403 rateLimitExceeded 1 回は再試行で吸収される") + require.Equal(t, "sync-after-retry", cursor) + require.Len(t, events, 1) + + mu.Lock() + defer mu.Unlock() + require.Equal(t, 2, calls) +} + +func TestChangesNormalization(t *testing.T) { + cases := []struct { + name string + itemJSON string + want model.NormalizedEvent + }{ + { + name: "cancelled は Deleted=true で ID のみ", + itemJSON: `{"id": "ev-del", "status": "cancelled"}`, + want: model.NormalizedEvent{ID: "ev-del", Deleted: true}, + }, + { + name: "transparency 省略は busy(opaque が既定)", + itemJSON: `{"id": "ev-busy", "status": "confirmed", "iCalUID": "uid-busy", + "start": {"dateTime": "2026-07-10T10:00:00Z"}, + "end": {"dateTime": "2026-07-10T11:00:00Z"}}`, + want: model.NormalizedEvent{ + ID: "ev-busy", ICalUID: "uid-busy", IsBusy: true, + StartUTC: time.Date(2026, 7, 10, 10, 0, 0, 0, time.UTC), + EndUTC: time.Date(2026, 7, 10, 11, 0, 0, 0, time.UTC), + }, + }, + { + name: "transparency=transparent は IsBusy=false", + itemJSON: `{"id": "ev-free", "status": "confirmed", "iCalUID": "uid-free", + "transparency": "transparent", + "start": {"dateTime": "2026-07-10T10:00:00Z"}, + "end": {"dateTime": "2026-07-10T11:00:00Z"}}`, + want: model.NormalizedEvent{ + ID: "ev-free", ICalUID: "uid-free", IsBusy: false, + StartUTC: time.Date(2026, 7, 10, 10, 0, 0, 0, time.UTC), + EndUTC: time.Date(2026, 7, 10, 11, 0, 0, 0, time.UTC), + }, + }, + { + name: "self=true の declined は IsDeclined=true", + itemJSON: `{"id": "ev-dec", "status": "confirmed", "iCalUID": "uid-dec", + "attendees": [ + {"email": "other@example.com", "responseStatus": "accepted"}, + {"email": "me@example.com", "self": true, "responseStatus": "declined"}], + "start": {"dateTime": "2026-07-10T10:00:00Z"}, + "end": {"dateTime": "2026-07-10T11:00:00Z"}}`, + want: model.NormalizedEvent{ + ID: "ev-dec", ICalUID: "uid-dec", IsBusy: true, IsDeclined: true, + StartUTC: time.Date(2026, 7, 10, 10, 0, 0, 0, time.UTC), + EndUTC: time.Date(2026, 7, 10, 11, 0, 0, 0, time.UTC), + }, + }, + { + name: "他人の declined は IsDeclined に影響しない", + itemJSON: `{"id": "ev-oth", "status": "confirmed", "iCalUID": "uid-oth", + "attendees": [ + {"email": "other@example.com", "responseStatus": "declined"}, + {"email": "me@example.com", "self": true, "responseStatus": "accepted"}], + "start": {"dateTime": "2026-07-10T10:00:00Z"}, + "end": {"dateTime": "2026-07-10T11:00:00Z"}}`, + want: model.NormalizedEvent{ + ID: "ev-oth", ICalUID: "uid-oth", IsBusy: true, IsDeclined: false, + StartUTC: time.Date(2026, 7, 10, 10, 0, 0, 0, time.UTC), + EndUTC: time.Date(2026, 7, 10, 11, 0, 0, 0, time.UTC), + }, + }, + { + name: "extendedProperties.private の calsync-origin を OriginTag へ", + itemJSON: `{"id": "ev-tag", "status": "confirmed", "iCalUID": "uid-tag", + "extendedProperties": {"private": {"calsync": "v1", "calsync-origin": "work:ev-9"}}, + "start": {"dateTime": "2026-07-10T10:00:00Z"}, + "end": {"dateTime": "2026-07-10T11:00:00Z"}}`, + want: model.NormalizedEvent{ + ID: "ev-tag", ICalUID: "uid-tag", IsBusy: true, OriginTag: "work:ev-9", + StartUTC: time.Date(2026, 7, 10, 10, 0, 0, 0, time.UTC), + EndUTC: time.Date(2026, 7, 10, 11, 0, 0, 0, time.UTC), + }, + }, + { + name: "dateTime のオフセットは UTC に正規化", + itemJSON: `{"id": "ev-jst", "status": "confirmed", "iCalUID": "uid-jst", + "start": {"dateTime": "2026-07-10T10:00:00+09:00", "timeZone": "Asia/Tokyo"}, + "end": {"dateTime": "2026-07-10T11:30:00+09:00", "timeZone": "Asia/Tokyo"}}`, + want: model.NormalizedEvent{ + ID: "ev-jst", ICalUID: "uid-jst", IsBusy: true, + StartUTC: time.Date(2026, 7, 10, 1, 0, 0, 0, time.UTC), + EndUTC: time.Date(2026, 7, 10, 2, 30, 0, 0, time.UTC), + }, + }, + { + name: "終日は date を現地日付のまま保持(end は排他的)", + itemJSON: `{"id": "ev-day", "status": "confirmed", "iCalUID": "uid-day", + "start": {"date": "2026-07-15"}, + "end": {"date": "2026-07-16"}}`, + want: model.NormalizedEvent{ + ID: "ev-day", ICalUID: "uid-day", IsBusy: true, + IsAllDay: true, AllDayStart: "2026-07-15", AllDayEnd: "2026-07-16", + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"items": [%s], "nextSyncToken": "s"}`, tc.itemJSON) + }) + c := newTestClient(t, handler) + + events, _, err := c.Changes(context.Background(), testRef, "cursor-x", testWindow) + require.NoError(t, err) + require.Len(t, events, 1) + require.Equal(t, tc.want, events[0]) + }) + } +} diff --git a/internal/provider/google/google.go b/internal/provider/google/google.go new file mode 100644 index 0000000..e11df02 --- /dev/null +++ b/internal/provider/google/google.go @@ -0,0 +1,156 @@ +// Package google は Google Calendar API(calendar/v3)向けの +// provider.Provider 実装を提供する。 +package google + +import ( + "context" + "errors" + "fmt" + "math/rand/v2" + "net/http" + "sync" + "time" + + "golang.org/x/oauth2" + calendar "google.golang.org/api/calendar/v3" + "google.golang.org/api/googleapi" + "google.golang.org/api/option" + + "github.com/cloudnative-co/calsync/internal/model" + "github.com/cloudnative-co/calsync/internal/provider" +) + +// maxRetries は 403/429(usageLimits 系)に対する再試行回数の上限(仕様書10章)。 +const maxRetries = 5 + +// Client は Google Calendar 用のプロバイダ実装。 +type Client struct { + ts oauth2.TokenSource + accountID string + + // baseURL はテストで httptest.Server.URL に差し替える。空なら本番エンドポイント。 + baseURL string + // retryBase はバックオフの基準待ち時間。テストでは短縮する。 + retryBase time.Duration + + mu sync.Mutex + svc *calendar.Service +} + +// New は Client を作る。calendar.Service は初回利用時に遅延構築する +// (構築前にテストが baseURL を差し替えられるようにするため)。 +func New(ts oauth2.TokenSource, accountID string) *Client { + return &Client{ts: ts, accountID: accountID, retryBase: 500 * time.Millisecond} +} + +// service は calendar.Service を遅延構築して返す(並行呼び出し安全)。 +// option.WithHTTPClient を渡すため、ライブラリ側のデフォルト認証解決は行われない +// (テストでは ts=nil でも動く)。 +func (c *Client) service(ctx context.Context) (*calendar.Service, error) { + c.mu.Lock() + defer c.mu.Unlock() + if c.svc != nil { + return c.svc, nil + } + opts := []option.ClientOption{option.WithHTTPClient(oauth2.NewClient(ctx, c.ts))} + if c.baseURL != "" { + opts = append(opts, option.WithEndpoint(c.baseURL)) + } + svc, err := calendar.NewService(ctx, opts...) + if err != nil { + return nil, fmt.Errorf("google[%s]: build calendar service: %w", c.accountID, err) + } + c.svc = svc + return svc, nil +} + +// doWithRetry は fn を実行し、403/429 の usageLimits 系エラーの場合のみ +// 指数バックオフ+ジッターで最大 maxRetries 回まで再試行する(仕様書10章)。 +// それ以外のエラー(410 等)は即座に返す。 +func (c *Client) doWithRetry(ctx context.Context, fn func() error) error { + base := c.retryBase + if base <= 0 { + base = 500 * time.Millisecond + } + var err error + for attempt := 0; ; attempt++ { + err = fn() + if err == nil || !isRateLimited(err) || attempt >= maxRetries { + return err + } + delay := base<"%20" replace only +// ever touches encoded spaces. +func encodeQuery(q url.Values) string { return strings.ReplaceAll(q.Encode(), "+", "%20") } + +// CreateBlocker implements provider.Provider. idemKey becomes the Graph +// transactionId; a 409 (duplicate transactionId) is resolved by looking up +// the existing event via its origin tag and returning its ID. +func (c *Client) CreateBlocker(ctx context.Context, cal model.CalendarRef, b model.Blocker, idemKey string) (string, error) { + payload, err := json.Marshal(blockerBody(b, idemKey)) + if err != nil { + return "", err + } + status, body, err := c.doRead(ctx, http.MethodPost, c.baseURL+"/me/events", payload) + if err != nil { + return "", err + } + switch { + case status == http.StatusCreated || status == http.StatusOK: + var created struct { + ID string `json:"id"` + } + if err := json.Unmarshal(body, &created); err != nil { + return "", fmt.Errorf("graph create blocker: decode: %w", err) + } + if created.ID == "" { + return "", fmt.Errorf("graph create blocker: response has no id") + } + return created.ID, nil + case status == http.StatusConflict: + // transactionId の再送(クラッシュ後の再実行)。既存ブロッカーをタグで特定する。 + return c.findBlockerByOriginTag(ctx, b.OriginTag) + default: + return "", fmt.Errorf("graph create blocker: status %d: %s", status, body) + } +} + +// findBlockerByOriginTag locates an existing blocker whose calsyncOrigin +// extended property equals originTag. +func (c *Client) findBlockerByOriginTag(ctx context.Context, originTag string) (string, error) { + q := url.Values{} + q.Set("$filter", fmt.Sprintf( + "singleValueExtendedProperties/Any(ep: ep/id eq '%s' and ep/value eq '%s')", + originPropertyID, odataQuote(originTag))) + q.Set("$select", "id") + status, body, err := c.doRead(ctx, http.MethodGet, c.baseURL+"/me/events?"+encodeQuery(q), nil) + if err != nil { + return "", err + } + if status != http.StatusOK { + return "", fmt.Errorf("graph find blocker by tag: status %d: %s", status, body) + } + var page struct { + Value []struct { + ID string `json:"id"` + } `json:"value"` + } + if err := json.Unmarshal(body, &page); err != nil { + return "", fmt.Errorf("graph find blocker by tag: decode: %w", err) + } + if len(page.Value) == 0 { + return "", fmt.Errorf("graph create blocker: 409 conflict but no event with origin tag %q", originTag) + } + return page.Value[0].ID, nil +} + +// UpdateBlocker implements provider.Provider (PATCH; no transactionId). +// A 404 (blocker deleted by hand etc.) maps to provider.ErrNotFound so the +// engine can fall back to re-creating it (design doc 8.4). +func (c *Client) UpdateBlocker(ctx context.Context, cal model.CalendarRef, eventID string, b model.Blocker) error { + payload, err := json.Marshal(blockerBody(b, "")) + if err != nil { + return err + } + status, body, err := c.doRead(ctx, http.MethodPatch, c.baseURL+"/me/events/"+url.PathEscape(eventID), payload) + if err != nil { + return err + } + if status == http.StatusNotFound { + return fmt.Errorf("graph update blocker %s: status 404: %w", eventID, provider.ErrNotFound) + } + if status != http.StatusOK { + return fmt.Errorf("graph update blocker %s: status %d: %s", eventID, status, body) + } + return nil +} + +// DeleteBlocker implements provider.Provider. 404 is treated as success +// (the blocker is already gone — deletion is idempotent). +func (c *Client) DeleteBlocker(ctx context.Context, cal model.CalendarRef, eventID string) error { + status, body, err := c.doRead(ctx, http.MethodDelete, c.baseURL+"/me/events/"+url.PathEscape(eventID), nil) + if err != nil { + return err + } + if status == http.StatusNoContent || status == http.StatusNotFound { + return nil + } + return fmt.Errorf("graph delete blocker %s: status %d: %s", eventID, status, body) +} + +// ListBlockers implements provider.Provider. Graph cannot return extended +// property values in a filtered listing (official limitation), so it lists +// matching event IDs first, then fetches each event with $expand to read the +// origin tag and times for the BlockerRecord. +func (c *Client) ListBlockers(ctx context.Context, cal model.CalendarRef, window model.Window) ([]model.BlockerRecord, error) { + q := url.Values{} + q.Set("$filter", fmt.Sprintf( + "singleValueExtendedProperties/Any(ep: ep/id eq '%s' and ep/value ne null)", + originPropertyID)) + listURL := c.baseURL + "/me/events?" + encodeQuery(q) + + var records []model.BlockerRecord + for listURL != "" { + status, body, err := c.doRead(ctx, http.MethodGet, listURL, nil) + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, fmt.Errorf("graph list blockers: status %d: %s", status, body) + } + var page struct { + Value []struct { + ID string `json:"id"` + } `json:"value"` + NextLink string `json:"@odata.nextLink"` + } + if err := json.Unmarshal(body, &page); err != nil { + return nil, fmt.Errorf("graph list blockers: decode: %w", err) + } + for _, item := range page.Value { + rec, err := c.getBlockerRecord(ctx, item.ID) + if err != nil { + return nil, err + } + records = append(records, rec) + } + listURL = page.NextLink + } + return records, nil +} + +// getBlockerRecord fetches one event with $expand to read the origin tag +// value and computes its TimeHash. +func (c *Client) getBlockerRecord(ctx context.Context, eventID string) (model.BlockerRecord, error) { + q := url.Values{} + q.Set("$expand", fmt.Sprintf("singleValueExtendedProperties($filter=id eq '%s')", originPropertyID)) + status, body, err := c.doRead(ctx, http.MethodGet, + c.baseURL+"/me/events/"+url.PathEscape(eventID)+"?"+encodeQuery(q), nil) + if err != nil { + return model.BlockerRecord{}, err + } + if status != http.StatusOK { + return model.BlockerRecord{}, fmt.Errorf("graph get blocker %s: status %d: %s", eventID, status, body) + } + var ev struct { + ID string `json:"id"` + IsAllDay bool `json:"isAllDay"` + Start graphTime `json:"start"` + End graphTime `json:"end"` + SingleValueExtendedProperties []singleValueProp `json:"singleValueExtendedProperties"` + } + if err := json.Unmarshal(body, &ev); err != nil { + return model.BlockerRecord{}, fmt.Errorf("graph get blocker %s: decode: %w", eventID, err) + } + rec := model.BlockerRecord{EventID: ev.ID} + for _, p := range ev.SingleValueExtendedProperties { + if p.ID == originPropertyID { + rec.OriginTag = p.Value + } + } + nev := model.NormalizedEvent{IsAllDay: ev.IsAllDay} + if ev.IsAllDay { + s, err := datePart(ev.Start.DateTime) + if err != nil { + return model.BlockerRecord{}, err + } + e, err := datePart(ev.End.DateTime) + if err != nil { + return model.BlockerRecord{}, err + } + nev.AllDayStart, nev.AllDayEnd = s, e + } else { + var s, e time.Time + if s, err = ev.Start.utc(); err != nil { + return model.BlockerRecord{}, err + } + if e, err = ev.End.utc(); err != nil { + return model.BlockerRecord{}, err + } + nev.StartUTC, nev.EndUTC = s, e + } + rec.TimeHash = model.TimeHash(nev) + return rec, nil +} + +// GetCalendarTimezone implements provider.Provider. It returns the +// mailboxSettings timeZone value verbatim (typically a Windows timezone +// name); it is passed back to Graph as-is when creating all-day blockers. +func (c *Client) GetCalendarTimezone(ctx context.Context, cal model.CalendarRef) (string, error) { + status, body, err := c.doRead(ctx, http.MethodGet, c.baseURL+"/me/mailboxSettings/timeZone", nil) + if err != nil { + return "", err + } + if status != http.StatusOK { + return "", fmt.Errorf("graph get mailbox timezone: status %d: %s", status, body) + } + var out struct { + Value string `json:"value"` + } + if err := json.Unmarshal(body, &out); err != nil { + return "", fmt.Errorf("graph get mailbox timezone: decode: %w", err) + } + if out.Value == "" { + return "", fmt.Errorf("graph get mailbox timezone: empty value") + } + return out.Value, nil +} diff --git a/internal/provider/microsoft/blockers_test.go b/internal/provider/microsoft/blockers_test.go new file mode 100644 index 0000000..d26b9bf --- /dev/null +++ b/internal/provider/microsoft/blockers_test.go @@ -0,0 +1,379 @@ +package microsoft + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/cloudnative-co/calsync/internal/model" + "github.com/cloudnative-co/calsync/internal/provider" +) + +const wantOriginPropID = "String {b7dbd76c-3a35-4b41-9d80-6a3f31f2a6b9} Name calsyncOrigin" + +func TestCreateBlocker(t *testing.T) { + tests := []struct { + name string + blocker model.Blocker + idemKey string + wantAllDay bool + wantStart map[string]string + wantEnd map[string]string + }{ + { + name: "timed blocker uses UTC dateTime", + blocker: model.Blocker{ + Title: "予定あり", + StartUTC: time.Date(2026, 7, 10, 1, 0, 0, 0, time.UTC), + EndUTC: time.Date(2026, 7, 10, 2, 0, 0, 0, time.UTC), + OriginTag: "personal:ev1", + }, + idemKey: "calsync-abc123", + wantAllDay: false, + wantStart: map[string]string{"dateTime": "2026-07-10T01:00:00", "timeZone": "UTC"}, + wantEnd: map[string]string{"dateTime": "2026-07-10T02:00:00", "timeZone": "UTC"}, + }, + { + name: "all-day blocker uses target timezone midnight bounds", + blocker: model.Blocker{ + Title: "予定あり", + IsAllDay: true, + AllDayStart: "2026-07-15", + AllDayEnd: "2026-07-16", + TargetTimezone: "Tokyo Standard Time", + OriginTag: "personal:ev2", + }, + idemKey: "calsync-def456", + wantAllDay: true, + wantStart: map[string]string{"dateTime": "2026-07-15T00:00:00", "timeZone": "Tokyo Standard Time"}, + wantEnd: map[string]string{"dateTime": "2026-07-16T00:00:00", "timeZone": "Tokyo Standard Time"}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var reqs []recordedRequest + handler := func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + fmt.Fprint(w, `{"id":"created-ev-1"}`) + } + srv := httptest.NewServer(record(&reqs, handler)) + defer srv.Close() + + c := newTestClient(t, srv.URL, []string{"busy"}) + id, err := c.CreateBlocker(context.Background(), testCal, tc.blocker, tc.idemKey) + require.NoError(t, err) + require.Equal(t, "created-ev-1", id) + + require.Len(t, reqs, 1) + require.Equal(t, http.MethodPost, reqs[0].Method) + require.Equal(t, "/me/events", reqs[0].URL) + + var body map[string]any + require.NoError(t, json.Unmarshal(reqs[0].Body, &body)) + require.Equal(t, "予定あり", body["subject"]) + require.Equal(t, "busy", body["showAs"]) + require.Equal(t, false, body["isReminderOn"]) + require.Equal(t, "private", body["sensitivity"]) + require.Equal(t, tc.idemKey, body["transactionId"]) + require.Equal(t, tc.wantAllDay, body["isAllDay"]) + + start, ok := body["start"].(map[string]any) + require.True(t, ok) + require.Equal(t, tc.wantStart["dateTime"], start["dateTime"]) + require.Equal(t, tc.wantStart["timeZone"], start["timeZone"]) + end, ok := body["end"].(map[string]any) + require.True(t, ok) + require.Equal(t, tc.wantEnd["dateTime"], end["dateTime"]) + require.Equal(t, tc.wantEnd["timeZone"], end["timeZone"]) + + props, ok := body["singleValueExtendedProperties"].([]any) + require.True(t, ok) + require.Len(t, props, 1) + prop, ok := props[0].(map[string]any) + require.True(t, ok) + require.Equal(t, wantOriginPropID, prop["id"]) + require.Equal(t, tc.blocker.OriginTag, prop["value"]) + + requireCommonHeaders(t, reqs) + }) + } +} + +func TestCreateBlockerConflictReturnsExistingID(t *testing.T) { + var reqs []recordedRequest + mux := http.NewServeMux() + mux.HandleFunc("/me/events", func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + w.WriteHeader(http.StatusConflict) + fmt.Fprint(w, `{"error":{"code":"ErrorDuplicateTransactionId","message":"duplicate transactionId"}}`) + return + } + fmt.Fprint(w, `{"value":[{"id":"existing-ev-9"}]}`) + }) + srv := httptest.NewServer(record(&reqs, mux.ServeHTTP)) + defer srv.Close() + + c := newTestClient(t, srv.URL, []string{"busy"}) + b := model.Blocker{ + Title: "予定あり", + StartUTC: time.Date(2026, 7, 10, 1, 0, 0, 0, time.UTC), + EndUTC: time.Date(2026, 7, 10, 2, 0, 0, 0, time.UTC), + OriginTag: "personal:ev1", + } + id, err := c.CreateBlocker(context.Background(), testCal, b, "calsync-abc123") + require.NoError(t, err) + require.Equal(t, "existing-ev-9", id) + + require.Len(t, reqs, 2) + require.Equal(t, http.MethodGet, reqs[1].Method) + u, err := url.Parse(reqs[1].URL) + require.NoError(t, err) + require.Equal(t, + "singleValueExtendedProperties/Any(ep: ep/id eq '"+wantOriginPropID+"' and ep/value eq 'personal:ev1')", + u.Query().Get("$filter")) + requireCommonHeaders(t, reqs) +} + +// TestFindBlockerByOriginTagEncodesSpacesAsPercent20 は 409 収容時の +// findBlockerByOriginTag が発行する $filter クエリで、空白が "+" ではなく +// "%20" でエンコードされていることを確認する。net/url は "+" と "%20" を +// symmetric に相互変換するため r.URL.Query() 経由の検査では検出できず、 +// 実際にワイヤへ送られるバイト列である r.URL.RawQuery を直接見る必要がある +// (Microsoft Graph の OData パーサは $filter/$expand 中の空白代替 "+" を +// 受理しない既知挙動があるため)。 +func TestFindBlockerByOriginTagEncodesSpacesAsPercent20(t *testing.T) { + var rawQuery string + mux := http.NewServeMux() + mux.HandleFunc("/me/events", func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + w.WriteHeader(http.StatusConflict) + fmt.Fprint(w, `{"error":{"code":"ErrorDuplicateTransactionId","message":"duplicate transactionId"}}`) + return + } + rawQuery = r.URL.RawQuery + fmt.Fprint(w, `{"value":[{"id":"existing-ev-9"}]}`) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + c := newTestClient(t, srv.URL, []string{"busy"}) + b := model.Blocker{ + Title: "予定あり", + StartUTC: time.Date(2026, 7, 10, 1, 0, 0, 0, time.UTC), + EndUTC: time.Date(2026, 7, 10, 2, 0, 0, 0, time.UTC), + OriginTag: "personal:ev1", + } + _, err := c.CreateBlocker(context.Background(), testCal, b, "calsync-abc123") + require.NoError(t, err) + + require.NotEmpty(t, rawQuery) + require.Contains(t, rawQuery, "%20", "raw query must percent-encode spaces as %%20: %s", rawQuery) + require.NotContains(t, rawQuery, "+", "raw query must not contain a literal '+' (Graph rejects it as a space substitute in $filter/$expand): %s", rawQuery) +} + +func TestUpdateBlocker(t *testing.T) { + var reqs []recordedRequest + handler := func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"id":"ev-1"}`) + } + srv := httptest.NewServer(record(&reqs, handler)) + defer srv.Close() + + c := newTestClient(t, srv.URL, []string{"busy"}) + b := model.Blocker{ + Title: "予定あり", + StartUTC: time.Date(2026, 7, 10, 3, 0, 0, 0, time.UTC), + EndUTC: time.Date(2026, 7, 10, 4, 0, 0, 0, time.UTC), + OriginTag: "personal:ev1", + } + err := c.UpdateBlocker(context.Background(), testCal, "ev-1", b) + require.NoError(t, err) + + require.Len(t, reqs, 1) + require.Equal(t, http.MethodPatch, reqs[0].Method) + require.Equal(t, "/me/events/ev-1", reqs[0].URL) + + var body map[string]any + require.NoError(t, json.Unmarshal(reqs[0].Body, &body)) + require.NotContains(t, body, "transactionId") // transactionId は作成専用 + start, ok := body["start"].(map[string]any) + require.True(t, ok) + require.Equal(t, "2026-07-10T03:00:00", start["dateTime"]) + require.Equal(t, "UTC", start["timeZone"]) + requireCommonHeaders(t, reqs) +} + +// 404(手動削除等でブロッカーが消えている)は provider.ErrNotFound に写像され、 +// エンジンの「pending 化して再作成」フォールバックを発動させる(仕様8章4)。 +func TestUpdateBlockerNotFoundMapsToErrNotFound(t *testing.T) { + handler := func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + fmt.Fprint(w, `{"error":{"code":"ErrorItemNotFound","message":"The specified object was not found in the store."}}`) + } + srv := httptest.NewServer(http.HandlerFunc(handler)) + defer srv.Close() + + c := newTestClient(t, srv.URL, []string{"busy"}) + b := model.Blocker{ + StartUTC: time.Date(2026, 7, 10, 3, 0, 0, 0, time.UTC), + EndUTC: time.Date(2026, 7, 10, 4, 0, 0, 0, time.UTC), + } + err := c.UpdateBlocker(context.Background(), testCal, "ev-gone", b) + require.ErrorIs(t, err, provider.ErrNotFound) +} + +func TestDeleteBlocker(t *testing.T) { + tests := []struct { + name string + status int + wantErr bool + }{ + {"204 No Content", http.StatusNoContent, false}, + {"404 Not Found is success (idempotent)", http.StatusNotFound, false}, + {"403 Forbidden is an error", http.StatusForbidden, true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var reqs []recordedRequest + handler := func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tc.status) + } + srv := httptest.NewServer(record(&reqs, handler)) + defer srv.Close() + + c := newTestClient(t, srv.URL, []string{"busy"}) + err := c.DeleteBlocker(context.Background(), testCal, "ev-1") + if tc.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + require.Len(t, reqs, 1) + require.Equal(t, http.MethodDelete, reqs[0].Method) + require.Equal(t, "/me/events/ev-1", reqs[0].URL) + requireCommonHeaders(t, reqs) + }) + } +} + +func TestListBlockers(t *testing.T) { + var reqs []recordedRequest + mux := http.NewServeMux() + mux.HandleFunc("/me/events", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"value":[{"id":"blk-1"},{"id":"blk-2"}]}`) + }) + mux.HandleFunc("/me/events/blk-1", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"id":"blk-1","isAllDay":false, + "start":{"dateTime":"2026-07-10T01:00:00.0000000","timeZone":"UTC"}, + "end":{"dateTime":"2026-07-10T02:00:00.0000000","timeZone":"UTC"}, + "singleValueExtendedProperties":[{"id":"String {b7dbd76c-3a35-4b41-9d80-6a3f31f2a6b9} Name calsyncOrigin","value":"personal:ev1"}]}`) + }) + mux.HandleFunc("/me/events/blk-2", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"id":"blk-2","isAllDay":true, + "start":{"dateTime":"2026-07-15T00:00:00.0000000","timeZone":"Tokyo Standard Time"}, + "end":{"dateTime":"2026-07-16T00:00:00.0000000","timeZone":"Tokyo Standard Time"}, + "singleValueExtendedProperties":[{"id":"String {b7dbd76c-3a35-4b41-9d80-6a3f31f2a6b9} Name calsyncOrigin","value":"work:ev2"}]}`) + }) + srv := httptest.NewServer(record(&reqs, mux.ServeHTTP)) + defer srv.Close() + + c := newTestClient(t, srv.URL, []string{"busy"}) + recs, err := c.ListBlockers(context.Background(), testCal, testWindow) + require.NoError(t, err) + + wantTimedHash := model.TimeHash(model.NormalizedEvent{ + StartUTC: time.Date(2026, 7, 10, 1, 0, 0, 0, time.UTC), + EndUTC: time.Date(2026, 7, 10, 2, 0, 0, 0, time.UTC), + }) + wantAllDayHash := model.TimeHash(model.NormalizedEvent{ + IsAllDay: true, AllDayStart: "2026-07-15", AllDayEnd: "2026-07-16", + }) + require.Equal(t, []model.BlockerRecord{ + {EventID: "blk-1", OriginTag: "personal:ev1", TimeHash: wantTimedHash}, + {EventID: "blk-2", OriginTag: "work:ev2", TimeHash: wantAllDayHash}, + }, recs) + + // 列挙: $filter(value ne null)。GUID の { } が URL エンコードされていること + require.Len(t, reqs, 3) + u0, err := url.Parse(reqs[0].URL) + require.NoError(t, err) + require.Equal(t, "/me/events", u0.Path) + require.Equal(t, + "singleValueExtendedProperties/Any(ep: ep/id eq '"+wantOriginPropID+"' and ep/value ne null)", + u0.Query().Get("$filter")) + require.Contains(t, reqs[0].URL, "%7Bb7dbd76c-3a35-4b41-9d80-6a3f31f2a6b9%7D") + + // 各件 GET + $expand で value を取得 + u1, err := url.Parse(reqs[1].URL) + require.NoError(t, err) + require.Equal(t, "/me/events/blk-1", u1.Path) + require.Equal(t, + "singleValueExtendedProperties($filter=id eq '"+wantOriginPropID+"')", + u1.Query().Get("$expand")) + + requireCommonHeaders(t, reqs) +} + +// TestListBlockersEncodesSpacesAsPercent20 は ListBlockers の一覧 $filter と、 +// 各件取得(getBlockerRecord)の $expand の両方で、空白が "+" ではなく "%20" +// でエンコードされていることを確認する。デコード後の r.URL.Query() では +// net/url が "+"/"%20" を対称変換してしまい検出できないため、実際に送信 +// されたバイト列である r.URL.RawQuery を直接検査する。 +func TestListBlockersEncodesSpacesAsPercent20(t *testing.T) { + var listRawQuery, itemRawQuery string + mux := http.NewServeMux() + mux.HandleFunc("/me/events", func(w http.ResponseWriter, r *http.Request) { + listRawQuery = r.URL.RawQuery + fmt.Fprint(w, `{"value":[{"id":"blk-1"}]}`) + }) + mux.HandleFunc("/me/events/blk-1", func(w http.ResponseWriter, r *http.Request) { + itemRawQuery = r.URL.RawQuery + fmt.Fprint(w, `{"id":"blk-1","isAllDay":false, + "start":{"dateTime":"2026-07-10T01:00:00.0000000","timeZone":"UTC"}, + "end":{"dateTime":"2026-07-10T02:00:00.0000000","timeZone":"UTC"}, + "singleValueExtendedProperties":[{"id":"String {b7dbd76c-3a35-4b41-9d80-6a3f31f2a6b9} Name calsyncOrigin","value":"personal:ev1"}]}`) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + c := newTestClient(t, srv.URL, []string{"busy"}) + _, err := c.ListBlockers(context.Background(), testCal, testWindow) + require.NoError(t, err) + + for _, tc := range []struct { + name string + raw string + }{ + {"$filter (list)", listRawQuery}, + {"$expand (item)", itemRawQuery}, + } { + require.NotEmpty(t, tc.raw, tc.name) + require.Contains(t, tc.raw, "%20", "%s: raw query must percent-encode spaces as %%20: %s", tc.name, tc.raw) + require.NotContains(t, tc.raw, "+", "%s: raw query must not contain a literal '+' (Graph rejects it as a space substitute in $filter/$expand): %s", tc.name, tc.raw) + } +} + +func TestGetCalendarTimezone(t *testing.T) { + var reqs []recordedRequest + handler := func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"@odata.context":"https://graph.microsoft.com/v1.0/$metadata#users('me')/mailboxSettings/timeZone","value":"Tokyo Standard Time"}`) + } + srv := httptest.NewServer(record(&reqs, handler)) + defer srv.Close() + + c := newTestClient(t, srv.URL, []string{"busy"}) + tz, err := c.GetCalendarTimezone(context.Background(), testCal) + require.NoError(t, err) + require.Equal(t, "Tokyo Standard Time", tz) // mailboxSettings の value をそのまま返す + + require.Len(t, reqs, 1) + require.Equal(t, "/me/mailboxSettings/timeZone", reqs[0].URL) + requireCommonHeaders(t, reqs) +} diff --git a/internal/provider/microsoft/delta.go b/internal/provider/microsoft/delta.go new file mode 100644 index 0000000..d940a04 --- /dev/null +++ b/internal/provider/microsoft/delta.go @@ -0,0 +1,154 @@ +package microsoft + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/cloudnative-co/calsync/internal/model" + "github.com/cloudnative-co/calsync/internal/provider" +) + +// maxDeltaPages is the per-round page cap of the pagination circuit breaker +// (design doc 5.2: known Graph infinite-pagination issue with recurring events). +const maxDeltaPages = 50 + +type deltaRemoved struct { + Reason string `json:"reason"` +} + +type deltaResponseStatus struct { + Response string `json:"response"` +} + +type deltaEvent struct { + ID string `json:"id"` + Removed *deltaRemoved `json:"@removed"` + ICalUID string `json:"iCalUId"` + IsCancelled bool `json:"isCancelled"` + IsAllDay bool `json:"isAllDay"` + ShowAs string `json:"showAs"` + Start *graphTime `json:"start"` + End *graphTime `json:"end"` + ResponseStatus *deltaResponseStatus `json:"responseStatus"` +} + +type deltaPage struct { + Value []deltaEvent `json:"value"` + NextLink string `json:"@odata.nextLink"` + DeltaLink string `json:"@odata.deltaLink"` +} + +// Changes implements provider.Provider. An empty cursor starts a windowed +// full sync via /me/calendarView/delta; a non-empty cursor (the previous +// deltaLink URL) is fetched as-is. newCursor is only non-empty when the +// round completed (deltaLink reached). +func (c *Client) Changes(ctx context.Context, cal model.CalendarRef, cursor string, window model.Window) ([]model.NormalizedEvent, string, error) { + reqURL := cursor + if reqURL == "" { + q := url.Values{} + q.Set("startDateTime", window.Start.UTC().Format(time.RFC3339)) + q.Set("endDateTime", window.End.UTC().Format(time.RFC3339)) + reqURL = c.baseURL + "/me/calendarView/delta?" + q.Encode() + } + + var events []model.NormalizedEvent + prevFingerprint := "" + for page := 0; ; page++ { + if page >= maxDeltaPages { + return nil, "", fmt.Errorf("graph delta: pagination exceeded %d pages: %w", maxDeltaPages, provider.ErrCursorInvalid) + } + status, body, err := c.doRead(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return nil, "", err + } + if status == http.StatusGone { + return nil, "", provider.ErrCursorInvalid + } + if status != http.StatusOK { + if graphErrorCode(body) == "syncStateNotFound" { + return nil, "", provider.ErrCursorInvalid + } + return nil, "", fmt.Errorf("graph delta: status %d: %s", status, body) + } + var pg deltaPage + if err := json.Unmarshal(body, &pg); err != nil { + return nil, "", fmt.Errorf("graph delta: decode page: %w", err) + } + fp := pageFingerprint(pg.Value) + if fp != "" && fp == prevFingerprint { + return nil, "", fmt.Errorf("graph delta: identical page repeated (pagination loop): %w", provider.ErrCursorInvalid) + } + prevFingerprint = fp + for _, de := range pg.Value { + ev, err := normalizeDeltaEvent(de, c.busyShowAs) + if err != nil { + return nil, "", err + } + events = append(events, ev) + } + if pg.DeltaLink != "" { + return events, pg.DeltaLink, nil + } + if pg.NextLink == "" { + return nil, "", fmt.Errorf("graph delta: page has neither nextLink nor deltaLink") + } + reqURL = pg.NextLink + } +} + +// pageFingerprint joins the event IDs of one page for loop detection. +func pageFingerprint(evs []deltaEvent) string { + ids := make([]string, len(evs)) + for i, e := range evs { + ids[i] = e.ID + } + return strings.Join(ids, "\x00") +} + +// normalizeDeltaEvent converts a Graph delta event into the provider-neutral +// form. @removed elements and isCancelled=true both map to Deleted=true +// (only the ID is guaranteed). OriginTag stays "" — Graph delta cannot +// return extended properties (design doc 6.3). +func normalizeDeltaEvent(de deltaEvent, busyShowAs map[string]bool) (model.NormalizedEvent, error) { + ev := model.NormalizedEvent{ID: de.ID, ICalUID: de.ICalUID} + if de.Removed != nil || de.IsCancelled { + ev.Deleted = true + return ev, nil + } + ev.IsBusy = busyShowAs[de.ShowAs] + if de.ResponseStatus != nil && de.ResponseStatus.Response == "declined" { + ev.IsDeclined = true + } + if de.Start == nil || de.End == nil { + return model.NormalizedEvent{}, fmt.Errorf("graph delta: event %q has no start/end", de.ID) + } + if de.IsAllDay { + ev.IsAllDay = true + // NOTE: 終日イベントの start/end が実 API で UTC 変換され日付がズレないこと要実測(spec 15章) + s, err := datePart(de.Start.DateTime) + if err != nil { + return model.NormalizedEvent{}, err + } + e, err := datePart(de.End.DateTime) + if err != nil { + return model.NormalizedEvent{}, err + } + ev.AllDayStart, ev.AllDayEnd = s, e + return ev, nil + } + start, err := de.Start.utc() + if err != nil { + return model.NormalizedEvent{}, err + } + end, err := de.End.utc() + if err != nil { + return model.NormalizedEvent{}, err + } + ev.StartUTC, ev.EndUTC = start, end + return ev, nil +} diff --git a/internal/provider/microsoft/delta_test.go b/internal/provider/microsoft/delta_test.go new file mode 100644 index 0000000..48340e1 --- /dev/null +++ b/internal/provider/microsoft/delta_test.go @@ -0,0 +1,408 @@ +package microsoft + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" + + "github.com/cloudnative-co/calsync/internal/model" + "github.com/cloudnative-co/calsync/internal/provider" +) + +// recordedRequest は httptest ハンドラが受けたリクエストの記録。 +// require はテスト goroutine 以外で FailNow できないため、 +// ハンドラ内では記録のみ行い、アサーションはテスト側で行う。 +type recordedRequest struct { + Method string + URL string // r.URL.String()(パス+クエリ) + Header http.Header + Body []byte +} + +func record(reqs *[]recordedRequest, next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + *reqs = append(*reqs, recordedRequest{ + Method: r.Method, + URL: r.URL.String(), + Header: r.Header.Clone(), + Body: body, + }) + next(w, r) + } +} + +// requireCommonHeaders は「全リクエストに Prefer: IdType="ImmutableId" が付き、 +// odata.maxpagesize が一切付かない」ことの共通アサート。 +func requireCommonHeaders(t *testing.T, reqs []recordedRequest) { + t.Helper() + require.NotEmpty(t, reqs) + for i, rr := range reqs { + require.Equal(t, `IdType="ImmutableId"`, rr.Header.Get("Prefer"), "request %d", i) + for _, v := range rr.Header.Values("Prefer") { + require.NotContains(t, v, "odata.maxpagesize", "request %d", i) + } + } +} + +func newTestClient(t *testing.T, srvURL string, busyShowAs []string) *Client { + t.Helper() + c := New(oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "test-token"}), "ms-acct", busyShowAs) + c.baseURL = srvURL + c.sleep = func(time.Duration) {} // テストでは待機しない(必要なテストは個別に差し替えて記録する) + return c +} + +var testWindow = model.Window{ + Start: time.Date(2026, 7, 3, 0, 0, 0, 0, time.UTC), + End: time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC), +} + +var testCal = model.CalendarRef{AccountID: "ms-acct", CalendarID: "primary"} + +func TestChangesInitialSyncPaging(t *testing.T) { + var reqs []recordedRequest + page := 0 + handler := func(w http.ResponseWriter, r *http.Request) { + page++ + switch page { + case 1: + fmt.Fprintf(w, `{ + "value": [{ + "id": "ev-1", + "iCalUId": "uid-1", + "showAs": "busy", + "isAllDay": false, + "start": {"dateTime": "2026-07-10T09:00:00.0000000", "timeZone": "UTC"}, + "end": {"dateTime": "2026-07-10T10:00:00.0000000", "timeZone": "UTC"}, + "responseStatus": {"response": "organizer"} + }], + "@odata.nextLink": "http://%s/me/calendarView/delta?$skiptoken=p2" + }`, r.Host) + case 2: + fmt.Fprintf(w, `{ + "value": [{ + "id": "ev-2", + "iCalUId": "uid-2", + "showAs": "free", + "isAllDay": false, + "start": {"dateTime": "2026-07-11T09:00:00.0000000", "timeZone": "UTC"}, + "end": {"dateTime": "2026-07-11T10:00:00.0000000", "timeZone": "UTC"}, + "responseStatus": {"response": "accepted"} + }], + "@odata.deltaLink": "http://%s/me/calendarView/delta?$deltatoken=final" + }`, r.Host) + } + } + srv := httptest.NewServer(record(&reqs, handler)) + defer srv.Close() + + c := newTestClient(t, srv.URL, []string{"busy", "oof", "tentative"}) + events, newCursor, err := c.Changes(context.Background(), testCal, "", testWindow) + require.NoError(t, err) + + require.Len(t, reqs, 2) + u0, err := url.Parse(reqs[0].URL) + require.NoError(t, err) + require.Equal(t, "/me/calendarView/delta", u0.Path) + require.Equal(t, "2026-07-03T00:00:00Z", u0.Query().Get("startDateTime")) + require.Equal(t, "2026-10-03T00:00:00Z", u0.Query().Get("endDateTime")) + u1, err := url.Parse(reqs[1].URL) + require.NoError(t, err) + require.Equal(t, "p2", u1.Query().Get("$skiptoken")) + + require.Len(t, events, 2) + require.Equal(t, "ev-1", events[0].ID) + require.Equal(t, "uid-1", events[0].ICalUID) + require.True(t, events[0].IsBusy) + require.Equal(t, time.Date(2026, 7, 10, 9, 0, 0, 0, time.UTC), events[0].StartUTC) + require.Equal(t, time.Date(2026, 7, 10, 10, 0, 0, 0, time.UTC), events[0].EndUTC) + require.Equal(t, "ev-2", events[1].ID) + require.False(t, events[1].IsBusy) // showAs=free は busyShowAs リストに含まれない + + require.Contains(t, newCursor, "$deltatoken=final") // deltaLink がそのまま newCursor + requireCommonHeaders(t, reqs) +} + +func TestChangesIncrementalUsesCursorAsIs(t *testing.T) { + var reqs []recordedRequest + handler := func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, `{"value":[],"@odata.deltaLink":"http://%s/me/calendarView/delta?$deltatoken=next"}`, r.Host) + } + srv := httptest.NewServer(record(&reqs, handler)) + defer srv.Close() + + c := newTestClient(t, srv.URL, []string{"busy"}) + cursor := srv.URL + "/me/calendarView/delta?$deltatoken=prev" + events, newCursor, err := c.Changes(context.Background(), testCal, cursor, testWindow) + require.NoError(t, err) + require.Empty(t, events) + require.Contains(t, newCursor, "$deltatoken=next") + + require.Len(t, reqs, 1) + u, err := url.Parse(reqs[0].URL) + require.NoError(t, err) + require.Equal(t, "prev", u.Query().Get("$deltatoken")) // cursor をそのまま GET + require.Empty(t, u.Query().Get("startDateTime")) // 増分にウィンドウは付けない + requireCommonHeaders(t, reqs) +} + +func TestNormalizeDeltaEvent(t *testing.T) { + busy := map[string]bool{"busy": true, "oof": true, "tentative": true} + tests := []struct { + name string + json string + want model.NormalizedEvent + }{ + { + name: "busy timed event in JST normalized to UTC", + json: `{"id":"ev1","iCalUId":"uid1","showAs":"busy","isAllDay":false, + "start":{"dateTime":"2026-07-10T09:00:00.0000000","timeZone":"Asia/Tokyo"}, + "end":{"dateTime":"2026-07-10T10:00:00.0000000","timeZone":"Asia/Tokyo"}, + "responseStatus":{"response":"accepted"}}`, + want: model.NormalizedEvent{ + ID: "ev1", ICalUID: "uid1", IsBusy: true, + StartUTC: time.Date(2026, 7, 10, 0, 0, 0, 0, time.UTC), + EndUTC: time.Date(2026, 7, 10, 1, 0, 0, 0, time.UTC), + }, + }, + { + name: "showAs=free is not busy", + json: `{"id":"ev2","iCalUId":"uid2","showAs":"free","isAllDay":false, + "start":{"dateTime":"2026-07-10T09:00:00.0000000","timeZone":"UTC"}, + "end":{"dateTime":"2026-07-10T10:00:00.0000000","timeZone":"UTC"}}`, + want: model.NormalizedEvent{ + ID: "ev2", ICalUID: "uid2", IsBusy: false, + StartUTC: time.Date(2026, 7, 10, 9, 0, 0, 0, time.UTC), + EndUTC: time.Date(2026, 7, 10, 10, 0, 0, 0, time.UTC), + }, + }, + { + name: "declined sets IsDeclined", + json: `{"id":"ev3","iCalUId":"uid3","showAs":"busy","isAllDay":false, + "start":{"dateTime":"2026-07-10T09:00:00.0000000","timeZone":"UTC"}, + "end":{"dateTime":"2026-07-10T10:00:00.0000000","timeZone":"UTC"}, + "responseStatus":{"response":"declined"}}`, + want: model.NormalizedEvent{ + ID: "ev3", ICalUID: "uid3", IsBusy: true, IsDeclined: true, + StartUTC: time.Date(2026, 7, 10, 9, 0, 0, 0, time.UTC), + EndUTC: time.Date(2026, 7, 10, 10, 0, 0, 0, time.UTC), + }, + }, + { + name: "all-day keeps local dates", + json: `{"id":"ev4","iCalUId":"uid4","showAs":"busy","isAllDay":true, + "start":{"dateTime":"2026-07-15T00:00:00.0000000","timeZone":"UTC"}, + "end":{"dateTime":"2026-07-16T00:00:00.0000000","timeZone":"UTC"}}`, + want: model.NormalizedEvent{ + ID: "ev4", ICalUID: "uid4", IsBusy: true, IsAllDay: true, + AllDayStart: "2026-07-15", AllDayEnd: "2026-07-16", + }, + }, + { + name: "@removed is Deleted with ID only", + json: `{"id":"gone-1","@removed":{"reason":"deleted"}}`, + want: model.NormalizedEvent{ID: "gone-1", Deleted: true}, + }, + { + name: "isCancelled is Deleted", + json: `{"id":"cancel-1","iCalUId":"uid-c","isCancelled":true,"showAs":"busy"}`, + want: model.NormalizedEvent{ID: "cancel-1", ICalUID: "uid-c", Deleted: true}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var de deltaEvent + require.NoError(t, json.Unmarshal([]byte(tc.json), &de)) + got, err := normalizeDeltaEvent(de, busy) + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } +} + +func TestChangesCursorInvalid(t *testing.T) { + tests := []struct { + name string + status int + body string + wantInvalid bool + }{ + { + name: "HTTP 410 Gone", + status: http.StatusGone, + body: `{"error":{"code":"fullSyncRequired","message":"full sync required"}}`, + wantInvalid: true, + }, + { + name: "404 with syncStateNotFound", + status: http.StatusNotFound, + body: `{"error":{"code":"syncStateNotFound","message":"sync state not found"}}`, + wantInvalid: true, + }, + { + name: "400 with syncStateNotFound", + status: http.StatusBadRequest, + body: `{"error":{"code":"syncStateNotFound","message":"sync state not found"}}`, + wantInvalid: true, + }, + { + name: "403 other error is not ErrCursorInvalid", + status: http.StatusForbidden, + body: `{"error":{"code":"ErrorAccessDenied","message":"denied"}}`, + wantInvalid: false, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var reqs []recordedRequest + handler := func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tc.status) + fmt.Fprint(w, tc.body) + } + srv := httptest.NewServer(record(&reqs, handler)) + defer srv.Close() + + c := newTestClient(t, srv.URL, []string{"busy"}) + _, newCursor, err := c.Changes(context.Background(), testCal, + srv.URL+"/me/calendarView/delta?$deltatoken=stale", testWindow) + require.Error(t, err) + require.Empty(t, newCursor) + if tc.wantInvalid { + require.ErrorIs(t, err, provider.ErrCursorInvalid) + } else { + require.NotErrorIs(t, err, provider.ErrCursorInvalid) + } + requireCommonHeaders(t, reqs) + }) + } +} + +func TestChangesCircuitBreaker(t *testing.T) { + t.Run("identical page repeated", func(t *testing.T) { + var reqs []recordedRequest + handler := func(w http.ResponseWriter, r *http.Request) { + // 常に同じイベントID列 + nextLink を返す(無限ページネーション状態) + fmt.Fprintf(w, `{ + "value": [{"id": "loop-ev", "iCalUId": "uid-loop", "showAs": "busy", "isAllDay": false, + "start": {"dateTime": "2026-07-10T09:00:00.0000000", "timeZone": "UTC"}, + "end": {"dateTime": "2026-07-10T10:00:00.0000000", "timeZone": "UTC"}}], + "@odata.nextLink": "http://%s/me/calendarView/delta?$skiptoken=again" + }`, r.Host) + } + srv := httptest.NewServer(record(&reqs, handler)) + defer srv.Close() + + c := newTestClient(t, srv.URL, []string{"busy"}) + _, _, err := c.Changes(context.Background(), testCal, "", testWindow) + require.ErrorIs(t, err, provider.ErrCursorInvalid) + require.Len(t, reqs, 2) // 2ページ目で「直前ページとID列同一」を検知して中断 + }) + + t.Run("page limit 50", func(t *testing.T) { + var reqs []recordedRequest + n := 0 + handler := func(w http.ResponseWriter, r *http.Request) { + n++ + // 毎ページ異なるIDを返す(フィンガープリント一致では止まらない) + fmt.Fprintf(w, `{ + "value": [{"id": "ev-%d", "iCalUId": "uid-%d", "showAs": "busy", "isAllDay": false, + "start": {"dateTime": "2026-07-10T09:00:00.0000000", "timeZone": "UTC"}, + "end": {"dateTime": "2026-07-10T10:00:00.0000000", "timeZone": "UTC"}}], + "@odata.nextLink": "http://%s/me/calendarView/delta?$skiptoken=p%d" + }`, n, n, r.Host, n+1) + } + srv := httptest.NewServer(record(&reqs, handler)) + defer srv.Close() + + c := newTestClient(t, srv.URL, []string{"busy"}) + _, _, err := c.Changes(context.Background(), testCal, "", testWindow) + require.ErrorIs(t, err, provider.ErrCursorInvalid) + require.Len(t, reqs, 50) // 1ラウンド50ページで打ち切り + }) +} + +func TestChanges429RetryAfter(t *testing.T) { + var reqs []recordedRequest + calls := 0 + handler := func(w http.ResponseWriter, r *http.Request) { + calls++ + if calls == 1 { + w.Header().Set("Retry-After", "1") + w.WriteHeader(http.StatusTooManyRequests) + return + } + fmt.Fprintf(w, `{"value":[],"@odata.deltaLink":"http://%s/me/calendarView/delta?$deltatoken=d1"}`, r.Host) + } + srv := httptest.NewServer(record(&reqs, handler)) + defer srv.Close() + + c := newTestClient(t, srv.URL, []string{"busy"}) + var slept []time.Duration + c.sleep = func(d time.Duration) { slept = append(slept, d) } // 実待機せず記録のみ + + events, newCursor, err := c.Changes(context.Background(), testCal, "", testWindow) + require.NoError(t, err) + require.Empty(t, events) + require.Contains(t, newCursor, "$deltatoken=d1") + require.Len(t, reqs, 2) + require.Equal(t, []time.Duration{1 * time.Second}, slept) // Retry-After: 1 に従った + requireCommonHeaders(t, reqs) +} + +func TestChanges5xxBackoff(t *testing.T) { + var reqs []recordedRequest + calls := 0 + handler := func(w http.ResponseWriter, r *http.Request) { + calls++ + if calls <= 2 { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + fmt.Fprintf(w, `{"value":[],"@odata.deltaLink":"http://%s/me/calendarView/delta?$deltatoken=d2"}`, r.Host) + } + srv := httptest.NewServer(record(&reqs, handler)) + defer srv.Close() + + c := newTestClient(t, srv.URL, []string{"busy"}) + var slept []time.Duration + c.sleep = func(d time.Duration) { slept = append(slept, d) } + + _, newCursor, err := c.Changes(context.Background(), testCal, "", testWindow) + require.NoError(t, err) + require.Contains(t, newCursor, "$deltatoken=d2") + require.Len(t, reqs, 3) + require.Len(t, slept, 2) + require.Greater(t, slept[1], slept[0]) // 指数バックオフで待機が伸びる + requireCommonHeaders(t, reqs) +} + +// TestChanges401AuthExpired はコーディネーター追加要件(Task 14 レビュー由来): +// Graph が HTTP 401 を返した場合、provider.ErrAuthExpired に正規化されることを +// 確認する(common error path = Client.doRead 経由、autherr.go の +// NormalizeAuthErr と揃える)。 +func TestChanges401AuthExpired(t *testing.T) { + var reqs []recordedRequest + handler := func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprint(w, `{"error":{"code":"InvalidAuthenticationToken","message":"access token is expired"}}`) + } + srv := httptest.NewServer(record(&reqs, handler)) + defer srv.Close() + + c := newTestClient(t, srv.URL, []string{"busy"}) + _, newCursor, err := c.Changes(context.Background(), testCal, "", testWindow) + require.Error(t, err) + require.Empty(t, newCursor) + require.ErrorIs(t, err, provider.ErrAuthExpired) + requireCommonHeaders(t, reqs) +} diff --git a/internal/provider/microsoft/microsoft.go b/internal/provider/microsoft/microsoft.go new file mode 100644 index 0000000..742eae5 --- /dev/null +++ b/internal/provider/microsoft/microsoft.go @@ -0,0 +1,173 @@ +// Package microsoft implements the calsync Provider for Microsoft Graph +// (v1.0, delegated permissions, primary calendar only in v1). +// Graph is called directly over net/http; no SDK or MSAL is used. +package microsoft + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "time" + + "golang.org/x/oauth2" + + "github.com/cloudnative-co/calsync/internal/provider" +) + +const defaultBaseURL = "https://graph.microsoft.com/v1.0" + +// originPropertyID is the fixed single-value extended property id that +// carries the calsync origin tag. The GUID is a source-embedded constant +// (design doc 6.3): making it configurable would orphan old tags. +const originPropertyID = "String {b7dbd76c-3a35-4b41-9d80-6a3f31f2a6b9} Name calsyncOrigin" + +// Client is the Microsoft Graph provider implementation. +type Client struct { + hc *http.Client + accountID string + busyShowAs map[string]bool // showAs values treated as busy (config busy_show_as) + baseURL string // replaced with an httptest URL in tests + sleep func(time.Duration) +} + +// New builds a Graph client. busyShowAs is the list of showAs values that +// count as busy (default: busy, oof, tentative). +func New(ts oauth2.TokenSource, accountID string, busyShowAs []string) *Client { + m := make(map[string]bool, len(busyShowAs)) + for _, s := range busyShowAs { + m[s] = true + } + return &Client{ + hc: oauth2.NewClient(context.Background(), ts), + accountID: accountID, + busyShowAs: m, + baseURL: defaultBaseURL, + sleep: time.Sleep, + } +} + +// do sends one Graph request with the mandatory Prefer: IdType="ImmutableId" +// header (never odata.maxpagesize) and the retry policy: +// - 429: wait Retry-After seconds, retry (max 3 retries) +// - 5xx: exponential backoff, retry (max 3 retries) +// +// Any other status is returned to the caller as-is. +func (c *Client) do(ctx context.Context, method, url string, body []byte) (*http.Response, error) { + const maxRetries = 3 + backoff := 500 * time.Millisecond + for attempt := 0; ; attempt++ { + var rd io.Reader + if body != nil { + rd = bytes.NewReader(body) + } + req, err := http.NewRequestWithContext(ctx, method, url, rd) + if err != nil { + return nil, err + } + req.Header.Set("Prefer", `IdType="ImmutableId"`) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := c.hc.Do(req) + if err != nil { + return nil, err + } + switch { + case resp.StatusCode == http.StatusTooManyRequests && attempt < maxRetries: + wait := backoff + if ra := resp.Header.Get("Retry-After"); ra != "" { + if secs, perr := strconv.Atoi(ra); perr == nil && secs >= 0 { + wait = time.Duration(secs) * time.Second + } + } + resp.Body.Close() + c.sleep(wait) + case resp.StatusCode >= 500 && attempt < maxRetries: + resp.Body.Close() + c.sleep(backoff) + backoff *= 2 + default: + return resp, nil + } + } +} + +// doRead runs do and drains/closes the body. +// +// It is also the common error path for authentication-expiry normalization +// (coordinator addendum, Task 14 review): a TokenSource refresh failure +// (invalid_grant / interaction_required) surfaces as an error from +// c.hc.Do inside do and is passed through provider.NormalizeAuthErr; an +// HTTP 401 response from Graph itself is wrapped as provider.ErrAuthExpired +// explicitly, since Graph does not reliably return a machine-readable +// error.code for token expiry. Centralizing this here means every caller +// built on doRead (delta.go today, blocker endpoints later) gets the same +// normalization without repeating the check. +func (c *Client) doRead(ctx context.Context, method, url string, body []byte) (int, []byte, error) { + resp, err := c.do(ctx, method, url, body) + if err != nil { + return 0, nil, provider.NormalizeAuthErr(err) + } + defer resp.Body.Close() + data, err := io.ReadAll(resp.Body) + if err != nil { + return 0, nil, err + } + if resp.StatusCode == http.StatusUnauthorized { + return resp.StatusCode, data, fmt.Errorf("graph: status 401: %w", provider.ErrAuthExpired) + } + return resp.StatusCode, data, nil +} + +// graphErrorCode extracts error.code from a Graph error body ("" if absent). +func graphErrorCode(body []byte) string { + var ge struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(body, &ge); err != nil { + return "" + } + return ge.Error.Code +} + +// graphTime is Graph's dateTimeTimeZone resource. +type graphTime struct { + DateTime string `json:"dateTime"` + TimeZone string `json:"timeZone"` +} + +// utc parses a Graph dateTime (optionally with fractional seconds) in its +// declared timeZone and converts it to UTC. calsync never sends +// Prefer: outlook.timezone, so Graph returns UTC (design doc 6.6); IANA +// names are handled as a fallback via time.LoadLocation. +func (t graphTime) utc() (time.Time, error) { + const layout = "2006-01-02T15:04:05.9999999" + loc := time.UTC + if t.TimeZone != "" && t.TimeZone != "UTC" { + l, err := time.LoadLocation(t.TimeZone) + if err != nil { + return time.Time{}, fmt.Errorf("graph time: unknown timeZone %q: %w", t.TimeZone, err) + } + loc = l + } + parsed, err := time.ParseInLocation(layout, t.DateTime, loc) + if err != nil { + return time.Time{}, fmt.Errorf("graph time: parse %q: %w", t.DateTime, err) + } + return parsed.UTC(), nil +} + +// datePart returns the YYYY-MM-DD prefix of a Graph dateTime string. +func datePart(dt string) (string, error) { + if len(dt) < 10 { + return "", fmt.Errorf("graph time: malformed dateTime %q", dt) + } + return dt[:10], nil +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go new file mode 100644 index 0000000..af66114 --- /dev/null +++ b/internal/provider/provider.go @@ -0,0 +1,32 @@ +package provider + +import ( + "context" + "errors" + + "github.com/cloudnative-co/calsync/internal/model" +) + +var ( + ErrCursorInvalid = errors.New("sync cursor invalidated") // Google 410 / Graph 410・syncStateNotFound系 + ErrAuthExpired = errors.New("authentication expired") // invalid_grant / interaction_required 系 + ErrNotFound = errors.New("event not found") +) + +type Provider interface { + // cursor=="" ならウィンドウ付きフル同期。newCursor は完走時のみ非空。 + // ウィンドウ外イベントも返りうる(エンジン側でフィルタ)。 + Changes(ctx context.Context, cal model.CalendarRef, cursor string, window model.Window) (events []model.NormalizedEvent, newCursor string, err error) + + // idemKey: Google はクライアント生成イベントID / Graph は transactionId。 + // 既に同キーで作成済みの場合もエラーにせず既存IDを返すこと。 + CreateBlocker(ctx context.Context, cal model.CalendarRef, b model.Blocker, idemKey string) (eventID string, err error) + UpdateBlocker(ctx context.Context, cal model.CalendarRef, eventID string, b model.Blocker) error + DeleteBlocker(ctx context.Context, cal model.CalendarRef, eventID string) error // 404 は成功扱い + + // calsync タグ付きイベントの列挙(リコンサイル・再構築用) + ListBlockers(ctx context.Context, cal model.CalendarRef, window model.Window) ([]model.BlockerRecord, error) + + // 終日ブロッカー用のタイムゾーン取得(Google: IANA / Graph: mailboxSettings の値をそのまま) + GetCalendarTimezone(ctx context.Context, cal model.CalendarRef) (string, error) +} diff --git a/internal/store/events.go b/internal/store/events.go new file mode 100644 index 0000000..fdc1161 --- /dev/null +++ b/internal/store/events.go @@ -0,0 +1,136 @@ +package store + +import ( + "database/sql" + "errors" + "time" + + "github.com/cloudnative-co/calsync/internal/model" +) + +func boolToInt(b bool) int { + if b { + return 1 + } + return 0 +} + +// UpsertEvent は busy イベントをキャッシュに upsert する。 +// time_hash は model.TimeHash で導出して保存する。 +func (s *Store) UpsertEvent(ref model.CalendarRef, ev model.NormalizedEvent) error { + _, err := s.db.Exec(` +INSERT INTO events (account_id, calendar_id, event_id, ical_uid, start_utc, end_utc, + is_all_day, all_day_start, all_day_end, time_hash) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT (account_id, calendar_id, event_id) DO UPDATE SET + ical_uid = excluded.ical_uid, + start_utc = excluded.start_utc, + end_utc = excluded.end_utc, + is_all_day = excluded.is_all_day, + all_day_start = excluded.all_day_start, + all_day_end = excluded.all_day_end, + time_hash = excluded.time_hash`, + ref.AccountID, ref.CalendarID, ev.ID, ev.ICalUID, + ev.StartUTC.UTC().Unix(), ev.EndUTC.UTC().Unix(), + boolToInt(ev.IsAllDay), ev.AllDayStart, ev.AllDayEnd, + model.TimeHash(ev)) + return err +} + +// GetEvent はキャッシュ行を NormalizedEvent に復元して返す。未存在は (nil, nil)。 +// キャッシュには busy イベントのみ入る契約のため IsBusy=true で復元する。 +func (s *Store) GetEvent(ref model.CalendarRef, eventID string) (*model.NormalizedEvent, error) { + row := s.db.QueryRow(` +SELECT ical_uid, start_utc, end_utc, is_all_day, all_day_start, all_day_end +FROM events WHERE account_id = ? AND calendar_id = ? AND event_id = ?`, + ref.AccountID, ref.CalendarID, eventID) + var ( + icalUID, adStart, adEnd sql.NullString + startUTC, endUTC sql.NullInt64 + isAllDay int + ) + err := row.Scan(&icalUID, &startUTC, &endUTC, &isAllDay, &adStart, &adEnd) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + ev := &model.NormalizedEvent{ + ID: eventID, + ICalUID: icalUID.String, + IsAllDay: isAllDay != 0, + AllDayStart: adStart.String, + AllDayEnd: adEnd.String, + IsBusy: true, + } + if startUTC.Valid { + ev.StartUTC = time.Unix(startUTC.Int64, 0).UTC() + } + if endUTC.Valid { + ev.EndUTC = time.Unix(endUTC.Int64, 0).UTC() + } + return ev, nil +} + +// DeleteEvent はキャッシュ行を削除する。未存在でもエラーにしない(冪等)。 +func (s *Store) DeleteEvent(ref model.CalendarRef, eventID string) error { + _, err := s.db.Exec(` +DELETE FROM events WHERE account_id = ? AND calendar_id = ? AND event_id = ?`, + ref.AccountID, ref.CalendarID, eventID) + return err +} + +// ListEventIDs はカレンダーのキャッシュ済みイベント ID を昇順で返す +// (リコンサイルの set-difference 用)。 +func (s *Store) ListEventIDs(ref model.CalendarRef) ([]string, error) { + rows, err := s.db.Query(` +SELECT event_id FROM events WHERE account_id = ? AND calendar_id = ? ORDER BY event_id`, + ref.AccountID, ref.CalendarID) + if err != nil { + return nil, err + } + defer rows.Close() + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + return ids, rows.Err() +} + +// DeleteEventsForAccount はアカウントの全キャッシュを削除する(accounts remove / フル再同期用)。 +func (s *Store) DeleteEventsForAccount(accountID string) error { + _, err := s.db.Exec(`DELETE FROM events WHERE account_id = ?`, accountID) + return err +} + +// HasBusyEventByICalUID はターゲットアカウントに「同一会議の実予定」があるかを返す(仕様書 6.5)。 +// allDayStart が非空なら ical_uid + all_day_start 一致、そうでなければ ical_uid + start_utc 一致で判定する。 +func (s *Store) HasBusyEventByICalUID(accountID, icalUID string, startUTC time.Time, allDayStart string) (bool, error) { + if icalUID == "" { + return false, nil + } + var ( + n int + err error + ) + if allDayStart != "" { + err = s.db.QueryRow(` +SELECT COUNT(1) FROM events +WHERE account_id = ? AND ical_uid = ? AND all_day_start = ?`, + accountID, icalUID, allDayStart).Scan(&n) + } else { + err = s.db.QueryRow(` +SELECT COUNT(1) FROM events +WHERE account_id = ? AND ical_uid = ? AND start_utc = ?`, + accountID, icalUID, startUTC.UTC().Unix()).Scan(&n) + } + if err != nil { + return false, err + } + return n > 0, nil +} diff --git a/internal/store/events_test.go b/internal/store/events_test.go new file mode 100644 index 0000000..aed0bea --- /dev/null +++ b/internal/store/events_test.go @@ -0,0 +1,159 @@ +package store + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/cloudnative-co/calsync/internal/model" +) + +// timedEvent は時刻指定の busy イベントを作るヘルパ(Task 5 のテストも使用)。 +func timedEvent(id, icalUID string, start time.Time, d time.Duration) model.NormalizedEvent { + return model.NormalizedEvent{ + ID: id, + ICalUID: icalUID, + StartUTC: start, + EndUTC: start.Add(d), + IsBusy: true, + } +} + +// allDayEvent は終日 busy イベントを作るヘルパ(end は排他的終了日)。 +func allDayEvent(id, icalUID, start, end string) model.NormalizedEvent { + return model.NormalizedEvent{ + ID: id, + ICalUID: icalUID, + IsAllDay: true, + AllDayStart: start, + AllDayEnd: end, + IsBusy: true, + } +} + +func TestEvents_UpsertGetDelete(t *testing.T) { + s := mustOpen(t) + ref := model.CalendarRef{AccountID: "acct-a", CalendarID: "primary"} + start := time.Date(2026, 7, 10, 9, 0, 0, 0, time.UTC) + ev := timedEvent("ev1", "uid-1@example.com", start, time.Hour) + + // 未存在は (nil, nil) + got, err := s.GetEvent(ref, "ev1") + require.NoError(t, err) + require.Nil(t, got) + + require.NoError(t, s.UpsertEvent(ref, ev)) + got, err = s.GetEvent(ref, "ev1") + require.NoError(t, err) + require.NotNil(t, got) + require.Equal(t, "ev1", got.ID) + require.Equal(t, "uid-1@example.com", got.ICalUID) + require.True(t, got.StartUTC.Equal(start)) + require.True(t, got.EndUTC.Equal(start.Add(time.Hour))) + require.False(t, got.IsAllDay) + require.True(t, got.IsBusy) // キャッシュには busy イベントのみ入る契約 + + // upsert: 同一キーで時刻を更新できる + moved := timedEvent("ev1", "uid-1@example.com", start.Add(2*time.Hour), time.Hour) + require.NoError(t, s.UpsertEvent(ref, moved)) + got, err = s.GetEvent(ref, "ev1") + require.NoError(t, err) + require.True(t, got.StartUTC.Equal(start.Add(2*time.Hour))) + + ids, err := s.ListEventIDs(ref) + require.NoError(t, err) + require.Equal(t, []string{"ev1"}, ids) + + require.NoError(t, s.DeleteEvent(ref, "ev1")) + got, err = s.GetEvent(ref, "ev1") + require.NoError(t, err) + require.Nil(t, got) + + // 存在しない ID の削除はエラーにならない(冪等) + require.NoError(t, s.DeleteEvent(ref, "ev1")) +} + +func TestEvents_AllDayRoundTrip(t *testing.T) { + s := mustOpen(t) + ref := model.CalendarRef{AccountID: "acct-a", CalendarID: "primary"} + ev := allDayEvent("ev-allday", "uid-ad@example.com", "2026-07-20", "2026-07-21") + + require.NoError(t, s.UpsertEvent(ref, ev)) + got, err := s.GetEvent(ref, "ev-allday") + require.NoError(t, err) + require.NotNil(t, got) + require.True(t, got.IsAllDay) + require.Equal(t, "2026-07-20", got.AllDayStart) + require.Equal(t, "2026-07-21", got.AllDayEnd) +} + +func TestEvents_ListIDsAndDeleteForAccount(t *testing.T) { + s := mustOpen(t) + refA1 := model.CalendarRef{AccountID: "acct-a", CalendarID: "primary"} + refA2 := model.CalendarRef{AccountID: "acct-a", CalendarID: "team"} + refB := model.CalendarRef{AccountID: "acct-b", CalendarID: "primary"} + start := time.Date(2026, 7, 10, 9, 0, 0, 0, time.UTC) + + require.NoError(t, s.UpsertEvent(refA1, timedEvent("a1-ev2", "u1", start, time.Hour))) + require.NoError(t, s.UpsertEvent(refA1, timedEvent("a1-ev1", "u2", start, time.Hour))) + require.NoError(t, s.UpsertEvent(refA2, timedEvent("a2-ev1", "u3", start, time.Hour))) + require.NoError(t, s.UpsertEvent(refB, timedEvent("b-ev1", "u4", start, time.Hour))) + + // event_id 昇順、他カレンダー・他アカウントは混ざらない + ids, err := s.ListEventIDs(refA1) + require.NoError(t, err) + require.Equal(t, []string{"a1-ev1", "a1-ev2"}, ids) + + ids, err = s.ListEventIDs(refB) + require.NoError(t, err) + require.Equal(t, []string{"b-ev1"}, ids) + + // アカウント単位削除は全カレンダーに及び、他アカウントに触れない + require.NoError(t, s.DeleteEventsForAccount("acct-a")) + ids, err = s.ListEventIDs(refA1) + require.NoError(t, err) + require.Empty(t, ids) + ids, err = s.ListEventIDs(refA2) + require.NoError(t, err) + require.Empty(t, ids) + ids, err = s.ListEventIDs(refB) + require.NoError(t, err) + require.Equal(t, []string{"b-ev1"}, ids) +} + +func TestHasBusyEventByICalUID(t *testing.T) { + s := mustOpen(t) + refB := model.CalendarRef{AccountID: "acct-b", CalendarID: "primary"} + refC := model.CalendarRef{AccountID: "acct-c", CalendarID: "primary"} + start := time.Date(2026, 7, 10, 9, 0, 0, 0, time.UTC) + + require.NoError(t, s.UpsertEvent(refB, timedEvent("b-timed", "meet-1", start, time.Hour))) + require.NoError(t, s.UpsertEvent(refB, allDayEvent("b-allday", "ad-1", "2026-07-20", "2026-07-21"))) + require.NoError(t, s.UpsertEvent(refC, timedEvent("c-timed", "meet-2", start, time.Hour))) + + cases := []struct { + name string + accountID string + icalUID string + startUTC time.Time + allDayStart string + want bool + }{ + {"時刻指定: ical_uid+start_utc 一致でヒット", "acct-b", "meet-1", start, "", true}, + {"時刻指定: start_utc 不一致はヒットしない", "acct-b", "meet-1", start.Add(time.Hour), "", false}, + {"時刻指定: 未知の ical_uid はヒットしない", "acct-b", "no-such-uid", start, "", false}, + {"時刻指定: 他アカウントのイベントはヒットしない", "acct-b", "meet-2", start, "", false}, + {"終日: ical_uid+all_day_start 一致でヒット", "acct-b", "ad-1", time.Time{}, "2026-07-20", true}, + {"終日: 日付不一致はヒットしない", "acct-b", "ad-1", time.Time{}, "2026-07-21", false}, + {"終日: allDayStart 指定時は時刻指定イベントにヒットしない", "acct-b", "meet-1", start, "2026-07-10", false}, + {"空の ical_uid は常に false", "acct-b", "", start, "", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := s.HasBusyEventByICalUID(tc.accountID, tc.icalUID, tc.startUTC, tc.allDayStart) + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } +} diff --git a/internal/store/mappings.go b/internal/store/mappings.go new file mode 100644 index 0000000..04b1f6a --- /dev/null +++ b/internal/store/mappings.go @@ -0,0 +1,168 @@ +package store + +import ( + "database/sql" + "errors" +) + +// Mapping は origin 予定 1 件 → ターゲットアカウント 1 件のブロッカー対応(仕様書 7 章)。 +type Mapping struct { + OriginAccount, OriginCalendar, OriginEventID string + TargetAccount, TargetCalendar string + BlockerEventID string // pending/suppressed 中は "" + IdempotencyKey string + TimeHash string + Status string // StatusPending / StatusActive / StatusSuppressed +} + +const ( + StatusPending = "pending" + StatusActive = "active" + StatusSuppressed = "suppressed" +) + +// SELECT 列の共通定義。blocker_event_id は NULL を "" に正規化して読む。 +const mappingColumns = `origin_account, origin_calendar, origin_event_id, +target_account, target_calendar, COALESCE(blocker_event_id, ''), idempotency_key, time_hash, status` + +func scanMapping(sc interface{ Scan(...any) error }) (Mapping, error) { + var m Mapping + err := sc.Scan(&m.OriginAccount, &m.OriginCalendar, &m.OriginEventID, + &m.TargetAccount, &m.TargetCalendar, &m.BlockerEventID, + &m.IdempotencyKey, &m.TimeHash, &m.Status) + return m, err +} + +func (s *Store) queryMappings(query string, args ...any) ([]Mapping, error) { + rows, err := s.db.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Mapping + for rows.Next() { + m, err := scanMapping(rows) + if err != nil { + return nil, err + } + out = append(out, m) + } + return out, rows.Err() +} + +// PutMapping は mapping を upsert する。BlockerEventID=="" は NULL として保存する +// (IsBlocker が空 ID にヒットしないことの担保)。 +func (s *Store) PutMapping(m Mapping) error { + var blockerID any + if m.BlockerEventID != "" { + blockerID = m.BlockerEventID + } + _, err := s.db.Exec(` +INSERT INTO mappings (origin_account, origin_calendar, origin_event_id, + target_account, target_calendar, blocker_event_id, + idempotency_key, time_hash, status) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT (origin_account, origin_calendar, origin_event_id, target_account) DO UPDATE SET + target_calendar = excluded.target_calendar, + blocker_event_id = excluded.blocker_event_id, + idempotency_key = excluded.idempotency_key, + time_hash = excluded.time_hash, + status = excluded.status`, + m.OriginAccount, m.OriginCalendar, m.OriginEventID, + m.TargetAccount, m.TargetCalendar, blockerID, + m.IdempotencyKey, m.TimeHash, m.Status) + return err +} + +// GetMapping は主キー一致の 1 行を返す。未存在は (nil, nil)。 +func (s *Store) GetMapping(originAcct, originCal, originEventID, targetAcct string) (*Mapping, error) { + row := s.db.QueryRow(` +SELECT `+mappingColumns+` +FROM mappings +WHERE origin_account = ? AND origin_calendar = ? AND origin_event_id = ? AND target_account = ?`, + originAcct, originCal, originEventID, targetAcct) + m, err := scanMapping(row) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &m, nil +} + +// DeleteMapping は主キー一致の 1 行を削除する。未存在でもエラーにしない(冪等)。 +func (s *Store) DeleteMapping(originAcct, originCal, originEventID, targetAcct string) error { + _, err := s.db.Exec(` +DELETE FROM mappings +WHERE origin_account = ? AND origin_calendar = ? AND origin_event_id = ? AND target_account = ?`, + originAcct, originCal, originEventID, targetAcct) + return err +} + +// ListMappingsForOrigin は 1 origin イベントの全ターゲット行を返す(ブロッカー一斉削除用)。 +func (s *Store) ListMappingsForOrigin(originAcct, originCal, originEventID string) ([]Mapping, error) { + return s.queryMappings(` +SELECT `+mappingColumns+` +FROM mappings +WHERE origin_account = ? AND origin_calendar = ? AND origin_event_id = ? +ORDER BY target_account`, originAcct, originCal, originEventID) +} + +// ListMappingsWhereOriginAccount は origin がそのアカウントの全行を返す(accounts remove: 配布済み削除用)。 +func (s *Store) ListMappingsWhereOriginAccount(accountID string) ([]Mapping, error) { + return s.queryMappings(` +SELECT `+mappingColumns+` +FROM mappings WHERE origin_account = ? +ORDER BY origin_calendar, origin_event_id, target_account`, accountID) +} + +// ListMappingsWhereTargetAccount は target がそのアカウントの全行を返す(accounts remove: 受領削除用)。 +func (s *Store) ListMappingsWhereTargetAccount(accountID string) ([]Mapping, error) { + return s.queryMappings(` +SELECT `+mappingColumns+` +FROM mappings WHERE target_account = ? +ORDER BY origin_account, origin_calendar, origin_event_id`, accountID) +} + +// IsBlocker はループ防止の一次判定(仕様書 6.3): 受信イベント ID が自作ブロッカーとして +// 登録済みかを返す。blocker_event_id が空(NULL)の行にはヒットしない。 +func (s *Store) IsBlocker(targetAcct, eventID string) (bool, error) { + if eventID == "" { + return false, nil + } + var n int + err := s.db.QueryRow(` +SELECT COUNT(1) FROM mappings WHERE target_account = ? AND blocker_event_id = ?`, + targetAcct, eventID).Scan(&n) + if err != nil { + return false, err + } + return n > 0, nil +} + +// ListPendingMappings は pending のまま残った行を返す(リコンサイルの解決対象。仕様書 6.4)。 +func (s *Store) ListPendingMappings() ([]Mapping, error) { + return s.queryMappings(` +SELECT `+mappingColumns+` +FROM mappings WHERE status = ? +ORDER BY origin_account, origin_calendar, origin_event_id, target_account`, StatusPending) +} + +// ListSuppressedByOriginICalUID は suppressed 昇格用(仕様書 6.5): ターゲットの実予定が +// 消えたとき、その iCalUID を origin 側イベントキャッシュと JOIN して逆引きする。 +// origin キャッシュに ical_uid 一致のイベントが実在する suppressed 行だけを返す。 +func (s *Store) ListSuppressedByOriginICalUID(targetAcct, icalUID string) ([]Mapping, error) { + return s.queryMappings(` +SELECT m.origin_account, m.origin_calendar, m.origin_event_id, + m.target_account, m.target_calendar, COALESCE(m.blocker_event_id, ''), + m.idempotency_key, m.time_hash, m.status +FROM mappings m +JOIN events e + ON e.account_id = m.origin_account + AND e.calendar_id = m.origin_calendar + AND e.event_id = m.origin_event_id +WHERE m.target_account = ? AND m.status = ? AND e.ical_uid = ? +ORDER BY m.origin_account, m.origin_calendar, m.origin_event_id`, + targetAcct, StatusSuppressed, icalUID) +} diff --git a/internal/store/mappings_test.go b/internal/store/mappings_test.go new file mode 100644 index 0000000..1a17b2d --- /dev/null +++ b/internal/store/mappings_test.go @@ -0,0 +1,160 @@ +package store + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/cloudnative-co/calsync/internal/model" +) + +func mkMapping(originAcct, originEventID, targetAcct, status, blockerID string) Mapping { + return Mapping{ + OriginAccount: originAcct, + OriginCalendar: "primary", + OriginEventID: originEventID, + TargetAccount: targetAcct, + TargetCalendar: "primary", + BlockerEventID: blockerID, + IdempotencyKey: "idem-" + originAcct + "-" + originEventID + "-" + targetAcct, + TimeHash: "1111222233334444", + Status: status, + } +} + +func TestMappings_PutGetDelete(t *testing.T) { + s := mustOpen(t) + + // 未存在は (nil, nil) + got, err := s.GetMapping("acct-a", "primary", "ev1", "acct-b") + require.NoError(t, err) + require.Nil(t, got) + + m := mkMapping("acct-a", "ev1", "acct-b", StatusPending, "") + require.NoError(t, s.PutMapping(m)) + + got, err = s.GetMapping("acct-a", "primary", "ev1", "acct-b") + require.NoError(t, err) + require.NotNil(t, got) + require.Equal(t, m, *got) // BlockerEventID は "" のまま往復する + + // upsert: pending → active + blocker_event_id 記録(仕様書 6.4 の状態遷移) + m.BlockerEventID = "blocker-1" + m.Status = StatusActive + require.NoError(t, s.PutMapping(m)) + got, err = s.GetMapping("acct-a", "primary", "ev1", "acct-b") + require.NoError(t, err) + require.NotNil(t, got) + require.Equal(t, "blocker-1", got.BlockerEventID) + require.Equal(t, StatusActive, got.Status) + + require.NoError(t, s.DeleteMapping("acct-a", "primary", "ev1", "acct-b")) + got, err = s.GetMapping("acct-a", "primary", "ev1", "acct-b") + require.NoError(t, err) + require.Nil(t, got) + + // 存在しない行の削除はエラーにならない(冪等) + require.NoError(t, s.DeleteMapping("acct-a", "primary", "ev1", "acct-b")) +} + +func TestMappings_ListQueries(t *testing.T) { + s := mustOpen(t) + m1 := mkMapping("acct-a", "ev1", "acct-b", StatusActive, "blk-ab-1") + m2 := mkMapping("acct-a", "ev1", "acct-c", StatusActive, "blk-ac-1") + m3 := mkMapping("acct-a", "ev2", "acct-b", StatusPending, "") + m4 := mkMapping("acct-b", "ev9", "acct-a", StatusActive, "blk-ba-9") + for _, m := range []Mapping{m1, m2, m3, m4} { + require.NoError(t, s.PutMapping(m)) + } + + cases := []struct { + name string + list func() ([]Mapping, error) + want []Mapping + }{ + { + name: "ListMappingsForOrigin は 1 origin イベントの全ターゲット行を返す", + list: func() ([]Mapping, error) { return s.ListMappingsForOrigin("acct-a", "primary", "ev1") }, + want: []Mapping{m1, m2}, // target_account 昇順 + }, + { + name: "ListMappingsWhereOriginAccount は origin がそのアカウントの全行を返す", + list: func() ([]Mapping, error) { return s.ListMappingsWhereOriginAccount("acct-a") }, + want: []Mapping{m1, m2, m3}, // origin_calendar, origin_event_id, target_account 昇順 + }, + { + name: "ListMappingsWhereTargetAccount は target がそのアカウントの全行を返す", + list: func() ([]Mapping, error) { return s.ListMappingsWhereTargetAccount("acct-b") }, + want: []Mapping{m1, m3}, // origin_account, origin_calendar, origin_event_id 昇順 + }, + { + name: "ListPendingMappings は pending 行のみ返す", + list: func() ([]Mapping, error) { return s.ListPendingMappings() }, + want: []Mapping{m3}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := tc.list() + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } +} + +func TestIsBlocker(t *testing.T) { + s := mustOpen(t) + require.NoError(t, s.PutMapping(mkMapping("acct-a", "ev1", "acct-b", StatusActive, "blk-1"))) + require.NoError(t, s.PutMapping(mkMapping("acct-a", "ev2", "acct-b", StatusPending, ""))) + + cases := []struct { + name string + targetAcct string + eventID string + want bool + }{ + {"登録済みブロッカー ID はヒット", "acct-b", "blk-1", true}, + {"別アカウントの検索ではヒットしない", "acct-c", "blk-1", false}, + {"未知の ID はヒットしない", "acct-b", "no-such", false}, + {"空文字は blocker_event_id が空(NULL)の行にヒットしない", "acct-b", "", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := s.IsBlocker(tc.targetAcct, tc.eventID) + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } +} + +func TestListSuppressedByOriginICalUID(t *testing.T) { + s := mustOpen(t) + origin := model.CalendarRef{AccountID: "acct-a", CalendarID: "primary"} + start := time.Date(2026, 7, 10, 9, 0, 0, 0, time.UTC) + + // origin 側イベントキャッシュ(ev-s2 は意図的にキャッシュしない) + require.NoError(t, s.UpsertEvent(origin, timedEvent("ev-s1", "meet-1", start, time.Hour))) + require.NoError(t, s.UpsertEvent(origin, timedEvent("ev-s3", "other-uid", start, time.Hour))) + require.NoError(t, s.UpsertEvent(origin, timedEvent("ev-s4", "meet-1", start, time.Hour))) + + suppressed := mkMapping("acct-a", "ev-s1", "acct-b", StatusSuppressed, "") + require.NoError(t, s.PutMapping(suppressed)) + // origin キャッシュに無い suppressed → 返らない + require.NoError(t, s.PutMapping(mkMapping("acct-a", "ev-s2", "acct-b", StatusSuppressed, ""))) + // iCalUID 不一致の suppressed → 返らない + require.NoError(t, s.PutMapping(mkMapping("acct-a", "ev-s3", "acct-b", StatusSuppressed, ""))) + // 別ターゲットの suppressed → 返らない + require.NoError(t, s.PutMapping(mkMapping("acct-a", "ev-s1", "acct-c", StatusSuppressed, ""))) + // iCalUID は一致するが active → 返らない + require.NoError(t, s.PutMapping(mkMapping("acct-a", "ev-s4", "acct-b", StatusActive, "blk-4"))) + + got, err := s.ListSuppressedByOriginICalUID("acct-b", "meet-1") + require.NoError(t, err) + require.Equal(t, []Mapping{suppressed}, got) + + // 該当なしは空 + got, err = s.ListSuppressedByOriginICalUID("acct-b", "no-such-uid") + require.NoError(t, err) + require.Empty(t, got) +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..0409cb8 --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,296 @@ +// Package store は calsync のステートストア(SQLite 1 ファイル + WAL + flock)を提供する。 +package store + +import ( + "database/sql" + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/gofrs/flock" + _ "modernc.org/sqlite" // driver name "sqlite" を登録 + + "github.com/cloudnative-co/calsync/internal/model" +) + +// ErrLocked は別の calsync プロセスがデータディレクトリを掴んでいる場合に Open が返す。 +var ErrLocked = errors.New("data directory is locked by another calsync process") + +// Store は SQLite に裏付けられたステートストア。単一プロセス前提(flock で保証)。 +type Store struct { + db *sql.DB + lock *flock.Flock +} + +// スキーマは仕様書 7 章 + events への all_day_start/all_day_end TEXT 追加(計画側補正)。 +const schema = ` +CREATE TABLE IF NOT EXISTS calendars ( + account_id TEXT NOT NULL, + calendar_id TEXT NOT NULL, + cursor TEXT, + window_start INTEGER, + window_end INTEGER, + timezone TEXT, + last_synced_at INTEGER, + last_error TEXT, + PRIMARY KEY (account_id, calendar_id) +); + +CREATE TABLE IF NOT EXISTS events ( + account_id TEXT NOT NULL, + calendar_id TEXT NOT NULL, + event_id TEXT NOT NULL, + ical_uid TEXT, + start_utc INTEGER, + end_utc INTEGER, + is_all_day INTEGER NOT NULL DEFAULT 0, + all_day_start TEXT, + all_day_end TEXT, + time_hash TEXT NOT NULL, + PRIMARY KEY (account_id, calendar_id, event_id) +); +CREATE INDEX IF NOT EXISTS idx_events_icaluid ON events (ical_uid, start_utc); + +CREATE TABLE IF NOT EXISTS mappings ( + origin_account TEXT NOT NULL, + origin_calendar TEXT NOT NULL, + origin_event_id TEXT NOT NULL, + target_account TEXT NOT NULL, + target_calendar TEXT NOT NULL, + blocker_event_id TEXT, + idempotency_key TEXT NOT NULL, + time_hash TEXT NOT NULL, + status TEXT NOT NULL, + PRIMARY KEY (origin_account, origin_calendar, origin_event_id, target_account) +); +CREATE INDEX IF NOT EXISTS idx_mappings_blocker ON mappings (target_account, blocker_event_id); +` + +// Open はデータディレクトリの flock を取得し(失敗は ErrLocked)、 +// WAL モードで SQLite を開いてスキーマを migrate する。 +func Open(dataDir string) (*Store, error) { + if err := os.MkdirAll(dataDir, 0o700); err != nil { + return nil, fmt.Errorf("create data dir: %w", err) + } + lock := flock.New(filepath.Join(dataDir, "calsync.lock")) + locked, err := lock.TryLock() + if err != nil { + return nil, fmt.Errorf("acquire lock: %w", err) + } + if !locked { + return nil, ErrLocked + } + // _pragma はコネクションごとに適用される(database/sql のプール対策)。 + dsn := "file:" + filepath.Join(dataDir, "calsync.db") + + "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)" + db, err := sql.Open("sqlite", dsn) + if err != nil { + _ = lock.Unlock() + return nil, fmt.Errorf("open sqlite: %w", err) + } + // SQLite は単一ライター。プール多重化による SQLITE_BUSY を避ける。 + db.SetMaxOpenConns(1) + + var mode string + if err := db.QueryRow("PRAGMA journal_mode").Scan(&mode); err != nil { + _ = db.Close() + _ = lock.Unlock() + return nil, fmt.Errorf("check journal mode: %w", err) + } + if mode != "wal" { + _ = db.Close() + _ = lock.Unlock() + return nil, fmt.Errorf("journal_mode is %q, want wal", mode) + } + if _, err := db.Exec(schema); err != nil { + _ = db.Close() + _ = lock.Unlock() + return nil, fmt.Errorf("migrate schema: %w", err) + } + return &Store{db: db, lock: lock}, nil +} + +// OpenReadOnly は flock を取らずに読み取り専用で SQLite を開く。 +// status / doctor 用: WAL は並行リーダーを許すため、稼働中のデーモンと共存できる。 +func OpenReadOnly(dataDir string) (*Store, error) { + dsn := "file:" + filepath.Join(dataDir, "calsync.db") + + "?mode=ro&_pragma=busy_timeout(5000)" + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, fmt.Errorf("open sqlite read-only: %w", err) + } + db.SetMaxOpenConns(1) + // sql.Open は遅延接続のため、DB ファイル欠如をここで検知する。 + if err := db.Ping(); err != nil { + _ = db.Close() + return nil, fmt.Errorf("open sqlite read-only: %w", err) + } + return &Store{db: db}, nil +} + +// Close は DB を閉じ、flock を保持していれば解放する(読み取り専用時は lock が nil)。 +func (s *Store) Close() error { + dbErr := s.db.Close() + var unlockErr error + if s.lock != nil { + unlockErr = s.lock.Unlock() + } + if dbErr != nil { + return dbErr + } + return unlockErr +} + +// CalendarState は calendars テーブルの 1 行。 +type CalendarState struct { + Ref model.CalendarRef + Cursor string + Window model.Window + Timezone string + LastSyncedAt time.Time + LastError string +} + +// UpsertCalendar は行が無ければ作る(あれば何もしない)。冪等。 +func (s *Store) UpsertCalendar(ref model.CalendarRef) error { + _, err := s.db.Exec(` +INSERT INTO calendars (account_id, calendar_id) VALUES (?, ?) +ON CONFLICT (account_id, calendar_id) DO NOTHING`, + ref.AccountID, ref.CalendarID) + return err +} + +// GetCalendar は 1 行を返す。未存在は (nil, nil)。 +func (s *Store) GetCalendar(ref model.CalendarRef) (*CalendarState, error) { + row := s.db.QueryRow(` +SELECT cursor, window_start, window_end, timezone, last_synced_at, last_error +FROM calendars WHERE account_id = ? AND calendar_id = ?`, + ref.AccountID, ref.CalendarID) + var ( + cursor, tz, lastError sql.NullString + wStart, wEnd, lastSynced sql.NullInt64 + ) + err := row.Scan(&cursor, &wStart, &wEnd, &tz, &lastSynced, &lastError) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + st := &CalendarState{ + Ref: ref, + Cursor: cursor.String, + Timezone: tz.String, + LastError: lastError.String, + } + if wStart.Valid { + st.Window.Start = time.Unix(wStart.Int64, 0).UTC() + } + if wEnd.Valid { + st.Window.End = time.Unix(wEnd.Int64, 0).UTC() + } + if lastSynced.Valid { + st.LastSyncedAt = time.Unix(lastSynced.Int64, 0).UTC() + } + return st, nil +} + +// ListCalendars は全行を (account_id, calendar_id) 昇順で返す。 +func (s *Store) ListCalendars() ([]CalendarState, error) { + rows, err := s.db.Query(` +SELECT account_id, calendar_id, cursor, window_start, window_end, timezone, last_synced_at, last_error +FROM calendars ORDER BY account_id, calendar_id`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []CalendarState + for rows.Next() { + var ( + acct, cal string + cursor, tz, lastError sql.NullString + wStart, wEnd, lastSynced sql.NullInt64 + ) + if err := rows.Scan(&acct, &cal, &cursor, &wStart, &wEnd, &tz, &lastSynced, &lastError); err != nil { + return nil, err + } + st := CalendarState{ + Ref: model.CalendarRef{AccountID: acct, CalendarID: cal}, + Cursor: cursor.String, + Timezone: tz.String, + LastError: lastError.String, + } + if wStart.Valid { + st.Window.Start = time.Unix(wStart.Int64, 0).UTC() + } + if wEnd.Valid { + st.Window.End = time.Unix(wEnd.Int64, 0).UTC() + } + if lastSynced.Valid { + st.LastSyncedAt = time.Unix(lastSynced.Int64, 0).UTC() + } + out = append(out, st) + } + return out, rows.Err() +} + +// SetCursor はカーソルとカーソル確立時のウィンドウを保存する(行が無ければ作る)。 +func (s *Store) SetCursor(ref model.CalendarRef, cursor string, w model.Window) error { + _, err := s.db.Exec(` +INSERT INTO calendars (account_id, calendar_id, cursor, window_start, window_end) +VALUES (?, ?, ?, ?, ?) +ON CONFLICT (account_id, calendar_id) DO UPDATE SET + cursor = excluded.cursor, + window_start = excluded.window_start, + window_end = excluded.window_end`, + ref.AccountID, ref.CalendarID, cursor, w.Start.Unix(), w.End.Unix()) + return err +} + +// ClearCursor はカーソルとウィンドウを NULL に戻す(フル再同期のトリガー)。 +func (s *Store) ClearCursor(ref model.CalendarRef) error { + _, err := s.db.Exec(` +UPDATE calendars SET cursor = NULL, window_start = NULL, window_end = NULL +WHERE account_id = ? AND calendar_id = ?`, + ref.AccountID, ref.CalendarID) + return err +} + +// SetCalendarTimezone は終日ブロッカー作成用のタイムゾーンをキャッシュする(行が無ければ作る)。 +func (s *Store) SetCalendarTimezone(ref model.CalendarRef, tz string) error { + _, err := s.db.Exec(` +INSERT INTO calendars (account_id, calendar_id, timezone) VALUES (?, ?, ?) +ON CONFLICT (account_id, calendar_id) DO UPDATE SET timezone = excluded.timezone`, + ref.AccountID, ref.CalendarID, tz) + return err +} + +// SetCalendarError は last_error を設定する(msg=="" で NULL クリア)。 +// 同期試行があった記録として last_synced_at も現在時刻で更新する。 +func (s *Store) SetCalendarError(ref model.CalendarRef, msg string) error { + var v any + if msg != "" { + v = msg + } + _, err := s.db.Exec(` +UPDATE calendars SET last_error = ?, last_synced_at = ? +WHERE account_id = ? AND calendar_id = ?`, + v, time.Now().Unix(), ref.AccountID, ref.CalendarID) + return err +} + +// TouchSynced は last_synced_at を指定時刻に更新する。 +func (s *Store) TouchSynced(ref model.CalendarRef, at time.Time) error { + _, err := s.db.Exec(` +UPDATE calendars SET last_synced_at = ? WHERE account_id = ? AND calendar_id = ?`, + at.Unix(), ref.AccountID, ref.CalendarID) + return err +} + +// DeleteCalendarsForAccount はアカウントの全カレンダー行を削除する(accounts remove 用)。 +func (s *Store) DeleteCalendarsForAccount(accountID string) error { + _, err := s.db.Exec(`DELETE FROM calendars WHERE account_id = ?`, accountID) + return err +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 0000000..47b3aa4 --- /dev/null +++ b/internal/store/store_test.go @@ -0,0 +1,237 @@ +package store + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/cloudnative-co/calsync/internal/model" +) + +// mustOpen は t.TempDir にストアを開き、テスト終了時に閉じる。 +// Task 4 / Task 5 のテストからも再利用する共有ヘルパ。 +func mustOpen(t *testing.T) *Store { + t.Helper() + s, err := Open(t.TempDir()) + require.NoError(t, err) + t.Cleanup(func() { _ = s.Close() }) + return s +} + +func TestOpen_CreatesSchemaAndWAL(t *testing.T) { + dir := t.TempDir() + s, err := Open(dir) + require.NoError(t, err) + t.Cleanup(func() { _ = s.Close() }) + + // DB ファイルが作成されている + _, statErr := os.Stat(filepath.Join(dir, "calsync.db")) + require.NoError(t, statErr) + + // WAL モードが有効 + var mode string + require.NoError(t, s.db.QueryRow("PRAGMA journal_mode").Scan(&mode)) + require.Equal(t, "wal", mode) + + // スキーマ: 3 テーブルが存在する + for _, tbl := range []string{"calendars", "events", "mappings"} { + var name string + err := s.db.QueryRow( + "SELECT name FROM sqlite_master WHERE type='table' AND name=?", tbl).Scan(&name) + require.NoError(t, err, "table %s must exist", tbl) + } +} + +func TestOpen_SecondOpenReturnsErrLocked(t *testing.T) { + dir := t.TempDir() + s1, err := Open(dir) + require.NoError(t, err) + + // 同一ディレクトリの二重 Open は flock で弾かれる + s2, err := Open(dir) + require.Nil(t, s2) + require.ErrorIs(t, err, ErrLocked) + + // Close で解放すれば再度開ける + require.NoError(t, s1.Close()) + s3, err := Open(dir) + require.NoError(t, err) + require.NoError(t, s3.Close()) +} + +func TestOpenReadOnly_CoexistsWithRunningDaemon(t *testing.T) { + dir := t.TempDir() + writer, err := Open(dir) // デーモン相当(flock 保持) + require.NoError(t, err) + t.Cleanup(func() { _ = writer.Close() }) + ref := model.CalendarRef{AccountID: "a", CalendarID: "primary"} + require.NoError(t, writer.UpsertCalendar(ref)) + + // flock 保持中でも読み取り専用オープンは成功し、読める + ro, err := OpenReadOnly(dir) + require.NoError(t, err) + t.Cleanup(func() { _ = ro.Close() }) + states, err := ro.ListCalendars() + require.NoError(t, err) + require.Len(t, states, 1) + + // 書き込みは拒否される(mode=ro) + require.Error(t, ro.UpsertCalendar(model.CalendarRef{AccountID: "b", CalendarID: "primary"})) +} + +func TestOpenReadOnly_FailsWhenDBMissing(t *testing.T) { + _, err := OpenReadOnly(t.TempDir()) + require.Error(t, err) +} + +func TestCalendars_UpsertAndGet(t *testing.T) { + s := mustOpen(t) + ref := model.CalendarRef{AccountID: "acct-a", CalendarID: "primary"} + + // 未存在は (nil, nil) + got, err := s.GetCalendar(ref) + require.NoError(t, err) + require.Nil(t, got) + + // Upsert は冪等(2 回呼んでもエラーにならず 1 行のまま) + require.NoError(t, s.UpsertCalendar(ref)) + require.NoError(t, s.UpsertCalendar(ref)) + + got, err = s.GetCalendar(ref) + require.NoError(t, err) + require.NotNil(t, got) + require.Equal(t, ref, got.Ref) + require.Empty(t, got.Cursor) + require.Empty(t, got.Timezone) + require.Empty(t, got.LastError) + require.True(t, got.Window.Start.IsZero()) + require.True(t, got.Window.End.IsZero()) + require.True(t, got.LastSyncedAt.IsZero()) + + list, err := s.ListCalendars() + require.NoError(t, err) + require.Len(t, list, 1) +} + +func TestCalendars_Mutations(t *testing.T) { + window := model.Window{ + Start: time.Date(2026, 7, 3, 0, 0, 0, 0, time.UTC), + End: time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC), + } + syncedAt := time.Date(2026, 7, 3, 12, 34, 56, 0, time.UTC) + + cases := []struct { + name string + mutate func(s *Store, ref model.CalendarRef) error + check func(t *testing.T, st *CalendarState) + }{ + { + name: "SetCursor はカーソルとウィンドウを保存する", + mutate: func(s *Store, ref model.CalendarRef) error { + return s.SetCursor(ref, "sync-token-1", window) + }, + check: func(t *testing.T, st *CalendarState) { + require.Equal(t, "sync-token-1", st.Cursor) + require.True(t, st.Window.Start.Equal(window.Start)) + require.True(t, st.Window.End.Equal(window.End)) + }, + }, + { + name: "ClearCursor はカーソルとウィンドウを消す", + mutate: func(s *Store, ref model.CalendarRef) error { + if err := s.SetCursor(ref, "sync-token-1", window); err != nil { + return err + } + return s.ClearCursor(ref) + }, + check: func(t *testing.T, st *CalendarState) { + require.Empty(t, st.Cursor) + require.True(t, st.Window.Start.IsZero()) + require.True(t, st.Window.End.IsZero()) + }, + }, + { + name: "SetCalendarTimezone はタイムゾーンを保存する", + mutate: func(s *Store, ref model.CalendarRef) error { + return s.SetCalendarTimezone(ref, "Asia/Tokyo") + }, + check: func(t *testing.T, st *CalendarState) { + require.Equal(t, "Asia/Tokyo", st.Timezone) + }, + }, + { + name: "SetCalendarError はメッセージを記録し last_synced_at も更新する", + mutate: func(s *Store, ref model.CalendarRef) error { + return s.SetCalendarError(ref, "boom") + }, + check: func(t *testing.T, st *CalendarState) { + require.Equal(t, "boom", st.LastError) + require.False(t, st.LastSyncedAt.IsZero()) + }, + }, + { + name: "SetCalendarError の空文字はエラーをクリアする", + mutate: func(s *Store, ref model.CalendarRef) error { + if err := s.SetCalendarError(ref, "boom"); err != nil { + return err + } + return s.SetCalendarError(ref, "") + }, + check: func(t *testing.T, st *CalendarState) { + require.Empty(t, st.LastError) + require.False(t, st.LastSyncedAt.IsZero()) + }, + }, + { + name: "TouchSynced は指定時刻を last_synced_at に保存する", + mutate: func(s *Store, ref model.CalendarRef) error { + return s.TouchSynced(ref, syncedAt) + }, + check: func(t *testing.T, st *CalendarState) { + require.True(t, st.LastSyncedAt.Equal(syncedAt)) + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + s := mustOpen(t) + ref := model.CalendarRef{AccountID: "acct-a", CalendarID: "primary"} + require.NoError(t, s.UpsertCalendar(ref)) + require.NoError(t, tc.mutate(s, ref)) + st, err := s.GetCalendar(ref) + require.NoError(t, err) + require.NotNil(t, st) + tc.check(t, st) + }) + } +} + +func TestCalendars_ListAndDeleteForAccount(t *testing.T) { + s := mustOpen(t) + refs := []model.CalendarRef{ + {AccountID: "acct-a", CalendarID: "primary"}, + {AccountID: "acct-a", CalendarID: "team"}, + {AccountID: "acct-b", CalendarID: "primary"}, + } + for _, ref := range refs { + require.NoError(t, s.UpsertCalendar(ref)) + } + + list, err := s.ListCalendars() + require.NoError(t, err) + require.Len(t, list, 3) + gotRefs := make([]model.CalendarRef, 0, len(list)) + for _, st := range list { + gotRefs = append(gotRefs, st.Ref) + } + require.Equal(t, refs, gotRefs) // account_id, calendar_id 昇順 + + require.NoError(t, s.DeleteCalendarsForAccount("acct-a")) + list, err = s.ListCalendars() + require.NoError(t, err) + require.Len(t, list, 1) + require.Equal(t, refs[2], list[0].Ref) +}