diff --git a/internal/api/admin.go b/internal/api/admin.go index 6a182b7..634efdf 100644 --- a/internal/api/admin.go +++ b/internal/api/admin.go @@ -144,3 +144,104 @@ func (c *Client) Profiles(ctx context.Context) ([]AdminProfile, error) { } return out.Profiles, nil } + +// AdminFeed is a subscribed feed source as the admin API returns it +// (GET/POST/PATCH /api/admin/feeds). authorHandle and listingSlug are joined +// display labels; the server stores profile/listing ids after resolving those +// handles. There is no DELETE verb — pause with enabled=false. +type AdminFeed struct { + ID string `json:"id"` + Label string `json:"label"` + FeedURL string `json:"feedUrl"` + Kind string `json:"kind"` // github_release | rss + AuthorProfileID *string `json:"authorProfileId"` + AuthorHandle *string `json:"authorHandle"` + ListingID *string `json:"listingId"` + ListingSlug *string `json:"listingSlug"` + DefaultCategory string `json:"defaultCategory"` + DefaultTags []string `json:"defaultTags"` + AutoPublish bool `json:"autoPublish"` + Enabled bool `json:"enabled"` + LastFetchedAt *string `json:"lastFetchedAt"` + LastStatus *string `json:"lastStatus"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +// FeedSyncSummary is the result of POST /api/admin/feeds/{id}/sync (or one +// entry of the bulk ingest run). Error is non-empty when the fetch/parse failed. +type FeedSyncSummary struct { + FeedID string `json:"feedId"` + Label string `json:"label"` + Fetched int `json:"fetched"` + Created int `json:"created"` + Updated int `json:"updated"` + Skipped int `json:"skipped"` + ItemErrors []string `json:"itemErrors"` + Error string `json:"error,omitempty"` +} + +// Feeds lists every feed source: GET /api/admin/feeds. Admin only. +func (c *Client) Feeds(ctx context.Context) ([]AdminFeed, error) { + var out struct { + Feeds []AdminFeed `json:"feeds"` + } + if err := c.do(ctx, http.MethodGet, "/api/admin/feeds", nil, nil, &out); err != nil { + return nil, err + } + return out.Feeds, nil +} + +// AdminFeed fetches one feed source by id: GET /api/admin/feeds/{id}. +func (c *Client) AdminFeed(ctx context.Context, id string) (*AdminFeed, error) { + var out struct { + Feed AdminFeed `json:"feed"` + } + path := "/api/admin/feeds/" + url.PathEscape(id) + if err := c.do(ctx, http.MethodGet, path, nil, nil, &out); err != nil { + return nil, err + } + return &out.Feed, nil +} + +// CreateFeed creates a feed source: POST /api/admin/feeds. fields is sent +// verbatim — the server validates kind/defaultCategory and resolves +// authorHandle/listingSlug to ids (422 unknown_profile / invalid_input when +// missing). The id is server-assigned. +func (c *Client) CreateFeed(ctx context.Context, fields map[string]any) (*AdminFeed, error) { + var out struct { + Feed AdminFeed `json:"feed"` + } + if err := c.do(ctx, http.MethodPost, "/api/admin/feeds", nil, fields, &out); err != nil { + return nil, err + } + return &out.Feed, nil +} + +// UpdateFeed patches a feed source: PATCH /api/admin/feeds/{id}. No DELETE — +// pause with {"enabled":false}. authorHandle/listingSlug are re-resolved +// server-side when present; empty string clears the attribution. +func (c *Client) UpdateFeed(ctx context.Context, id string, patch map[string]any) (*AdminFeed, error) { + var out struct { + Feed AdminFeed `json:"feed"` + } + path := "/api/admin/feeds/" + url.PathEscape(id) + if err := c.do(ctx, http.MethodPatch, path, nil, patch, &out); err != nil { + return nil, err + } + return &out.Feed, nil +} + +// SyncFeed ingests one feed now: POST /api/admin/feeds/{id}/sync. A failed +// fetch/parse is a 502 carrying the summary — the typed *APIError still +// surfaces; callers that need the summary on success use this path. +func (c *Client) SyncFeed(ctx context.Context, id string) (*FeedSyncSummary, error) { + var out struct { + Summary FeedSyncSummary `json:"summary"` + } + path := "/api/admin/feeds/" + url.PathEscape(id) + "/sync" + if err := c.do(ctx, http.MethodPost, path, nil, nil, &out); err != nil { + return nil, err + } + return &out.Summary, nil +} diff --git a/internal/api/admin_test.go b/internal/api/admin_test.go index 2ae12fd..ed6a96f 100644 --- a/internal/api/admin_test.go +++ b/internal/api/admin_test.go @@ -216,6 +216,104 @@ func TestAdminProfileMethods(t *testing.T) { }) } +// Feed equivalents follow the same wire contract under /api/admin/feeds: +// list GETs every feed source; create POSTs the field map; get/update use the +// id path; sync is POST …/sync and decodes the summary. +func TestAdminFeedMethods(t *testing.T) { + t.Run("list", func(t *testing.T) { + e := &adminEcho{status: http.StatusOK, + answer: `{"feeds":[{"id":"01F","label":"Buzz","feedUrl":"https://github.com/block/buzz","kind":"github_release","defaultCategory":"Releases","defaultTags":["buzz"],"autoPublish":true,"enabled":true,"createdAt":"t","updatedAt":"t"}]}`} + c := adminClient(t, e) + feeds, err := c.Feeds(context.Background()) + if err != nil { + t.Fatalf("Feeds: %v", err) + } + if e.method != http.MethodGet || e.path != "/api/admin/feeds" { + t.Errorf("request = %s %s, want GET /api/admin/feeds", e.method, e.path) + } + if len(feeds) != 1 || feeds[0].Label != "Buzz" { + t.Errorf("feeds = %+v, want the decoded feed list", feeds) + } + }) + + t.Run("create", func(t *testing.T) { + e := &adminEcho{status: http.StatusCreated, + answer: `{"feed":{"id":"01F","label":"Buzz","feedUrl":"https://github.com/block/buzz","kind":"github_release","defaultCategory":"Releases","defaultTags":[],"autoPublish":true,"enabled":true,"createdAt":"t","updatedAt":"t"}}`} + c := adminClient(t, e) + feed, err := c.CreateFeed(context.Background(), map[string]any{ + "label": "Buzz", "feedUrl": "https://github.com/block/buzz", + "kind": "github_release", "defaultCategory": "Releases", "autoPublish": true, + }) + if err != nil { + t.Fatalf("CreateFeed: %v", err) + } + if e.method != http.MethodPost || e.path != "/api/admin/feeds" { + t.Errorf("request = %s %s, want POST /api/admin/feeds", e.method, e.path) + } + var sent map[string]any + if err := json.Unmarshal(e.body, &sent); err != nil { + t.Fatalf("request body is not JSON: %v (%q)", err, e.body) + } + if sent["label"] != "Buzz" || sent["autoPublish"] != true { + t.Errorf("sent body = %v, want the field map verbatim", sent) + } + if feed.ID != "01F" || feed.Label != "Buzz" { + t.Errorf("feed = %+v, want the decoded created feed", feed) + } + }) + + t.Run("get", func(t *testing.T) { + e := &adminEcho{status: http.StatusOK, + answer: `{"feed":{"id":"01F","label":"Buzz","feedUrl":"u","kind":"github_release","defaultCategory":"Releases","defaultTags":[],"autoPublish":false,"enabled":true,"createdAt":"t","updatedAt":"t"}}`} + c := adminClient(t, e) + feed, err := c.AdminFeed(context.Background(), "01F") + if err != nil { + t.Fatalf("AdminFeed: %v", err) + } + if e.method != http.MethodGet || e.path != "/api/admin/feeds/01F" { + t.Errorf("request = %s %s, want GET /api/admin/feeds/01F", e.method, e.path) + } + if feed.Label != "Buzz" { + t.Errorf("feed = %+v, want the decoded feed", feed) + } + }) + + t.Run("update", func(t *testing.T) { + e := &adminEcho{status: http.StatusOK, + answer: `{"feed":{"id":"01F","label":"Buzz","feedUrl":"u","kind":"github_release","defaultCategory":"Releases","defaultTags":[],"autoPublish":true,"enabled":false,"createdAt":"t","updatedAt":"t"}}`} + c := adminClient(t, e) + feed, err := c.UpdateFeed(context.Background(), "01F", map[string]any{"enabled": false}) + if err != nil { + t.Fatalf("UpdateFeed: %v", err) + } + if e.method != http.MethodPatch || e.path != "/api/admin/feeds/01F" { + t.Errorf("request = %s %s, want PATCH /api/admin/feeds/01F", e.method, e.path) + } + if string(e.body) != `{"enabled":false}` { + t.Errorf("sent body = %s, want only the patched field", e.body) + } + if feed.Enabled { + t.Errorf("feed.Enabled = true, want false") + } + }) + + t.Run("sync", func(t *testing.T) { + e := &adminEcho{status: http.StatusOK, + answer: `{"summary":{"feedId":"01F","label":"Buzz","fetched":2,"created":2,"updated":0,"skipped":0,"itemErrors":[]}}`} + c := adminClient(t, e) + sum, err := c.SyncFeed(context.Background(), "01F") + if err != nil { + t.Fatalf("SyncFeed: %v", err) + } + if e.method != http.MethodPost || e.path != "/api/admin/feeds/01F/sync" { + t.Errorf("request = %s %s, want POST /api/admin/feeds/01F/sync", e.method, e.path) + } + if sum.FeedID != "01F" || sum.Created != 2 { + t.Errorf("summary = %+v, want the decoded sync summary", sum) + } + }) +} + // A non-2xx admin response must surface as the typed *APIError carrying the // server's envelope verbatim — the CLI prints validator messages raw. func TestAdminErrorPassthrough(t *testing.T) { diff --git a/internal/cli/admin.go b/internal/cli/admin.go index fb9d8c5..856cbad 100644 --- a/internal/cli/admin.go +++ b/internal/cli/admin.go @@ -39,8 +39,8 @@ const defaultLoopSourceURL = "https://positronick.com/registry/loop" // registerAdminCommands attaches the hidden admin commands to the existing // tree by lookup — soul create/update under the soul noun, loop create under -// the loop noun, plus a hidden `listing` parent for the type-agnostic -// create/update — then reveals them all if the cached login is an admin. +// the loop noun, plus hidden `listing`/`profile`/`feed` parents — then +// reveals them all if the cached login is an admin. func registerAdminCommands(root *cobra.Command) { if soul := findCommand(root, "soul"); soul != nil { soul.AddCommand(newSoulCreateCmd(), newSoulUpdateCmd()) @@ -50,6 +50,7 @@ func registerAdminCommands(root *cobra.Command) { } root.AddCommand(newListingCmd()) root.AddCommand(newProfileCmd()) + root.AddCommand(newFeedCmd()) revealAdminCommands(root) } diff --git a/internal/cli/admin_test.go b/internal/cli/admin_test.go index ced63d3..07abd80 100644 --- a/internal/cli/admin_test.go +++ b/internal/cli/admin_test.go @@ -165,6 +165,11 @@ var adminCommandPaths = [][]string{ {"profile"}, {"profile", "create"}, {"profile", "list"}, + {"feed"}, + {"feed", "list"}, + {"feed", "create"}, + {"feed", "update"}, + {"feed", "sync"}, } // The admin commands are hidden by default and flip visible from the CACHED @@ -246,6 +251,8 @@ func TestRevealAdminCommands(t *testing.T) { "positronick soul create", "positronick soul update", "positronick listing create", "positronick loop create", "positronick profile create", "positronick profile list", + "positronick feed list", "positronick feed create", + "positronick feed update", "positronick feed sync", } { if !strings.Contains(stdout, "## "+want) { t.Errorf("agent-docs for an admin must document %q", want) diff --git a/internal/cli/feed.go b/internal/cli/feed.go new file mode 100644 index 0000000..f8f1d1b --- /dev/null +++ b/internal/cli/feed.go @@ -0,0 +1,330 @@ +package cli + +import ( + "sort" + + "github.com/positronick/cli/internal/api" + "github.com/positronick/cli/internal/output" + "github.com/spf13/cobra" +) + +// This file owns the hidden admin `feed` command group (list/create/update/sync) +// against /api/admin/feeds. Feed sources drive the blog's release/RSS ingest — +// GitHub releases use kind=github_release with feedUrl=the repo URL; RSS uses +// the feed URL. Like the other admin commands they are Hidden + annotated +// (revealed for cached admins) and map a 401/403 to exit 4 via adminAPIError. +// There is no delete verb: pause with `feed update --disabled`. + +// feedAdminNote is the Long-help tail for feed commands. Feeds have no +// --status draft unpublish path (adminNote would mislead), so they get their +// own note pointing at --disabled. +const feedAdminNote = "\n\nRequires an admin account (positronick login). Ids are server-assigned " + + "ULIDs — never supply one. There is no delete verb: pause with feed update --disabled." + +// feedListResult is the `feed list --json` contract. +type feedListResult struct { + Count int `json:"count"` + Feeds []api.AdminFeed `json:"feeds"` +} + +// feedCreateResult is the `feed create --json` contract. +type feedCreateResult struct { + Feed api.AdminFeed `json:"feed"` + Created bool `json:"created"` +} + +// feedUpdateResult is the `feed update --json` contract. +type feedUpdateResult struct { + Feed api.AdminFeed `json:"feed"` +} + +// feedSyncResult is the `feed sync --json` contract. +type feedSyncResult struct { + Summary api.FeedSyncSummary `json:"summary"` +} + +func newFeedCmd() *cobra.Command { + cmd := markAdmin(&cobra.Command{ + Use: "feed", + Short: "Subscribe and manage release/RSS feed sources (admin)", + Long: "Manage feed sources the blog ingestor mirrors into release/link posts. " + + "GitHub releases use kind=github_release with feedUrl=the repo URL; RSS uses the feed URL. " + + "No delete verb — pause with `feed update --disabled`." + feedAdminNote, + }) + cmd.AddCommand( + newFeedListCmd(), + newFeedCreateCmd(), + newFeedUpdateCmd(), + newFeedSyncCmd(), + ) + return cmd +} + +func newFeedListCmd() *cobra.Command { + return markAdmin(&cobra.Command{ + Use: "list", + Short: "List all feed sources (admin)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + p, client, err := printerAndClient(cmd) + if err != nil { + return err + } + feeds, err := client.Feeds(cmd.Context()) + if err != nil { + return adminAPIError(err) + } + // Stable order for human + golden output; the API returns DB order. + sort.Slice(feeds, func(i, j int) bool { return feeds[i].Label < feeds[j].Label }) + if p.Mode.JSON { + return p.EmitJSON(feedListResult{Count: len(feeds), Feeds: feeds}) + } + renderFeedTable(p, feeds) + return nil + }, + }) +} + +func newFeedCreateCmd() *cobra.Command { + cmd := markAdmin(&cobra.Command{ + Use: "create --label LABEL --url URL", + Short: "Subscribe a release/RSS feed source (admin)", + Long: "Create a feed source the blog ingestor will mirror. For GitHub releases, " + + "--url is the repository URL and --kind defaults to github_release. --author " + + "and --listing must already exist (same attribution rule as listings). " + + "--auto-publish defaults false; --disabled starts the feed paused." + feedAdminNote, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + p, client, err := printerAndClient(cmd) + if err != nil { + return err + } + fields, err := feedFieldsFromFlags(cmd, false) + if err != nil { + return err + } + feed, err := client.CreateFeed(cmd.Context(), fields) + if err != nil { + return adminAPIError(err) + } + if p.Mode.JSON { + return p.EmitJSON(feedCreateResult{Feed: *feed, Created: true}) + } + p.Human("Created feed %s (%s, id %s) — listing=%s author=%s autoPublish=%v enabled=%v\n", + feed.Label, feed.Kind, feed.ID, + deref(feed.ListingSlug), deref(feed.AuthorHandle), + feed.AutoPublish, feed.Enabled) + return nil + }, + }) + addFeedFieldFlags(cmd, true) + for _, required := range []string{"label", "url"} { + _ = cmd.MarkFlagRequired(required) + } + return cmd +} + +func newFeedUpdateCmd() *cobra.Command { + cmd := markAdmin(&cobra.Command{ + Use: "update ", + Short: "Update a feed source (admin)", + Long: "Patch a feed source in place: only the flags you provide change. Empty " + + "--author or --listing clears the attribution. --enabled / --disabled are " + + "mutually exclusive; there is no delete verb — pause with --disabled." + feedAdminNote, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + p, client, err := printerAndClient(cmd) + if err != nil { + return err + } + patch, err := feedFieldsFromFlags(cmd, true) + if err != nil { + return err + } + if len(patch) == 0 { + return output.Errorf("nothing to update — provide field flags, --enabled or --disabled") + } + feed, err := client.UpdateFeed(cmd.Context(), args[0], patch) + if err != nil { + return adminAPIError(err) + } + if p.Mode.JSON { + return p.EmitJSON(feedUpdateResult{Feed: *feed}) + } + p.Human("Updated feed %s (id %s, enabled=%v, autoPublish=%v)\n", + feed.Label, feed.ID, feed.Enabled, feed.AutoPublish) + return nil + }, + }) + addFeedFieldFlags(cmd, false) + return cmd +} + +func newFeedSyncCmd() *cobra.Command { + return markAdmin(&cobra.Command{ + Use: "sync ", + Short: "Ingest one feed now (admin)", + Long: "Run the blog ingestor against a single feed source immediately and print " + + "the sync summary (fetched/created/updated/skipped). A failed fetch surfaces " + + "as a server error; there is no offline dry-run." + feedAdminNote, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + p, client, err := printerAndClient(cmd) + if err != nil { + return err + } + sum, err := client.SyncFeed(cmd.Context(), args[0]) + if err != nil { + return adminAPIError(err) + } + if p.Mode.JSON { + return p.EmitJSON(feedSyncResult{Summary: *sum}) + } + p.Human("Synced feed %s (id %s): fetched=%d created=%d updated=%d skipped=%d\n", + sum.Label, sum.FeedID, sum.Fetched, sum.Created, sum.Updated, sum.Skipped) + if len(sum.ItemErrors) > 0 { + p.Human("Item errors: %d\n", len(sum.ItemErrors)) + } + return nil + }, + }) +} + +// addFeedFieldFlags registers the shared create/update field flags. create=true +// sets defaults (kind/category) and only --disabled; update adds --enabled and +// makes the pair mutually exclusive. +func addFeedFieldFlags(cmd *cobra.Command, create bool) { + f := cmd.Flags() + f.String("label", "", "display label for the feed source") + f.String("url", "", "repository URL (github_release) or feed URL (rss)") + kindDefault, categoryDefault := "", "" + if create { + kindDefault, categoryDefault = "github_release", "Releases" + } + f.String("kind", kindDefault, "feed kind: github_release or rss") + f.String("category", categoryDefault, "default blog category for ingested posts") + f.String("author", "", "authoring profile handle (must exist); empty on update clears") + f.String("listing", "", "linked listing slug (must exist); empty on update clears") + f.StringArray("tag", nil, "default tag applied to ingested posts, repeatable") + f.Bool("auto-publish", false, "auto-publish ingested posts (default false)") + f.Bool("disabled", false, "create/update the feed as paused (enabled=false)") + if !create { + f.Bool("enabled", false, "re-enable a paused feed") + cmd.MarkFlagsMutuallyExclusive("enabled", "disabled") + } +} + +// feedFieldsFromFlags builds the admin-API field map from explicitly set flags. +// partial=true (update) only includes Changed flags and allows empty +// author/listing to clear attribution; partial=false (create) always sends +// required/defaulted fields and omits empty optionals. +func feedFieldsFromFlags(cmd *cobra.Command, partial bool) (map[string]any, error) { + fields := map[string]any{} + + setString := func(flag, key string, always, allowEmpty bool) error { + if partial && !cmd.Flags().Changed(flag) { + return nil + } + v, err := cmd.Flags().GetString(flag) + if err != nil { + return err + } + if !always && v == "" && !allowEmpty { + return nil + } + // On update, empty author/listing is meaningful (clear); on create we + // already skipped empties above unless always. + if allowEmpty || v != "" || always { + fields[key] = v + } + return nil + } + + if err := setString("label", "label", !partial, false); err != nil { + return nil, err + } + if err := setString("url", "feedUrl", !partial, false); err != nil { + return nil, err + } + if err := setString("kind", "kind", !partial, false); err != nil { + return nil, err + } + if err := setString("category", "defaultCategory", !partial, false); err != nil { + return nil, err + } + // author/listing: on update, empty string clears; on create, omit if empty. + if err := setString("author", "authorHandle", false, partial); err != nil { + return nil, err + } + if err := setString("listing", "listingSlug", false, partial); err != nil { + return nil, err + } + + if !partial || cmd.Flags().Changed("tag") { + tags, err := cmd.Flags().GetStringArray("tag") + if err != nil { + return nil, err + } + if tags == nil { + tags = []string{} + } + // Always send on create (empty array is the server default); on update + // only when --tag was set so we never wipe tags by accident. + fields["defaultTags"] = tags + } + + // On create always send so the wire body is explicit; on update only when + // Changed (covers --auto-publish and --auto-publish=false). + if !partial || cmd.Flags().Changed("auto-publish") { + v, err := cmd.Flags().GetBool("auto-publish") + if err != nil { + return nil, err + } + fields["autoPublish"] = v + } + + if cmd.Flags().Changed("disabled") { + disabled, err := cmd.Flags().GetBool("disabled") + if err != nil { + return nil, err + } + // --disabled means enabled=false; --disabled=false on update re-enables. + // On create without the flag we omit enabled so the server defaults true. + fields["enabled"] = !disabled + } + + if partial && cmd.Flags().Lookup("enabled") != nil && cmd.Flags().Changed("enabled") { + enabled, err := cmd.Flags().GetBool("enabled") + if err != nil { + return nil, err + } + fields["enabled"] = enabled + } + + return fields, nil +} + +func renderFeedTable(p *output.Printer, feeds []api.AdminFeed) { + rows := make([][]string, len(feeds)) + for i, f := range feeds { + rows[i] = []string{ + f.ID, + f.Label, + f.Kind, + deref(f.ListingSlug), + deref(f.AuthorHandle), + yn(f.AutoPublish), + yn(f.Enabled), + deref(f.LastStatus), + f.FeedURL, + } + } + output.RenderTable(p.Out, []string{"ID", "LABEL", "KIND", "LISTING", "AUTHOR", "AUTO", "ON", "LAST", "URL"}, rows) +} + +func yn(v bool) string { + if v { + return "yes" + } + return "no" +} diff --git a/internal/cli/feed_test.go b/internal/cli/feed_test.go new file mode 100644 index 0000000..5775408 --- /dev/null +++ b/internal/cli/feed_test.go @@ -0,0 +1,385 @@ +package cli + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/positronick/cli/internal/output" +) + +// feed list against a fresh mock (no seed feeds): empty --json and human +// table are pinned as goldens. +func TestFeedListGolden(t *testing.T) { + t.Run("json", func(t *testing.T) { + adminEnv(t) + srv := newMockServer(t) + stdout, stderr, code := executeAgainst(t, srv.URL, "feed", "list", "--json") + if code != 0 { + t.Fatalf("exit code = %d, want 0 (stderr: %s)", code, stderr) + } + assertGolden(t, "feed-list.json", stdout) + }) + + t.Run("human", func(t *testing.T) { + adminEnv(t) + srv := newMockServer(t) + stdout, stderr, code := executeAgainst(t, srv.URL, "feed", "list") + if code != 0 { + t.Fatalf("exit code = %d, want 0 (stderr: %s)", code, stderr) + } + assertGolden(t, "feed-list.txt", stdout) + }) +} + +// feed create against a seeded listing/author (claude-code / anthropic): the +// --json shape and human summary are pinned as goldens. +func TestFeedCreateGolden(t *testing.T) { + t.Run("json", func(t *testing.T) { + adminEnv(t) + srv := newMockServer(t) + stdout, stderr, code := executeAgainst(t, srv.URL, + "feed", "create", + "--label", "Claude Code", + "--url", "https://github.com/anthropics/claude-code", + "--listing", "claude-code", + "--author", "anthropic", + "--json") + if code != 0 { + t.Fatalf("exit code = %d, want 0 (stderr: %s)", code, stderr) + } + assertGolden(t, "feed-create.json", stdout) + }) + + t.Run("human", func(t *testing.T) { + adminEnv(t) + srv := newMockServer(t) + stdout, stderr, code := executeAgainst(t, srv.URL, + "feed", "create", + "--label", "Claude Code", + "--url", "https://github.com/anthropics/claude-code", + "--listing", "claude-code", + "--author", "anthropic") + if code != 0 { + t.Fatalf("exit code = %d, want 0 (stderr: %s)", code, stderr) + } + assertGolden(t, "feed-create.txt", stdout) + if !strings.Contains(stdout, "claude-code") || !strings.Contains(stdout, "anthropic") { + t.Errorf("stdout = %q, want listing and author in the summary", stdout) + } + }) +} + +// An unknown author is a 422 unknown_profile → exit 1 with the server's message. +func TestFeedCreateUnknownAuthor(t *testing.T) { + adminEnv(t) + srv := newMockServer(t) + _, stderr, code := executeAgainst(t, srv.URL, + "feed", "create", + "--label", "Nobody", + "--url", "https://github.com/nobody/repo", + "--author", "nobody", + "--json") + if code != output.ExitError { + t.Fatalf("exit code = %d, want %d (stderr: %s)", code, output.ExitError, stderr) + } + if !strings.Contains(stderr, `"code":"unknown_profile"`) || !strings.Contains(stderr, "nobody") { + t.Errorf("stderr = %q, want the unknown_profile envelope", stderr) + } +} + +// create then sync: summary is deterministic (mock never hits the network) and +// the feed's lastStatus becomes "ok" with a non-null lastFetchedAt. +func TestFeedSync(t *testing.T) { + adminEnv(t) + srv := newMockServer(t) + + stdout, stderr, code := executeAgainst(t, srv.URL, + "feed", "create", + "--label", "Claude Code", + "--url", "https://github.com/anthropics/claude-code", + "--listing", "claude-code", + "--author", "anthropic", + "--json") + if code != 0 { + t.Fatalf("create exit = %d, want 0 (stderr: %s)", code, stderr) + } + var created struct { + Feed struct { + ID string `json:"id"` + } `json:"feed"` + } + if err := json.Unmarshal([]byte(stdout), &created); err != nil { + t.Fatalf("create response is not JSON: %v", err) + } + + stdout, stderr, code = executeAgainst(t, srv.URL, + "feed", "sync", created.Feed.ID, "--json") + if code != 0 { + t.Fatalf("sync exit = %d, want 0 (stderr: %s)", code, stderr) + } + var synced struct { + Summary struct { + FeedID string `json:"feedId"` + Label string `json:"label"` + Fetched int `json:"fetched"` + Created int `json:"created"` + } `json:"summary"` + } + if err := json.Unmarshal([]byte(stdout), &synced); err != nil { + t.Fatalf("sync response is not JSON: %v", err) + } + if synced.Summary.FeedID != created.Feed.ID { + t.Errorf("summary.feedId = %q, want %q", synced.Summary.FeedID, created.Feed.ID) + } + if synced.Summary.Fetched != 1 || synced.Summary.Created != 1 { + t.Errorf("summary = %+v, want fetched=1 created=1", synced.Summary) + } + if synced.Summary.Label != "Claude Code" { + t.Errorf("summary.label = %q, want Claude Code", synced.Summary.Label) + } + + // After sync, list must show lastStatus=ok and a stamped lastFetchedAt. + stdout, stderr, code = executeAgainst(t, srv.URL, "feed", "list", "--json") + if code != 0 { + t.Fatalf("list exit = %d, want 0 (stderr: %s)", code, stderr) + } + var listed struct { + Feeds []struct { + ID string `json:"id"` + LastStatus *string `json:"lastStatus"` + LastFetchedAt *string `json:"lastFetchedAt"` + } `json:"feeds"` + } + if err := json.Unmarshal([]byte(stdout), &listed); err != nil { + t.Fatalf("list response is not JSON: %v", err) + } + var found bool + for _, f := range listed.Feeds { + if f.ID != created.Feed.ID { + continue + } + found = true + if f.LastStatus == nil || *f.LastStatus != "ok" { + t.Errorf("lastStatus = %v, want \"ok\" after sync", f.LastStatus) + } + if f.LastFetchedAt == nil || *f.LastFetchedAt == "" { + t.Errorf("lastFetchedAt = %v, want non-null after sync", f.LastFetchedAt) + } + } + if !found { + t.Fatalf("feed %q not found in list after sync", created.Feed.ID) + } +} + +// create then update --disabled: the feed comes back enabled=false. +func TestFeedUpdateDisable(t *testing.T) { + adminEnv(t) + srv := newMockServer(t) + + stdout, stderr, code := executeAgainst(t, srv.URL, + "feed", "create", + "--label", "Claude Code", + "--url", "https://github.com/anthropics/claude-code", + "--listing", "claude-code", + "--author", "anthropic", + "--json") + if code != 0 { + t.Fatalf("create exit = %d, want 0 (stderr: %s)", code, stderr) + } + var created struct { + Feed struct { + ID string `json:"id"` + Enabled bool `json:"enabled"` + } `json:"feed"` + } + if err := json.Unmarshal([]byte(stdout), &created); err != nil { + t.Fatalf("create response is not JSON: %v", err) + } + if !created.Feed.Enabled { + t.Fatal("created feed must start enabled") + } + + stdout, stderr, code = executeAgainst(t, srv.URL, + "feed", "update", created.Feed.ID, "--disabled", "--json") + if code != 0 { + t.Fatalf("update exit = %d, want 0 (stderr: %s)", code, stderr) + } + var updated struct { + Feed struct { + Enabled bool `json:"enabled"` + } `json:"feed"` + } + if err := json.Unmarshal([]byte(stdout), &updated); err != nil { + t.Fatalf("update response is not JSON: %v", err) + } + if updated.Feed.Enabled { + t.Error("feed.Enabled = true, want false after --disabled") + } +} + +// Empty --author / --listing on update clears attribution (null on the wire). +func TestFeedUpdateClearAttribution(t *testing.T) { + adminEnv(t) + srv := newMockServer(t) + + stdout, stderr, code := executeAgainst(t, srv.URL, + "feed", "create", + "--label", "Claude Code", + "--url", "https://github.com/anthropics/claude-code", + "--listing", "claude-code", + "--author", "anthropic", + "--json") + if code != 0 { + t.Fatalf("create exit = %d, want 0 (stderr: %s)", code, stderr) + } + var created struct { + Feed struct { + ID string `json:"id"` + AuthorHandle *string `json:"authorHandle"` + ListingSlug *string `json:"listingSlug"` + } `json:"feed"` + } + if err := json.Unmarshal([]byte(stdout), &created); err != nil { + t.Fatalf("create response is not JSON: %v", err) + } + if created.Feed.AuthorHandle == nil || created.Feed.ListingSlug == nil { + t.Fatal("created feed must carry author and listing") + } + + stdout, stderr, code = executeAgainst(t, srv.URL, + "feed", "update", created.Feed.ID, + "--author", "", "--listing", "", "--json") + if code != 0 { + t.Fatalf("update exit = %d, want 0 (stderr: %s)", code, stderr) + } + var updated struct { + Feed struct { + AuthorHandle *string `json:"authorHandle"` + ListingSlug *string `json:"listingSlug"` + } `json:"feed"` + } + if err := json.Unmarshal([]byte(stdout), &updated); err != nil { + t.Fatalf("update response is not JSON: %v", err) + } + if updated.Feed.AuthorHandle != nil && *updated.Feed.AuthorHandle != "" { + t.Errorf("authorHandle = %v, want null/empty after clear", updated.Feed.AuthorHandle) + } + if updated.Feed.ListingSlug != nil && *updated.Feed.ListingSlug != "" { + t.Errorf("listingSlug = %v, want null/empty after clear", updated.Feed.ListingSlug) + } +} + +// Update with no field flags is a client-side error (nothing to send). +func TestFeedUpdateEmpty(t *testing.T) { + adminEnv(t) + // Unreachable server: validation must fire before any request. + _, stderr, code := executeAgainst(t, "http://127.0.0.1:1", + "feed", "update", "01FDOESNOTEXIST0000000000") + if code != output.ExitError { + t.Fatalf("exit code = %d, want %d (stderr: %s)", code, output.ExitError, stderr) + } + if !strings.Contains(stderr, "nothing to update") { + t.Errorf("stderr = %q, want \"nothing to update\"", stderr) + } +} + +// create with only required --label/--url must put the CLI defaults on the +// wire and omit enabled so the server defaults true. +func TestFeedCreateWireDefaults(t *testing.T) { + adminEnv(t) + srv, last := newCaptureServer(t) + + _, stderr, code := executeAgainst(t, srv.URL, + "feed", "create", + "--label", "Bare", + "--url", "https://github.com/example/repo", + "--json") + if code != 0 { + t.Fatalf("exit code = %d, want 0 (stderr: %s)", code, stderr) + } + var sent map[string]any + if err := json.Unmarshal(last.body, &sent); err != nil { + t.Fatalf("sent body is not JSON: %v", err) + } + if sent["kind"] != "github_release" { + t.Errorf("kind = %v, want github_release", sent["kind"]) + } + if sent["defaultCategory"] != "Releases" { + t.Errorf("defaultCategory = %v, want Releases", sent["defaultCategory"]) + } + if sent["autoPublish"] != false { + t.Errorf("autoPublish = %v, want false", sent["autoPublish"]) + } + tags, ok := sent["defaultTags"].([]any) + if !ok { + t.Errorf("defaultTags = %T %v, want a JSON array", sent["defaultTags"], sent["defaultTags"]) + } else if tags == nil { + t.Error("defaultTags is null, want a present array (possibly empty)") + } + if _, present := sent["enabled"]; present { + t.Errorf("enabled = %v, want key ABSENT unless --disabled", sent["enabled"]) + } + if sent["label"] != "Bare" || sent["feedUrl"] != "https://github.com/example/repo" { + t.Errorf("sent = %v, want label/url from flags", sent) + } +} + +// create → --disabled → --enabled re-enables the feed. +func TestFeedUpdateReenable(t *testing.T) { + adminEnv(t) + srv := newMockServer(t) + + stdout, stderr, code := executeAgainst(t, srv.URL, + "feed", "create", + "--label", "Claude Code", + "--url", "https://github.com/anthropics/claude-code", + "--listing", "claude-code", + "--author", "anthropic", + "--json") + if code != 0 { + t.Fatalf("create exit = %d, want 0 (stderr: %s)", code, stderr) + } + var created struct { + Feed struct { + ID string `json:"id"` + } `json:"feed"` + } + if err := json.Unmarshal([]byte(stdout), &created); err != nil { + t.Fatalf("create response is not JSON: %v", err) + } + + stdout, stderr, code = executeAgainst(t, srv.URL, + "feed", "update", created.Feed.ID, "--disabled", "--json") + if code != 0 { + t.Fatalf("disable exit = %d, want 0 (stderr: %s)", code, stderr) + } + var disabled struct { + Feed struct { + Enabled bool `json:"enabled"` + } `json:"feed"` + } + if err := json.Unmarshal([]byte(stdout), &disabled); err != nil { + t.Fatalf("disable response is not JSON: %v", err) + } + if disabled.Feed.Enabled { + t.Fatal("feed still enabled after --disabled") + } + + stdout, stderr, code = executeAgainst(t, srv.URL, + "feed", "update", created.Feed.ID, "--enabled", "--json") + if code != 0 { + t.Fatalf("reenable exit = %d, want 0 (stderr: %s)", code, stderr) + } + var reenabled struct { + Feed struct { + Enabled bool `json:"enabled"` + } `json:"feed"` + } + if err := json.Unmarshal([]byte(stdout), &reenabled); err != nil { + t.Fatalf("reenable response is not JSON: %v", err) + } + if !reenabled.Feed.Enabled { + t.Error("feed.Enabled = false, want true after --enabled") + } +} diff --git a/internal/cli/testdata/golden/feed-create.json b/internal/cli/testdata/golden/feed-create.json new file mode 100644 index 0000000..d1c0e01 --- /dev/null +++ b/internal/cli/testdata/golden/feed-create.json @@ -0,0 +1,21 @@ +{ + "feed": { + "id": "01FCREATED0000000000000001", + "label": "Claude Code", + "feedUrl": "https://github.com/anthropics/claude-code", + "kind": "github_release", + "authorProfileId": "01PROFILE00000000000000001", + "authorHandle": "anthropic", + "listingId": "01LSTCLAUDECODE000000000XX", + "listingSlug": "claude-code", + "defaultCategory": "Releases", + "defaultTags": [], + "autoPublish": false, + "enabled": true, + "lastFetchedAt": null, + "lastStatus": null, + "createdAt": "2026-06-09T09:00:00.000Z", + "updatedAt": "2026-06-09T09:00:00.000Z" + }, + "created": true +} diff --git a/internal/cli/testdata/golden/feed-create.txt b/internal/cli/testdata/golden/feed-create.txt new file mode 100644 index 0000000..a39cb2c --- /dev/null +++ b/internal/cli/testdata/golden/feed-create.txt @@ -0,0 +1 @@ +Created feed Claude Code (github_release, id 01FCREATED0000000000000001) — listing=claude-code author=anthropic autoPublish=false enabled=true diff --git a/internal/cli/testdata/golden/feed-list.json b/internal/cli/testdata/golden/feed-list.json new file mode 100644 index 0000000..4f38699 --- /dev/null +++ b/internal/cli/testdata/golden/feed-list.json @@ -0,0 +1,4 @@ +{ + "count": 0, + "feeds": [] +} diff --git a/internal/cli/testdata/golden/feed-list.txt b/internal/cli/testdata/golden/feed-list.txt new file mode 100644 index 0000000..d5749df --- /dev/null +++ b/internal/cli/testdata/golden/feed-list.txt @@ -0,0 +1 @@ +ID LABEL KIND LISTING AUTHOR AUTO ON LAST URL diff --git a/internal/mockapi/admin.go b/internal/mockapi/admin.go index 1c0bb80..c4aa4a2 100644 --- a/internal/mockapi/admin.go +++ b/internal/mockapi/admin.go @@ -46,6 +46,9 @@ var ( soulFrameworks = []string{"hermes", "openclaw", "claude-code", "cursor"} listingCategories = []string{"AI/ML", "DevOps", "Cloud", "Web", "Data", "Security", "Technical", "Productivity"} statuses = []string{"draft", "pending", "published"} + // blogCategories mirrors BLOG_CATEGORIES — feed defaultCategory validation. + blogCategories = []string{"Releases", "Announcements", "Tutorials", "Guides", "Engineering", "Community"} + feedKinds = []string{"github_release", "rss"} ) // Patchable field sets, mirroring SOUL_PATCH_FIELDS / LISTING_PATCH_FIELDS in @@ -60,6 +63,9 @@ var ( // profileCreateFields mirrors PROFILE_CREATE_FIELDS in the product repo. profileCreateFields = []string{"handle", "name", "kind", "avatarUrl", "website", "githubUrl", "githubUserId", "bio", "socials", "verified", "official"} + // feedCreateFields mirrors FEED_FIELDS in the product repo (feedFields.ts). + feedCreateFields = []string{"label", "feedUrl", "kind", "authorHandle", "listingSlug", + "defaultCategory", "defaultTags", "autoPublish", "enabled"} ) // adminState is one Handler instance's mutable admin dataset. @@ -68,9 +74,18 @@ type adminState struct { souls []soulRow listings []listingRow profiles []profileRow + feeds []feedRow soulSeq int listingSeq int profileSeq int + feedSeq int +} + +// feedRow is the mock's in-memory feed source. Display handles/slugs are +// stored alongside the resolved ids so list/get responses match production's +// joined shape without a second lookup. +type feedRow struct { + api.AdminFeed } type soulRow struct { @@ -108,6 +123,11 @@ func registerAdmin(mux *http.ServeMux) { mux.HandleFunc("PATCH /api/admin/listings/{id}", st.patchListing) mux.HandleFunc("POST /api/admin/profiles", st.createProfile) mux.HandleFunc("GET /api/admin/profiles", st.listProfiles) + mux.HandleFunc("GET /api/admin/feeds", st.listFeeds) + mux.HandleFunc("POST /api/admin/feeds", st.createFeed) + mux.HandleFunc("GET /api/admin/feeds/{id}", st.getFeed) + mux.HandleFunc("PATCH /api/admin/feeds/{id}", st.patchFeed) + mux.HandleFunc("POST /api/admin/feeds/{id}/sync", st.syncFeed) } // seedProfiles builds the fixture's curated authors from the listing handles @@ -846,3 +866,332 @@ func (st *adminState) profileByHandle(handle string) *profileRow { } return nil } + +// ── Feeds ──────────────────────────────────────────────────────────────────── + +// listFeeds returns every feed source. The mock starts empty (no seed) so +// goldens pin an empty list; create populates the in-memory set. +func (st *adminState) listFeeds(w http.ResponseWriter, r *http.Request) { + if adminDenied(w, r) { + return + } + st.mu.Lock() + defer st.mu.Unlock() + out := make([]api.AdminFeed, len(st.feeds)) + for i := range st.feeds { + out[i] = st.feeds[i].AdminFeed + } + writeJSON(w, http.StatusOK, map[string]any{"feeds": out}) +} + +func (st *adminState) createFeed(w http.ResponseWriter, r *http.Request) { + if adminDenied(w, r) { + return + } + body := readBody(w, r) + if body == nil { + return + } + const ctx = "admin feed create" + if _, ok := body["id"]; ok { + invalid(w, ctx+`: ids are server-assigned — omit "id"`) + return + } + if key, ok := unknownKey(body, feedCreateFields); ok { + invalid(w, fmt.Sprintf("%s: unknown field %q", ctx, key)) + return + } + for _, key := range []string{"label", "feedUrl", "kind", "defaultCategory"} { + if blank(body[key]) { + invalid(w, fmt.Sprintf("%s: missing required field %q", ctx, key)) + return + } + } + kind := toStr(body["kind"]) + if !slices.Contains(feedKinds, kind) { + invalid(w, fmt.Sprintf("%s: invalid kind %q (expected one of: %s)", + ctx, kind, strings.Join(feedKinds, ", "))) + return + } + category := toStr(body["defaultCategory"]) + if !slices.Contains(blogCategories, category) { + invalid(w, fmt.Sprintf("%s: invalid defaultCategory %q (expected one of: %s)", + ctx, category, strings.Join(blogCategories, ", "))) + return + } + + st.mu.Lock() + defer st.mu.Unlock() + + var authorProfileID, authorHandle *string + if v, ok := body["authorHandle"]; ok && !blank(v) { + handle := toStr(v) + profile := st.profileByHandle(handle) + if profile == nil { + writeError(w, http.StatusUnprocessableEntity, "unknown_profile", + fmt.Sprintf("profile %q does not exist", handle)) + return + } + authorProfileID = ptr(profile.ID) + authorHandle = ptr(handle) + } + + var listingID, listingSlug *string + if v, ok := body["listingSlug"]; ok && !blank(v) { + slug := toStr(v) + listing := st.listingBySlug(slug) + if listing == nil { + // invalid_input (not unknown_listing): product feeds.ts uses invalid_input for unknown listing. + writeError(w, http.StatusUnprocessableEntity, "invalid_input", + fmt.Sprintf("listing %q does not exist", slug)) + return + } + listingID = ptr(listing.ID) + listingSlug = ptr(slug) + } + + tags := []string{} + if v, ok := body["defaultTags"]; ok { + tags = toStrSlice(v) + } + autoPublish := toBool(body["autoPublish"], false) + enabled := toBool(body["enabled"], true) + + st.feedSeq++ + row := feedRow{ + AdminFeed: api.AdminFeed{ + ID: fmt.Sprintf("01FCREATED%016d", st.feedSeq), + Label: toStr(body["label"]), + FeedURL: toStr(body["feedUrl"]), + Kind: kind, + AuthorProfileID: authorProfileID, + AuthorHandle: authorHandle, + ListingID: listingID, + ListingSlug: listingSlug, + DefaultCategory: category, + DefaultTags: tags, + AutoPublish: autoPublish, + Enabled: enabled, + CreatedAt: createdStamp, + UpdatedAt: createdStamp, + }, + } + st.feeds = append(st.feeds, row) + writeJSON(w, http.StatusCreated, map[string]any{"feed": row.AdminFeed}) +} + +func (st *adminState) getFeed(w http.ResponseWriter, r *http.Request) { + if adminDenied(w, r) { + return + } + st.mu.Lock() + defer st.mu.Unlock() + id := r.PathValue("id") + if row := st.feedByID(id); row != nil { + writeJSON(w, http.StatusOK, map[string]any{"feed": row.AdminFeed}) + return + } + writeError(w, http.StatusNotFound, "not_found", fmt.Sprintf("feed source %q not found", id)) +} + +// patchFeed validates every field (and resolves author/listing) into locals +// first, then applies all row mutations once. Partial writes must not survive +// a late 422 on author/listing — product feeds.ts is all-or-nothing too. +func (st *adminState) patchFeed(w http.ResponseWriter, r *http.Request) { + if adminDenied(w, r) { + return + } + body := readBody(w, r) + if body == nil { + return + } + const ctx = "admin feed update" + if key, ok := unknownKey(body, feedCreateFields); ok { + invalid(w, fmt.Sprintf("%s: unknown field %q", ctx, key)) + return + } + + st.mu.Lock() + defer st.mu.Unlock() + id := r.PathValue("id") + row := st.feedByID(id) + if row == nil { + writeError(w, http.StatusNotFound, "not_found", fmt.Sprintf("feed source %q not found", id)) + return + } + + // ── Phase 1: validate + resolve into locals (no row writes) ─────────── + var ( + kind, category, label, feedURL *string + tags *[]string + autoPublish, enabled *bool + setAuthor, setListing bool + authorProfileID, authorHandle *string + listingID, listingSlug *string + ) + + if v, ok := body["kind"]; ok { + k := toStr(v) + if !slices.Contains(feedKinds, k) { + invalid(w, fmt.Sprintf("%s: invalid kind %q (expected one of: %s)", + ctx, k, strings.Join(feedKinds, ", "))) + return + } + kind = &k + } + if v, ok := body["defaultCategory"]; ok { + c := toStr(v) + if !slices.Contains(blogCategories, c) { + invalid(w, fmt.Sprintf("%s: invalid defaultCategory %q (expected one of: %s)", + ctx, c, strings.Join(blogCategories, ", "))) + return + } + category = &c + } + if v, ok := body["label"]; ok { + if blank(v) { + invalid(w, fmt.Sprintf("%s: missing required field %q", ctx, "label")) + return + } + s := toStr(v) + label = &s + } + if v, ok := body["feedUrl"]; ok { + if blank(v) { + invalid(w, fmt.Sprintf("%s: missing required field %q", ctx, "feedUrl")) + return + } + s := toStr(v) + feedURL = &s + } + if v, ok := body["defaultTags"]; ok { + t := toStrSlice(v) + tags = &t + } + if v, ok := body["autoPublish"]; ok { + b := toBool(v, row.AutoPublish) + autoPublish = &b + } + if v, ok := body["enabled"]; ok { + b := toBool(v, row.Enabled) + enabled = &b + } + // unknown author → unknown_profile; unknown listing → invalid_input + // (product feeds.ts intentional asymmetry — do not unify the codes). + if v, present := body["authorHandle"]; present { + setAuthor = true + if !blank(v) { + handle := toStr(v) + profile := st.profileByHandle(handle) + if profile == nil { + writeError(w, http.StatusUnprocessableEntity, "unknown_profile", + fmt.Sprintf("profile %q does not exist", handle)) + return + } + authorProfileID = ptr(profile.ID) + authorHandle = ptr(handle) + } + // blank clears attribution (both nil) + } + if v, present := body["listingSlug"]; present { + setListing = true + if !blank(v) { + slug := toStr(v) + listing := st.listingBySlug(slug) + if listing == nil { + // invalid_input (not unknown_listing): product feeds.ts uses invalid_input for unknown listing. + writeError(w, http.StatusUnprocessableEntity, "invalid_input", + fmt.Sprintf("listing %q does not exist", slug)) + return + } + listingID = ptr(listing.ID) + listingSlug = ptr(slug) + } + // blank clears attribution (both nil) + } + + // ── Phase 2: apply all validated changes once ───────────────────────── + if kind != nil { + row.Kind = *kind + } + if category != nil { + row.DefaultCategory = *category + } + if label != nil { + row.Label = *label + } + if feedURL != nil { + row.FeedURL = *feedURL + } + if tags != nil { + row.DefaultTags = *tags + } + if autoPublish != nil { + row.AutoPublish = *autoPublish + } + if enabled != nil { + row.Enabled = *enabled + } + if setAuthor { + row.AuthorProfileID, row.AuthorHandle = authorProfileID, authorHandle + } + if setListing { + row.ListingID, row.ListingSlug = listingID, listingSlug + } + row.UpdatedAt = updatedStamp + writeJSON(w, http.StatusOK, map[string]any{"feed": row.AdminFeed}) +} + +// syncFeed fakes a successful ingest with no network: stamps lastFetchedAt +// and returns a deterministic summary so CLI goldens stay stable offline. +func (st *adminState) syncFeed(w http.ResponseWriter, r *http.Request) { + if adminDenied(w, r) { + return + } + st.mu.Lock() + defer st.mu.Unlock() + id := r.PathValue("id") + row := st.feedByID(id) + if row == nil { + writeError(w, http.StatusNotFound, "not_found", fmt.Sprintf("feed source %q not found", id)) + return + } + row.LastFetchedAt = ptr(updatedStamp) + row.LastStatus = ptr("ok") + row.UpdatedAt = updatedStamp + writeJSON(w, http.StatusOK, map[string]any{ + "summary": api.FeedSyncSummary{ + FeedID: row.ID, + Label: row.Label, + Fetched: 1, + Created: 1, + Updated: 0, + Skipped: 0, + ItemErrors: []string{}, + }, + }) +} + +func (st *adminState) feedByID(id string) *feedRow { + for i := range st.feeds { + if st.feeds[i].ID == id { + return &st.feeds[i] + } + } + return nil +} + +// toBool coerces a JSON value to bool; nil/unknown fall back to defaultVal. +func toBool(v any, defaultVal bool) bool { + if v == nil { + return defaultVal + } + switch t := v.(type) { + case bool: + return t + case string: + return t == "true" + default: + return defaultVal + } +}