package store import ( "context" "errors" "sync" "testing" "time" ) func TestListDueSubscriptionsBoundaryAndPaused(t *testing.T) { pool := testPool(t, true) s := New(pool) ctx := context.Background() now := time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC) for _, id := range []string{"null", "old", "boundary", "fresh", "paused"} { if created, err := s.RegisterSubscription(ctx, id, "番組 "+id, nil, false); err != nil || !created { t.Fatalf("登録 %s: %t %v", id, created, err) } } updates := map[string]*time.Time{ "null": nil, "old": timePointer(now.Add(-25 * time.Hour)), "boundary": timePointer(now.Add(-24 * time.Hour)), "fresh": timePointer(now.Add(-24*time.Hour + time.Microsecond)), "paused": timePointer(now.Add(-48 * time.Hour)), } for id, checked := range updates { if _, err := pool.Exec(ctx, `UPDATE subscriptions SET last_checked_at=$2 WHERE series_id=$1`, id, checked); err != nil { t.Fatal(err) } } if err := s.SetSubscriptionEnabled(ctx, "paused", false); err != nil { t.Fatal(err) } due, err := s.ListDueSubscriptions(ctx, now.Add(-24*time.Hour)) if err != nil { t.Fatal(err) } if len(due) != 3 || due[0].SeriesID != "null" || due[1].SeriesID != "old" || due[2].SeriesID != "boundary" { t.Fatalf("24時間境界と一時停止: %+v", due) } } func TestCompleteSubscriptionCheckKnownPreviewErrorAndAtomicity(t *testing.T) { pool := testPool(t, true) s := New(pool) ctx := context.Background() if created, err := s.RegisterSubscription(ctx, "series-check", "番組", []Episode{seriesEpisode("known", "series-check")}, false); err != nil || !created { t.Fatalf("登録: %t %v", created, err) } before := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) failedAt := time.Date(2026, 10, 3, 1, 2, 3, 0, time.UTC) if _, err := pool.Exec(ctx, `UPDATE subscriptions SET last_checked_at=$2 WHERE series_id=$1`, "series-check", before); err != nil { t.Fatal(err) } if err := s.RecordSubscriptionCheckFailure(ctx, "series-check", failedAt, "network token=[REDACTED]"); err != nil { t.Fatal(err) } var unchanged time.Time if err := pool.QueryRow(ctx, `SELECT last_checked_at FROM subscriptions WHERE series_id='series-check'`).Scan(&unchanged); err != nil || !unchanged.Equal(before) { t.Fatalf("失敗時に最終確認を更新: %v %v", unchanged, err) } checkedAt := time.Date(2026, 10, 3, 4, 5, 6, 0, time.UTC) preview := seriesEpisode("preview", "series-check") preview.DurationSeconds = 180 normal := seriesEpisode("normal", "series-check") normal.DurationSeconds = 181 count, err := s.CompleteSubscriptionCheck(ctx, "series-check", []Episode{ seriesEpisode("known", "series-check"), preview, normal, }, 180, checkedAt) if err != nil || count != 1 { t.Fatalf("確認完了: count=%d err=%v", count, err) } unknown, err := s.UnknownEpisodeIDs(ctx, "series-check", []string{"known", "preview", "normal"}) if err != nil || len(unknown) != 0 { t.Fatalf("予告を含む既知化: %v %v", unknown, err) } var jobs int if err := pool.QueryRow(ctx, `SELECT count(*) FROM download_jobs WHERE series_id='series-check'`).Scan(&jobs); err != nil || jobs != 1 { t.Fatalf("予告の投入除外: %d %v", jobs, err) } var last time.Time var errorAt, errorMessage any if err := pool.QueryRow(ctx, `SELECT last_checked_at, last_check_error_at, last_check_error FROM subscriptions WHERE series_id='series-check'`).Scan(&last, &errorAt, &errorMessage); err != nil || !last.Equal(checkedAt) || errorAt != nil || errorMessage != nil { t.Fatalf("成功時の日時・エラー消去: %v %v %v %v", last, errorAt, errorMessage, err) } bad := seriesEpisode("bad", "series-check") bad.DurationSeconds = -1 if _, err := s.CompleteSubscriptionCheck(ctx, "series-check", []Episode{seriesEpisode("rollback", "series-check"), bad}, 180, checkedAt.Add(time.Hour)); err == nil { t.Fatal("不正な回を含む確認が成功した") } unknown, err = s.UnknownEpisodeIDs(ctx, "series-check", []string{"rollback"}) if err != nil || len(unknown) != 1 { t.Fatalf("失敗時の既知記録を巻き戻さない: %v %v", unknown, err) } if err := pool.QueryRow(ctx, `SELECT last_checked_at FROM subscriptions WHERE series_id='series-check'`).Scan(&last); err != nil || !last.Equal(checkedAt) { t.Fatalf("失敗時の最終確認を巻き戻さない: %v %v", last, err) } if err := s.SetSubscriptionEnabled(ctx, "series-check", false); err != nil { t.Fatal(err) } if _, err := s.CompleteSubscriptionCheck(ctx, "series-check", nil, 180, checkedAt.Add(time.Hour)); !errors.Is(err, ErrSubscriptionPaused) { t.Fatalf("一時停止中の確認完了: %v", err) } if err := s.RecordSubscriptionCheckFailure(ctx, "series-check", failedAt, "ignored"); !errors.Is(err, ErrInvalidTransition) { t.Fatalf("一時停止中のエラー記録: %v", err) } } func TestCompleteSubscriptionCheckKeepsExistingManualJob(t *testing.T) { pool := testPool(t, true) s := New(pool) ctx := context.Background() if created, err := s.RegisterSubscription(ctx, "series-manual", "番組", nil, false); err != nil || !created { t.Fatalf("登録: %t %v", created, err) } episode := seriesEpisode("manual-existing", "series-manual") manual, _, err := s.Enqueue(ctx, episode, "manual") if err != nil { t.Fatal(err) } checkedAt := time.Date(2026, 10, 3, 4, 5, 6, 0, time.UTC) queued, err := s.CompleteSubscriptionCheck(ctx, "series-manual", []Episode{episode, episode}, 180, checkedAt) if err != nil || queued != 0 { t.Fatalf("既存手動ジョブの確認: queued=%d err=%v", queued, err) } got, err := s.GetJob(ctx, manual.ID) if err != nil || got.Source != "manual" { t.Fatalf("既存手動ジョブを変更した: %+v %v", got, err) } var jobs, known int if err := pool.QueryRow(ctx, `SELECT count(*) FROM download_jobs WHERE episode_id=$1`, episode.ID).Scan(&jobs); err != nil || jobs != 1 { t.Fatalf("ジョブが二重化した: %d %v", jobs, err) } if err := pool.QueryRow(ctx, `SELECT count(*) FROM known_episodes WHERE series_id=$1 AND episode_id=$2`, episode.SeriesID, episode.ID).Scan(&known); err != nil || known != 1 { t.Fatalf("既知記録: %d %v", known, err) } var last time.Time if err := pool.QueryRow(ctx, `SELECT last_checked_at FROM subscriptions WHERE series_id=$1`, episode.SeriesID).Scan(&last); err != nil || !last.Equal(checkedAt) { t.Fatalf("最終確認日時: %v %v", last, err) } } func timePointer(value time.Time) *time.Time { return &value } func seriesEpisode(id, seriesID string) Episode { ep := sampleEpisode(id) ep.SeriesID = seriesID return ep } func TestRegisterSubscriptionSnapshotAndRollback(t *testing.T) { pool := testPool(t, true) s := New(pool) ctx := context.Background() episodes := []Episode{seriesEpisode("ep1", "series-1"), seriesEpisode("ep2", "series-1")} created, err := s.RegisterSubscription(ctx, "series-1", "番組", episodes, false) if err != nil || !created { t.Fatalf("既存回をダウンロードしない登録: %t %v", created, err) } subs, err := s.ListSubscriptions(ctx) if err != nil || len(subs) != 1 || subs[0].LastCheckedAt == nil || subs[0].LastCheckedAt.Before(subs[0].CreatedAt) { t.Fatalf("登録時の最終確認日時: %+v %v", subs, err) } unknown, err := s.UnknownEpisodeIDs(ctx, "series-1", []string{"ep1", "ep2", "ep3"}) if err != nil || len(unknown) != 1 || unknown[0] != "ep3" { t.Fatalf("既知の記録: %v %v", unknown, err) } var jobs int if err := pool.QueryRow(ctx, `SELECT count(*) FROM download_jobs WHERE series_id='series-1'`).Scan(&jobs); err != nil || jobs != 0 { t.Fatalf("既存回が投入された: %d %v", jobs, err) } created, err = s.RegisterSubscription(ctx, "series-1", "別の名前", episodes, true) if err != nil || created { t.Fatalf("重複登録: %t %v", created, err) } if err := pool.QueryRow(ctx, `SELECT count(*) FROM download_jobs WHERE series_id='series-1'`).Scan(&jobs); err != nil || jobs != 0 { t.Fatalf("重複登録が投入した: %d %v", jobs, err) } bad := seriesEpisode("bad", "series-bad") bad.DurationSeconds = -1 created, err = s.RegisterSubscription(ctx, "series-bad", "番組", []Episode{seriesEpisode("good", "series-bad"), bad}, true) if err == nil || created { t.Fatalf("壊れた回の登録が成功: %t %v", created, err) } for _, table := range []string{"subscriptions", "known_episodes", "download_jobs"} { var count int if err := pool.QueryRow(ctx, `SELECT count(*) FROM `+table+` WHERE series_id='series-bad'`).Scan(&count); err != nil || count != 0 { t.Errorf("取引の巻き戻し %s: %d %v", table, count, err) } } deleted, err := s.DeleteSubscription(ctx, "series-1") if err != nil || !deleted { t.Fatalf("解除: %t %v", deleted, err) } if err := pool.QueryRow(ctx, `SELECT count(*) FROM known_episodes WHERE series_id='series-1'`).Scan(&jobs); err != nil || jobs != 0 { t.Fatalf("解除後に既知が残った: %d %v", jobs, err) } created, err = s.RegisterSubscription(ctx, "series-1", "番組", episodes, true) if err != nil || !created { t.Fatalf("解除後の再登録: %t %v", created, err) } if err := pool.QueryRow(ctx, `SELECT count(*) FROM download_jobs WHERE series_id='series-1'`).Scan(&jobs); err != nil || jobs != 2 { t.Fatalf("再登録で既存回を選び直せない: %d %v", jobs, err) } } func TestRegisterAndQueueUnseenEpisodes(t *testing.T) { pool := testPool(t, true) s := New(pool) ctx := context.Background() manual, _, err := s.Enqueue(ctx, seriesEpisode("manual", "series-2"), "manual") if err != nil { t.Fatal(err) } created, err := s.RegisterSubscription(ctx, "series-2", "番組", []Episode{seriesEpisode("manual", "series-2"), seriesEpisode("past", "series-2")}, true) if err != nil || !created { t.Fatalf("公開中の回を投入: %t %v", created, err) } manualAfter, err := s.GetJob(ctx, manual.ID) if err != nil || manualAfter.Source != "manual" { t.Fatalf("既存の手動ジョブを変更: %+v %v", manualAfter, err) } var subscriptionJobs int if err := pool.QueryRow(ctx, `SELECT count(*) FROM download_jobs WHERE series_id='series-2' AND source='subscription' AND status='queued'`).Scan(&subscriptionJobs); err != nil || subscriptionJobs != 1 { t.Fatalf("登録時に作ったジョブ: %d %v", subscriptionJobs, err) } subs, err := s.ListSubscriptions(ctx) if err != nil || len(subs) != 1 || subs[0].JobCount != 2 || !subs[0].Enabled || subs[0].LastCheckedAt == nil { t.Fatalf("登録一覧と関連ジョブ数: %+v %v", subs, err) } unknown, err := s.UnknownEpisodeIDs(ctx, "series-2", []string{"past", "new", "manual", "new"}) if err != nil || len(unknown) != 1 || unknown[0] != "new" { t.Fatalf("未確認の抽出: %v %v", unknown, err) } if err := s.SetSubscriptionEnabled(ctx, "series-2", false); err != nil { t.Fatal(err) } if _, err := s.QueueUnseenEpisodes(ctx, "series-2", []Episode{seriesEpisode("new", "series-2")}); !errors.Is(err, ErrSubscriptionPaused) { t.Fatalf("一時停止中の投入: %v", err) } unknown, err = s.UnknownEpisodeIDs(ctx, "series-2", []string{"new"}) if err != nil || len(unknown) != 1 { t.Fatalf("一時停止中に既知となった: %v %v", unknown, err) } if err := s.SetSubscriptionEnabled(ctx, "series-2", true); err != nil { t.Fatal(err) } invalid := seriesEpisode("invalid", "series-2") invalid.DurationSeconds = -1 if _, err := s.QueueUnseenEpisodes(ctx, "series-2", []Episode{seriesEpisode("newer", "series-2"), invalid}); err == nil { t.Fatal("新規回の投入が途中失敗でも確定した") } unknown, err = s.UnknownEpisodeIDs(ctx, "series-2", []string{"newer"}) if err != nil || len(unknown) != 1 { t.Fatalf("新規回の投入失敗が巻き戻らない: %v %v", unknown, err) } count, err := s.QueueUnseenEpisodes(ctx, "series-2", []Episode{seriesEpisode("past", "series-2"), seriesEpisode("new", "series-2")}) if err != nil || count != 1 { t.Fatalf("再開後の新しい回: %d %v", count, err) } count, err = s.QueueUnseenEpisodes(ctx, "series-2", []Episode{seriesEpisode("new", "series-2")}) if err != nil || count != 0 { t.Fatalf("重複した新しい回: %d %v", count, err) } var source string if err := pool.QueryRow(ctx, `SELECT source FROM download_jobs WHERE episode_id='new'`).Scan(&source); err != nil || source != "subscription" { t.Fatalf("新しい回の登録元: %q %v", source, err) } statuses, err := s.SubscribedSeries(ctx, []string{"series-2", "missing"}) if err != nil || !statuses["series-2"] || statuses["missing"] { t.Fatalf("登録済みシリーズの一括取得: %v %v", statuses, err) } } func TestQueueUnseenEpisodesConcurrent(t *testing.T) { pool := testPool(t, true) s := New(pool) ctx := context.Background() created, err := s.RegisterSubscription(ctx, "series-concurrent", "番組", nil, false) if err != nil || !created { t.Fatalf("登録: %t %v", created, err) } episodes := []Episode{ seriesEpisode("concurrent-1", "series-concurrent"), seriesEpisode("concurrent-2", "series-concurrent"), } const workers = 8 counts := make(chan int, workers) errs := make(chan error, workers) var wg sync.WaitGroup for range workers { wg.Add(1) go func() { defer wg.Done() count, err := s.QueueUnseenEpisodes(ctx, "series-concurrent", episodes) counts <- count errs <- err }() } wg.Wait() close(counts) close(errs) for err := range errs { if err != nil { t.Errorf("同時投入: %v", err) } } total := 0 positive := 0 for count := range counts { total += count if count > 0 { positive++ } } if total != 2 || positive != 1 { t.Fatalf("同時投入の記録件数: total=%d positive workers=%d", total, positive) } for table := range map[string]struct{}{"known_episodes": {}, "download_jobs": {}} { var count int if err := pool.QueryRow(ctx, `SELECT count(*) FROM `+table+` WHERE series_id='series-concurrent'`).Scan(&count); err != nil || count != 2 { t.Errorf("%s の重複防止: %d %v", table, count, err) } } }