package cli import ( "bytes" "context" "encoding/json" "os" "path/filepath" "strings" "testing" "time" "github.com/openclaw/gitcrawl/internal/capture" "github.com/openclaw/gitcrawl/internal/config" gh "github.com/openclaw/gitcrawl/internal/github" "github.com/openclaw/gitcrawl/internal/store" ) func TestCaptureCommandWritesV1Atomically(t *testing.T) { ctx := context.Background() root := t.TempDir() configPath := filepath.Join(root, "config.toml") dbPath := filepath.Join(root, "gitcrawl.db") cfg := config.Default() cfg.DBPath = dbPath cfg.CacheDir = filepath.Join(root, "cache") if err := config.Save(configPath, cfg); err != nil { t.Fatalf("save config: %v", err) } st, err := store.Open(ctx, dbPath) if err != nil { t.Fatalf("open store: %v", err) } repoID, err := st.UpsertRepository(ctx, store.Repository{ Owner: "openclaw", Name: "gitcrawl", FullName: "openclaw/gitcrawl", GitHubRepoID: "12345", UpdatedAt: "2026-07-30T20:00:00Z", }) if err != nil { t.Fatalf("upsert repository: %v", err) } threadID, err := st.UpsertThread(ctx, store.Thread{ RepoID: repoID, GitHubID: "I_1", Number: 1, Kind: "issue", State: "open", Title: "Capture this", Body: "Body", HTMLURL: "https://github.com/openclaw/gitcrawl/issues/1", LabelsJSON: "[]", AssigneesJSON: "[]", RawJSON: "{}", ContentHash: "source", UpdatedAtGitHub: "2026-07-30T19:00:00Z", UpdatedAt: "2026-07-30T19:00:00Z", }) if err != nil { t.Fatalf("upsert thread: %v", err) } reserveCaptureComments(t, ctx, st, threadID, "2026-07-30T19:00:00Z") if _, err := st.RecordRun(ctx, store.RunRecord{ RepoID: repoID, Kind: "sync", Scope: "all", Status: "success", StartedAt: "2026-07-30T19:59:00Z", FinishedAt: "2026-07-30T20:00:00Z", StatsJSON: `{"comments_included":true}`, }); err != nil { t.Fatalf("record sync: %v", err) } if err := st.Close(); err != nil { t.Fatalf("close store: %v", err) } outputPath := filepath.Join(root, "captures", "capture.json") app := seedCaptureRateLimit(t, ctx, configPath) if err := app.Run(ctx, []string{ "--config", configPath, "capture", "openclaw/gitcrawl", "--schema", capture.SchemaV1, "--output", outputPath, }); err != nil { t.Fatalf("run capture: %v", err) } data, err := os.ReadFile(outputPath) if err != nil { t.Fatalf("read capture: %v", err) } var got capture.Capture if err := json.Unmarshal(data, &got); err != nil { t.Fatalf("decode capture: %v", err) } if got.Schema != capture.SchemaV1 || got.Repository.ID != "12345" || got.RateLimit.Resource != "core" || got.RateLimit.Remaining != 37 || len(got.Threads) != 1 { t.Fatalf("capture = %+v", got) } entries, err := os.ReadDir(filepath.Dir(outputPath)) if err != nil { t.Fatalf("read output directory: %v", err) } if len(entries) != 1 || entries[0].Name() != "capture.json" { t.Fatalf("temporary output remained: %+v", entries) } } func TestCaptureCommandRejectsUnknownSchema(t *testing.T) { err := New().Run(context.Background(), []string{ "capture", "openclaw/gitcrawl", "--schema", "gitcrawl.capture.v2", }) if err == nil || !strings.Contains(err.Error(), "unsupported capture schema") { t.Fatalf("schema error = %v", err) } } func TestCaptureCommandWritesJSONToStdout(t *testing.T) { ctx := context.Background() root := t.TempDir() configPath := filepath.Join(root, "config.toml") dbPath := filepath.Join(root, "gitcrawl.db") cfg := config.Default() cfg.DBPath = dbPath cfg.CacheDir = filepath.Join(root, "cache") if err := config.Save(configPath, cfg); err != nil { t.Fatalf("save config: %v", err) } st, err := store.Open(ctx, dbPath) if err != nil { t.Fatalf("open store: %v", err) } repoID, err := st.UpsertRepository(ctx, store.Repository{ Owner: "openclaw", Name: "gitcrawl", FullName: "openclaw/gitcrawl", GitHubRepoID: "12345", UpdatedAt: "2026-07-30T20:00:00Z", }) if err != nil { t.Fatalf("upsert repository: %v", err) } if _, err := st.RecordRun(ctx, store.RunRecord{ RepoID: repoID, Kind: "sync", Scope: "all", Status: "success", StartedAt: "2026-07-30T19:59:00Z", FinishedAt: "2026-07-30T20:00:00Z", StatsJSON: `{"comments_included":true}`, }); err != nil { t.Fatalf("record sync: %v", err) } if err := st.Close(); err != nil { t.Fatalf("close store: %v", err) } app := seedCaptureRateLimit(t, ctx, configPath) var stdout bytes.Buffer app.Stdout = &stdout if err := app.Run(ctx, []string{ "--config", configPath, "capture", "openclaw/gitcrawl", }); err != nil { t.Fatalf("run capture: %v", err) } var got capture.Capture if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { t.Fatalf("decode stdout: %v", err) } if got.Schema != capture.SchemaV1 || got.Threads == nil { t.Fatalf("stdout capture = %+v", got) } } func seedCaptureRateLimit( t *testing.T, ctx context.Context, configPath string, ) *App { t.Helper() const token = "capture-test-token" t.Setenv(config.DefaultTokenEnv, token) app := New() app.configPath = configPath if err := app.writeSharedRateLimit(ctx, token, gh.RateLimitSnapshot{ Limit: 5000, Remaining: 37, ResetAt: time.Now().UTC().Add(time.Hour), Resource: "core", }, "capture test"); err != nil { t.Fatalf("write capture rate limit: %v", err) } return app } func TestWriteCaptureAtomicallyRejectsEmptyOutput(t *testing.T) { if err := writeCaptureAtomically("", []byte("{}")); err == nil || !strings.Contains(err.Error(), "output path is empty") { t.Fatalf("empty output error = %v", err) } } // reserveCaptureComments records complete comment coverage for a CLI fixture. func reserveCaptureComments( t *testing.T, ctx context.Context, st *store.Store, threadID int64, sourceUpdatedAt string, ) { t.Helper() sequence, err := st.NextThreadObservationSequence(ctx, sourceUpdatedAt) if err != nil { t.Fatalf("next observation sequence: %v", err) } applied, err := st.ReserveThreadChildObservation( ctx, threadID, store.ThreadChildComments, sourceUpdatedAt, sequence, ) if err != nil || !applied { t.Fatalf("reserve comment observation = %t, %v", applied, err) } comments, err := st.ListComments(ctx, threadID) if err != nil { t.Fatalf("list observed comments: %v", err) } memberIDs := make([]int64, 0, len(comments)) for _, comment := range comments { memberIDs = append(memberIDs, comment.ID) } if err := st.ReplaceThreadChildObservationMembers( ctx, threadID, store.ThreadChildComments, sequence, memberIDs, ); err != nil { t.Fatalf("replace comment observation members: %v", err) } }