package app import ( "bytes" "os" "path/filepath" "strings" "testing" "gitea.maximumdirect.net/eric/narratio/internal/adapters/storage" "gitea.maximumdirect.net/eric/narratio/internal/artifacts" ) func TestExecuteCleanSessionDeletesWorkAndSpoolButPreservesCache(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) workDir := artifacts.SessionWorkDirForCampaign(workspaceRoot, "sample-campaign", "2026-05-03") spoolDir := artifacts.SessionSpoolDir(filepath.Join(workspaceRoot, "spool"), "sample-campaign", "2026-05-03") cachePath, err := artifacts.S3AudioCachePath(filepath.Join(workspaceRoot, "cache"), "test-bucket", "dnd/campaigns/sample-campaign/sessions/2026-05-03/audio/alice.flac") if err != nil { t.Fatalf("S3AudioCachePath() error = %v", err) } mustWriteTestFile(t, filepath.Join(workDir, "manifest.json"), "{}") mustWriteTestFile(t, filepath.Join(spoolDir, "run-1", "audio", "alice.flac"), "audio") mustWriteTestFile(t, cachePath, "cached-audio") var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{"clean", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--session-id", "2026-05-03"}, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } cleanAssertMissing(t, workDir) cleanAssertMissing(t, spoolDir) cleanAssertExists(t, cachePath) if !strings.Contains(stdout.String(), "Cache: preserved") { t.Fatalf("stdout = %q, want cache preserved", stdout.String()) } } func TestExecuteCleanSessionDryRunDeletesNothing(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) workDir := artifacts.SessionWorkDirForCampaign(workspaceRoot, "sample-campaign", "2026-05-03") spoolDir := artifacts.SessionSpoolDir(filepath.Join(workspaceRoot, "spool"), "sample-campaign", "2026-05-03") mustWriteTestFile(t, filepath.Join(workDir, "manifest.json"), "{}") mustWriteTestFile(t, filepath.Join(spoolDir, "run-1", "audio", "alice.flac"), "audio") var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{"clean", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--session-id", "2026-05-03", "--dry-run"}, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } cleanAssertExists(t, workDir) cleanAssertExists(t, spoolDir) if !strings.Contains(stdout.String(), "Would delete:") { t.Fatalf("stdout = %q, want dry-run delete plan", stdout.String()) } } func TestExecuteCleanMissingSessionPathsSucceeds(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{"clean", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--session-id", "2026-05-03"}, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } if !strings.Contains(stdout.String(), "Missing:") { t.Fatalf("stdout = %q, want missing path output", stdout.String()) } } func TestExecuteCleanSessionClearCacheRemovesOnlyS3AudioCache(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) if err := os.WriteFile(sessionPath, []byte(`session_id: 2026-05-03 inputs: audio_s3: prefix: audio/ `), 0o644); err != nil { t.Fatalf("write session: %v", err) } audioKey := "dnd/campaigns/sample-campaign/sessions/2026-05-03/audio/alice.flac" fake := &storage.FakeBackend{} fake.SeedObject(storage.FakeObject{Key: audioKey, Data: []byte("audio")}) var storeInitCalls int restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath}) cacheRoot := filepath.Join(workspaceRoot, "cache") cachePath, err := artifacts.S3AudioCachePath(cacheRoot, "test-bucket", audioKey) if err != nil { t.Fatalf("S3AudioCachePath() error = %v", err) } otherCachePath, err := artifacts.S3AudioCachePath(cacheRoot, "test-bucket", "dnd/campaigns/other/sessions/2026-05-03/audio/bob.flac") if err != nil { t.Fatalf("S3AudioCachePath() error = %v", err) } mustWriteTestFile(t, cachePath, "cached-audio") mustWriteTestFile(t, otherCachePath, "other-audio") var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{"clean", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--session-id", "2026-05-03", "--clear-cache"}, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } cleanAssertMissing(t, cachePath) cleanAssertExists(t, otherCachePath) if storeInitCalls != 1 { t.Fatalf("object store init calls = %d, want 1", storeInitCalls) } } func TestExecuteCleanLocalAudioClearCacheIsNoop(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{"clean", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--session-id", "2026-05-03", "--clear-cache"}, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } if !strings.Contains(stdout.String(), "Cache: skipped (session does not use audio_s3)") { t.Fatalf("stdout = %q, want local audio cache no-op", stdout.String()) } } func TestExecuteCleanAllDeletesWorkAndSpoolContentsButPreservesCache(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, _, _ := writeValidConfigFiles(t, workspaceRoot) workRoot := filepath.Join(workspaceRoot, "work") spoolRoot := filepath.Join(workspaceRoot, "spool") cachePath := filepath.Join(workspaceRoot, "cache", "keep.txt") mustWriteTestFile(t, filepath.Join(workRoot, "sample-campaign", "2026-05-03", "manifest.json"), "{}") mustWriteTestFile(t, filepath.Join(spoolRoot, "sample-campaign", "2026-05-03", "run-1", "audio", "alice.flac"), "audio") mustWriteTestFile(t, cachePath, "cache") var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{"clean", "--config", pipelinePath, "--all"}, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } cleanAssertMissing(t, workRoot) cleanAssertExists(t, spoolRoot) cleanAssertMissing(t, filepath.Join(spoolRoot, "sample-campaign")) cleanAssertExists(t, cachePath) } func TestExecuteCleanAllClearCacheRemovesS3AudioNamespaceOnly(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, _, _ := writeValidConfigFiles(t, workspaceRoot) cacheRoot := filepath.Join(workspaceRoot, "cache") audioCachePath, err := artifacts.S3AudioCachePath(cacheRoot, "test-bucket", "dnd/campaigns/sample-campaign/sessions/2026-05-03/audio/alice.flac") if err != nil { t.Fatalf("S3AudioCachePath() error = %v", err) } otherCachePath, err := artifacts.S3AudioCachePath(cacheRoot, "test-bucket", "other-root/campaigns/sample-campaign/sessions/2026-05-03/audio/alice.flac") if err != nil { t.Fatalf("S3AudioCachePath() error = %v", err) } mustWriteTestFile(t, audioCachePath, "cached-audio") mustWriteTestFile(t, otherCachePath, "other-cache") var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{"clean", "--config", pipelinePath, "--all", "--clear-cache"}, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } cleanAssertMissing(t, audioCachePath) cleanAssertExists(t, otherCachePath) } func TestExecuteCleanAllRejectsSessionScopedFlags(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot) var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{"clean", "--config", pipelinePath, "--campaign", campaignPath, "--all"}, &stdout, &stderr) if code == 0 { t.Fatal("exit code = 0, want non-zero") } if !strings.Contains(stderr.String(), "--all cannot be combined") { t.Fatalf("stderr = %q, want --all conflict", stderr.String()) } } func TestCleanRequiresSessionID(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{"clean"}, &stdout, &stderr) if code == 0 { t.Fatal("exit code = 0, want non-zero") } if !strings.Contains(stderr.String(), "--session-id is required unless --all is set") { t.Fatalf("stderr = %q, want missing session-id", stderr.String()) } } func TestCleanRejectsUnsafeTargets(t *testing.T) { root := t.TempDir() outside := t.TempDir() if err := reportCleanScopedDir(&bytes.Buffer{}, root, filepath.Join(outside, "target"), "test.outside", false); err == nil { t.Fatal("outside target error = nil, want error") } if err := reportCleanScopedDir(&bytes.Buffer{}, root, root, "test.root", false); err == nil { t.Fatal("root target error = nil, want error") } filePath := filepath.Join(root, "file.txt") mustWriteTestFile(t, filePath, "file") if err := reportCleanScopedDir(&bytes.Buffer{}, root, filePath, "test.file", false); err == nil { t.Fatal("file target error = nil, want error") } symlinkPath := filepath.Join(root, "link") if err := os.Symlink(filepath.Join(root, "missing"), symlinkPath); err != nil { t.Fatalf("Symlink() error = %v", err) } if err := reportCleanScopedDir(&bytes.Buffer{}, root, symlinkPath, "test.symlink", false); err == nil { t.Fatal("symlink target error = nil, want error") } } func TestClearIsNotCommandAlias(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer code := Execute([]string{"clear"}, &stdout, &stderr) if code == 0 { t.Fatal("exit code = 0, want non-zero") } if !strings.Contains(stderr.String(), `unknown command: "clear"`) { t.Fatalf("stderr = %q, want unknown clear command", stderr.String()) } } func cleanAssertExists(t *testing.T, path string) { t.Helper() if _, err := os.Stat(path); err != nil { t.Fatalf("expected %q to exist: %v", path, err) } } func cleanAssertMissing(t *testing.T, path string) { t.Helper() if _, err := os.Stat(path); !os.IsNotExist(err) { t.Fatalf("expected %q to be missing, stat err=%v", path, err) } }