diff --git a/AGENTS.md b/AGENTS.md index 18da9ee35..038d752a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -167,6 +167,12 @@ If anything in this file conflicts with the `Makefile` or the Developer Guide, t - CLI command tests: use `RunWithTestContext(cmd, args)` to capture output and avoid `os.Exit`; assert `cli.ExitCoder` codes when you need them. - Reports are quoted: strings in CLI "show" output are rendered with quotes by the report helpers. Prefer `assert.Contains`/regex over strict, fully formatted equality when validating content. +#### Test Data & Fixtures (storage/testdata) +- Shared test files live under `storage/testdata`. The lifecycle is managed by `internal/config/test.go`. +- `NewTestConfig("")` now calls `InitializeTestData()` so required directories exist (originals, import, cache, temp) before tests run. +- If you build a custom `*config.Config`, call `c.InitializeTestData()` (and optionally `c.AssertTestData(t)`) before asserting on filesystem paths. +- `InitializeTestData()` deletes existing testdata (`RemoveTestData()`), downloads/unzips fixtures if needed, and then calls `CreateDirectories()` to ensure required directories exist. + ### Roles & ACL - Always map roles via the central tables: diff --git a/internal/commands/cluster_health.go b/internal/commands/cluster_health.go index c60caf270..62af30bc5 100644 --- a/internal/commands/cluster_health.go +++ b/internal/commands/cluster_health.go @@ -20,7 +20,7 @@ type healthResponse struct { var ClusterHealthCommand = &cli.Command{ Name: "health", Usage: "Shows cluster health (Portal-only)", - Flags: append(report.CliFlags, JsonFlag), + Flags: report.CliFlags, Action: clusterHealthAction, } diff --git a/internal/commands/cluster_nodes_list.go b/internal/commands/cluster_nodes_list.go index 849a492b3..f685eab6b 100644 --- a/internal/commands/cluster_nodes_list.go +++ b/internal/commands/cluster_nodes_list.go @@ -29,7 +29,7 @@ var ClusterNodesCommands = &cli.Command{ var ClusterNodesListCommand = &cli.Command{ Name: "ls", Usage: "Lists registered cluster nodes (Portal-only)", - Flags: append(append(report.CliFlags, JsonFlag), CountFlag, OffsetFlag), + Flags: append(report.CliFlags, CountFlag, OffsetFlag), ArgsUsage: "", Action: clusterNodesListAction, } diff --git a/internal/commands/cluster_nodes_rotate.go b/internal/commands/cluster_nodes_rotate.go index 0fec7fec2..15571763d 100644 --- a/internal/commands/cluster_nodes_rotate.go +++ b/internal/commands/cluster_nodes_rotate.go @@ -27,7 +27,7 @@ var ClusterNodesRotateCommand = &cli.Command{ Name: "rotate", Usage: "Rotates a node's DB and/or secret via Portal (HTTP)", ArgsUsage: "", - Flags: append([]cli.Flag{rotateDatabaseFlag, rotateSecretFlag, &cli.BoolFlag{Name: "yes", Aliases: []string{"y"}, Usage: "runs the command non-interactively"}, rotatePortalURL, rotatePortalTok, JsonFlag}, report.CliFlags...), + Flags: append([]cli.Flag{rotateDatabaseFlag, rotateSecretFlag, &cli.BoolFlag{Name: "yes", Aliases: []string{"y"}, Usage: "runs the command non-interactively"}, rotatePortalURL, rotatePortalTok}, report.CliFlags...), Action: clusterNodesRotateAction, } diff --git a/internal/commands/cluster_nodes_show.go b/internal/commands/cluster_nodes_show.go index 28e5689c7..a95820b41 100644 --- a/internal/commands/cluster_nodes_show.go +++ b/internal/commands/cluster_nodes_show.go @@ -17,7 +17,7 @@ var ClusterNodesShowCommand = &cli.Command{ Name: "show", Usage: "Shows node details (Portal-only)", ArgsUsage: "", - Flags: append(report.CliFlags, JsonFlag), + Flags: report.CliFlags, Action: clusterNodesShowAction, } diff --git a/internal/commands/cluster_register.go b/internal/commands/cluster_register.go index 4284fd8f7..d1b7f3963 100644 --- a/internal/commands/cluster_register.go +++ b/internal/commands/cluster_register.go @@ -40,7 +40,7 @@ var ( var ClusterRegisterCommand = &cli.Command{ Name: "register", Usage: "Registers/rotates a node via Portal (HTTP)", - Flags: append(append([]cli.Flag{regNameFlag, regRoleFlag, regIntUrlFlag, regLabelFlag, regRotateDatabase, regRotateSec, regPortalURL, regPortalTok, regWriteConf, regForceFlag, JsonFlag}, report.CliFlags...)), + Flags: append(append([]cli.Flag{regNameFlag, regRoleFlag, regIntUrlFlag, regLabelFlag, regRotateDatabase, regRotateSec, regPortalURL, regPortalTok, regWriteConf, regForceFlag}, report.CliFlags...)), Action: clusterRegisterAction, } diff --git a/internal/commands/cluster_summary.go b/internal/commands/cluster_summary.go index 2f841f115..4998b5df0 100644 --- a/internal/commands/cluster_summary.go +++ b/internal/commands/cluster_summary.go @@ -17,7 +17,7 @@ import ( var ClusterSummaryCommand = &cli.Command{ Name: "summary", Usage: "Shows cluster summary (Portal-only)", - Flags: append(report.CliFlags, JsonFlag), + Flags: report.CliFlags, Action: clusterSummaryAction, } diff --git a/internal/commands/cluster_theme_pull.go b/internal/commands/cluster_theme_pull.go index 96377f6d2..5d8e3dff4 100644 --- a/internal/commands/cluster_theme_pull.go +++ b/internal/commands/cluster_theme_pull.go @@ -36,7 +36,7 @@ var ClusterThemePullCommand = &cli.Command{ &cli.StringFlag{Name: "join-token", Usage: "Portal access `TOKEN` (defaults to global config)"}, &cli.StringFlag{Name: "client-id", Usage: "Node client `ID` (defaults to NodeID from config)"}, &cli.StringFlag{Name: "client-secret", Usage: "Node client `SECRET` (defaults to NodeSecret from config)"}, - JsonFlag, + // JSON output supported via report.CliFlags on parent command where applicable }, Action: clusterThemePullAction, }, diff --git a/internal/commands/download_e2e_test.go b/internal/commands/download_e2e_test.go index 984977120..1ecc37365 100644 --- a/internal/commands/download_e2e_test.go +++ b/internal/commands/download_e2e_test.go @@ -35,7 +35,7 @@ func createFakeYtDlp(t *testing.T) string { b.WriteString("set -euo pipefail\n") b.WriteString("OUT_TPL=\"\"\n") b.WriteString("i=0; while [[ $i -lt $# ]]; do i=$((i+1)); arg=\"${!i}\"; if [[ \"$arg\" == \"--dump-single-json\" ]]; then echo '{\"id\":\"abc\",\"title\":\"Test\",\"url\":\"http://example.com\",\"_type\":\"video\"}'; exit 0; fi; if [[ \"$arg\" == \"--output\" ]]; then i=$((i+1)); OUT_TPL=\"${!i}\"; fi; done\n") - b.WriteString("if [[ $* == *'--print '* ]]; then OUT=\"$OUT_TPL\"; OUT=${OUT//%(id)s/abc}; OUT=${OUT//%(ext)s/mp4}; mkdir -p \"$(dirname \"$OUT\")\"; CONTENT=\"${YTDLP_DUMMY_CONTENT:-dummy}\"; echo \"$CONTENT\" > \"$OUT\"; echo \"$OUT\"; exit 0; fi\n") + b.WriteString("if [[ $* == *'--print '* ]]; then OUT=\"$OUT_TPL\"; OUT=${OUT//%(id)s/abc}; OUT=${OUT//%(ext)s/mp4}; mkdir -p \"$(dirname \"$OUT\")\"; CONTENT=\"${YTDLP_DUMMY_CONTENT:-dummy}\"; echo \"$CONTENT\" > \"$OUT\"; echo \"$OUT\"; exit 0; fi\n") if err := os.WriteFile(path, []byte(b.String()), 0o755); err != nil { t.Fatalf("failed to write fake yt-dlp: %v", err) } @@ -83,18 +83,18 @@ func TestDownloadImpl_FileMethod_AutoSkipsRemux(t *testing.T) { } func TestDownloadImpl_FileMethod_Skip_NoRemux(t *testing.T) { - fake := createFakeYtDlp(t) - orig := dl.YtDlpBin - defer func() { dl.YtDlpBin = orig }() + fake := createFakeYtDlp(t) + orig := dl.YtDlpBin + defer func() { dl.YtDlpBin = orig }() - dest := "dl-e2e-skip" - // Ensure different file content so duplicate detection won't collapse into prior test's file - t.Setenv("YTDLP_DUMMY_CONTENT", "dummy2") - if c := get.Config(); c != nil { - c.Options().FFmpegBin = "/bin/false" // would fail if remux attempted - s := c.Settings() - s.Index.Convert = false - } + dest := "dl-e2e-skip" + // Ensure different file content so duplicate detection won't collapse into prior test's file + t.Setenv("YTDLP_DUMMY_CONTENT", "dummy2") + if c := get.Config(); c != nil { + c.Options().FFmpegBin = "/bin/false" // would fail if remux attempted + s := c.Settings() + s.Index.Convert = false + } conf := get.Config() if conf == nil { t.Fatalf("missing test config") diff --git a/internal/commands/find.go b/internal/commands/find.go index 56690fa39..98b8d90ae 100644 --- a/internal/commands/find.go +++ b/internal/commands/find.go @@ -17,8 +17,8 @@ import ( // FindCommand configures the command name, flags, and action. var FindCommand = &cli.Command{ Name: "find", - Usage: "Searches the index for specific files", - ArgsUsage: "[filter]", + Usage: "Finds indexed files that match the specified search filters", + ArgsUsage: "[filter]...", Flags: append(report.CliFlags, &cli.UintFlag{ Name: "count", Aliases: []string{"n"}, diff --git a/internal/commands/show_config.go b/internal/commands/show_config.go index 0e6742f49..21aa4e76c 100644 --- a/internal/commands/show_config.go +++ b/internal/commands/show_config.go @@ -1,6 +1,7 @@ package commands import ( + "encoding/json" "fmt" "strings" @@ -40,21 +41,34 @@ func showConfigAction(ctx *cli.Context) error { log.Debug(err) } + format, ferr := report.CliFormatStrict(ctx) + if ferr != nil { + return ferr + } + + if format == report.JSON { + type section struct { + Title string `json:"title"` + Items []map[string]string `json:"items"` + } + sections := make([]section, 0, len(ConfigReports)) + for _, rep := range ConfigReports { + rows, cols := rep.Report(conf) + sections = append(sections, section{Title: rep.Title, Items: report.RowsToObjects(rows, cols)}) + } + b, _ := json.Marshal(map[string]interface{}{"sections": sections}) + fmt.Println(string(b)) + return nil + } + for _, rep := range ConfigReports { - // Get values. rows, cols := rep.Report(conf) - - // Render report. - opt := report.Options{Format: report.CliFormat(ctx), NoWrap: rep.NoWrap} + opt := report.Options{Format: format, NoWrap: rep.NoWrap} result, _ := report.Render(rows, cols, opt) - - // Show report. if opt.Format == report.Default { fmt.Printf("\n%s\n\n", strings.ToUpper(rep.Title)) } - fmt.Println(result) } - return nil } diff --git a/internal/commands/show_config_options.go b/internal/commands/show_config_options.go index c9cf136c8..9fec4f6f6 100644 --- a/internal/commands/show_config_options.go +++ b/internal/commands/show_config_options.go @@ -1,6 +1,7 @@ package commands import ( + "encoding/json" "fmt" "strings" @@ -25,16 +26,54 @@ func showConfigOptionsAction(ctx *cli.Context) error { conf.SetLogLevel(logrus.FatalLevel) rows, cols := config.Flags.Report() + format, ferr := report.CliFormatStrict(ctx) + if ferr != nil { + return ferr + } - // CSV Export? - if ctx.Bool("csv") || ctx.Bool("tsv") { - result, err := report.RenderFormat(rows, cols, report.CliFormat(ctx)) - + // CSV/TSV exports use default single-table rendering + if format == report.CSV || format == report.TSV { + result, err := report.RenderFormat(rows, cols, format) fmt.Println(result) - return err } + // JSON aggregation path + if format == report.JSON { + type section struct { + Title string `json:"title"` + Info string `json:"info,omitempty"` + Items []map[string]string `json:"items"` + } + sectionsCfg := config.OptionsReportSections + agg := make([]section, 0, len(sectionsCfg)) + j := 0 + for i, sec := range sectionsCfg { + secRows := make([][]string, 0) + for { + row := rows[j] + if len(row) < 1 { + continue + } + if i < len(sectionsCfg)-1 && sectionsCfg[i+1].Start == row[0] { + break + } + secRows = append(secRows, row) + j++ + if j >= len(rows) { + break + } + } + agg = append(agg, section{Title: sec.Title, Info: sec.Info, Items: report.RowsToObjects(secRows, cols)}) + if j >= len(rows) { + break + } + } + b, _ := json.Marshal(map[string]interface{}{"sections": agg}) + fmt.Println(string(b)) + return nil + } + markDown := ctx.Bool("md") sections := config.OptionsReportSections @@ -74,7 +113,8 @@ func showConfigOptionsAction(ctx *cli.Context) error { } } - result, err := report.RenderFormat(secRows, cols, report.CliFormat(ctx)) + // JSON handled earlier; Markdown and default render per section below + result, err := report.RenderFormat(secRows, cols, format) if err != nil { return err diff --git a/internal/commands/show_config_yaml.go b/internal/commands/show_config_yaml.go index 57e456850..cf678f8a9 100644 --- a/internal/commands/show_config_yaml.go +++ b/internal/commands/show_config_yaml.go @@ -1,6 +1,7 @@ package commands import ( + "encoding/json" "fmt" "strings" @@ -25,16 +26,54 @@ func showConfigYamlAction(ctx *cli.Context) error { conf.SetLogLevel(logrus.TraceLevel) rows, cols := conf.Options().Report() + format, ferr := report.CliFormatStrict(ctx) + if ferr != nil { + return ferr + } - // CSV Export? - if ctx.Bool("csv") || ctx.Bool("tsv") { - result, err := report.RenderFormat(rows, cols, report.CliFormat(ctx)) - + // CSV/TSV exports use default single-table rendering + if format == report.CSV || format == report.TSV { + result, err := report.RenderFormat(rows, cols, format) fmt.Println(result) - return err } + // JSON aggregation path + if format == report.JSON { + type section struct { + Title string `json:"title"` + Info string `json:"info,omitempty"` + Items []map[string]string `json:"items"` + } + sectionsCfg := config.YamlReportSections + agg := make([]section, 0, len(sectionsCfg)) + j := 0 + for i, sec := range sectionsCfg { + secRows := make([][]string, 0) + for { + row := rows[j] + if len(row) < 1 { + continue + } + if i < len(sectionsCfg)-1 && sectionsCfg[i+1].Start == row[0] { + break + } + secRows = append(secRows, row) + j++ + if j >= len(rows) { + break + } + } + agg = append(agg, section{Title: sec.Title, Info: sec.Info, Items: report.RowsToObjects(secRows, cols)}) + if j >= len(rows) { + break + } + } + b, _ := json.Marshal(map[string]interface{}{"sections": agg}) + fmt.Println(string(b)) + return nil + } + markDown := ctx.Bool("md") sections := config.YamlReportSections @@ -74,7 +113,8 @@ func showConfigYamlAction(ctx *cli.Context) error { } } - result, err := report.RenderFormat(secRows, cols, report.CliFormat(ctx)) + // JSON handled earlier; Markdown and default render per section below + result, err := report.RenderFormat(secRows, cols, format) if err != nil { return err diff --git a/internal/commands/show_file_formats.go b/internal/commands/show_file_formats.go index e0175b27d..8be124ceb 100644 --- a/internal/commands/show_file_formats.go +++ b/internal/commands/show_file_formats.go @@ -26,10 +26,11 @@ var ShowFileFormatsCommand = &cli.Command{ // showFileFormatsAction displays supported media and sidecar file formats. func showFileFormatsAction(ctx *cli.Context) error { rows, cols := media.Report(fs.Extensions.Types(true), !ctx.Bool("short"), true, true) - - result, err := report.RenderFormat(rows, cols, report.CliFormat(ctx)) - + format, ferr := report.CliFormatStrict(ctx) + if ferr != nil { + return ferr + } + result, err := report.RenderFormat(rows, cols, format) fmt.Println(result) - return err } diff --git a/internal/commands/show_json_test.go b/internal/commands/show_json_test.go new file mode 100644 index 000000000..7c31ff5ec --- /dev/null +++ b/internal/commands/show_json_test.go @@ -0,0 +1,197 @@ +package commands + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestShowThumbSizes_JSON(t *testing.T) { + out, err := RunWithTestContext(ShowThumbSizesCommand, []string{"thumb-sizes", "--json"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var v []map[string]string + if err := json.Unmarshal([]byte(out), &v); err != nil { + t.Fatalf("invalid json: %v\n%s", err, out) + } + if len(v) == 0 { + t.Fatalf("expected at least one item") + } + // Expected keys for thumb-sizes detailed report + for _, k := range []string{"name", "width", "height", "aspect_ratio", "available", "usage"} { + if _, ok := v[0][k]; !ok { + t.Fatalf("expected key '%s' in first item", k) + } + } +} + +func TestShowSources_JSON(t *testing.T) { + out, err := RunWithTestContext(ShowSourcesCommand, []string{"sources", "--json"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var v []map[string]string + if err := json.Unmarshal([]byte(out), &v); err != nil { + t.Fatalf("invalid json: %v\n%s", err, out) + } + if len(v) == 0 { + t.Fatalf("expected at least one item") + } + if _, ok := v[0]["source"]; !ok { + t.Fatalf("expected key 'source' in first item") + } + if _, ok := v[0]["priority"]; !ok { + t.Fatalf("expected key 'priority' in first item") + } +} + +func TestShowMetadata_JSON(t *testing.T) { + out, err := RunWithTestContext(ShowMetadataCommand, []string{"metadata", "--json"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var v struct { + Items []map[string]string `json:"items"` + Docs []map[string]string `json:"docs"` + } + if err := json.Unmarshal([]byte(out), &v); err != nil { + t.Fatalf("invalid json: %v\n%s", err, out) + } + if len(v.Items) == 0 { + t.Fatalf("expected items") + } +} + +func TestShowConfig_JSON(t *testing.T) { + out, err := RunWithTestContext(ShowConfigCommand, []string{"config", "--json"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var v struct { + Sections []struct { + Title string `json:"title"` + Items []map[string]string `json:"items"` + } `json:"sections"` + } + if err := json.Unmarshal([]byte(out), &v); err != nil { + t.Fatalf("invalid json: %v\n%s", err, out) + } + if len(v.Sections) == 0 || len(v.Sections[0].Items) == 0 { + t.Fatalf("expected sections with items") + } +} + +func TestShowConfigOptions_JSON(t *testing.T) { + out, err := RunWithTestContext(ShowConfigOptionsCommand, []string{"config-options", "--json"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var v struct { + Sections []struct { + Title string `json:"title"` + Items []map[string]string `json:"items"` + } `json:"sections"` + } + if err := json.Unmarshal([]byte(out), &v); err != nil { + t.Fatalf("invalid json: %v\n%s", err, out) + } + if len(v.Sections) == 0 || len(v.Sections[0].Items) == 0 { + t.Fatalf("expected sections with items") + } +} + +func TestShowConfigYaml_JSON(t *testing.T) { + out, err := RunWithTestContext(ShowConfigYamlCommand, []string{"config-yaml", "--json"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var v struct { + Sections []struct { + Title string `json:"title"` + Items []map[string]string `json:"items"` + } `json:"sections"` + } + if err := json.Unmarshal([]byte(out), &v); err != nil { + t.Fatalf("invalid json: %v\n%s", err, out) + } + if len(v.Sections) == 0 || len(v.Sections[0].Items) == 0 { + t.Fatalf("expected sections with items") + } +} + +func TestShowFormatConflict_Error(t *testing.T) { + out, err := RunWithTestContext(ShowSourcesCommand, []string{"sources", "--json", "--csv"}) + if err == nil { + t.Fatalf("expected error for conflicting flags, got nil; output=%s", out) + } + // Expect an ExitCoder with code 2 + if ec, ok := err.(interface{ ExitCode() int }); ok { + if ec.ExitCode() != 2 { + t.Fatalf("expected exit code 2, got %d", ec.ExitCode()) + } + } else { + t.Fatalf("expected exit coder error, got %T: %v", err, err) + } +} + +func TestShowConfigOptions_MarkdownSections(t *testing.T) { + out, err := RunWithTestContext(ShowConfigOptionsCommand, []string{"config-options", "--md"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(out, "### Authentication") { + t.Fatalf("expected Markdown section heading '### Authentication' in output\n%s", out[:min(400, len(out))]) + } +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +func TestShowFileFormats_JSON(t *testing.T) { + out, err := RunWithTestContext(ShowFileFormatsCommand, []string{"file-formats", "--json"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var v []map[string]string + if err := json.Unmarshal([]byte(out), &v); err != nil { + t.Fatalf("invalid json: %v\n%s", err, out) + } + if len(v) == 0 { + t.Fatalf("expected at least one item") + } + // Keys depend on report settings in command: should include format, description, type, extensions + if _, ok := v[0]["format"]; !ok { + t.Fatalf("expected key 'format' in first item") + } + if _, ok := v[0]["type"]; !ok { + t.Fatalf("expected key 'type' in first item") + } + if _, ok := v[0]["extensions"]; !ok { + t.Fatalf("expected key 'extensions' in first item") + } +} + +func TestShowVideoSizes_JSON(t *testing.T) { + out, err := RunWithTestContext(ShowVideoSizesCommand, []string{"video-sizes", "--json"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var v []map[string]string + if err := json.Unmarshal([]byte(out), &v); err != nil { + t.Fatalf("invalid json: %v\n%s", err, out) + } + if len(v) == 0 { + t.Fatalf("expected at least one item") + } + if _, ok := v[0]["size"]; !ok { + t.Fatalf("expected key 'size' in first item") + } + if _, ok := v[0]["usage"]; !ok { + t.Fatalf("expected key 'usage' in first item") + } +} diff --git a/internal/commands/show_metadata.go b/internal/commands/show_metadata.go index 29d35e400..70989eb6b 100644 --- a/internal/commands/show_metadata.go +++ b/internal/commands/show_metadata.go @@ -1,6 +1,7 @@ package commands import ( + "encoding/json" "fmt" "sort" @@ -37,20 +38,36 @@ func showMetadataAction(ctx *cli.Context) error { }) // Output overview of supported metadata tags. - format := report.CliFormat(ctx) + format, ferr := report.CliFormatStrict(ctx) + if ferr != nil { + return ferr + } + if format == report.JSON { + resp := struct { + Items []map[string]string `json:"items"` + Docs []map[string]string `json:"docs,omitempty"` + }{ + Items: report.RowsToObjects(rows, cols), + } + if !ctx.Bool("short") { + resp.Docs = report.RowsToObjects(meta.Docs, []string{"Namespace", "Documentation"}) + } + b, err := json.Marshal(resp) + if err != nil { + return err + } + fmt.Println(string(b)) + return nil + } + result, err := report.RenderFormat(rows, cols, format) - fmt.Println(result) - if err != nil || ctx.Bool("short") || format == report.TSV { return err } - // Documentation links for those who want to delve deeper. result, err = report.RenderFormat(meta.Docs, []string{"Namespace", "Documentation"}, format) - fmt.Printf("## Metadata Tags by Namespace ##\n\n") fmt.Println(result) - return err } diff --git a/internal/commands/show_search_filters.go b/internal/commands/show_search_filters.go index 9eb752f5d..58e911f1f 100644 --- a/internal/commands/show_search_filters.go +++ b/internal/commands/show_search_filters.go @@ -30,9 +30,11 @@ func showSearchFiltersAction(ctx *cli.Context) error { } }) - result, err := report.RenderFormat(rows, cols, report.CliFormat(ctx)) - + format, ferr := report.CliFormatStrict(ctx) + if ferr != nil { + return ferr + } + result, err := report.RenderFormat(rows, cols, format) fmt.Println(result) - return err } diff --git a/internal/commands/show_sources.go b/internal/commands/show_sources.go index f3cbfe70f..43052e34d 100644 --- a/internal/commands/show_sources.go +++ b/internal/commands/show_sources.go @@ -20,10 +20,11 @@ var ShowSourcesCommand = &cli.Command{ // showSourcesAction displays supported metadata sources. func showSourcesAction(ctx *cli.Context) error { rows, cols := entity.SrcPriority.Report() - - result, err := report.RenderFormat(rows, cols, report.CliFormat(ctx)) - + format, ferr := report.CliFormatStrict(ctx) + if ferr != nil { + return ferr + } + result, err := report.RenderFormat(rows, cols, format) fmt.Println(result) - return err } diff --git a/internal/commands/show_thumb_sizes.go b/internal/commands/show_thumb_sizes.go index d6fa62432..7310af1d9 100644 --- a/internal/commands/show_thumb_sizes.go +++ b/internal/commands/show_thumb_sizes.go @@ -21,10 +21,11 @@ var ShowThumbSizesCommand = &cli.Command{ // showThumbSizesAction displays supported standard thumbnail sizes. func showThumbSizesAction(ctx *cli.Context) error { rows, cols := thumb.Report(thumb.Sizes.All(), false) - - result, err := report.RenderFormat(rows, cols, report.CliFormat(ctx)) - + format, ferr := report.CliFormatStrict(ctx) + if ferr != nil { + return ferr + } + result, err := report.RenderFormat(rows, cols, format) fmt.Println(result) - return err } diff --git a/internal/commands/show_video_sizes.go b/internal/commands/show_video_sizes.go index 945641a2e..0befab587 100644 --- a/internal/commands/show_video_sizes.go +++ b/internal/commands/show_video_sizes.go @@ -20,10 +20,11 @@ var ShowVideoSizesCommand = &cli.Command{ // showVideoSizesAction displays supported standard video sizes. func showVideoSizesAction(ctx *cli.Context) error { rows, cols := thumb.Report(thumb.VideoSizes, true) - - result, err := report.RenderFormat(rows, cols, report.CliFormat(ctx)) - + format, ferr := report.CliFormatStrict(ctx) + if ferr != nil { + return ferr + } + result, err := report.RenderFormat(rows, cols, format) fmt.Println(result) - return err } diff --git a/internal/config/config_storage_test.go b/internal/config/config_storage_test.go index 1e1194806..d0b1beb62 100644 --- a/internal/config/config_storage_test.go +++ b/internal/config/config_storage_test.go @@ -444,23 +444,15 @@ func TestConfig_OriginalsPath2(t *testing.T) { } func TestConfig_OriginalsDeletable(t *testing.T) { - c := NewConfig(CliTestContext()) + c := TestConfig() c.Settings().Features.Delete = true - c.options.ReadOnly = false + c.Options().ReadOnly = false + c.AssertTestData(t) assert.True(t, c.OriginalsDeletable()) } -func TestConfig_ImportPath2(t *testing.T) { - c := NewConfig(CliTestContext()) - assert.Equal(t, "/go/src/github.com/photoprism/photoprism/storage/testdata/import", c.ImportPath()) - c.options.ImportPath = "" - if s := c.ImportPath(); s != "" && s != "/photoprism/import" { - t.Errorf("unexpected import path: %s", s) - } -} - func TestConfig_ImportAllow(t *testing.T) { c := NewConfig(CliTestContext()) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index e7fb3d6fd..5864db2af 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -161,10 +161,19 @@ func TestConfig_OriginalsPath(t *testing.T) { func TestConfig_ImportPath(t *testing.T) { c := NewConfig(CliTestContext()) + c.AssertTestData(t) + assert.Equal(t, "/go/src/github.com/photoprism/photoprism/storage/testdata/import", c.ImportPath()) result := c.ImportPath() assert.True(t, strings.HasPrefix(result, "/")) assert.True(t, strings.HasSuffix(result, "/storage/testdata/import")) + + c.options.ImportPath = "" + if s := c.ImportPath(); s != "" && s != "/photoprism/import" { + t.Errorf("unexpected import path: %s", s) + } + + c.options.ImportPath = result } func TestConfig_CachePath(t *testing.T) { diff --git a/internal/config/config_test_test.go b/internal/config/config_test_test.go index 5a4179c81..7697e59dd 100644 --- a/internal/config/config_test_test.go +++ b/internal/config/config_test_test.go @@ -12,3 +12,12 @@ func TestConfig_InitializeTestData(t *testing.T) { err := c.InitializeTestData() assert.NoError(t, err) } + +func TestConfig_AssertTestData(t *testing.T) { + c := NewConfig(CliTestContext()) + // Ensure fixtures are initialized, then verify required directories. + if err := c.InitializeTestData(); err != nil { + t.Fatalf("InitializeTestData failed: %v", err) + } + c.AssertTestData(t) +} diff --git a/internal/config/test.go b/internal/config/test.go index 531327c01..504497f78 100644 --- a/internal/config/test.go +++ b/internal/config/test.go @@ -8,6 +8,7 @@ import ( "regexp" "strconv" "sync" + "testing" "time" "github.com/urfave/cli/v2" @@ -22,6 +23,7 @@ import ( "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" "github.com/photoprism/photoprism/pkg/rnd" + "github.com/photoprism/photoprism/pkg/txt/report" ) // Download URL and ZIP hash for test files. @@ -161,6 +163,9 @@ func TestConfig() *Config { } // NewTestConfig returns a valid test config. +// +// NewTestConfig initializes test data so required directories exist before tests run. +// See AGENTS.md (Test Data & Fixtures) and specs/dev/backend-testing.md for guidance. func NewTestConfig(pkg string) *Config { defer log.Debug(capture.Time(time.Now(), "config: new test config created")) @@ -187,6 +192,10 @@ func NewTestConfig(pkg string) *Config { log.Fatalf("config: %s", err.Error()) } + if err := c.InitializeTestData(); err != nil { + log.Fatalf("config: %s", err.Error()) + } + c.RegisterDb() c.InitTestDb() @@ -358,26 +367,89 @@ func (c *Config) UnzipTestData() error { return nil } -// InitializeTestData resets the test file directory. +// InitializeTestData resets "storage/testdata" to a clean state. +// +// The function removes prior artifacts, downloads fixtures when missing, +// unzips them, and then calls CreateDirectories so required directories exist. +// See AGENTS.md (Test Data & Fixtures) for details. func (c *Config) InitializeTestData() (err error) { testDataMutex.Lock() defer testDataMutex.Unlock() start := time.Now() + // Delete existing test files and directories in "storage/testdata". if err = c.RemoveTestData(); err != nil { return err } + // If the test file archive "/tmp/photoprism/testdata.zip" is missing, + // download it from https://dl.photoprism.app/qa/testdata.zip. if err = c.DownloadTestData(); err != nil { return err } + // Extract "/tmp/photoprism/testdata.zip" in "storage/testdata". if err = c.UnzipTestData(); err != nil { return err } + // Make sure all the required directories exist in "storage/testdata. + if err = c.CreateDirectories(); err != nil { + return err + } + log.Infof("config: initialized test data [%s]", time.Since(start)) return nil } + +// AssertTestData verifies the existence of the required test directories in "storage/testdata". +// +// Use this helper early in tests when diagnosing fixture setup issues. It logs +// presence/emptiness of required directories to testing.T. See the backend testing +// guide for additional patterns. +func (c *Config) AssertTestData(t *testing.T) { + reportDir := func(dir string) { + if fs.PathExists(dir) { + t.Logf("testdata: dir %s exists (%s)", clean.Log(dir), + report.Bool(fs.DirIsEmpty(dir), "empty", "not empty")) + } else { + t.Logf("testdata: dir %s is missing %s, but required", clean.Log(dir), report.CrossMark) + } + } + + reportErr := func(funcName string) { + t.Errorf("testdata: *Config.%s() must not return an empty string %s", funcName, report.CrossMark) + } + + if dir := c.AssetsPath(); dir != "" { + reportDir(dir) + } else { + reportErr("AssetsPath") + } + + if dir := c.ConfigPath(); dir != "" { + reportDir(dir) + } else { + reportErr("ConfigPath") + } + + if dir := c.ImportPath(); dir != "" { + reportDir(dir) + } else { + reportErr("ImportPath") + } + + if dir := c.OriginalsPath(); dir != "" { + reportDir(dir) + } else { + reportErr("OriginalsPath") + } + + if dir := c.SidecarPath(); dir != "" { + reportDir(dir) + } else { + reportErr("SidecarPath") + } +} diff --git a/internal/photoprism/convert_fix_test.go b/internal/photoprism/convert_fix_test.go index 05162746b..8469b1137 100644 --- a/internal/photoprism/convert_fix_test.go +++ b/internal/photoprism/convert_fix_test.go @@ -17,7 +17,8 @@ func TestConvert_FixJpeg(t *testing.T) { } cnf := config.TestConfig() - cnf.InitializeTestData() + initErr := cnf.InitializeTestData() + assert.NoError(t, initErr) convert := NewConvert(cnf) t.Run("elephants.jpg", func(t *testing.T) { diff --git a/internal/photoprism/convert_image_test.go b/internal/photoprism/convert_image_test.go index f6fcf828a..2b1ea1ef8 100644 --- a/internal/photoprism/convert_image_test.go +++ b/internal/photoprism/convert_image_test.go @@ -18,7 +18,8 @@ func TestConvert_ToImage(t *testing.T) { } cnf := config.TestConfig() - cnf.InitializeTestData() + initErr := cnf.InitializeTestData() + assert.NoError(t, initErr) convert := NewConvert(cnf) t.Run("Video", func(t *testing.T) { diff --git a/internal/photoprism/convert_test.go b/internal/photoprism/convert_test.go index 2bcc1c647..b22718a73 100644 --- a/internal/photoprism/convert_test.go +++ b/internal/photoprism/convert_test.go @@ -26,7 +26,8 @@ func TestConvert_Start(t *testing.T) { c := config.TestConfig() - c.InitializeTestData() + initErr := c.InitializeTestData() + assert.NoError(t, initErr) convert := NewConvert(c) diff --git a/internal/photoprism/import_test.go b/internal/photoprism/import_test.go index 21cd2e180..a8d32dfb0 100644 --- a/internal/photoprism/import_test.go +++ b/internal/photoprism/import_test.go @@ -22,9 +22,8 @@ func TestNewImport(t *testing.T) { func TestImport_DestinationFilename(t *testing.T) { cfg := config.TestConfig() - if err := cfg.InitializeTestData(); err != nil { - t.Fatal(err) - } + initErr := cfg.InitializeTestData() + assert.NoError(t, initErr) convert := NewConvert(cfg) @@ -66,7 +65,8 @@ func TestImport_Start(t *testing.T) { cfg := config.TestConfig() - cfg.InitializeTestData() + initErr := cfg.InitializeTestData() + assert.NoError(t, initErr) convert := NewConvert(cfg) diff --git a/internal/photoprism/import_worker_test.go b/internal/photoprism/import_worker_test.go index 1a2cfc5d1..0841f54cc 100644 --- a/internal/photoprism/import_worker_test.go +++ b/internal/photoprism/import_worker_test.go @@ -13,9 +13,8 @@ func TestImportWorker_OriginalFileNames(t *testing.T) { // settings/paths from the code under test. cfg := Config() - if err := cfg.InitializeTestData(); err != nil { - t.Fatal(err) - } + initErr := cfg.InitializeTestData() + assert.NoError(t, initErr) convert := NewConvert(cfg) ind := NewIndex(cfg, convert, NewFiles(), NewPhotos()) diff --git a/internal/photoprism/index_mediafile_test.go b/internal/photoprism/index_mediafile_test.go index bf134f016..3c04f5e46 100644 --- a/internal/photoprism/index_mediafile_test.go +++ b/internal/photoprism/index_mediafile_test.go @@ -17,7 +17,8 @@ func TestIndex_MediaFile(t *testing.T) { t.Run("flash.jpg", func(t *testing.T) { cfg := config.TestConfig() - cfg.InitializeTestData() + initErr := cfg.InitializeTestData() + assert.NoError(t, initErr) convert := NewConvert(cfg) @@ -51,7 +52,8 @@ func TestIndex_MediaFile(t *testing.T) { t.Run("blue-go-video.mp4", func(t *testing.T) { cfg := config.TestConfig() - cfg.InitializeTestData() + initErr := cfg.InitializeTestData() + assert.NoError(t, initErr) convert := NewConvert(cfg) @@ -71,7 +73,8 @@ func TestIndex_MediaFile(t *testing.T) { t.Run("Error", func(t *testing.T) { cfg := config.TestConfig() - cfg.InitializeTestData() + initErr := cfg.InitializeTestData() + assert.NoError(t, initErr) convert := NewConvert(cfg) diff --git a/internal/photoprism/index_test.go b/internal/photoprism/index_test.go index 99650e80d..9668a51bf 100644 --- a/internal/photoprism/index_test.go +++ b/internal/photoprism/index_test.go @@ -16,7 +16,8 @@ func TestIndex_Start(t *testing.T) { } cfg := config.TestConfig() - cfg.InitializeTestData() + initErr := cfg.InitializeTestData() + assert.NoError(t, initErr) convert := NewConvert(cfg) ind := NewIndex(cfg, convert, NewFiles(), NewPhotos()) @@ -60,7 +61,8 @@ func TestIndex_File(t *testing.T) { } cfg := config.TestConfig() - cfg.InitializeTestData() + initErr := cfg.InitializeTestData() + assert.NoError(t, initErr) convert := NewConvert(cfg) ind := NewIndex(cfg, convert, NewFiles(), NewPhotos()) diff --git a/internal/photoprism/thumbs_test.go b/internal/photoprism/thumbs_test.go index 7f61bda9f..62ebd0fc3 100644 --- a/internal/photoprism/thumbs_test.go +++ b/internal/photoprism/thumbs_test.go @@ -25,7 +25,8 @@ func TestResample_Start(t *testing.T) { t.Fatal(err) } - cfg.InitializeTestData() + initErr := cfg.InitializeTestData() + assert.NoError(t, initErr) convert := NewConvert(cfg) ind := NewIndex(cfg, convert, NewFiles(), NewPhotos()) diff --git a/pkg/txt/report/cli.go b/pkg/txt/report/cli.go index 739c8d308..924592522 100644 --- a/pkg/txt/report/cli.go +++ b/pkg/txt/report/cli.go @@ -1,9 +1,13 @@ package report -import "github.com/urfave/cli/v2" +import ( + "github.com/urfave/cli/v2" +) func CliFormat(ctx *cli.Context) Format { switch { + case ctx.Bool("json"): + return JSON case ctx.Bool("md"), ctx.Bool("markdown"): return Markdown case ctx.Bool("tsv"): @@ -15,7 +19,34 @@ func CliFormat(ctx *cli.Context) Format { } } +// CliFormatStrict selects a single output format from flags and returns +// a usage error (exit code 2) if multiple format flags are provided. +func CliFormatStrict(ctx *cli.Context) (Format, error) { + count := 0 + if ctx.Bool("json") { + count++ + } + if ctx.Bool("md") || ctx.Bool("markdown") { + count++ + } + if ctx.Bool("tsv") { + count++ + } + if ctx.Bool("csv") { + count++ + } + if count > 1 { + return Default, cli.Exit("choose exactly one output format: --json | --md | --csv | --tsv", 2) + } + return CliFormat(ctx), nil +} + var CliFlags = []cli.Flag{ + &cli.BoolFlag{ + Name: "json", + Aliases: []string{"j"}, + Usage: "print machine-readable JSON", + }, &cli.BoolFlag{ Name: "md", Aliases: []string{"m"}, diff --git a/pkg/txt/report/const.go b/pkg/txt/report/const.go index eea29272b..a6b3fd70d 100644 --- a/pkg/txt/report/const.go +++ b/pkg/txt/report/const.go @@ -6,4 +6,6 @@ const ( Yes = "Yes" No = "No" NotAssigned = "n/a" + CheckMark = "✅" + CrossMark = "❌" ) diff --git a/pkg/txt/report/format.go b/pkg/txt/report/format.go index d1bec75a4..38a9ae710 100644 --- a/pkg/txt/report/format.go +++ b/pkg/txt/report/format.go @@ -7,4 +7,5 @@ const ( Markdown = "markdown" TSV = "tsv" CSV = "csv" + JSON = "json" ) diff --git a/pkg/txt/report/json.go b/pkg/txt/report/json.go new file mode 100644 index 000000000..64064309a --- /dev/null +++ b/pkg/txt/report/json.go @@ -0,0 +1,38 @@ +package report + +import ( + "encoding/json" +) + +// RowsToObjects converts a table (rows + column names) into a slice of +// objects keyed by canonicalized column names. +func RowsToObjects(rows [][]string, cols []string) []map[string]string { + out := make([]map[string]string, 0, len(rows)) + keys := make([]string, len(cols)) + for i, c := range cols { + keys[i] = CanonKey(c) + } + for _, r := range rows { + obj := make(map[string]string, len(keys)) + for i := range keys { + val := "" + if i < len(r) { + val = r[i] + } + obj[keys[i]] = val + } + out = append(out, obj) + } + return out +} + +// JSONExport returns a JSON string for a single-table report as a top-level +// array of objects keyed by canonicalized column names. +func JSONExport(rows [][]string, cols []string) (string, error) { + data := RowsToObjects(rows, cols) + b, err := json.Marshal(data) + if err != nil { + return "", err + } + return string(b), nil +} diff --git a/pkg/txt/report/keys.go b/pkg/txt/report/keys.go new file mode 100644 index 000000000..81a920d68 --- /dev/null +++ b/pkg/txt/report/keys.go @@ -0,0 +1,24 @@ +package report + +import ( + "regexp" + "strings" +) + +var nonAlnum = regexp.MustCompile(`[^a-z0-9_]+`) +var underscores = regexp.MustCompile(`_+`) + +// CanonKey converts a column title into a stable snake_case key suitable +// for JSON output. It lowercases, replaces spaces/hyphens/slashes with '_', +// removes other punctuation, collapses repeats, and trims edges. +func CanonKey(s string) string { + k := strings.ToLower(s) + k = strings.NewReplacer(" ", "_", "-", "_", "/", "_").Replace(k) + k = nonAlnum.ReplaceAllString(k, "_") + k = underscores.ReplaceAllString(k, "_") + k = strings.Trim(k, "_") + if k == "" { + return "col" + } + return k +} diff --git a/pkg/txt/report/render.go b/pkg/txt/report/render.go index 7447dda1b..168873626 100644 --- a/pkg/txt/report/render.go +++ b/pkg/txt/report/render.go @@ -10,6 +10,8 @@ import ( // so the output can be pasted into the docs. func RenderFormat(rows [][]string, cols []string, format Format) (string, error) { switch format { + case JSON: + return JSONExport(rows, cols) case CSV: return Render(rows, cols, Options{Format: CSV}) case TSV: @@ -27,6 +29,8 @@ func RenderFormat(rows [][]string, cols []string, format Format) (string, error) // so the output can be pasted into the docs. func Render(rows [][]string, cols []string, opt Options) (string, error) { switch opt.Format { + case JSON: + return JSONExport(rows, cols) case CSV: return CsvExport(rows, cols, ';') case TSV: