From 2f7b32d4ba1dceec30c2340d6e5ed5a98a313008 Mon Sep 17 00:00:00 2001 From: Koopa Date: Wed, 24 Jun 2026 15:37:51 +0800 Subject: [PATCH 01/31] refactor(mcp): remove dead Server.registry field and WithRegistry option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The registry field was injected via WithRegistry and stored but never dereferenced: callerIdentity resolves the caller from the as value alone (Option B — attribution only, no tool-layer authz). The accompanying doc comments described a caller-resolution gate that does not exist. The agent roster sync that genuinely needs a registry runs in cmd/mcp/main.go via agent.SyncToTable on its own agentRegistry, untouched here. --- cmd/mcp/main.go | 1 - internal/mcp/handler_test.go | 3 --- internal/mcp/integration_test.go | 1 - internal/mcp/server.go | 16 ---------------- 4 files changed, 21 deletions(-) diff --git a/cmd/mcp/main.go b/cmd/mcp/main.go index 73b6b608..e53c1b51 100644 --- a/cmd/mcp/main.go +++ b/cmd/mcp/main.go @@ -85,7 +85,6 @@ func run(ctx context.Context, cfg *config, logger *slog.Logger) error { opts := []mcp.ServerOption{ mcp.WithLocation(taipeiLoc), mcp.WithCallerAgent(cfg.CallerAgent), - mcp.WithRegistry(agentRegistry), } // Enable search_knowledge semantic branch when Gemini is configured. // Embedder construction fails fast on an invalid client setup; FTS-only diff --git a/internal/mcp/handler_test.go b/internal/mcp/handler_test.go index f91562e5..c0d4750f 100644 --- a/internal/mcp/handler_test.go +++ b/internal/mcp/handler_test.go @@ -10,8 +10,6 @@ import ( "time" "github.com/modelcontextprotocol/go-sdk/mcp" - - "github.com/Koopa0/koopa/internal/agent" ) // newTestServer creates a Server with no stores — only useful for validation @@ -22,7 +20,6 @@ func newTestServer() *Server { return &Server{ logger: slog.Default(), callerAgent: "human", - registry: agent.NewBuiltinRegistry(), loc: time.UTC, } } diff --git a/internal/mcp/integration_test.go b/internal/mcp/integration_test.go index 66a5199d..faa639ec 100644 --- a/internal/mcp/integration_test.go +++ b/internal/mcp/integration_test.go @@ -57,7 +57,6 @@ func setupServer(t *testing.T) *Server { t.Fatalf("agent.SyncToTable: %v", err) } return NewServer(testPool, slog.Default(), - WithRegistry(registry), WithCallerAgent("planner"), ) } diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 53418c5c..85f9c8f2 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -17,7 +17,6 @@ import ( "github.com/jackc/pgx/v5/pgxpool" "github.com/modelcontextprotocol/go-sdk/mcp" - "github.com/Koopa0/koopa/internal/agent" "github.com/Koopa0/koopa/internal/content" "github.com/Koopa0/koopa/internal/daily" "github.com/Koopa0/koopa/internal/embedder" @@ -43,13 +42,6 @@ type Server struct { // Goals goals *goal.Store - // Agent registry — source of truth for caller identity resolution: the - // `as` value maps to an agent name used for attribution (created_by / - // activity_events.actor). Wired in from - // cmd/app/main.go so the CLI and tests can inject custom rosters when - // needed. - registry *agent.Registry - // Content and feeds feeds *feed.Store feedEntries *entry.Store @@ -89,13 +81,6 @@ func WithLocation(loc *time.Location) ServerOption { return func(s *Server) { s.loc = loc } } -// WithRegistry injects a pre-built agent registry. Required in production -// because callers of mutation tools must be resolved against it; optional in -// tests that use the default BuiltinAgents. -func WithRegistry(r *agent.Registry) ServerOption { - return func(s *Server) { s.registry = r } -} - // WithEmbedder enables the semantic branch of search_knowledge. When unset // (or set to nil) the tool falls back to FTS-only — that path remains // functional in every deployment, so embedder wiring is deliberately @@ -112,7 +97,6 @@ func NewServer(pool *pgxpool.Pool, logger *slog.Logger, opts ...ServerOption) *S contents: content.NewStore(pool), projects: project.NewStore(pool), goals: goal.NewStore(pool), - registry: agent.NewBuiltinRegistry(), feedEntries: entry.NewStore(pool), feeds: feed.NewStore(pool, logger), stats: stats.NewStore(pool), From 3a6468ce66772264c9e178f123d8e0ffc698c095 Mon Sep 17 00:00:00 2001 From: Koopa Date: Wed, 24 Jun 2026 15:39:41 +0800 Subject: [PATCH 02/31] refactor: remove zero-reference dead types and sentinels - activity.DiffStats: never constructed, serialized, or read (Event.Metadata carries the GitHub payload as raw json.RawMessage). - activity.ErrNotFound / ErrConflict: no handler branches on them and the package is read-only (two SELECT :many queries), so neither condition arises. - agent.RegistryRow.SyncedAt / RetiredAt: populated from the DB but read by no consumer; the SQL columns stay (the upsert/retire SQL maintains them). - todo.ProjectCompletion: type referenced nowhere in the tree. --- internal/activity/activity.go | 17 ----------------- internal/agent/agent.go | 3 --- internal/agent/store.go | 2 -- internal/todo/todo.go | 6 ------ 4 files changed, 28 deletions(-) diff --git a/internal/activity/activity.go b/internal/activity/activity.go index a9151507..cfdbfcc9 100644 --- a/internal/activity/activity.go +++ b/internal/activity/activity.go @@ -5,7 +5,6 @@ package activity import ( "encoding/json" - "errors" "time" "github.com/google/uuid" @@ -35,14 +34,6 @@ type Event struct { CreatedAt time.Time `json:"created_at"` } -// DiffStats holds GitHub diff statistics for a push event. -type DiffStats struct { - LinesAdded int `json:"lines_added"` - LinesRemoved int `json:"lines_removed"` - FilesChanged int `json:"files_changed"` - CommitCount int `json:"commit_count"` -} - // ChangelogDay groups events for a single calendar date. type ChangelogDay struct { Date string `json:"date"` @@ -69,11 +60,3 @@ type ChangelogEvent struct { type ChangelogResponse struct { Days []ChangelogDay `json:"days"` } - -var ( - // ErrNotFound indicates the event does not exist. - ErrNotFound = errors.New("activity: not found") - - // ErrConflict indicates a duplicate event (dedup hit). - ErrConflict = errors.New("activity: conflict") -) diff --git a/internal/agent/agent.go b/internal/agent/agent.go index b9dd848a..d8bc0db5 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -10,7 +10,6 @@ package agent import ( "errors" - "time" ) // ErrUnknownAgent means the caller name is not present in the registry or @@ -91,6 +90,4 @@ type RegistryRow struct { Platform string Description string Status Status - SyncedAt time.Time - RetiredAt *time.Time } diff --git a/internal/agent/store.go b/internal/agent/store.go index 0e8659e2..7e66fe56 100644 --- a/internal/agent/store.go +++ b/internal/agent/store.go @@ -41,8 +41,6 @@ func (s *Store) List(ctx context.Context) ([]RegistryRow, error) { Platform: r.Platform, Description: r.Description, Status: Status(r.Status), - SyncedAt: r.SyncedAt, - RetiredAt: r.RetiredAt, } } return out, nil diff --git a/internal/todo/todo.go b/internal/todo/todo.go index c365d75a..70766ff4 100644 --- a/internal/todo/todo.go +++ b/internal/todo/todo.go @@ -397,12 +397,6 @@ type Pending struct { Due string } -// ProjectCompletion holds a per-project completion count. -type ProjectCompletion struct { - ProjectTitle string - Completed int64 -} - // PendingDetail is a pending todo with project context. // // CreatedBy and Description are populated by BacklogItems only (the admin From 0a107dfe29abc4b43df453d2ac2302b2a2227a22 Mon Sep 17 00:00:00 2001 From: Koopa Date: Wed, 24 Jun 2026 15:50:42 +0800 Subject: [PATCH 03/31] refactor: remove zero-reference store methods and orphaned queries Residue from retired features (learning subsystem, rss-highlight aggregates). Each removed method had zero callers tree-wide; each removed query had no remaining s.q.* consumer after the method went. Regenerated sqlc. - feed/entry: RecentFeedEntries, LatestFeedEntries, TopUnreadFeedEntriesRecent, DeleteOldIgnored, TopItems (+ their queries) - feed: EnabledFeeds no-schedule variant (kept EnabledFeedsBySchedule) - todo: PendingItemsByTitle, PendingItems, ItemsCreatedSince, StaleSomedayCount, InboxCount, InboxItems, PendingItemsWithProject (+ queries + orphaned Pending type) - goal: legacy CreateGoal 7-arg, bare GoalByID, GoalByTitle, CreateMilestoneSimple (kept shared CreateGoal/GoalByID/CreateMilestoneWithPosition queries) - daily: ItemByID method + query + dead ErrNotFound sentinel Shared queries still consumed by live code were deliberately preserved. --- internal/daily/daily.go | 3 - internal/daily/query.sql | 5 - internal/daily/store.go | 18 +- internal/db/query.sql.go | 582 ---------------------------------- internal/feed/entry/query.sql | 41 --- internal/feed/entry/store.go | 105 ------ internal/feed/query.sql | 7 - internal/feed/store.go | 14 - internal/goal/query.sql | 6 - internal/goal/store.go | 75 ----- internal/todo/history.go | 18 -- internal/todo/query.sql | 57 ---- internal/todo/todo.go | 20 -- internal/todo/views.go | 80 ----- 14 files changed, 2 insertions(+), 1029 deletions(-) diff --git a/internal/daily/daily.go b/internal/daily/daily.go index d97dcdbb..4215933f 100644 --- a/internal/daily/daily.go +++ b/internal/daily/daily.go @@ -14,9 +14,6 @@ import ( "github.com/google/uuid" ) -// ErrNotFound indicates the daily plan item does not exist. -var ErrNotFound = errors.New("dailyplan: not found") - // ErrItemResolved indicates a daily plan item for the date already reached a // terminal state (done, deferred, or dropped) and so cannot be re-planned. var ErrItemResolved = errors.New("dailyplan: item already resolved for date") diff --git a/internal/daily/query.sql b/internal/daily/query.sql index e5694db3..c2f5d797 100644 --- a/internal/daily/query.sql +++ b/internal/daily/query.sql @@ -33,11 +33,6 @@ LEFT JOIN projects p ON p.id = t.project_id WHERE dpi.plan_date = @plan_date ORDER BY dpi.position, dpi.created_at; --- name: ItemByID :one --- Get a single daily plan item by ID. -SELECT id, plan_date, todo_id, selected_by, position, reason, status, created_at, updated_at -FROM daily_plan_items WHERE id = @id; - -- name: DeletePlannedItemsByDate :many -- Remove only 'planned' items for a date (used when re-planning). -- Preserves done/deferred/dropped items as historical records. diff --git a/internal/daily/store.go b/internal/daily/store.go index 204d0bd2..7edacfb3 100644 --- a/internal/daily/store.go +++ b/internal/daily/store.go @@ -7,9 +7,8 @@ // and returns ErrItemResolved when the existing row is already in a // terminal state. // - ItemsByDate returns Items with denormalised todo + project -// fields for the list view; ItemByID returns the bare row. -// The two converters (rawToItem vs itemsByDateRowToItem) reflect -// this split. +// fields for the list view via itemsByDateRowToItem; Create returns +// the bare row via rawToItem. // - DeletePlannedByDate removes only items still in 'planned' state, // preserving done/deferred/dropped as historical record — the // safe "re-plan today" reset. @@ -22,7 +21,6 @@ import ( "fmt" "time" - "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/Koopa0/koopa/internal/db" @@ -71,18 +69,6 @@ func (s *Store) ItemsByDate(ctx context.Context, date time.Time) ([]Item, error) return items, nil } -// ItemByID returns a single daily plan item by ID (without todo item joins). -func (s *Store) ItemByID(ctx context.Context, id uuid.UUID) (*Item, error) { - r, err := s.q.ItemByID(ctx, id) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrNotFound - } - return nil, fmt.Errorf("querying daily plan item %s: %w", id, err) - } - return rawToItem(&r), nil -} - // DeletePlannedByDate removes only 'planned' items for a date (re-planning). // Preserves done/deferred/dropped items as historical records. Returns the // removed rows (id + todo_id + title) so callers can surface "what was diff --git a/internal/db/query.sql.go b/internal/db/query.sql.go index 26ec50de..4aca4b9c 100644 --- a/internal/db/query.sql.go +++ b/internal/db/query.sql.go @@ -2186,19 +2186,6 @@ func (q *Queries) DeleteMilestone(ctx context.Context, arg DeleteMilestoneParams return result.RowsAffected(), nil } -const deleteOldIgnored = `-- name: DeleteOldIgnored :execrows -DELETE FROM feed_entries WHERE status = 'ignored' AND collected_at < $1 -` - -// Cleanup: delete ignored collected data older than the given cutoff. -func (q *Queries) DeleteOldIgnored(ctx context.Context, cutoff time.Time) (int64, error) { - result, err := q.db.Exec(ctx, deleteOldIgnored, cutoff) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} - const deletePlannedItemsByDate = `-- name: DeletePlannedItemsByDate :many WITH deleted AS ( DELETE FROM daily_plan_items @@ -2332,50 +2319,6 @@ func (q *Queries) DeleteTopic(ctx context.Context, id uuid.UUID) error { return err } -const enabledFeeds = `-- name: EnabledFeeds :many -SELECT id, url, name, schedule, enabled, priority, etag, last_modified, - last_fetched_at, consecutive_failures, last_error, disabled_reason, - filter_config, created_at, updated_at -FROM feeds WHERE enabled = true -ORDER BY created_at -` - -func (q *Queries) EnabledFeeds(ctx context.Context) ([]Feed, error) { - rows, err := q.db.Query(ctx, enabledFeeds) - if err != nil { - return nil, err - } - defer rows.Close() - items := []Feed{} - for rows.Next() { - var i Feed - if err := rows.Scan( - &i.ID, - &i.Url, - &i.Name, - &i.Schedule, - &i.Enabled, - &i.Priority, - &i.Etag, - &i.LastModified, - &i.LastFetchedAt, - &i.ConsecutiveFailures, - &i.LastError, - &i.DisabledReason, - &i.FilterConfig, - &i.CreatedAt, - &i.UpdatedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - const enabledFeedsBySchedule = `-- name: EnabledFeedsBySchedule :many SELECT id, url, name, schedule, enabled, priority, etag, last_modified, last_fetched_at, consecutive_failures, last_error, disabled_reason, @@ -2888,32 +2831,6 @@ func (q *Queries) GoalByIDWithArea(ctx context.Context, id uuid.UUID) (GoalByIDW return i, err } -const goalByTitle = `-- name: GoalByTitle :one -SELECT id, title, description, status, area_id, quarter, deadline, created_by, proposal_rationale, - created_at, updated_at -FROM goals WHERE LOWER(title) = LOWER($1) -` - -// Find a goal by case-insensitive title match. -func (q *Queries) GoalByTitle(ctx context.Context, title string) (Goal, error) { - row := q.db.QueryRow(ctx, goalByTitle, title) - var i Goal - err := row.Scan( - &i.ID, - &i.Title, - &i.Description, - &i.Status, - &i.AreaID, - &i.Quarter, - &i.Deadline, - &i.CreatedBy, - &i.ProposalRationale, - &i.CreatedAt, - &i.UpdatedAt, - ) - return i, err -} - const goalRecentActivity = `-- name: GoalRecentActivity :many SELECT activity_type::text AS activity_type, @@ -3214,48 +3131,6 @@ func (q *Queries) IgnoreFeedEntry(ctx context.Context, id uuid.UUID) error { return err } -const inboxTodoItems = `-- name: InboxTodoItems :many -SELECT id, title, state, due, project_id, completed_at, energy, priority, recur_interval, recur_unit, recur_weekdays, last_completed_on, description, created_by, created_at, updated_at FROM todos WHERE state = 'inbox' ORDER BY created_at DESC -` - -// List all inbox todo items, newest first. -func (q *Queries) InboxTodoItems(ctx context.Context) ([]Todo, error) { - rows, err := q.db.Query(ctx, inboxTodoItems) - if err != nil { - return nil, err - } - defer rows.Close() - items := []Todo{} - for rows.Next() { - var i Todo - if err := rows.Scan( - &i.ID, - &i.Title, - &i.State, - &i.Due, - &i.ProjectID, - &i.CompletedAt, - &i.Energy, - &i.Priority, - &i.RecurInterval, - &i.RecurUnit, - &i.RecurWeekdays, - &i.LastCompletedOn, - &i.Description, - &i.CreatedBy, - &i.CreatedAt, - &i.UpdatedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - const incrementFeedFailure = `-- name: IncrementFeedFailure :one UPDATE feeds SET consecutive_failures = consecutive_failures + 1, @@ -3511,29 +3386,6 @@ func (q *Queries) InternalSemanticSearchContents(ctx context.Context, arg Intern return items, nil } -const itemByID = `-- name: ItemByID :one -SELECT id, plan_date, todo_id, selected_by, position, reason, status, created_at, updated_at -FROM daily_plan_items WHERE id = $1 -` - -// Get a single daily plan item by ID. -func (q *Queries) ItemByID(ctx context.Context, id uuid.UUID) (DailyPlanItem, error) { - row := q.db.QueryRow(ctx, itemByID, id) - var i DailyPlanItem - err := row.Scan( - &i.ID, - &i.PlanDate, - &i.TodoID, - &i.SelectedBy, - &i.Position, - &i.Reason, - &i.Status, - &i.CreatedAt, - &i.UpdatedAt, - ) - return i, err -} - const itemsByDate = `-- name: ItemsByDate :many SELECT dpi.id, dpi.plan_date, dpi.todo_id, dpi.selected_by, dpi.position, @@ -3608,71 +3460,6 @@ func (q *Queries) ItemsByDate(ctx context.Context, planDate time.Time) ([]ItemsB return items, nil } -const latestFeedEntries = `-- name: LatestFeedEntries :many -SELECT cd.id, cd.source_url, cd.title, cd.original_content, - cd.status, cd.curated_content_id, cd.collected_at, - cd.url_hash, cd.feed_id, cd.published_at, - COALESCE(f.name, '') AS feed_name -FROM feed_entries cd -LEFT JOIN feeds f ON cd.feed_id = f.id -WHERE ($1::timestamptz IS NULL OR cd.collected_at >= $1) -ORDER BY COALESCE(cd.published_at, cd.collected_at) DESC -LIMIT $2 -` - -type LatestFeedEntriesParams struct { - Since *time.Time `json:"since"` - MaxResults int32 `json:"max_results"` -} - -type LatestFeedEntriesRow struct { - ID uuid.UUID `json:"id"` - SourceUrl string `json:"source_url"` - Title string `json:"title"` - OriginalContent string `json:"original_content"` - Status FeedEntryStatus `json:"status"` - CuratedContentID *uuid.UUID `json:"curated_content_id"` - CollectedAt time.Time `json:"collected_at"` - UrlHash string `json:"url_hash"` - FeedID *uuid.UUID `json:"feed_id"` - PublishedAt *time.Time `json:"published_at"` - FeedName string `json:"feed_name"` -} - -// Get latest collected data, optionally filtered by time range. -// When days is NULL, returns the latest N items regardless of time. -func (q *Queries) LatestFeedEntries(ctx context.Context, arg LatestFeedEntriesParams) ([]LatestFeedEntriesRow, error) { - rows, err := q.db.Query(ctx, latestFeedEntries, arg.Since, arg.MaxResults) - if err != nil { - return nil, err - } - defer rows.Close() - items := []LatestFeedEntriesRow{} - for rows.Next() { - var i LatestFeedEntriesRow - if err := rows.Scan( - &i.ID, - &i.SourceUrl, - &i.Title, - &i.OriginalContent, - &i.Status, - &i.CuratedContentID, - &i.CollectedAt, - &i.UrlHash, - &i.FeedID, - &i.PublishedAt, - &i.FeedName, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - const listAgents = `-- name: ListAgents :many SELECT name, display_name, platform, description, status, synced_at, retired_at FROM agents @@ -3923,177 +3710,6 @@ func (q *Queries) OverdueTodoItems(ctx context.Context, today *time.Time) ([]Ove return items, nil } -const pendingTodoItems = `-- name: PendingTodoItems :many -SELECT id, title, state, due, project_id, - completed_at, energy, priority, recur_interval, recur_unit, recur_weekdays, last_completed_on, - description, created_by, created_at, updated_at -FROM todos WHERE state != 'done' -ORDER BY due NULLS LAST, created_at -` - -// List todo items that are not done, ordered by due date. -func (q *Queries) PendingTodoItems(ctx context.Context) ([]Todo, error) { - rows, err := q.db.Query(ctx, pendingTodoItems) - if err != nil { - return nil, err - } - defer rows.Close() - items := []Todo{} - for rows.Next() { - var i Todo - if err := rows.Scan( - &i.ID, - &i.Title, - &i.State, - &i.Due, - &i.ProjectID, - &i.CompletedAt, - &i.Energy, - &i.Priority, - &i.RecurInterval, - &i.RecurUnit, - &i.RecurWeekdays, - &i.LastCompletedOn, - &i.Description, - &i.CreatedBy, - &i.CreatedAt, - &i.UpdatedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const pendingTodoItemsByTitle = `-- name: PendingTodoItemsByTitle :many -SELECT id, title, state, due, project_id, - completed_at, energy, priority, recur_interval, recur_unit, recur_weekdays, last_completed_on, - description, created_by, created_at, updated_at -FROM todos -WHERE state != 'done' AND title ILIKE '%' || $1 || '%' -ORDER BY due NULLS LAST, updated_at ASC -LIMIT 10 -` - -// Find pending todo items matching a title (case-insensitive contains). -func (q *Queries) PendingTodoItemsByTitle(ctx context.Context, searchTitle *string) ([]Todo, error) { - rows, err := q.db.Query(ctx, pendingTodoItemsByTitle, searchTitle) - if err != nil { - return nil, err - } - defer rows.Close() - items := []Todo{} - for rows.Next() { - var i Todo - if err := rows.Scan( - &i.ID, - &i.Title, - &i.State, - &i.Due, - &i.ProjectID, - &i.CompletedAt, - &i.Energy, - &i.Priority, - &i.RecurInterval, - &i.RecurUnit, - &i.RecurWeekdays, - &i.LastCompletedOn, - &i.Description, - &i.CreatedBy, - &i.CreatedAt, - &i.UpdatedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const pendingTodoItemsWithProject = `-- name: PendingTodoItemsWithProject :many -SELECT t.id, t.title, t.state, t.due, t.project_id, - t.energy, t.priority, t.recur_interval, t.recur_unit, t.recur_weekdays, t.last_completed_on, - t.created_at, t.updated_at, - COALESCE(p.title, '') AS project_title, - COALESCE(p.slug, '') AS project_slug -FROM todos t -LEFT JOIN projects p ON t.project_id = p.id -WHERE t.state != 'done' - AND ($1::text IS NULL OR p.slug = $1) -ORDER BY - (t.due IS NOT NULL) DESC, - t.due ASC NULLS LAST, - t.updated_at ASC -LIMIT $2 -` - -type PendingTodoItemsWithProjectParams struct { - ProjectSlug *string `json:"project_slug"` - MaxResults int32 `json:"max_results"` -} - -type PendingTodoItemsWithProjectRow struct { - ID uuid.UUID `json:"id"` - Title string `json:"title"` - State TodoState `json:"state"` - Due *time.Time `json:"due"` - ProjectID *uuid.UUID `json:"project_id"` - Energy *string `json:"energy"` - Priority *string `json:"priority"` - RecurInterval *int32 `json:"recur_interval"` - RecurUnit *string `json:"recur_unit"` - RecurWeekdays *int16 `json:"recur_weekdays"` - LastCompletedOn *time.Time `json:"last_completed_on"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - ProjectTitle string `json:"project_title"` - ProjectSlug string `json:"project_slug"` -} - -// List pending todo items with project info. -func (q *Queries) PendingTodoItemsWithProject(ctx context.Context, arg PendingTodoItemsWithProjectParams) ([]PendingTodoItemsWithProjectRow, error) { - rows, err := q.db.Query(ctx, pendingTodoItemsWithProject, arg.ProjectSlug, arg.MaxResults) - if err != nil { - return nil, err - } - defer rows.Close() - items := []PendingTodoItemsWithProjectRow{} - for rows.Next() { - var i PendingTodoItemsWithProjectRow - if err := rows.Scan( - &i.ID, - &i.Title, - &i.State, - &i.Due, - &i.ProjectID, - &i.Energy, - &i.Priority, - &i.RecurInterval, - &i.RecurUnit, - &i.RecurWeekdays, - &i.LastCompletedOn, - &i.CreatedAt, - &i.UpdatedAt, - &i.ProjectTitle, - &i.ProjectSlug, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - const projectByID = `-- name: ProjectByID :one SELECT id, slug, title, description, status, repo, area_id, goal_id, deadline, last_activity_at, expected_cadence, created_by, proposal_rationale, created_at, updated_at @@ -5104,70 +4720,6 @@ func (q *Queries) PublishedWithEmbeddings(ctx context.Context) ([]PublishedWithE return items, nil } -const recentFeedEntries = `-- name: RecentFeedEntries :many -SELECT cd.id, cd.source_url, cd.title, cd.original_content, - cd.status, cd.curated_content_id, cd.collected_at, - cd.url_hash, cd.feed_id, cd.published_at, - COALESCE(f.name, '') AS feed_name -FROM feed_entries cd -LEFT JOIN feeds f ON cd.feed_id = f.id -WHERE cd.collected_at >= $1 AND cd.collected_at < $2 -ORDER BY COALESCE(cd.published_at, cd.collected_at) DESC -LIMIT $3 -` - -type RecentFeedEntriesParams struct { - CollectedAt time.Time `json:"collected_at"` - CollectedAt_2 time.Time `json:"collected_at_2"` - Limit int32 `json:"limit"` -} - -type RecentFeedEntriesRow struct { - ID uuid.UUID `json:"id"` - SourceUrl string `json:"source_url"` - Title string `json:"title"` - OriginalContent string `json:"original_content"` - Status FeedEntryStatus `json:"status"` - CuratedContentID *uuid.UUID `json:"curated_content_id"` - CollectedAt time.Time `json:"collected_at"` - UrlHash string `json:"url_hash"` - FeedID *uuid.UUID `json:"feed_id"` - PublishedAt *time.Time `json:"published_at"` - FeedName string `json:"feed_name"` -} - -func (q *Queries) RecentFeedEntries(ctx context.Context, arg RecentFeedEntriesParams) ([]RecentFeedEntriesRow, error) { - rows, err := q.db.Query(ctx, recentFeedEntries, arg.CollectedAt, arg.CollectedAt_2, arg.Limit) - if err != nil { - return nil, err - } - defer rows.Close() - items := []RecentFeedEntriesRow{} - for rows.Next() { - var i RecentFeedEntriesRow - if err := rows.Scan( - &i.ID, - &i.SourceUrl, - &i.Title, - &i.OriginalContent, - &i.Status, - &i.CuratedContentID, - &i.CollectedAt, - &i.UrlHash, - &i.FeedID, - &i.PublishedAt, - &i.FeedName, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - const recurringTodoItemsDueToday = `-- name: RecurringTodoItemsDueToday :many SELECT id, title, state, due, project_id, @@ -5841,19 +5393,6 @@ func (q *Queries) SimilarContents(ctx context.Context, arg SimilarContentsParams return items, nil } -const staleSomedayTodoCount = `-- name: StaleSomedayTodoCount :one -SELECT count(*)::int FROM todos -WHERE state = 'someday' AND updated_at < $1 -` - -// Count of someday todo items not updated in N days (GTD review signal). -func (q *Queries) StaleSomedayTodoCount(ctx context.Context, staleBefore time.Time) (int32, error) { - row := q.db.QueryRow(ctx, staleSomedayTodoCount, staleBefore) - var column_1 int32 - err := row.Scan(&column_1) - return column_1, err -} - const statsActivityBySource = `-- name: StatsActivityBySource :many SELECT entity_type AS source, COUNT(*)::int AS count FROM activity_events @@ -6402,18 +5941,6 @@ func (q *Queries) SubmitContentForReview(ctx context.Context, id uuid.UUID) (Sub return i, err } -const todoInboxCount = `-- name: TodoInboxCount :one -SELECT count(*)::int FROM todos WHERE state = 'inbox' -` - -// Count of todo items in inbox state (for needs_attention badge). -func (q *Queries) TodoInboxCount(ctx context.Context) (int32, error) { - row := q.db.QueryRow(ctx, todoInboxCount) - var column_1 int32 - err := row.Scan(&column_1) - return column_1, err -} - const todoItemByID = `-- name: TodoItemByID :one SELECT id, title, state, due, project_id, completed_at, energy, priority, recur_interval, recur_unit, recur_weekdays, last_completed_on, @@ -6549,50 +6076,6 @@ func (q *Queries) TodoItemsByProjectGrouped(ctx context.Context, projectID *uuid return items, nil } -const todoItemsCreatedSince = `-- name: TodoItemsCreatedSince :many -SELECT t.id, t.title, t.created_at, t.project_id, - COALESCE(p.title, '') AS project_title -FROM todos t -LEFT JOIN projects p ON t.project_id = p.id -WHERE t.created_at >= $1 -ORDER BY t.created_at DESC -` - -type TodoItemsCreatedSinceRow struct { - ID uuid.UUID `json:"id"` - Title string `json:"title"` - CreatedAt time.Time `json:"created_at"` - ProjectID *uuid.UUID `json:"project_id"` - ProjectTitle string `json:"project_title"` -} - -// Get todo items created since a given time with project context. -func (q *Queries) TodoItemsCreatedSince(ctx context.Context, since time.Time) ([]TodoItemsCreatedSinceRow, error) { - rows, err := q.db.Query(ctx, todoItemsCreatedSince, since) - if err != nil { - return nil, err - } - defer rows.Close() - items := []TodoItemsCreatedSinceRow{} - for rows.Next() { - var i TodoItemsCreatedSinceRow - if err := rows.Scan( - &i.ID, - &i.Title, - &i.CreatedAt, - &i.ProjectID, - &i.ProjectTitle, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - const todoItemsDueInRange = `-- name: TodoItemsDueInRange :many SELECT t.id, t.title, t.state, t.due, t.project_id, t.energy, t.priority, t.recur_interval, t.recur_unit, t.recur_weekdays, t.last_completed_on, @@ -6840,71 +6323,6 @@ func (q *Queries) ToggleMilestone(ctx context.Context, id uuid.UUID) (ToggleMile return i, err } -const topUnreadFeedEntriesRecent = `-- name: TopUnreadFeedEntriesRecent :many -SELECT cd.id, cd.source_url, cd.title, cd.original_content, - cd.status, cd.curated_content_id, cd.collected_at, - cd.url_hash, cd.feed_id, cd.published_at, - COALESCE(f.name, '') AS feed_name -FROM feed_entries cd -LEFT JOIN feeds f ON cd.feed_id = f.id -WHERE cd.collected_at >= $1 - AND cd.status = 'unread' -ORDER BY COALESCE(cd.published_at, cd.collected_at) DESC -LIMIT $2 -` - -type TopUnreadFeedEntriesRecentParams struct { - Since time.Time `json:"since"` - MaxResults int32 `json:"max_results"` -} - -type TopUnreadFeedEntriesRecentRow struct { - ID uuid.UUID `json:"id"` - SourceUrl string `json:"source_url"` - Title string `json:"title"` - OriginalContent string `json:"original_content"` - Status FeedEntryStatus `json:"status"` - CuratedContentID *uuid.UUID `json:"curated_content_id"` - CollectedAt time.Time `json:"collected_at"` - UrlHash string `json:"url_hash"` - FeedID *uuid.UUID `json:"feed_id"` - PublishedAt *time.Time `json:"published_at"` - FeedName string `json:"feed_name"` -} - -// Get unread collected data since a given time. -func (q *Queries) TopUnreadFeedEntriesRecent(ctx context.Context, arg TopUnreadFeedEntriesRecentParams) ([]TopUnreadFeedEntriesRecentRow, error) { - rows, err := q.db.Query(ctx, topUnreadFeedEntriesRecent, arg.Since, arg.MaxResults) - if err != nil { - return nil, err - } - defer rows.Close() - items := []TopUnreadFeedEntriesRecentRow{} - for rows.Next() { - var i TopUnreadFeedEntriesRecentRow - if err := rows.Scan( - &i.ID, - &i.SourceUrl, - &i.Title, - &i.OriginalContent, - &i.Status, - &i.CuratedContentID, - &i.CollectedAt, - &i.UrlHash, - &i.FeedID, - &i.PublishedAt, - &i.FeedName, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - const topicBySlug = `-- name: TopicBySlug :one SELECT t.id, t.slug, t.name, t.description, t.icon, t.sort_order, t.created_at, t.updated_at, COUNT(ct.content_id) FILTER (WHERE c.status = 'published' AND c.is_public = true) AS content_count diff --git a/internal/feed/entry/query.sql b/internal/feed/entry/query.sql index 4e9265cc..cc8e4c54 100644 --- a/internal/feed/entry/query.sql +++ b/internal/feed/entry/query.sql @@ -39,43 +39,6 @@ RETURNING id, source_url, title, original_content, -- name: IgnoreFeedEntry :exec UPDATE feed_entries SET status = 'ignored' WHERE id = $1; --- name: RecentFeedEntries :many -SELECT cd.id, cd.source_url, cd.title, cd.original_content, - cd.status, cd.curated_content_id, cd.collected_at, - cd.url_hash, cd.feed_id, cd.published_at, - COALESCE(f.name, '') AS feed_name -FROM feed_entries cd -LEFT JOIN feeds f ON cd.feed_id = f.id -WHERE cd.collected_at >= $1 AND cd.collected_at < $2 -ORDER BY COALESCE(cd.published_at, cd.collected_at) DESC -LIMIT $3; - --- name: LatestFeedEntries :many --- Get latest collected data, optionally filtered by time range. --- When days is NULL, returns the latest N items regardless of time. -SELECT cd.id, cd.source_url, cd.title, cd.original_content, - cd.status, cd.curated_content_id, cd.collected_at, - cd.url_hash, cd.feed_id, cd.published_at, - COALESCE(f.name, '') AS feed_name -FROM feed_entries cd -LEFT JOIN feeds f ON cd.feed_id = f.id -WHERE (sqlc.narg('since')::timestamptz IS NULL OR cd.collected_at >= sqlc.narg('since')) -ORDER BY COALESCE(cd.published_at, cd.collected_at) DESC -LIMIT @max_results; - --- name: TopUnreadFeedEntriesRecent :many --- Get unread collected data since a given time. -SELECT cd.id, cd.source_url, cd.title, cd.original_content, - cd.status, cd.curated_content_id, cd.collected_at, - cd.url_hash, cd.feed_id, cd.published_at, - COALESCE(f.name, '') AS feed_name -FROM feed_entries cd -LEFT JOIN feeds f ON cd.feed_id = f.id -WHERE cd.collected_at >= @since - AND cd.status = 'unread' -ORDER BY COALESCE(cd.published_at, cd.collected_at) DESC -LIMIT @max_results; - -- name: HighPriorityRecentFeedEntries :many -- Get unread collected data from high-priority feeds in the past N hours. SELECT cd.id, cd.source_url, cd.title, cd.original_content, @@ -89,7 +52,3 @@ WHERE f.priority = 'high' AND cd.collected_at >= @since ORDER BY COALESCE(cd.published_at, cd.collected_at) DESC LIMIT @max_results; - --- name: DeleteOldIgnored :execrows --- Cleanup: delete ignored collected data older than the given cutoff. -DELETE FROM feed_entries WHERE status = 'ignored' AND collected_at < @cutoff; diff --git a/internal/feed/entry/store.go b/internal/feed/entry/store.go index 58d8d021..089b6b67 100644 --- a/internal/feed/entry/store.go +++ b/internal/feed/entry/store.go @@ -153,77 +153,6 @@ func (s *Store) ItemByURLHash(ctx context.Context, urlHash string) (*Item, error return &d, nil } -// RecentFeedEntries returns recently collected items in a time range, ordered by collected_at DESC. -func (s *Store) RecentFeedEntries(ctx context.Context, start, end time.Time, limit int32) ([]Item, error) { - rows, err := s.q.RecentFeedEntries(ctx, db.RecentFeedEntriesParams{ - CollectedAt: start, - CollectedAt_2: end, - Limit: limit, - }) - if err != nil { - return nil, fmt.Errorf("listing recent collected data: %w", err) - } - data := make([]Item, len(rows)) - for i := range rows { - r := &rows[i] - data[i] = rowToItem(collectedRow{ - ID: r.ID, SourceUrl: r.SourceUrl, Title: r.Title, OriginalContent: r.OriginalContent, - Status: r.Status, - CuratedContentID: r.CuratedContentID, CollectedAt: r.CollectedAt, UrlHash: r.UrlHash, - FeedID: r.FeedID, - PublishedAt: r.PublishedAt, FeedName: r.FeedName, - }) - } - return data, nil -} - -// LatestFeedEntries returns the latest collected items, optionally filtered by a since timestamp. -// When since is nil, returns the latest maxResults items regardless of time. -func (s *Store) LatestFeedEntries(ctx context.Context, since *time.Time, maxResults int32) ([]Item, error) { - rows, err := s.q.LatestFeedEntries(ctx, db.LatestFeedEntriesParams{ - Since: since, - MaxResults: maxResults, - }) - if err != nil { - return nil, fmt.Errorf("listing latest collected data: %w", err) - } - data := make([]Item, len(rows)) - for i := range rows { - r := &rows[i] - data[i] = rowToItem(collectedRow{ - ID: r.ID, SourceUrl: r.SourceUrl, Title: r.Title, OriginalContent: r.OriginalContent, - Status: r.Status, - CuratedContentID: r.CuratedContentID, CollectedAt: r.CollectedAt, UrlHash: r.UrlHash, - FeedID: r.FeedID, - PublishedAt: r.PublishedAt, FeedName: r.FeedName, - }) - } - return data, nil -} - -// TopUnreadFeedEntriesRecent returns unread collected items since the given time. -func (s *Store) TopUnreadFeedEntriesRecent(ctx context.Context, since time.Time, maxResults int32) ([]Item, error) { - rows, err := s.q.TopUnreadFeedEntriesRecent(ctx, db.TopUnreadFeedEntriesRecentParams{ - Since: since, - MaxResults: maxResults, - }) - if err != nil { - return nil, fmt.Errorf("listing top unread collected data: %w", err) - } - data := make([]Item, len(rows)) - for i := range rows { - r := &rows[i] - data[i] = rowToItem(collectedRow{ - ID: r.ID, SourceUrl: r.SourceUrl, Title: r.Title, OriginalContent: r.OriginalContent, - Status: r.Status, - CuratedContentID: r.CuratedContentID, CollectedAt: r.CollectedAt, UrlHash: r.UrlHash, - FeedID: r.FeedID, - PublishedAt: r.PublishedAt, FeedName: r.FeedName, - }) - } - return data, nil -} - // HighPriorityRecent returns unread items from high-priority feeds since the given time. func (s *Store) HighPriorityRecent(ctx context.Context, since time.Time, maxResults int32) ([]Item, error) { rows, err := s.q.HighPriorityRecentFeedEntries(ctx, db.HighPriorityRecentFeedEntriesParams{ @@ -247,40 +176,6 @@ func (s *Store) HighPriorityRecent(ctx context.Context, since time.Time, maxResu return data, nil } -// DeleteOldIgnored deletes ignored collected data with collected_at before cutoff. -// Returns the number of rows deleted. -func (s *Store) DeleteOldIgnored(ctx context.Context, cutoff time.Time) (int64, error) { - n, err := s.q.DeleteOldIgnored(ctx, cutoff) - if err != nil { - return 0, fmt.Errorf("deleting old ignored collected data: %w", err) - } - return n, nil -} - -// TopItems returns the top N most recent unread items from the last 7 days. -func (s *Store) TopItems(ctx context.Context, limit int) ([]Item, error) { - since := time.Now().AddDate(0, 0, -7) - rows, err := s.q.TopUnreadFeedEntriesRecent(ctx, db.TopUnreadFeedEntriesRecentParams{ - Since: since, - MaxResults: int32(limit), // #nosec G115 -- limit bounded by caller - }) - if err != nil { - return nil, fmt.Errorf("listing top collected items: %w", err) - } - items := make([]Item, len(rows)) - for i := range rows { - r := &rows[i] - items[i] = rowToItem(collectedRow{ - ID: r.ID, SourceUrl: r.SourceUrl, Title: r.Title, OriginalContent: r.OriginalContent, - Status: r.Status, - CuratedContentID: r.CuratedContentID, CollectedAt: r.CollectedAt, UrlHash: r.UrlHash, - FeedID: r.FeedID, - PublishedAt: r.PublishedAt, FeedName: r.FeedName, - }) - } - return items, nil -} - // collectedRow is the common field set shared by all sqlc-generated collected data // row types. Each query returns a different Row type (CollectedDataRow, LatestFeedEntriesRow, etc.) // but they all share the same fields including FeedName from the LEFT JOIN. diff --git a/internal/feed/query.sql b/internal/feed/query.sql index 2ece723b..c51cbb3d 100644 --- a/internal/feed/query.sql +++ b/internal/feed/query.sql @@ -21,13 +21,6 @@ LEFT JOIN topics t ON t.id = ft.topic_id WHERE f.id = $1 GROUP BY f.id; --- name: EnabledFeeds :many -SELECT id, url, name, schedule, enabled, priority, etag, last_modified, - last_fetched_at, consecutive_failures, last_error, disabled_reason, - filter_config, created_at, updated_at -FROM feeds WHERE enabled = true -ORDER BY created_at; - -- name: EnabledFeedsBySchedule :many SELECT id, url, name, schedule, enabled, priority, etag, last_modified, last_fetched_at, consecutive_failures, last_error, disabled_reason, diff --git a/internal/feed/store.go b/internal/feed/store.go index 7d695f5c..bd65e755 100644 --- a/internal/feed/store.go +++ b/internal/feed/store.go @@ -114,20 +114,6 @@ func (s *Store) Feed(ctx context.Context, id uuid.UUID) (*Feed, error) { return &f, nil } -// EnabledFeeds returns all enabled feeds regardless of schedule. -func (s *Store) EnabledFeeds(ctx context.Context) ([]Feed, error) { - rows, err := s.q.EnabledFeeds(ctx) - if err != nil { - return nil, fmt.Errorf("listing enabled feeds: %w", err) - } - feeds := make([]Feed, len(rows)) - for i := range rows { - r := rows[i] - feeds[i] = dbToFeed(&r) - } - return feeds, nil -} - // EnabledFeedsBySchedule returns all enabled feeds for the given schedule. func (s *Store) EnabledFeedsBySchedule(ctx context.Context, schedule string) ([]Feed, error) { rows, err := s.q.EnabledFeedsBySchedule(ctx, schedule) diff --git a/internal/goal/query.sql b/internal/goal/query.sql index fe2f982a..3ce62461 100644 --- a/internal/goal/query.sql +++ b/internal/goal/query.sql @@ -7,12 +7,6 @@ WHERE id = @id RETURNING id, title, description, status, area_id, quarter, deadline, created_by, proposal_rationale, created_at, updated_at; --- name: GoalByTitle :one --- Find a goal by case-insensitive title match. -SELECT id, title, description, status, area_id, quarter, deadline, created_by, proposal_rationale, - created_at, updated_at -FROM goals WHERE LOWER(title) = LOWER(@title); - -- name: CreateGoal :one -- Create a new goal (v2: PostgreSQL-native). INSERT INTO goals (title, description, status, area_id, quarter, deadline) diff --git a/internal/goal/store.go b/internal/goal/store.go index 69862a87..ef4abd8f 100644 --- a/internal/goal/store.go +++ b/internal/goal/store.go @@ -4,15 +4,6 @@ // milestones, and the cross-table RecentActivity UNION. Kept in one // file because Milestone and ActivityItem are read-only siblings of // Goal with no independent lifecycle worth splitting out. -// -// Naming quirks worth knowing before adding callers: -// - Create(ctx, *CreateParams) is the idiomatic constructor. -// CreateGoal(ctx, title, description, status, areaID, quarter, -// deadline) is a legacy 7-arg signature kept for existing callers. -// New code should use Create + CreateParams. -// - ByID(ctx, id) returns *GoalWithArea (joins area name). -// GoalByID(ctx, id) returns bare *Goal. Pick the one matching what -// you need — don't pay for the join if the area name is unused. package goal @@ -68,19 +59,6 @@ func mapWriteError(err error, operation string) error { } } -// GoalByTitle returns a goal by case-insensitive title match. -func (s *Store) GoalByTitle(ctx context.Context, title string) (*Goal, error) { - r, err := s.q.GoalByTitle(ctx, title) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrNotFound - } - return nil, fmt.Errorf("querying goal by title %q: %w", title, err) - } - g := rowToGoal(&r) - return &g, nil -} - // UpdateStatus updates a goal's status. func (s *Store) UpdateStatus(ctx context.Context, id uuid.UUID, status Status) (*Goal, error) { r, err := s.q.UpdateGoalStatus(ctx, db.UpdateGoalStatusParams{ @@ -97,36 +75,6 @@ func (s *Store) UpdateStatus(ctx context.Context, id uuid.UUID, status Status) ( return &g, nil } -// CreateGoal inserts a new goal. -func (s *Store) CreateGoal(ctx context.Context, title, description, status string, areaID *uuid.UUID, quarter *string, deadline *time.Time) (*Goal, error) { - r, err := s.q.CreateGoal(ctx, db.CreateGoalParams{ - Title: title, - Description: description, - Status: db.GoalStatus(status), - AreaID: areaID, - Quarter: quarter, - Deadline: deadline, - }) - if err != nil { - return nil, mapWriteError(err, "creating goal") - } - g := rowToGoal(&r) - return &g, nil -} - -// GoalByID returns a single goal by ID. -func (s *Store) GoalByID(ctx context.Context, id uuid.UUID) (*Goal, error) { - r, err := s.q.GoalByID(ctx, id) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrNotFound - } - return nil, fmt.Errorf("querying goal %s: %w", id, err) - } - g := rowToGoal(&r) - return &g, nil -} - // Milestone represents a goal milestone. type Milestone struct { ID uuid.UUID `json:"id"` @@ -334,29 +282,6 @@ func (s *Store) Create(ctx context.Context, p *CreateParams) (*Goal, error) { return &g, nil } -// CreateMilestoneSimple inserts a new milestone with only title and position. -func (s *Store) CreateMilestoneSimple(ctx context.Context, goalID uuid.UUID, title string, position int32) (*Milestone, error) { - r, err := s.q.CreateMilestoneWithPosition(ctx, db.CreateMilestoneWithPositionParams{ - GoalID: goalID, - Title: title, - Position: position, - }) - if err != nil { - return nil, mapWriteError(err, "creating milestone") - } - return &Milestone{ - ID: r.ID, - GoalID: r.GoalID, - Title: r.Title, - Description: r.Description, - TargetDeadline: r.TargetDeadline, - CompletedAt: r.CompletedAt, - Position: r.Position, - CreatedAt: r.CreatedAt, - UpdatedAt: r.UpdatedAt, - }, nil -} - // ToggleMilestone toggles a milestone's completed_at (set to now if null, null if set). func (s *Store) ToggleMilestone(ctx context.Context, id uuid.UUID) (*Milestone, error) { r, err := s.q.ToggleMilestone(ctx, id) diff --git a/internal/todo/history.go b/internal/todo/history.go index a30ec0e3..dc67184b 100644 --- a/internal/todo/history.go +++ b/internal/todo/history.go @@ -73,21 +73,3 @@ func (s *Store) CompletedItemsDetailSince(ctx context.Context, since time.Time) } return result, nil } - -// ItemsCreatedSince returns todo items created since the given time. -func (s *Store) ItemsCreatedSince(ctx context.Context, since time.Time) ([]CreatedDetail, error) { - rows, err := s.q.TodoItemsCreatedSince(ctx, since) - if err != nil { - return nil, fmt.Errorf("listing todo items created since %s: %w", since.Format(time.DateOnly), err) - } - result := make([]CreatedDetail, len(rows)) - for i, r := range rows { - result[i] = CreatedDetail{ - ID: r.ID, - Title: r.Title, - CreatedAt: r.CreatedAt, - ProjectTitle: r.ProjectTitle, - } - } - return result, nil -} diff --git a/internal/todo/query.sql b/internal/todo/query.sql index 0fa9af8a..0435072d 100644 --- a/internal/todo/query.sql +++ b/internal/todo/query.sql @@ -58,31 +58,6 @@ SELECT id, title, state, due, project_id, description, created_by, created_at, updated_at FROM todos ORDER BY state, due NULLS LAST, created_at DESC; --- name: PendingTodoItems :many --- List todo items that are not done, ordered by due date. -SELECT id, title, state, due, project_id, - completed_at, energy, priority, recur_interval, recur_unit, recur_weekdays, last_completed_on, - description, created_by, created_at, updated_at -FROM todos WHERE state != 'done' -ORDER BY due NULLS LAST, created_at; - --- name: PendingTodoItemsWithProject :many --- List pending todo items with project info. -SELECT t.id, t.title, t.state, t.due, t.project_id, - t.energy, t.priority, t.recur_interval, t.recur_unit, t.recur_weekdays, t.last_completed_on, - t.created_at, t.updated_at, - COALESCE(p.title, '') AS project_title, - COALESCE(p.slug, '') AS project_slug -FROM todos t -LEFT JOIN projects p ON t.project_id = p.id -WHERE t.state != 'done' - AND (sqlc.narg('project_slug')::text IS NULL OR p.slug = sqlc.narg('project_slug')) -ORDER BY - (t.due IS NOT NULL) DESC, - t.due ASC NULLS LAST, - t.updated_at ASC -LIMIT sqlc.arg('max_results'); - -- name: TodoItemByID :one -- Get a todo item by ID. SELECT id, title, state, due, project_id, @@ -90,16 +65,6 @@ SELECT id, title, state, due, project_id, description, created_by, created_at, updated_at FROM todos WHERE id = @id; --- name: PendingTodoItemsByTitle :many --- Find pending todo items matching a title (case-insensitive contains). -SELECT id, title, state, due, project_id, - completed_at, energy, priority, recur_interval, recur_unit, recur_weekdays, last_completed_on, - description, created_by, created_at, updated_at -FROM todos -WHERE state != 'done' AND title ILIKE '%' || @search_title || '%' -ORDER BY due NULLS LAST, updated_at ASC -LIMIT 10; - -- name: TodosByCreator :many -- List todos created by a given agent, newest first. Powers the list_tasks -- MCP readback loop: an agent reads the disposition of the todos it created. @@ -151,15 +116,6 @@ LEFT JOIN projects p ON t.project_id = p.id WHERE t.state = 'done' AND t.completed_at >= @since ORDER BY t.completed_at DESC; --- name: TodoItemsCreatedSince :many --- Get todo items created since a given time with project context. -SELECT t.id, t.title, t.created_at, t.project_id, - COALESCE(p.title, '') AS project_title -FROM todos t -LEFT JOIN projects p ON t.project_id = p.id -WHERE t.created_at >= @since -ORDER BY t.created_at DESC; - -- name: UpdateTodoItem :one -- Update editable todo item fields. State transitions go through -- UpdateTodoItemState, never here. Only non-null parameters are applied. @@ -251,19 +207,6 @@ SET last_completed_on = @completed_on::date, WHERE id = @id AND created_by = @created_by AND (recur_weekdays IS NOT NULL OR recur_interval IS NOT NULL); --- name: TodoInboxCount :one --- Count of todo items in inbox state (for needs_attention badge). -SELECT count(*)::int FROM todos WHERE state = 'inbox'; - --- name: StaleSomedayTodoCount :one --- Count of someday todo items not updated in N days (GTD review signal). -SELECT count(*)::int FROM todos -WHERE state = 'someday' AND updated_at < @stale_before; - --- name: InboxTodoItems :many --- List all inbox todo items, newest first. -SELECT * FROM todos WHERE state = 'inbox' ORDER BY created_at DESC; - -- name: ClarifyTodoItem :one -- Promote inbox todo item to todo state with clarification fields. UPDATE todos diff --git a/internal/todo/todo.go b/internal/todo/todo.go index 70766ff4..96ccceb2 100644 --- a/internal/todo/todo.go +++ b/internal/todo/todo.go @@ -120,20 +120,6 @@ func (s *Store) ItemByID(ctx context.Context, id uuid.UUID) (*Item, error) { return &t, nil } -// PendingItemsByTitle finds pending todo items matching a title (case-insensitive). -func (s *Store) PendingItemsByTitle(ctx context.Context, title string) ([]Item, error) { - escaped := escapeILIKE(title) - rows, err := s.q.PendingTodoItemsByTitle(ctx, &escaped) - if err != nil { - return nil, fmt.Errorf("searching pending todo items by title %q: %w", title, err) - } - items := make([]Item, len(rows)) - for i := range rows { - items[i] = rowToItem(&rows[i]) - } - return items, nil -} - // TodosByCreator returns the todos created by createdBy, newest first. It // backs the list_tasks MCP readback loop: an agent reads the disposition // (state) of the todos it created. createdBy is the resolved caller @@ -391,12 +377,6 @@ type Resolution struct { State State } -// Pending is a lightweight projection used by brief(morning) and the Today aggregate. -type Pending struct { - Title string - Due string -} - // PendingDetail is a pending todo with project context. // // CreatedBy and Description are populated by BacklogItems only (the admin diff --git a/internal/todo/views.go b/internal/todo/views.go index c1ba8ff3..1069dfb1 100644 --- a/internal/todo/views.go +++ b/internal/todo/views.go @@ -80,86 +80,6 @@ func (s *Store) ItemsDueInRange(ctx context.Context, start, end time.Time) ([]Pe return items, nil } -// PendingItems returns todo items that are not done (lightweight). -func (s *Store) PendingItems(ctx context.Context) ([]Pending, error) { - rows, err := s.q.PendingTodoItems(ctx) - if err != nil { - return nil, fmt.Errorf("listing pending todo items: %w", err) - } - items := make([]Pending, 0, len(rows)) - for i := range rows { - r := &rows[i] - var due string - if r.Due != nil { - due = r.Due.Format(time.DateOnly) - } - items = append(items, Pending{Title: r.Title, Due: due}) - } - return items, nil -} - -// PendingItemsWithProject returns pending todo items with project context. -func (s *Store) PendingItemsWithProject(ctx context.Context, projectSlug *string, maxResults int32) ([]PendingDetail, error) { - rows, err := s.q.PendingTodoItemsWithProject(ctx, db.PendingTodoItemsWithProjectParams{ - ProjectSlug: projectSlug, - MaxResults: maxResults, - }) - if err != nil { - return nil, fmt.Errorf("listing pending todo items with project: %w", err) - } - items := make([]PendingDetail, len(rows)) - for i := range rows { - r := &rows[i] - items[i] = PendingDetail{ - ID: r.ID, - Title: r.Title, - State: State(r.State), - Due: r.Due, - ProjectTitle: r.ProjectTitle, - ProjectSlug: r.ProjectSlug, - Energy: r.Energy, - Priority: r.Priority, - RecurInterval: r.RecurInterval, - RecurUnit: r.RecurUnit, - CreatedAt: r.CreatedAt, - UpdatedAt: r.UpdatedAt, - } - } - return items, nil -} - -// InboxCount returns the number of todo items in the inbox state. -func (s *Store) InboxCount(ctx context.Context) (int, error) { - n, err := s.q.TodoInboxCount(ctx) - if err != nil { - return 0, fmt.Errorf("counting inbox todo items: %w", err) - } - return int(n), nil -} - -// StaleSomedayCount returns the number of someday todo items not updated in staleDays. -func (s *Store) StaleSomedayCount(ctx context.Context, staleDays int) (int, error) { - before := time.Now().AddDate(0, 0, -staleDays) - n, err := s.q.StaleSomedayTodoCount(ctx, before) - if err != nil { - return 0, fmt.Errorf("counting stale someday todo items: %w", err) - } - return int(n), nil -} - -// InboxItems returns all todo items in the inbox state, newest first. -func (s *Store) InboxItems(ctx context.Context) ([]Item, error) { - rows, err := s.q.InboxTodoItems(ctx) - if err != nil { - return nil, fmt.Errorf("listing inbox todo items: %w", err) - } - items := make([]Item, len(rows)) - for i := range rows { - items[i] = rowToItem(&rows[i]) - } - return items, nil -} - // BacklogItems returns a filtered list for the admin backlog view. // Empty states, projectID, energy, priority, search, or sort are treated // as "no filter / default ordering" so the admin UI can request the full From d003d083fe9fc0cd1f87464f7cf1c01d1c3290cc Mon Sep 17 00:00:00 2001 From: Koopa Date: Wed, 24 Jun 2026 15:52:39 +0800 Subject: [PATCH 04/31] fix: catch SIGTERM for graceful shutdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit signal.NotifyContext registered only os.Interrupt (SIGINT), which arrives from a TTY Ctrl-C. Docker/VPS deployment (CLAUDE.md tech stack) stops containers with SIGTERM, which was not caught — so production stops would skip the drain path and hard-kill mid-request/mid-pass. Register SIGTERM alongside SIGINT in all three entrypoints (app run, app embed-backfill, mcp). --- cmd/app/main.go | 5 +++-- cmd/mcp/main.go | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/cmd/app/main.go b/cmd/app/main.go index 00b58dbd..c0ea3295 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -16,6 +16,7 @@ import ( "os/signal" "slices" "sync" + "syscall" "time" "github.com/exaring/otelpgx" @@ -81,7 +82,7 @@ func main() { func runBackfill(logger *slog.Logger) error { cfg := loadBackfillConfig(logger) - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() pool, err := connectDB(ctx, cfg.DatabaseURL, nil) @@ -144,7 +145,7 @@ func passLogAttrs(res embedder.Result) []any { func run(logger *slog.Logger) error { cfg := loadConfig(logger) - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() meterProvider, metricsHandler, observabilityShutdown, err := setupObservability(ctx, observabilityConfig{ diff --git a/cmd/mcp/main.go b/cmd/mcp/main.go index e53c1b51..3bb0d75f 100644 --- a/cmd/mcp/main.go +++ b/cmd/mcp/main.go @@ -17,6 +17,7 @@ import ( "net/http" "os" "os/signal" + "syscall" "time" "github.com/jackc/pgx/v5/pgxpool" @@ -41,7 +42,7 @@ func main() { logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) cfg := loadConfig(logger) - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) err := run(ctx, &cfg, logger) stop() if err != nil { From 2b3077876aae7745923255e3f8ee314cb8fd02bf Mon Sep 17 00:00:00 2001 From: Koopa Date: Wed, 24 Jun 2026 15:53:56 +0800 Subject: [PATCH 05/31] fix: drain background workers when the listener errors The run() select returned immediately on the errCh branch, before srv.Shutdown and wg.Wait. The scheduler and reconciler goroutines select on ctx, which the errCh branch never cancels (only a signal does), so they kept running while the deferred pool.Close() closed the pool out from under their in-flight queries. Cancel ctx via stop() on the error branch so both workers observe cancellation and drain before the pool closes; the captured error is still returned. --- cmd/app/main.go | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/cmd/app/main.go b/cmd/app/main.go index c0ea3295..2298210c 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -344,9 +344,15 @@ func run(logger *slog.Logger) error { } }() + // A listener error and a shutdown signal share one drain path: capture the + // error, then cancel ctx via stop() so the scheduler and reconciler — both + // selecting on ctx — exit and wg.Wait() can return before the deferred + // pool.Close() runs. Returning early on the errCh branch would leak those + // workers and close the pool out from under an in-flight query. + var runErr error select { - case err := <-errCh: - return err + case runErr = <-errCh: + stop() case <-ctx.Done(): } @@ -354,12 +360,12 @@ func run(logger *slog.Logger) error { shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - if err := srv.Shutdown(shutdownCtx); err != nil { - return fmt.Errorf("server shutdown: %w", err) + if err := srv.Shutdown(shutdownCtx); err != nil && runErr == nil { + runErr = fmt.Errorf("server shutdown: %w", err) } wg.Wait() logger.Info("server stopped") - return nil + return runErr } // connectDB opens the pgxpool. When tracer is non-nil, it is set on the From 702646676dd2b0d69e4cf76d68f4c54b6f49cbb0 Mon Sep 17 00:00:00 2001 From: Koopa Date: Wed, 24 Jun 2026 15:55:03 +0800 Subject: [PATCH 06/31] fix: map goal CHECK violation to 400 not 500 mapWriteError mapped only unique (23505) and foreign-key (23503) violations, so a blank title hitting chk_goal_title_not_blank (23514) fell through to a wrapped 500. The sibling mapProposeError already maps CheckViolation to ErrInvalidInput; mirror it so the owner create/update paths return 400 for a blank title. A handler-level trim+reject and the asserting integration case follow in the goal test pass. --- internal/goal/store.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/internal/goal/store.go b/internal/goal/store.go index ef4abd8f..3a048cec 100644 --- a/internal/goal/store.go +++ b/internal/goal/store.go @@ -42,8 +42,9 @@ func (s *Store) WithTx(tx pgx.Tx) *Store { // mapWriteError classifies a PostgreSQL goal/milestone-write failure into a // feature sentinel. A unique violation (23505) becomes ErrConflict; a // foreign-key violation (23503 — a goal's area_id or a milestone's goal_id -// pointing at a non-existent row) becomes ErrInvalidInput; any other error is -// wrapped with the supplied context. +// pointing at a non-existent row) or a CHECK violation (23514 — a blank +// title via chk_goal_title_not_blank / chk_milestone_title_not_blank) becomes +// ErrInvalidInput; any other error is wrapped with the supplied context. func mapWriteError(err error, operation string) error { pgErr, ok := errors.AsType[*pgconn.PgError](err) if !ok { @@ -52,7 +53,7 @@ func mapWriteError(err error, operation string) error { switch pgErr.Code { case pgerrcode.UniqueViolation: return ErrConflict - case pgerrcode.ForeignKeyViolation: + case pgerrcode.ForeignKeyViolation, pgerrcode.CheckViolation: return ErrInvalidInput default: return fmt.Errorf("%s: %w", operation, err) From b324f2eb70bf055ea3a2aae69cab8c52af976d84 Mon Sep 17 00:00:00 2001 From: Koopa Date: Wed, 24 Jun 2026 15:57:12 +0800 Subject: [PATCH 07/31] refactor(mcp): remove the never-supported search_knowledge project filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Project input field was declared in the wire schema only to be rejected as unsupported_filter on every non-empty value — the canonical catalog never advertised it and content.SearchFilter has no project dimension. Per the owner's call, drop the reserved field (and its reject-stub validator + test) rather than carry a parameter that can only ever error. The result struct's Project (parent project title) is unrelated and kept. --- internal/mcp/search.go | 9 --------- internal/mcp/search_test.go | 5 ----- 2 files changed, 14 deletions(-) diff --git a/internal/mcp/search.go b/internal/mcp/search.go index 42f697be..7c5c72c2 100644 --- a/internal/mcp/search.go +++ b/internal/mcp/search.go @@ -48,7 +48,6 @@ const SourceTypeContent = "content" type SearchKnowledgeInput struct { Query string `json:"query" jsonschema:"required" jsonschema_description:"Search query text"` ContentType *string `json:"content_type,omitempty" jsonschema_description:"Filter by content type: article, essay, build-log, til, digest. An unknown value is rejected."` - Project *string `json:"project,omitempty" jsonschema_description:"NOT SUPPORTED — passing a non-empty value is rejected as an unsupported_filter. Reserved for a future content project filter."` After *string `json:"after,omitempty" jsonschema_description:"Filter by created date YYYY-MM-DD: keep rows created on or after the whole of this day (server timezone, UTC by default)."` Before *string `json:"before,omitempty" jsonschema_description:"Filter by created date YYYY-MM-DD: keep rows created on or before the whole of this day, i.e. through 23:59:59 of the date (server timezone, UTC by default)."` Limit FlexInt `json:"limit,omitempty" jsonschema_description:"Max results (default 20, max 50)."` @@ -318,13 +317,5 @@ func validateSearchKnowledgeInput(input SearchKnowledgeInput) error { return fmt.Errorf("unsupported content_type %q (supported: article, essay, build-log, til, digest)", *input.ContentType) } - // project is declared in the schema but has no retrieval path. Rather - // than silently ignore it (a caller passing project would get unfiltered - // results and never know), reject it as an unsupported filter until a - // real content project filter is wired. - if input.Project != nil && *input.Project != "" { - return fmt.Errorf("unsupported_filter: project is not supported by search_knowledge") - } - return nil } diff --git a/internal/mcp/search_test.go b/internal/mcp/search_test.go index 4004bf24..3904a905 100644 --- a/internal/mcp/search_test.go +++ b/internal/mcp/search_test.go @@ -141,11 +141,6 @@ func TestSearchKnowledge_Validation(t *testing.T) { input: SearchKnowledgeInput{Query: "go", ContentType: new("banana-not-a-type")}, wantErr: "unsupported content_type", }, - { - name: "project filter rejected as unsupported", - input: SearchKnowledgeInput{Query: "go", Project: new("koopa")}, - wantErr: "unsupported_filter", - }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { From 1bc4807ad151043062f387606afd16f238c6cb04 Mon Sep 17 00:00:00 2001 From: Koopa Date: Wed, 24 Jun 2026 15:59:18 +0800 Subject: [PATCH 08/31] fix: narrow PutPlan to the plan_day state allowlist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HTTP plan-write guard rejected only inbox, so the admin API accepted a done/someday/archived todo onto today's plan while the MCP plan_day tool (execution.go) rejected the same todo via a todo|in_progress allowlist — despite PutPlan's doc calling itself the human equivalent of plan_day. Mirror the MCP allowlist and fix the doc. Integration test seeds a someday todo and asserts 400 + zero plan rows (real Postgres). --- internal/daily/handler.go | 10 ++++---- internal/daily/integration_test.go | 37 ++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/internal/daily/handler.go b/internal/daily/handler.go index fab6d2bb..9e5f45cf 100644 --- a/internal/daily/handler.go +++ b/internal/daily/handler.go @@ -149,8 +149,9 @@ type putPlanResponse struct { // PutPlan handles PUT /api/admin/commitment/daily-plan — the human // equivalent of the MCP plan_day tool. It is idempotent for the given // date: it replaces the date's 'planned' rows with the supplied items in -// one transaction. Each todo MUST exist and be in state=todo (inbox-state -// todos are rejected — clarify them first). Empty items is a 400. The +// one transaction. Each todo MUST exist and be in state=todo or in_progress, +// mirroring the plan_day allowlist (inbox/done/someday/archived/dismissed are +// rejected — clarify an inbox todo first). Empty items is a 400. The // delete-then-insert runs atomically so a mid-loop validation failure // leaves the previous plan intact. func (h *Handler) PutPlan(w http.ResponseWriter, r *http.Request) { @@ -255,9 +256,10 @@ func (h *Handler) insertPlanItem(w http.ResponseWriter, r *http.Request, txDaily api.HandleError(w, h.logger, err, todoStoreErrors...) return false } - if t.State == todo.StateInbox { + if t.State != todo.StateTodo && t.State != todo.StateInProgress { api.Error(w, http.StatusBadRequest, "BAD_REQUEST", - "todo "+item.TodoID.String()+" is in inbox state; clarify it to state=todo before planning") + "todo "+item.TodoID.String()+" is in state "+string(t.State)+ + " — only todo or in_progress items can be planned (clarify an inbox todo first; done/someday/archived are not today's work)") return false } pos := i diff --git a/internal/daily/integration_test.go b/internal/daily/integration_test.go index 458fea87..f90f2b8c 100644 --- a/internal/daily/integration_test.go +++ b/internal/daily/integration_test.go @@ -270,6 +270,43 @@ func TestIntegration_Daily_PutPlan_InboxRejected(t *testing.T) { } } +// TestIntegration_Daily_PutPlan_NonActionableRejected asserts that planning a +// todo in a non-actionable state (someday) is rejected with 400. This pins the +// narrowed guard: PutPlan accepts only state=todo/in_progress, mirroring the +// plan_day allowlist, rather than rejecting inbox alone (which would let a +// someday/done/archived todo onto today's plan via the admin API). +func TestIntegration_Daily_PutPlan_NonActionableRejected(t *testing.T) { + truncate(t) + h := newHandler() + + someday := seedTodo(t, "Maybe later", "someday") + + req := putJSON(t, "/api/admin/commitment/daily-plan", map[string]any{ + "items": []map[string]any{ + {"todo_id": someday.String()}, + }, + }) + rec := serve(t, h.PutPlan, req) + + resp := rec.Result() + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400 for someday-state todo (body=%s)", resp.StatusCode, body) + } + + var count int + if err := testPool.QueryRow(t.Context(), + `SELECT COUNT(*) FROM daily_plan_items WHERE plan_date = CURRENT_DATE`, + ).Scan(&count); err != nil { + t.Fatalf("counting plan rows: %v", err) + } + if count != 0 { + t.Errorf("plan row count = %d, want 0 (non-actionable rejection must precede any commit)", count) + } +} + // TestIntegration_Daily_PutPlan_PositionOutOfRange asserts an out-of-bounds // position is rejected with 400 before any write. func TestIntegration_Daily_PutPlan_PositionOutOfRange(t *testing.T) { From d11f1d8e9af5e9375e2e4ef69b7915fce72e5a01 Mon Sep 17 00:00:00 2001 From: Koopa Date: Wed, 24 Jun 2026 16:13:56 +0800 Subject: [PATCH 09/31] fix(content): reject control characters in admin Create/Update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MCP write path (propose_content/revise_content) and admin SendBack already reject control characters, but admin Create/Update validated only length, type, and status — so a control char smuggled in via a JSON \u escape reached the DB through the admin boundary while the MCP boundary caught it. Apply the same strict-for-title/excerpt, prose-for-body check to both handlers, matching the input-validation-checklist handler-consistency rule. Table-driven tests assert a 400 per field. --- internal/content/admin.go | 43 ++++++++++ internal/content/validation_test.go | 121 ++++++++++++++++++++++++++++ 2 files changed, 164 insertions(+) diff --git a/internal/content/admin.go b/internal/content/admin.go index c3fb01a0..80f86668 100644 --- a/internal/content/admin.go +++ b/internal/content/admin.go @@ -25,6 +25,41 @@ import ( "github.com/Koopa0/koopa/internal/db" ) +// containsControlChars reports whether s contains any control character — ASCII +// C0 (0x00–0x1F), DEL (0x7F), or Unicode C1 (0x80–0x9F). This is the strict +// single-line check for title/excerpt; it is the same predicate the MCP write +// path applies via goal.ContainsControlChars (kept local here because content +// cannot import goal without an import cycle: content → goal → project → +// content). Body uses containsProseControlChars instead, which permits HT/LF/CR. +func containsControlChars(s string) bool { + for _, r := range s { + if r < 0x20 || r == 0x7f || (r >= 0x80 && r <= 0x9f) { + return true + } + } + return false +} + +// checkContentControlChars rejects control characters in the content write +// fields, mirroring the MCP write path (propose_content / revise_content): +// title and excerpt are single-line fields validated with the strict check +// (every control char), while body is multi-line Markdown validated with the +// prose check (HT/LF/CR permitted). A nil argument is skipped so the same +// check serves Create (all present) and partial Update (only changed fields). +// It returns the field name of the first offending field, or "" when clean. +func checkContentControlChars(title, excerpt, body *string) string { + if title != nil && containsControlChars(*title) { + return "title" + } + if excerpt != nil && containsControlChars(*excerpt) { + return "excerpt" + } + if body != nil && containsProseControlChars(*body) { + return "body" + } + return "" +} + // Contents returns a paginated list across all statuses / visibilities. // The authenticated admin listing route consumes this; the public-facing // variant is PublicContents. @@ -121,6 +156,10 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { api.Error(w, http.StatusBadRequest, "BAD_REQUEST", err.Error()) return } + if field := checkContentControlChars(&p.Title, &p.Excerpt, &p.Body); field != "" { + api.Error(w, http.StatusBadRequest, "BAD_REQUEST", field+" must not contain control characters") + return + } if p.Status == "" { p.Status = StatusDraft } @@ -170,6 +209,10 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { api.Error(w, http.StatusBadRequest, "BAD_REQUEST", err.Error()) return } + if field := checkContentControlChars(p.Title, p.Excerpt, p.Body); field != "" { + api.Error(w, http.StatusBadRequest, "BAD_REQUEST", field+" must not contain control characters") + return + } // IsPublic is a bool pointer — no validation needed beyond JSON decode tx, ok := h.mustAdminTx(w, r) diff --git a/internal/content/validation_test.go b/internal/content/validation_test.go index 00888f78..38468241 100644 --- a/internal/content/validation_test.go +++ b/internal/content/validation_test.go @@ -412,6 +412,127 @@ func TestHandler_Update_OversizedBody(t *testing.T) { } } +// ============================================================================= +// Handler.Create / Handler.Update — control-character rejection +// ============================================================================= + +// TestHandler_Create_RejectsControlChars verifies Create rejects a control +// character in title, excerpt, or body with 400 BAD_REQUEST, matching the MCP +// write path (propose_content) so the two write boundaries are identical. +// title/excerpt use the strict check (every C0/DEL/C1 char); body uses the +// prose check, which still rejects C0 chars other than HT/LF/CR (here U+0001). +// Each case carries all required fields so the only reason to reject is the +// control char — and rejection happens before the nil store is reached. +func TestHandler_Create_RejectsControlChars(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + body string + wantField string + }{ + { + name: "control char in title", + body: `{"slug":"s","title":"bad\u0001title","type":"article"}`, + wantField: "title", + }, + { + name: "control char in excerpt", + body: `{"slug":"s","title":"T","type":"article","excerpt":"bad\u0001excerpt"}`, + wantField: "excerpt", + }, + { + name: "control char in body", + body: `{"slug":"s","title":"T","type":"article","body":"bad\u0001body"}`, + wantField: "body", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + h := newTestHandler() + req := httptest.NewRequest(http.MethodPost, "/api/admin/contents", + strings.NewReader(tt.body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + h.Create(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("Create(%q) status = %d, want %d\nbody: %s", + tt.name, w.Code, http.StatusBadRequest, w.Body.String()) + } + got := decodeErrorBody(t, w.Body) + if got.Error.Code != "BAD_REQUEST" { + t.Errorf("Create(%q) error.code = %q, want BAD_REQUEST", tt.name, got.Error.Code) + } + wantMsg := tt.wantField + " must not contain control characters" + if got.Error.Message != wantMsg { + t.Errorf("Create(%q) error.message = %q, want %q", tt.name, got.Error.Message, wantMsg) + } + }) + } +} + +// TestHandler_Update_RejectsControlChars verifies Update rejects a control +// character in title, excerpt, or body with 400 BAD_REQUEST. Update fields are +// optional pointers, so each case sets only the field under test — the same +// control-char gate must fire regardless of which field is supplied. +func TestHandler_Update_RejectsControlChars(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + body string + wantField string + }{ + { + name: "control char in title", + body: `{"title":"bad\u0001title"}`, + wantField: "title", + }, + { + name: "control char in excerpt", + body: `{"excerpt":"bad\u0001excerpt"}`, + wantField: "excerpt", + }, + { + name: "control char in body", + body: `{"body":"bad\u0001body"}`, + wantField: "body", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + h := newTestHandler() + id := uuid.New().String() + req := httptest.NewRequest(http.MethodPut, "/api/admin/contents/"+id, + strings.NewReader(tt.body)) + req.Header.Set("Content-Type", "application/json") + req.SetPathValue("id", id) + w := httptest.NewRecorder() + + h.Update(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("Update(%q) status = %d, want %d\nbody: %s", + tt.name, w.Code, http.StatusBadRequest, w.Body.String()) + } + got := decodeErrorBody(t, w.Body) + if got.Error.Code != "BAD_REQUEST" { + t.Errorf("Update(%q) error.code = %q, want BAD_REQUEST", tt.name, got.Error.Code) + } + wantMsg := tt.wantField + " must not contain control characters" + if got.Error.Message != wantMsg { + t.Errorf("Update(%q) error.message = %q, want %q", tt.name, got.Error.Message, wantMsg) + } + }) + } +} + // ============================================================================= // Handler.Delete — invalid UUID path parameter // ============================================================================= From e34d1e87b60db70344833b33b1267dc12ec9cc87 Mon Sep 17 00:00:00 2001 From: Koopa Date: Wed, 24 Jun 2026 16:14:30 +0800 Subject: [PATCH 10/31] feat(auth): purge expired refresh tokens periodically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ConsumeRefreshToken only deletes the exact presented token, so expired-but- unconsumed rows accumulated forever — the expires_at index and the schema 'eligible for cleanup' comment promised a sweep that did not exist. Add DeleteExpiredRefreshTokens (served by that index) and a background worker wired through the same wg.Go/ctx drain as the scheduler and reconciler: it purges once at startup, then hourly, and exits on ctx cancellation. Integration test asserts only the expired token is removed. --- cmd/app/main.go | 45 ++++++++++++++++++ internal/auth/integration_test.go | 76 +++++++++++++++++++++++++++++++ internal/auth/query.sql | 3 ++ internal/auth/store.go | 13 ++++++ internal/db/query.sql.go | 12 +++++ 5 files changed, 149 insertions(+) create mode 100644 internal/auth/integration_test.go diff --git a/cmd/app/main.go b/cmd/app/main.go index 2298210c..ee0eb0c7 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -58,6 +58,11 @@ const agentSyncTimeout = 10 * time.Second // within roughly one interval of landing. const embedReconcileInterval = 60 * time.Second +// refreshTokenCleanupInterval is how often expired refresh tokens are purged. +// ConsumeRefreshToken only deletes the exact presented token, so expired-but- +// unconsumed rows would accumulate forever without this periodic sweep. +const refreshTokenCleanupInterval = time.Hour + func main() { logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) if len(os.Args) == 2 && os.Args[1] == "embed-backfill" { @@ -242,6 +247,12 @@ func run(logger *slog.Logger) error { logger.Info("embedding reconciler disabled, search stays FTS-only (GEMINI_API_KEY unset)") } + // Refresh-token cleanup — background goroutine purging expired tokens. + // Shares the scheduler WaitGroup so a SIGTERM-cancelled ctx drains it + // before pool.Close(), exactly like the feed scheduler and embedding + // reconciler above. + wg.Go(func() { runRefreshTokenCleanup(ctx, authStore, logger) }) + // Auth (optional — only if Google OAuth is configured) var authHandler *auth.Handler if cfg.GoogleClientID != "" { @@ -420,6 +431,40 @@ func startFeedScheduler(ctx context.Context, wg *sync.WaitGroup, deps feedSchedu return nil } +// runRefreshTokenCleanup purges expired refresh tokens once immediately, then +// again on every refreshTokenCleanupInterval tick, until ctx is cancelled. It +// owns no goroutine — the caller launches it on the scheduler WaitGroup and +// waits for it during shutdown, so a SIGTERM-cancelled ctx drains it before the +// pool closes. A cleanup error is logged and the loop continues; a transient DB +// blip must not kill the worker. Mirrors the embedding reconciler's run loop. +func runRefreshTokenCleanup(ctx context.Context, store *auth.Store, logger *slog.Logger) { + cleanup := func() { + n, err := store.DeleteExpiredRefreshTokens(ctx) + if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return + } + logger.Error("refresh token cleanup failed", "error", err) + return + } + if n > 0 { + logger.Info("refresh token cleanup", "expired_refresh_tokens_deleted", n) + } + } + + cleanup() + ticker := time.NewTicker(refreshTokenCleanupInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + cleanup() + } + } +} + // setupPool opens the pgxpool with an optional otelpgx tracer and, when // enabled, registers the pool-stats collector. The single boolean folds // the all-or-nothing kill-switch check (Q3): the caller decides whether diff --git a/internal/auth/integration_test.go b/internal/auth/integration_test.go new file mode 100644 index 00000000..2287b828 --- /dev/null +++ b/internal/auth/integration_test.go @@ -0,0 +1,76 @@ +// Copyright 2026 Koopa. All rights reserved. + +//go:build integration + +package auth_test + +import ( + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Koopa0/koopa/internal/auth" + "github.com/Koopa0/koopa/internal/testdb" +) + +var testPool *pgxpool.Pool + +func TestMain(m *testing.M) { + pool, cleanup := testdb.NewPool() + testPool = pool + code := m.Run() + cleanup() + os.Exit(code) +} + +// TestStore_DeleteExpiredRefreshTokens proves the cleanup removes only the +// expired rows: an expired token is gone after the sweep while a live token +// survives, and the returned count reflects exactly the deletions. This is the +// cleanup the expires_at index and schema comment promise — ConsumeRefreshToken +// alone deletes only the exact presented token, so expired-but-unconsumed rows +// would otherwise accumulate forever. +func TestStore_DeleteExpiredRefreshTokens(t *testing.T) { + if err := testdb.TruncateCtx(t.Context(), testPool, "refresh_tokens", "users"); err != nil { + t.Fatal(err) + } + store := auth.NewStore(testPool) + ctx := t.Context() + + user, err := store.UpsertUserByEmail(ctx, "cleanup@example.com") + if err != nil { + t.Fatalf("UpsertUserByEmail() error: %v", err) + } + + // One token already past expiry, one valid for another hour. + const expiredHash = "expired-token-hash" + const liveHash = "live-token-hash" + if err := store.CreateRefreshToken(ctx, user.ID, expiredHash, time.Now().Add(-time.Hour)); err != nil { + t.Fatalf("CreateRefreshToken(expired) error: %v", err) + } + if err := store.CreateRefreshToken(ctx, user.ID, liveHash, time.Now().Add(time.Hour)); err != nil { + t.Fatalf("CreateRefreshToken(live) error: %v", err) + } + + deleted, err := store.DeleteExpiredRefreshTokens(ctx) + if err != nil { + t.Fatalf("DeleteExpiredRefreshTokens() error: %v", err) + } + if deleted != 1 { + t.Errorf("DeleteExpiredRefreshTokens() deleted = %d, want 1", deleted) + } + + // The expired token is gone: consuming it now returns ErrNotFound. + if _, err := store.ConsumeRefreshToken(ctx, expiredHash); err == nil { + t.Error("ConsumeRefreshToken(expired) succeeded after cleanup, want it removed") + } + // The live token survives: consuming it succeeds and yields the right row. + row, err := store.ConsumeRefreshToken(ctx, liveHash) + if err != nil { + t.Fatalf("ConsumeRefreshToken(live) after cleanup error: %v", err) + } + if row.TokenHash != liveHash { + t.Errorf("ConsumeRefreshToken(live) token_hash = %q, want %q", row.TokenHash, liveHash) + } +} diff --git a/internal/auth/query.sql b/internal/auth/query.sql index ca7e6924..cf883058 100644 --- a/internal/auth/query.sql +++ b/internal/auth/query.sql @@ -17,3 +17,6 @@ VALUES ($1, $2, $3); DELETE FROM refresh_tokens WHERE token_hash = $1 RETURNING id, user_id, token_hash, expires_at, created_at; + +-- name: DeleteExpiredRefreshTokens :execrows +DELETE FROM refresh_tokens WHERE expires_at < now(); diff --git a/internal/auth/store.go b/internal/auth/store.go index ac686c8f..fed4e4bd 100644 --- a/internal/auth/store.go +++ b/internal/auth/store.go @@ -90,3 +90,16 @@ func (s *Store) ConsumeRefreshToken(ctx context.Context, tokenHash string) (*Ref CreatedAt: row.CreatedAt, }, nil } + +// DeleteExpiredRefreshTokens removes every refresh token whose expires_at is in +// the past, returning the number of rows deleted. ConsumeRefreshToken only +// deletes the exact presented token, so expired-but-unconsumed rows accumulate +// forever; a periodic call to this method is the cleanup the expires_at index +// exists to support. +func (s *Store) DeleteExpiredRefreshTokens(ctx context.Context) (int64, error) { + n, err := s.q.DeleteExpiredRefreshTokens(ctx) + if err != nil { + return 0, fmt.Errorf("deleting expired refresh tokens: %w", err) + } + return n, nil +} diff --git a/internal/db/query.sql.go b/internal/db/query.sql.go index 4aca4b9c..531bd4a7 100644 --- a/internal/db/query.sql.go +++ b/internal/db/query.sql.go @@ -2146,6 +2146,18 @@ func (q *Queries) DeleteContentTopics(ctx context.Context, contentID uuid.UUID) return err } +const deleteExpiredRefreshTokens = `-- name: DeleteExpiredRefreshTokens :execrows +DELETE FROM refresh_tokens WHERE expires_at < now() +` + +func (q *Queries) DeleteExpiredRefreshTokens(ctx context.Context) (int64, error) { + result, err := q.db.Exec(ctx, deleteExpiredRefreshTokens) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + const deleteFeed = `-- name: DeleteFeed :exec DELETE FROM feeds WHERE id = $1 ` From bf0ea19d38f1d5ccc9eb6b30792f7996d3197e36 Mon Sep 17 00:00:00 2001 From: Koopa Date: Wed, 24 Jun 2026 16:14:47 +0800 Subject: [PATCH 11/31] feat(content): surface review_note in the admin detail read The owner's review_note (set when sending content back for changes) was selected by ContentsByCreator (the MCP list_content readback) but not by ContentByID, so reopening a changes_requested item in admin showed no note. Add review_note to the ContentByID query and hydrate Content.ReviewNote in the detail read. ListContents is left unchanged (owner decided detail-read only). Integration test sends a draft back with a note and asserts ContentByID returns it. --- internal/content/content.go | 1 + internal/content/integration_test.go | 35 ++++++++++++++++++++++++++++ internal/content/query.sql | 2 +- internal/db/query.sql.go | 4 +++- 4 files changed, 40 insertions(+), 2 deletions(-) diff --git a/internal/content/content.go b/internal/content/content.go index 2745ff10..39539728 100644 --- a/internal/content/content.go +++ b/internal/content/content.go @@ -463,6 +463,7 @@ func (s *Store) Content(ctx context.Context, id uuid.UUID) (*Content, error) { IsPublic: r.IsPublic, ProjectID: r.ProjectID, AiMetadata: r.AiMetadata, ReadingTimeMin: r.ReadingTimeMin, CoverImage: r.CoverImage, CreatedBy: r.CreatedBy, ProposalRationale: r.ProposalRationale, + ReviewNote: r.ReviewNote, PublishedAt: r.PublishedAt, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, }) diff --git a/internal/content/integration_test.go b/internal/content/integration_test.go index 330b75ba..7fa45f89 100644 --- a/internal/content/integration_test.go +++ b/internal/content/integration_test.go @@ -734,6 +734,41 @@ func TestStore_UpdateContent_NotFound(t *testing.T) { } } +// TestStore_Content_SurfacesReviewNote proves the admin detail read (the Content +// store method backing ContentByID) returns the owner's review_note after a +// send-back, so reopening a changes_requested item in admin shows the note. The +// MCP list_content readback (ContentsByCreator) already surfaced it; this closes +// the gap where the detail read selected every column EXCEPT review_note. +func TestStore_Content_SurfacesReviewNote(t *testing.T) { + s := setup(t) + ctx := t.Context() + + id := createDraftContent(t, s, ctx, "review-note-detail") + if _, err := s.SubmitContentForReview(ctx, id); err != nil { + t.Fatalf("SubmitContentForReview() error: %v", err) + } + + const note = "please tighten the intro and add a code sample" + sentBack, err := s.SendBackForChanges(ctx, id, note) + if err != nil { + t.Fatalf("SendBackForChanges() error: %v", err) + } + if sentBack.Status != StatusChangesRequested { + t.Fatalf("SendBackForChanges() status = %q, want %q", sentBack.Status, StatusChangesRequested) + } + + got, err := s.Content(ctx, id) + if err != nil { + t.Fatalf("Content() error: %v", err) + } + if got.ReviewNote == nil { + t.Fatal("Content() review_note = nil, want the owner's send-back note") + } + if *got.ReviewNote != note { + t.Errorf("Content() review_note = %q, want %q", *got.ReviewNote, note) + } +} + // TestStore_CreateContent_InvalidInput verifies that a client-supplied value // the database rejects — a foreign key pointing at a non-existent project, or // a slug that violates chk_content_slug_format — surfaces as ErrInvalidInput diff --git a/internal/content/query.sql b/internal/content/query.sql index f5cb0b76..2ba7c528 100644 --- a/internal/content/query.sql +++ b/internal/content/query.sql @@ -1,7 +1,7 @@ -- name: ContentByID :one SELECT id, slug, title, body, excerpt, type, status, series_id, series_order, is_public, project_id, ai_metadata, reading_time_min, - cover_image, created_by, proposal_rationale, published_at, created_at, updated_at + cover_image, created_by, proposal_rationale, review_note, published_at, created_at, updated_at FROM contents WHERE id = $1; -- name: PublishedContents :many diff --git a/internal/db/query.sql.go b/internal/db/query.sql.go index 531bd4a7..fb70f6f1 100644 --- a/internal/db/query.sql.go +++ b/internal/db/query.sql.go @@ -1128,7 +1128,7 @@ func (q *Queries) ContentBriefsByProjectID(ctx context.Context, projectID *uuid. const contentByID = `-- name: ContentByID :one SELECT id, slug, title, body, excerpt, type, status, series_id, series_order, is_public, project_id, ai_metadata, reading_time_min, - cover_image, created_by, proposal_rationale, published_at, created_at, updated_at + cover_image, created_by, proposal_rationale, review_note, published_at, created_at, updated_at FROM contents WHERE id = $1 ` @@ -1149,6 +1149,7 @@ type ContentByIDRow struct { CoverImage *string `json:"cover_image"` CreatedBy *string `json:"created_by"` ProposalRationale *string `json:"proposal_rationale"` + ReviewNote *string `json:"review_note"` PublishedAt *time.Time `json:"published_at"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -1174,6 +1175,7 @@ func (q *Queries) ContentByID(ctx context.Context, id uuid.UUID) (ContentByIDRow &i.CoverImage, &i.CreatedBy, &i.ProposalRationale, + &i.ReviewNote, &i.PublishedAt, &i.CreatedAt, &i.UpdatedAt, From 8eabf9d02a00174b1dfdd6131fd66b4e8a0e2cdc Mon Sep 17 00:00:00 2001 From: Koopa Date: Wed, 24 Jun 2026 16:17:29 +0800 Subject: [PATCH 12/31] =?UTF-8?q?fix(frontend):=20SSR=20hardening=20?= =?UTF-8?q?=E2=80=94=20explicit=20transfer=20cache,=20prerender=20legal=20?= =?UTF-8?q?pages,=20zoneless=20server?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app.config.ts: make the HTTP transfer cache explicit via withHttpTransferCacheOptions so Server-rendered pages do not refetch their API data on hydration (the v22 default, now self-documented; withHttpTransferCache does not exist in v22). - app.routes.server.ts: prerender /privacy and /terms (static legal text) instead of paying per-request SSR via the ** fallback, matching /about. - main.server.ts: drop provideZoneChangeDetection — it contradicted the zoneless browser config and required zone.js, which is not installed; the server now inherits provideZonelessChangeDetection from the shared config. --- frontend/src/app/app.config.ts | 10 ++++++++-- frontend/src/app/app.routes.server.ts | 10 ++++++++++ frontend/src/main.server.ts | 4 ++-- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/app.config.ts b/frontend/src/app/app.config.ts index 6e5e7d1d..6876acd9 100644 --- a/frontend/src/app/app.config.ts +++ b/frontend/src/app/app.config.ts @@ -9,7 +9,10 @@ import { withInMemoryScrolling, withViewTransitions, } from '@angular/router'; -import { provideClientHydration } from '@angular/platform-browser'; +import { + provideClientHydration, + withHttpTransferCacheOptions, +} from '@angular/platform-browser'; import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; import { provideHttpClient, withInterceptors } from '@angular/common/http'; @@ -53,6 +56,9 @@ export const appConfig: ApplicationConfig = { provideHttpClient(withInterceptors([authInterceptor, errorInterceptor])), // Incremental hydration is the v22 default and enables event replay // automatically; @defer blocks opt into lazy hydration via hydrate triggers. - provideClientHydration(), + // withHttpTransferCacheOptions() serializes server-fetched GET/HEAD responses + // into the hydration payload so the client reuses them instead of re-fetching + // on hydration. The transfer cache is on by default; this states it explicitly. + provideClientHydration(withHttpTransferCacheOptions({})), ], }; diff --git a/frontend/src/app/app.routes.server.ts b/frontend/src/app/app.routes.server.ts index 20594be3..d9689b81 100644 --- a/frontend/src/app/app.routes.server.ts +++ b/frontend/src/app/app.routes.server.ts @@ -34,6 +34,16 @@ export const serverRoutes: ServerRoute[] = [ path: 'about', renderMode: RenderMode.Prerender, }, + { + // Static single-owner legal text — prerendered like /about. + path: 'privacy', + renderMode: RenderMode.Prerender, + }, + { + // Static single-owner legal text — prerendered like /about. + path: 'terms', + renderMode: RenderMode.Prerender, + }, { path: 'login', renderMode: RenderMode.Client, diff --git a/frontend/src/main.server.ts b/frontend/src/main.server.ts index 46562acb..af34c9ca 100644 --- a/frontend/src/main.server.ts +++ b/frontend/src/main.server.ts @@ -1,8 +1,8 @@ -import { provideZoneChangeDetection } from "@angular/core"; import { bootstrapApplication, BootstrapContext } from '@angular/platform-browser'; import { AppComponent } from './app/app'; import { config } from './app/app.config.server'; -const bootstrap = (context: BootstrapContext) => bootstrapApplication(AppComponent, {...config, providers: [provideZoneChangeDetection(), ...config.providers]}, context); +const bootstrap = (context: BootstrapContext) => + bootstrapApplication(AppComponent, config, context); export default bootstrap; From e6bbf878a864cb92cc1d522008423b606746416d Mon Sep 17 00:00:00 2001 From: Koopa Date: Wed, 24 Jun 2026 16:17:29 +0800 Subject: [PATCH 13/31] fix(frontend): render an error state on the home page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit home had only a loading flag, so a failed content/topics load fell through to the @else branch and rendered the false empty-state 'Nothing published yet' — a backend outage looked like 'the author published nothing'. Add a hasError computed (mirroring the articles page) and an error/retry branch, and gate the recent band on !hasError. Spec flushes a 500 and asserts the error UI renders. --- frontend/src/app/pages/home/home.html | 19 ++++++++++++++++++- frontend/src/app/pages/home/home.spec.ts | 21 +++++++++++++++++++++ frontend/src/app/pages/home/home.ts | 9 +++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/pages/home/home.html b/frontend/src/app/pages/home/home.html index a9700b54..131ee7d0 100644 --- a/frontend/src/app/pages/home/home.html +++ b/frontend/src/app/pages/home/home.html @@ -25,6 +25,23 @@ } + } @else if (hasError()) { +
+

+ Could not load the feed. Please try again. +

+ +
} @else {
@for (theme of themes(); track theme.topic.slug) { @@ -81,7 +98,7 @@

Recent

- @if (!isLoading()) { + @if (!isLoading() && !hasError()) {
@for (piece of recent(); track piece.id) { { expect(el.textContent).toContain("Notes, systems, and what I'm working out."); expect(component['recent']().length).toBe(0); }); + + it('should render the error UI with retry when the feed fails (500)', async () => { + await settle(); + + const req = httpTesting.expectOne( + (r) => r.url.includes('/api/contents') && r.method === 'GET', + ); + req.flush('Server error', { + status: 500, + statusText: 'Internal Server Error', + }); + flushTopics([buildMockTopic({ content_count: 0 })]); + await settle(); + + const el = fixture.nativeElement as HTMLElement; + expect(component['hasError']()).toBe(true); + expect(el.querySelector('[data-testid="home-error"]')).toBeTruthy(); + expect(el.querySelector('[data-testid="home-error-retry"]')).toBeTruthy(); + // The false "Nothing published yet" empty state must NOT render on error. + expect(el.querySelector('[data-testid="recent-empty"]')).toBeNull(); + }); }); diff --git a/frontend/src/app/pages/home/home.ts b/frontend/src/app/pages/home/home.ts index c189415a..69b67d7f 100644 --- a/frontend/src/app/pages/home/home.ts +++ b/frontend/src/app/pages/home/home.ts @@ -79,6 +79,10 @@ export class HomeComponent implements OnInit { this.topicsResource.status() === 'loading', ); + protected readonly hasError = computed( + () => this.contentsResource.status() === 'error', + ); + /** Themes with published pieces, sorted by the topic's own sort order. */ protected readonly themes = computed(() => { const feed = this.contents(); @@ -123,4 +127,9 @@ export class HomeComponent implements OnInit { protected primaryTopicName(content: ApiContent): string { return content.topics[0]?.name ?? this.typeLabel(content.type); } + + protected retry(): void { + this.contentsResource.reload(); + this.topicsResource.reload(); + } } From c06fec86d2c2eaeea5c29889964090ce339cdbe3 Mon Sep 17 00:00:00 2001 From: Koopa Date: Wed, 24 Jun 2026 16:17:29 +0800 Subject: [PATCH 14/31] fix(frontend): trap focus in the command palette The palette is role=dialog aria-modal but had no focus trap, so Tab escaped to the background page. Add cdkTrapFocus + cdkTrapFocusAutoCapture (A11yModule), mirroring the modal component, and mark the backdrop aria-hidden. --- .../app/shared/command-palette/command-palette.component.html | 3 +++ .../app/shared/command-palette/command-palette.component.ts | 2 ++ 2 files changed, 5 insertions(+) diff --git a/frontend/src/app/shared/command-palette/command-palette.component.html b/frontend/src/app/shared/command-palette/command-palette.component.html index a7c27e61..89637735 100644 --- a/frontend/src/app/shared/command-palette/command-palette.component.html +++ b/frontend/src/app/shared/command-palette/command-palette.component.html @@ -4,6 +4,7 @@ class="fixed inset-0 z-[100] bg-black/50 backdrop-blur-xs" (click)="close()" (keydown.escape)="close()" + aria-hidden="true" tabindex="-1" @fadeBackdrop >
@@ -21,6 +22,8 @@ class="w-full max-w-[600px] overflow-hidden rounded-lg border border-border-strong bg-elevated shadow-(--shadow-2)" (click)="$event.stopPropagation()" (keydown.escape)="$event.stopPropagation()" + cdkTrapFocus + cdkTrapFocusAutoCapture tabindex="-1" @scaleIn > diff --git a/frontend/src/app/shared/command-palette/command-palette.component.ts b/frontend/src/app/shared/command-palette/command-palette.component.ts index 509cbe8d..9aa11336 100644 --- a/frontend/src/app/shared/command-palette/command-palette.component.ts +++ b/frontend/src/app/shared/command-palette/command-palette.component.ts @@ -12,6 +12,7 @@ import { PLATFORM_ID, } from '@angular/core'; import { isPlatformBrowser } from '@angular/common'; +import { A11yModule } from '@angular/cdk/a11y'; import { Router } from '@angular/router'; import { Subject, debounceTime } from 'rxjs'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @@ -33,6 +34,7 @@ interface GroupedAction { @Component({ selector: 'app-command-palette', + imports: [A11yModule], templateUrl: './command-palette.component.html', changeDetection: ChangeDetectionStrategy.OnPush, animations: [ From f7e84f1e23e61768f51fe75185c84fa9f67a71d8 Mon Sep 17 00:00:00 2001 From: Koopa Date: Wed, 24 Jun 2026 16:17:42 +0800 Subject: [PATCH 15/31] refactor(frontend): prune 33 unused design-system components and the dead graph API The shared/components barrel held a Claude Design ingest of 41 components; 33 had zero template usage and zero TS imports anywhere in the app (this app is a consumer, not a component-library producer). Delete them and trim the barrel to the 8 actually used (data-table, empty-state, energy-meter, form-field, kbd, loading-spinner, modal, status-badge). Also remove content.service.getKnowledgeGraph and its now-orphaned ApiKnowledgeGraph/ApiGraphNode/ApiGraphLink types (zero callers). --- frontend/src/app/core/models/api.model.ts | 22 -- .../src/app/core/services/content.service.ts | 6 - .../accordion-item.component.spec.ts | 221 --------------- .../accordion-item.component.ts | 78 ------ .../accordion/accordion.component.spec.ts | 47 ---- .../accordion/accordion.component.ts | 20 -- .../components/alert/alert.component.spec.ts | 133 --------- .../components/alert/alert.component.ts | 49 ---- .../avatar-group.component.spec.ts | 134 --------- .../avatar-group/avatar-group.component.ts | 34 --- .../avatar/avatar.component.spec.ts | 178 ------------ .../components/avatar/avatar.component.ts | 76 ----- .../components/badge/badge.component.spec.ts | 112 -------- .../components/badge/badge.component.ts | 49 ---- .../breadcrumbs/breadcrumbs.component.spec.ts | 144 ---------- .../breadcrumbs/breadcrumbs.component.ts | 53 ---- .../button/button.component.spec.ts | 168 ----------- .../components/button/button.component.ts | 82 ------ .../callout/callout.component.spec.ts | 134 --------- .../components/callout/callout.component.ts | 64 ----- .../checkbox/checkbox.component.spec.ts | 133 --------- .../components/checkbox/checkbox.component.ts | 60 ---- .../components/chip/chip.component.spec.ts | 125 --------- .../shared/components/chip/chip.component.ts | 59 ---- .../code-block/code-block.component.spec.ts | 85 ------ .../code-block/code-block.component.ts | 91 ------ .../content-type.component.spec.ts | 122 -------- .../content-type/content-type.component.ts | 44 --- .../description-list.component.spec.ts | 126 --------- .../description-list.component.ts | 48 ---- .../drawer/drawer.component.spec.ts | 183 ------------ .../components/drawer/drawer.component.ts | 69 ----- .../hextile/hextile.component.spec.ts | 105 ------- .../components/hextile/hextile.component.ts | 24 -- frontend/src/app/shared/components/index.ts | 95 ------- .../components/input/input.component.spec.ts | 231 ---------------- .../components/input/input.component.ts | 65 ----- .../menu-item/menu-item.component.spec.ts | 109 -------- .../menu-item/menu-item.component.ts | 54 ---- .../components/menu/menu.component.spec.ts | 188 ------------- .../shared/components/menu/menu.component.ts | 146 ---------- .../nav-item/nav-item.component.spec.ts | 181 ------------ .../components/nav-item/nav-item.component.ts | 75 ----- .../pagination/pagination.component.spec.ts | 250 ----------------- .../pagination/pagination.component.ts | 132 --------- .../progress/progress.component.spec.ts | 208 -------------- .../components/progress/progress.component.ts | 52 ---- .../components/radio/radio.component.spec.ts | 169 ------------ .../components/radio/radio.component.ts | 59 ---- .../segmented/segmented.component.spec.ts | 167 ----------- .../segmented/segmented.component.ts | 56 ---- .../select/select.component.spec.ts | 261 ------------------ .../components/select/select.component.ts | 92 ------ .../separator/separator.component.spec.ts | 116 -------- .../separator/separator.component.ts | 56 ---- .../stat-card/stat-card.component.spec.ts | 171 ------------ .../stat-card/stat-card.component.ts | 55 ---- .../stepper/stepper.component.spec.ts | 142 ---------- .../components/stepper/stepper.component.ts | 106 ------- .../switch/switch.component.spec.ts | 140 ---------- .../components/switch/switch.component.ts | 61 ---- .../components/tabs/tabs.component.spec.ts | 138 --------- .../shared/components/tabs/tabs.component.ts | 50 ---- .../components/tag/tag.component.spec.ts | 90 ------ .../shared/components/tag/tag.component.ts | 23 -- .../textarea/textarea.component.spec.ts | 222 --------------- .../components/textarea/textarea.component.ts | 63 ----- .../tooltip/tooltip.directive.spec.ts | 169 ------------ .../components/tooltip/tooltip.directive.ts | 153 ---------- 69 files changed, 7423 deletions(-) delete mode 100644 frontend/src/app/shared/components/accordion-item/accordion-item.component.spec.ts delete mode 100644 frontend/src/app/shared/components/accordion-item/accordion-item.component.ts delete mode 100644 frontend/src/app/shared/components/accordion/accordion.component.spec.ts delete mode 100644 frontend/src/app/shared/components/accordion/accordion.component.ts delete mode 100644 frontend/src/app/shared/components/alert/alert.component.spec.ts delete mode 100644 frontend/src/app/shared/components/alert/alert.component.ts delete mode 100644 frontend/src/app/shared/components/avatar-group/avatar-group.component.spec.ts delete mode 100644 frontend/src/app/shared/components/avatar-group/avatar-group.component.ts delete mode 100644 frontend/src/app/shared/components/avatar/avatar.component.spec.ts delete mode 100644 frontend/src/app/shared/components/avatar/avatar.component.ts delete mode 100644 frontend/src/app/shared/components/badge/badge.component.spec.ts delete mode 100644 frontend/src/app/shared/components/badge/badge.component.ts delete mode 100644 frontend/src/app/shared/components/breadcrumbs/breadcrumbs.component.spec.ts delete mode 100644 frontend/src/app/shared/components/breadcrumbs/breadcrumbs.component.ts delete mode 100644 frontend/src/app/shared/components/button/button.component.spec.ts delete mode 100644 frontend/src/app/shared/components/button/button.component.ts delete mode 100644 frontend/src/app/shared/components/callout/callout.component.spec.ts delete mode 100644 frontend/src/app/shared/components/callout/callout.component.ts delete mode 100644 frontend/src/app/shared/components/checkbox/checkbox.component.spec.ts delete mode 100644 frontend/src/app/shared/components/checkbox/checkbox.component.ts delete mode 100644 frontend/src/app/shared/components/chip/chip.component.spec.ts delete mode 100644 frontend/src/app/shared/components/chip/chip.component.ts delete mode 100644 frontend/src/app/shared/components/code-block/code-block.component.spec.ts delete mode 100644 frontend/src/app/shared/components/code-block/code-block.component.ts delete mode 100644 frontend/src/app/shared/components/content-type/content-type.component.spec.ts delete mode 100644 frontend/src/app/shared/components/content-type/content-type.component.ts delete mode 100644 frontend/src/app/shared/components/description-list/description-list.component.spec.ts delete mode 100644 frontend/src/app/shared/components/description-list/description-list.component.ts delete mode 100644 frontend/src/app/shared/components/drawer/drawer.component.spec.ts delete mode 100644 frontend/src/app/shared/components/drawer/drawer.component.ts delete mode 100644 frontend/src/app/shared/components/hextile/hextile.component.spec.ts delete mode 100644 frontend/src/app/shared/components/hextile/hextile.component.ts delete mode 100644 frontend/src/app/shared/components/input/input.component.spec.ts delete mode 100644 frontend/src/app/shared/components/input/input.component.ts delete mode 100644 frontend/src/app/shared/components/menu-item/menu-item.component.spec.ts delete mode 100644 frontend/src/app/shared/components/menu-item/menu-item.component.ts delete mode 100644 frontend/src/app/shared/components/menu/menu.component.spec.ts delete mode 100644 frontend/src/app/shared/components/menu/menu.component.ts delete mode 100644 frontend/src/app/shared/components/nav-item/nav-item.component.spec.ts delete mode 100644 frontend/src/app/shared/components/nav-item/nav-item.component.ts delete mode 100644 frontend/src/app/shared/components/pagination/pagination.component.spec.ts delete mode 100644 frontend/src/app/shared/components/pagination/pagination.component.ts delete mode 100644 frontend/src/app/shared/components/progress/progress.component.spec.ts delete mode 100644 frontend/src/app/shared/components/progress/progress.component.ts delete mode 100644 frontend/src/app/shared/components/radio/radio.component.spec.ts delete mode 100644 frontend/src/app/shared/components/radio/radio.component.ts delete mode 100644 frontend/src/app/shared/components/segmented/segmented.component.spec.ts delete mode 100644 frontend/src/app/shared/components/segmented/segmented.component.ts delete mode 100644 frontend/src/app/shared/components/select/select.component.spec.ts delete mode 100644 frontend/src/app/shared/components/select/select.component.ts delete mode 100644 frontend/src/app/shared/components/separator/separator.component.spec.ts delete mode 100644 frontend/src/app/shared/components/separator/separator.component.ts delete mode 100644 frontend/src/app/shared/components/stat-card/stat-card.component.spec.ts delete mode 100644 frontend/src/app/shared/components/stat-card/stat-card.component.ts delete mode 100644 frontend/src/app/shared/components/stepper/stepper.component.spec.ts delete mode 100644 frontend/src/app/shared/components/stepper/stepper.component.ts delete mode 100644 frontend/src/app/shared/components/switch/switch.component.spec.ts delete mode 100644 frontend/src/app/shared/components/switch/switch.component.ts delete mode 100644 frontend/src/app/shared/components/tabs/tabs.component.spec.ts delete mode 100644 frontend/src/app/shared/components/tabs/tabs.component.ts delete mode 100644 frontend/src/app/shared/components/tag/tag.component.spec.ts delete mode 100644 frontend/src/app/shared/components/tag/tag.component.ts delete mode 100644 frontend/src/app/shared/components/textarea/textarea.component.spec.ts delete mode 100644 frontend/src/app/shared/components/textarea/textarea.component.ts delete mode 100644 frontend/src/app/shared/components/tooltip/tooltip.directive.spec.ts delete mode 100644 frontend/src/app/shared/components/tooltip/tooltip.directive.ts diff --git a/frontend/src/app/core/models/api.model.ts b/frontend/src/app/core/models/api.model.ts index dc4a51c5..72aa82d8 100644 --- a/frontend/src/app/core/models/api.model.ts +++ b/frontend/src/app/core/models/api.model.ts @@ -158,28 +158,6 @@ export interface ApiUpdateContentRequest { is_public?: boolean; } -/** Public — Knowledge Graph */ -export interface ApiKnowledgeGraph { - nodes: ApiGraphNode[]; - links: ApiGraphLink[]; -} - -export interface ApiGraphNode { - id: string; - label: string; - type: string; - content_type: string | null; - topic: string | null; - count: number | null; -} - -export interface ApiGraphLink { - source: string; - target: string; - type: string; - similarity: number | null; -} - /** Public — Related Content */ export interface ApiRelatedContent { slug: string; diff --git a/frontend/src/app/core/services/content.service.ts b/frontend/src/app/core/services/content.service.ts index 74e433aa..6e7bf69c 100644 --- a/frontend/src/app/core/services/content.service.ts +++ b/frontend/src/app/core/services/content.service.ts @@ -7,7 +7,6 @@ import type { ApiCreateContentRequest, ApiUpdateContentRequest, ApiRelatedContent, - ApiKnowledgeGraph, ContentStatus, ContentType, } from '../models'; @@ -164,9 +163,4 @@ export class ContentService { `/api/contents/related/${slug}`, ); } - - /** Public — get knowledge graph (rate-limited) */ - getKnowledgeGraph(): Observable { - return this.api.getData('/api/knowledge-graph'); - } } diff --git a/frontend/src/app/shared/components/accordion-item/accordion-item.component.spec.ts b/frontend/src/app/shared/components/accordion-item/accordion-item.component.spec.ts deleted file mode 100644 index 05a0efd7..00000000 --- a/frontend/src/app/shared/components/accordion-item/accordion-item.component.spec.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { Component } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { AccordionItemComponent } from './accordion-item.component'; - -// --------------------------------------------------------------------------- -// Host helpers -// --------------------------------------------------------------------------- - -@Component({ - imports: [AccordionItemComponent], - template: ` - -

Panel body text

-
- `, -}) -class DefaultHostComponent {} - -@Component({ - imports: [AccordionItemComponent], - template: ` - -

Pre-opened body

-
- `, -}) -class DefaultOpenHostComponent {} - -@Component({ - imports: [AccordionItemComponent], - template: ` - -

Should not appear

-
- `, -}) -class DisabledHostComponent {} - -@Component({ - imports: [AccordionItemComponent], - template: ` - -

Custom testId body

-
- `, -}) -class CustomTestIdHostComponent {} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function trigger(fixture: ComponentFixture): HTMLButtonElement { - return fixture.nativeElement.querySelector( - '[data-testid="accordion-trigger"]', - ) as HTMLButtonElement; -} - -function panel(fixture: ComponentFixture): HTMLElement | null { - return fixture.nativeElement.querySelector( - '[data-testid="accordion-panel"]', - ) as HTMLElement | null; -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe('AccordionItemComponent', () => { - describe('default (closed) state', () => { - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [DefaultHostComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(DefaultHostComponent); - fixture.detectChanges(); - }); - - it('should create', () => { - expect(trigger(fixture)).toBeTruthy(); - }); - - it('should render the title when provided', () => { - expect(trigger(fixture).textContent).toContain('Panel A'); - }); - - it('should be closed by default when defaultOpen is not set', () => { - expect(trigger(fixture).getAttribute('aria-expanded')).toBe('false'); - expect(panel(fixture)).toBeNull(); - }); - - it('should open the panel when trigger is clicked', () => { - trigger(fixture).click(); - fixture.detectChanges(); - - expect(trigger(fixture).getAttribute('aria-expanded')).toBe('true'); - expect(panel(fixture)).toBeTruthy(); - }); - - it('should close the panel on a second click', () => { - trigger(fixture).click(); - fixture.detectChanges(); - trigger(fixture).click(); - fixture.detectChanges(); - - expect(trigger(fixture).getAttribute('aria-expanded')).toBe('false'); - expect(panel(fixture)).toBeNull(); - }); - - it('should project content into the panel when open', () => { - trigger(fixture).click(); - fixture.detectChanges(); - - expect( - fixture.nativeElement.querySelector('[data-testid="panel-content"]'), - ).toBeTruthy(); - }); - - it('should wire aria-controls to the panel id', () => { - const btn = trigger(fixture); - const panelId = btn.getAttribute('aria-controls'); - expect(panelId).toBeTruthy(); - - trigger(fixture).click(); - fixture.detectChanges(); - - const panelEl = panel(fixture); - expect(panelEl?.id).toBe(panelId); - }); - - it('should have aria-labelledby on the panel pointing to the trigger', () => { - trigger(fixture).click(); - fixture.detectChanges(); - - const panelEl = panel(fixture); - const triggerId = trigger(fixture).id; - expect(panelEl?.getAttribute('aria-labelledby')).toBe(triggerId); - }); - - it('should set role="region" on the panel', () => { - trigger(fixture).click(); - fixture.detectChanges(); - - expect(panel(fixture)?.getAttribute('role')).toBe('region'); - }); - }); - - describe('defaultOpen input', () => { - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [DefaultOpenHostComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(DefaultOpenHostComponent); - fixture.detectChanges(); - }); - - it('should be open on first render when defaultOpen is true', () => { - expect(trigger(fixture).getAttribute('aria-expanded')).toBe('true'); - expect(panel(fixture)).toBeTruthy(); - }); - - it('should project content immediately when defaultOpen is true', () => { - expect( - fixture.nativeElement.querySelector( - '[data-testid="open-panel-content"]', - ), - ).toBeTruthy(); - }); - }); - - describe('disabled input', () => { - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [DisabledHostComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(DisabledHostComponent); - fixture.detectChanges(); - }); - - it('should have disabled attribute on button when disabled is true', () => { - expect(trigger(fixture).disabled).toBe(true); - }); - - it('should not open panel when disabled trigger is clicked', () => { - trigger(fixture).click(); - fixture.detectChanges(); - - expect(panel(fixture)).toBeNull(); - }); - }); - - describe('testId input', () => { - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [CustomTestIdHostComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(CustomTestIdHostComponent); - fixture.detectChanges(); - }); - - it('should use the custom testId on the trigger button', () => { - const btn = fixture.nativeElement.querySelector( - '[data-testid="my-trigger"]', - ); - expect(btn).toBeTruthy(); - expect(btn.textContent).toContain('Custom ID'); - }); - }); -}); diff --git a/frontend/src/app/shared/components/accordion-item/accordion-item.component.ts b/frontend/src/app/shared/components/accordion-item/accordion-item.component.ts deleted file mode 100644 index 54168307..00000000 --- a/frontend/src/app/shared/components/accordion-item/accordion-item.component.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { - Component, - ChangeDetectionStrategy, - input, - linkedSignal, -} from '@angular/core'; - -let nextId = 0; - -/** - * DS accordion item — `ui-accordion-item`. A disclosure row: a full-width - * trigger button (title + rotating chevron) toggling a projected panel. - * Implements the WAI-ARIA accordion pattern (aria-expanded + region). - */ -@Component({ - selector: 'app-accordion-item', - template: ` -

- -

- @if (open()) { -
- -
- } - `, - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class AccordionItemComponent { - readonly title = input.required(); - readonly disabled = input(false); - readonly defaultOpen = input(false); - readonly testId = input(null); - - private readonly uid = nextId++; - protected readonly triggerId = `accordion-trigger-${this.uid}`; - protected readonly panelId = `accordion-panel-${this.uid}`; - - /** Writable open state, seeded from `defaultOpen`. */ - protected readonly open = linkedSignal(() => this.defaultOpen()); - - protected toggle(): void { - if (this.disabled()) { - return; - } - this.open.update((v) => !v); - } -} diff --git a/frontend/src/app/shared/components/accordion/accordion.component.spec.ts b/frontend/src/app/shared/components/accordion/accordion.component.spec.ts deleted file mode 100644 index a5c131be..00000000 --- a/frontend/src/app/shared/components/accordion/accordion.component.spec.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { Component } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { AccordionComponent } from './accordion.component'; - -@Component({ - imports: [AccordionComponent], - template: ` - - Content A - Content B - - `, -}) -class HostComponent {} - -describe('AccordionComponent', () => { - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [HostComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(HostComponent); - fixture.detectChanges(); - }); - - it('should create', () => { - const el = fixture.nativeElement.querySelector('[data-testid="accordion"]'); - expect(el).toBeTruthy(); - }); - - it('should project slotted content into the container', () => { - const el = fixture.nativeElement.querySelector('[data-testid="accordion"]'); - expect(el.querySelector('[data-testid="projected-content"]')).toBeTruthy(); - expect( - el.querySelector('[data-testid="projected-content-b"]'), - ).toBeTruthy(); - }); - - it('should render a single container wrapping all projected nodes', () => { - const containers = fixture.nativeElement.querySelectorAll( - '[data-testid="accordion"]', - ); - expect(containers.length).toBe(1); - }); -}); diff --git a/frontend/src/app/shared/components/accordion/accordion.component.ts b/frontend/src/app/shared/components/accordion/accordion.component.ts deleted file mode 100644 index 720284c9..00000000 --- a/frontend/src/app/shared/components/accordion/accordion.component.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Component, ChangeDetectionStrategy } from '@angular/core'; - -/** - * DS accordion container — `ui-accordion`. A bordered, r-md surface that wraps - * one or more `app-accordion-item` projected via content. Each item owns its - * own open/closed state (WAI-ARIA accordion pattern). - */ -@Component({ - selector: 'app-accordion', - template: ` -
- -
- `, - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class AccordionComponent {} diff --git a/frontend/src/app/shared/components/alert/alert.component.spec.ts b/frontend/src/app/shared/components/alert/alert.component.spec.ts deleted file mode 100644 index 94375f1d..00000000 --- a/frontend/src/app/shared/components/alert/alert.component.spec.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { Component, signal } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { AlertComponent, type AlertVariant } from './alert.component'; - -@Component({ - template: ` - - - {{ message() }} - - `, - imports: [AlertComponent], -}) -class HostComponent { - readonly variant = signal('info'); - readonly heading = signal(null); - readonly testId = signal(null); - readonly message = signal('Something happened.'); -} - -describe('AlertComponent', () => { - let fixture: ComponentFixture; - let host: HostComponent; - - function alertEl(): HTMLElement { - return fixture.nativeElement.querySelector('[role="alert"]') as HTMLElement; - } - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [HostComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(HostComponent); - host = fixture.componentInstance; - await fixture.whenStable(); - }); - - it('should create', () => { - expect(alertEl()).toBeTruthy(); - }); - - it('should have role="alert" on the container', () => { - expect(alertEl().getAttribute('role')).toBe('alert'); - }); - - it('should project message text into the content area', async () => { - host.message.set('File saved successfully.'); - await fixture.whenStable(); - expect(alertEl().textContent).toContain('File saved successfully.'); - }); - - it('should project icon content into the alert-icon slot', () => { - const icon = fixture.nativeElement.querySelector( - '[data-testid="alert-icon-slot"]', - ); - expect(icon).toBeTruthy(); - }); - - it('should not render heading element when heading is null', async () => { - host.heading.set(null); - await fixture.whenStable(); - expect(alertEl().querySelector('strong')).toBeNull(); - }); - - it('should render heading text when heading input is provided', async () => { - host.heading.set('Warning'); - await fixture.whenStable(); - const strong = alertEl().querySelector('strong'); - expect(strong).toBeTruthy(); - expect(strong?.textContent?.trim()).toBe('Warning'); - }); - - it('should forward testId to data-testid attribute when provided', async () => { - host.testId.set('form-alert'); - await fixture.whenStable(); - expect(alertEl().getAttribute('data-testid')).toBe('form-alert'); - }); - - it('should have no data-testid attribute when testId is null', async () => { - await fixture.whenStable(); - expect(alertEl().getAttribute('data-testid')).toBeNull(); - }); - - it('should apply info classes when variant is info', async () => { - host.variant.set('info'); - await fixture.whenStable(); - expect(alertEl().className).toContain('bg-info-bg'); - expect(alertEl().className).toContain('text-info'); - }); - - it('should apply success classes when variant is success', async () => { - host.variant.set('success'); - await fixture.whenStable(); - expect(alertEl().className).toContain('bg-success-bg'); - expect(alertEl().className).toContain('text-success'); - }); - - it('should apply warn classes when variant is warn', async () => { - host.variant.set('warn'); - await fixture.whenStable(); - expect(alertEl().className).toContain('bg-warn-bg'); - expect(alertEl().className).toContain('text-warn'); - }); - - it('should apply error classes when variant is error', async () => { - host.variant.set('error'); - await fixture.whenStable(); - expect(alertEl().className).toContain('bg-error-bg'); - expect(alertEl().className).toContain('text-error'); - }); - - it('should always include base layout classes regardless of variant', async () => { - host.variant.set('warn'); - await fixture.whenStable(); - expect(alertEl().className).toContain('flex'); - expect(alertEl().className).toContain('rounded-sm'); - }); - - it('should update heading text when heading input changes', async () => { - host.heading.set('First'); - await fixture.whenStable(); - expect(alertEl().querySelector('strong')?.textContent?.trim()).toBe( - 'First', - ); - - host.heading.set('Second'); - await fixture.whenStable(); - expect(alertEl().querySelector('strong')?.textContent?.trim()).toBe( - 'Second', - ); - }); -}); diff --git a/frontend/src/app/shared/components/alert/alert.component.ts b/frontend/src/app/shared/components/alert/alert.component.ts deleted file mode 100644 index 1ab247a5..00000000 --- a/frontend/src/app/shared/components/alert/alert.component.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { - Component, - ChangeDetectionStrategy, - input, - computed, -} from '@angular/core'; - -export type AlertVariant = 'info' | 'success' | 'warn' | 'error'; - -const VARIANT_CLASSES: Record = { - info: 'bg-info-bg text-info', - success: 'bg-success-bg text-success', - warn: 'bg-warn-bg text-warn', - error: 'bg-error-bg text-error', -}; - -/** - * DS inline alert — `ui-alert`. Denser than a callout: icon slot + text. - * Project an icon into `[alert-icon]` and the message as content. - */ -@Component({ - selector: 'app-alert', - template: ` -
- - - -
- @if (heading()) { - {{ heading() }} - } - -
-
- `, - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class AlertComponent { - readonly variant = input('info'); - readonly heading = input(null); - readonly testId = input(null); - - protected readonly classes = computed(() => - [ - 'flex gap-2.5 rounded-sm px-3 py-2.5 font-sans text-[13px] leading-normal', - VARIANT_CLASSES[this.variant()], - ].join(' '), - ); -} diff --git a/frontend/src/app/shared/components/avatar-group/avatar-group.component.spec.ts b/frontend/src/app/shared/components/avatar-group/avatar-group.component.spec.ts deleted file mode 100644 index 9b1cc6d8..00000000 --- a/frontend/src/app/shared/components/avatar-group/avatar-group.component.spec.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { Component, signal } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { AvatarGroupComponent } from './avatar-group.component'; -import { AvatarComponent } from '../avatar/avatar.component'; - -@Component({ - standalone: true, - imports: [AvatarGroupComponent, AvatarComponent], - template: ` - - - - - `, -}) -class AvatarGroupHostComponent { - readonly overflow = signal(0); - readonly testId = signal(null); -} - -describe('AvatarGroupComponent', () => { - let fixture: ComponentFixture; - let host: AvatarGroupHostComponent; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [AvatarGroupHostComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(AvatarGroupHostComponent); - host = fixture.componentInstance; - await fixture.whenStable(); - }); - - it('should create', () => { - expect(host).toBeTruthy(); - }); - - describe('projected avatars', () => { - it('should render projected avatar children', () => { - const avatars = fixture.nativeElement.querySelectorAll('app-avatar'); - expect(avatars.length).toBe(2); - }); - }); - - describe('overflow bubble', () => { - it('should not render overflow bubble when overflow is 0', async () => { - host.overflow.set(0); - await fixture.whenStable(); - - const bubble = fixture.nativeElement.querySelector( - '[data-testid$="-overflow"]', - ); - expect(bubble).toBeNull(); - }); - - it('should render overflow bubble with correct count when overflow is positive', async () => { - host.overflow.set(3); - await fixture.whenStable(); - - const bubbles = fixture.nativeElement.querySelectorAll('span'); - const overflowBubble = Array.from(bubbles).find((el) => - (el as HTMLElement).textContent?.trim().startsWith('+'), - ) as HTMLElement | undefined; - expect(overflowBubble).toBeTruthy(); - expect(overflowBubble?.textContent?.trim()).toBe('+3'); - }); - - it('should update overflow bubble text when overflow input changes', async () => { - host.overflow.set(5); - await fixture.whenStable(); - - const spansBefore = fixture.nativeElement.querySelectorAll('span'); - const bubbleBefore = Array.from(spansBefore).find((el) => - (el as HTMLElement).textContent?.trim().startsWith('+'), - ) as HTMLElement | undefined; - expect(bubbleBefore?.textContent?.trim()).toBe('+5'); - - host.overflow.set(12); - await fixture.whenStable(); - - const spansAfter = fixture.nativeElement.querySelectorAll('span'); - const bubbleAfter = Array.from(spansAfter).find((el) => - (el as HTMLElement).textContent?.trim().startsWith('+'), - ) as HTMLElement | undefined; - expect(bubbleAfter?.textContent?.trim()).toBe('+12'); - }); - - it('should hide overflow bubble again when overflow changes back to 0', async () => { - host.overflow.set(4); - await fixture.whenStable(); - - host.overflow.set(0); - await fixture.whenStable(); - - const spans = fixture.nativeElement.querySelectorAll('span'); - const overflowBubble = Array.from(spans).find((el) => - (el as HTMLElement).textContent?.trim().startsWith('+'), - ); - expect(overflowBubble).toBeUndefined(); - }); - }); - - describe('testId', () => { - it('should set data-testid on the container when testId is provided', async () => { - host.testId.set('team-avatars'); - await fixture.whenStable(); - - const container = fixture.nativeElement.querySelector( - '[data-testid="team-avatars"]', - ); - expect(container).toBeTruthy(); - }); - - it('should set data-testid on the overflow bubble when testId is provided', async () => { - host.testId.set('team-avatars'); - host.overflow.set(2); - await fixture.whenStable(); - - const overflowEl = fixture.nativeElement.querySelector( - '[data-testid="team-avatars-overflow"]', - ); - expect(overflowEl).toBeTruthy(); - }); - - it('should not set data-testid on container when testId is null', async () => { - host.testId.set(null); - await fixture.whenStable(); - - const container = fixture.nativeElement.querySelector('div[data-testid]'); - expect(container).toBeNull(); - }); - }); -}); diff --git a/frontend/src/app/shared/components/avatar-group/avatar-group.component.ts b/frontend/src/app/shared/components/avatar-group/avatar-group.component.ts deleted file mode 100644 index dbe13682..00000000 --- a/frontend/src/app/shared/components/avatar-group/avatar-group.component.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Component, ChangeDetectionStrategy, input } from '@angular/core'; - -/** - * DS avatar group — `ui-avatar-group`. Overlaps projected `app-avatar` - * children with a panel-coloured ring, optionally capping the visible count - * and appending a `+N` more bubble. The caller is responsible for projecting - * only the avatars that should be shown; `max`/`total` drive the overflow - * bubble label. - */ -@Component({ - selector: 'app-avatar-group', - template: ` -
- - @if (overflow() > 0) { - - +{{ overflow() }} - - } -
- `, - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class AvatarGroupComponent { - /** Number of hidden avatars to surface in the `+N` bubble. */ - readonly overflow = input(0); - readonly testId = input(null); -} diff --git a/frontend/src/app/shared/components/avatar/avatar.component.spec.ts b/frontend/src/app/shared/components/avatar/avatar.component.spec.ts deleted file mode 100644 index 7e34ec93..00000000 --- a/frontend/src/app/shared/components/avatar/avatar.component.spec.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { AvatarComponent } from './avatar.component'; - -describe('AvatarComponent', () => { - let fixture: ComponentFixture; - let component: AvatarComponent; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [AvatarComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(AvatarComponent); - component = fixture.componentInstance; - }); - - it('should create', () => { - fixture.detectChanges(); - expect(component).toBeTruthy(); - }); - - describe('image rendering', () => { - it('should render an img element when src input is provided', () => { - fixture.componentRef.setInput('src', 'https://example.com/avatar.png'); - fixture.componentRef.setInput('alt', 'Jane Doe'); - fixture.detectChanges(); - - const img = fixture.nativeElement.querySelector( - 'img', - ) as HTMLImageElement; - expect(img).toBeTruthy(); - expect(img.src).toContain('https://example.com/avatar.png'); - }); - - it('should set alt attribute on img when alt input is provided', () => { - fixture.componentRef.setInput('src', 'https://example.com/avatar.png'); - fixture.componentRef.setInput('alt', 'Jane Doe'); - fixture.detectChanges(); - - const img = fixture.nativeElement.querySelector( - 'img', - ) as HTMLImageElement; - expect(img.alt).toBe('Jane Doe'); - }); - - it('should not render img element when src is null', () => { - fixture.componentRef.setInput('src', null); - fixture.componentRef.setInput('initials', 'JD'); - fixture.detectChanges(); - - const img = fixture.nativeElement.querySelector('img'); - expect(img).toBeNull(); - }); - }); - - describe('initials fallback', () => { - it('should display initials when src is null', () => { - fixture.componentRef.setInput('src', null); - fixture.componentRef.setInput('initials', 'AB'); - fixture.detectChanges(); - - const el = fixture.nativeElement as HTMLElement; - expect(el.textContent?.trim()).toBe('AB'); - }); - - it('should hide initials span with aria-hidden when src is absent', () => { - fixture.componentRef.setInput('src', null); - fixture.componentRef.setInput('initials', 'XY'); - fixture.detectChanges(); - - const initialsSpan = fixture.nativeElement.querySelector( - '[aria-hidden="true"]', - ) as HTMLElement; - expect(initialsSpan).toBeTruthy(); - expect(initialsSpan.textContent?.trim()).toBe('XY'); - }); - - it('should show empty initials when initials input defaults to empty string', () => { - fixture.componentRef.setInput('src', null); - fixture.detectChanges(); - - const el = fixture.nativeElement as HTMLElement; - // textContent will be empty string — no crash - expect(el.textContent?.trim()).toBe(''); - }); - }); - - describe('size classes', () => { - it('should apply size-6 class when size is sm', () => { - fixture.componentRef.setInput('size', 'sm'); - fixture.detectChanges(); - - const span = fixture.nativeElement.querySelector('span') as HTMLElement; - expect(span.className).toContain('size-6'); - }); - - it('should apply size-8 class when size is md', () => { - fixture.componentRef.setInput('size', 'md'); - fixture.detectChanges(); - - const span = fixture.nativeElement.querySelector('span') as HTMLElement; - expect(span.className).toContain('size-8'); - }); - - it('should apply size-11 class when size is lg', () => { - fixture.componentRef.setInput('size', 'lg'); - fixture.detectChanges(); - - const span = fixture.nativeElement.querySelector('span') as HTMLElement; - expect(span.className).toContain('size-11'); - }); - - it('should default to md size when size input is not provided', () => { - fixture.detectChanges(); - - expect(component.size()).toBe('md'); - const span = fixture.nativeElement.querySelector('span') as HTMLElement; - expect(span.className).toContain('size-8'); - }); - }); - - describe('actor colours', () => { - it('should apply inline background-color style when actor is human', () => { - fixture.componentRef.setInput('actor', 'human'); - fixture.detectChanges(); - - const span = fixture.nativeElement.querySelector('span') as HTMLElement; - expect(span.style.backgroundColor).toBeTruthy(); - }); - - it('should not apply inline colour styles when actor is null', () => { - fixture.componentRef.setInput('actor', null); - fixture.detectChanges(); - - const span = fixture.nativeElement.querySelector('span') as HTMLElement; - // style.backgroundColor is empty string when not set - expect(span.style.backgroundColor).toBe(''); - }); - - it('should apply different background colours for claude-code vs human', () => { - // Render human actor - fixture.componentRef.setInput('actor', 'human'); - fixture.detectChanges(); - const humanBg = ( - fixture.nativeElement.querySelector('span') as HTMLElement - ).style.backgroundColor; - - // Render claude-code actor - fixture.componentRef.setInput('actor', 'claude-code'); - fixture.detectChanges(); - const codeBg = ( - fixture.nativeElement.querySelector('span') as HTMLElement - ).style.backgroundColor; - - expect(humanBg).not.toBe(codeBg); - }); - }); - - describe('testId', () => { - it('should set data-testid attribute when testId input is provided', () => { - fixture.componentRef.setInput('testId', 'user-avatar'); - fixture.detectChanges(); - - const el = fixture.nativeElement.querySelector( - '[data-testid="user-avatar"]', - ); - expect(el).toBeTruthy(); - }); - - it('should not render data-testid attribute when testId is null', () => { - fixture.componentRef.setInput('testId', null); - fixture.detectChanges(); - - const el = fixture.nativeElement.querySelector('[data-testid]'); - expect(el).toBeNull(); - }); - }); -}); diff --git a/frontend/src/app/shared/components/avatar/avatar.component.ts b/frontend/src/app/shared/components/avatar/avatar.component.ts deleted file mode 100644 index b5d097ab..00000000 --- a/frontend/src/app/shared/components/avatar/avatar.component.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { - Component, - ChangeDetectionStrategy, - input, - computed, -} from '@angular/core'; - -export type AvatarSize = 'sm' | 'md' | 'lg'; -/** koopa pack — AI actor identities (admin surfaces). */ -export type AvatarActor = 'human' | 'claude-cowork' | 'claude-code' | 'system'; - -const SIZE_CLASSES: Record = { - sm: 'size-6 text-[10px]', - md: 'size-8 text-xs', - lg: 'size-11 text-base', -}; - -const ACTOR_COLORS: Record = { - human: { bg: 'var(--brand-muted)', fg: 'var(--brand-strong)' }, - 'claude-cowork': { bg: 'var(--info-bg)', fg: 'var(--info)' }, - 'claude-code': { - bg: 'color-mix(in oklab, var(--dot-essay) 18%, transparent)', - fg: 'var(--dot-essay)', - }, - system: { bg: 'var(--overlay)', fg: 'var(--fg-subtle)' }, -}; - -/** - * DS avatar — `ui-avatar`. Circular image with an initials fallback when - * `src` is absent (or while it loads). Three sizes: 24 / 32 / 44px. - */ -@Component({ - selector: 'app-avatar', - template: ` - - @if (src()) { - - } @else { - - } - - `, - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class AvatarComponent { - readonly src = input(null); - readonly alt = input(''); - readonly initials = input(''); - readonly size = input('md'); - readonly actor = input(null); - readonly testId = input(null); - - protected readonly actorColor = computed(() => { - const a = this.actor(); - return a ? ACTOR_COLORS[a] : null; - }); - - protected readonly classes = computed(() => - [ - 'inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full', - 'bg-elevated font-display font-semibold text-fg-muted select-none', - SIZE_CLASSES[this.size()], - ].join(' '), - ); -} diff --git a/frontend/src/app/shared/components/badge/badge.component.spec.ts b/frontend/src/app/shared/components/badge/badge.component.spec.ts deleted file mode 100644 index ad58faf6..00000000 --- a/frontend/src/app/shared/components/badge/badge.component.spec.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { Component, signal } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BadgeComponent, type BadgeTone } from './badge.component'; - -@Component({ - imports: [BadgeComponent], - template: `{{ - label() - }}`, -}) -class HostComponent { - readonly tone = signal('neutral'); - readonly testId = signal(null); - readonly label = signal('Draft'); -} - -describe('BadgeComponent', () => { - let fixture: ComponentFixture; - let host: HostComponent; - - function spanEl(): HTMLSpanElement { - return fixture.nativeElement.querySelector('span') as HTMLSpanElement; - } - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [HostComponent], - }).compileComponents(); - fixture = TestBed.createComponent(HostComponent); - host = fixture.componentInstance; - await fixture.whenStable(); - }); - - it('should create', () => { - expect(spanEl()).toBeTruthy(); - }); - - it('should project label text into the badge', async () => { - host.label.set('Published'); - await fixture.whenStable(); - expect(spanEl().textContent?.trim()).toBe('Published'); - }); - - it('should forward testId to data-testid attribute when provided', async () => { - host.testId.set('status-badge'); - await fixture.whenStable(); - expect(spanEl().getAttribute('data-testid')).toBe('status-badge'); - }); - - it('should have no data-testid attribute when testId is null', async () => { - await fixture.whenStable(); - expect(spanEl().getAttribute('data-testid')).toBeNull(); - }); - - it('should apply neutral tone classes when tone is neutral', async () => { - await fixture.whenStable(); - expect(spanEl().className).toContain('bg-elevated'); - expect(spanEl().className).toContain('text-fg-muted'); - }); - - it('should apply success tone classes when tone is success', async () => { - host.tone.set('success'); - await fixture.whenStable(); - expect(spanEl().className).toContain('bg-success-bg'); - expect(spanEl().className).toContain('text-success'); - }); - - it('should apply error tone classes when tone is error', async () => { - host.tone.set('error'); - await fixture.whenStable(); - expect(spanEl().className).toContain('bg-error-bg'); - expect(spanEl().className).toContain('text-error'); - }); - - it('should apply warn tone classes when tone is warn', async () => { - host.tone.set('warn'); - await fixture.whenStable(); - expect(spanEl().className).toContain('bg-warn-bg'); - expect(spanEl().className).toContain('text-warn'); - }); - - it('should apply brand tone classes when tone is brand', async () => { - host.tone.set('brand'); - await fixture.whenStable(); - expect(spanEl().className).toContain('bg-brand-muted'); - expect(spanEl().className).toContain('text-brand-strong'); - }); - - it('should apply info tone classes when tone is info', async () => { - host.tone.set('info'); - await fixture.whenStable(); - expect(spanEl().className).toContain('bg-info-bg'); - expect(spanEl().className).toContain('text-info'); - }); - - it('should always include base layout classes regardless of tone', async () => { - host.tone.set('success'); - await fixture.whenStable(); - expect(spanEl().className).toContain('inline-flex'); - expect(spanEl().className).toContain('rounded-sm'); - }); - - it('should update displayed text when label changes', async () => { - host.label.set('Active'); - await fixture.whenStable(); - expect(spanEl().textContent?.trim()).toBe('Active'); - - host.label.set('Inactive'); - await fixture.whenStable(); - expect(spanEl().textContent?.trim()).toBe('Inactive'); - }); -}); diff --git a/frontend/src/app/shared/components/badge/badge.component.ts b/frontend/src/app/shared/components/badge/badge.component.ts deleted file mode 100644 index bc1bdc3e..00000000 --- a/frontend/src/app/shared/components/badge/badge.component.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { - Component, - ChangeDetectionStrategy, - input, - computed, -} from '@angular/core'; - -export type BadgeTone = - | 'neutral' - | 'brand' - | 'success' - | 'warn' - | 'error' - | 'info'; - -const TONE_CLASSES: Record = { - neutral: 'bg-elevated text-fg-muted border-border-faint', - brand: 'bg-brand-muted text-brand-strong border-transparent', - success: 'bg-success-bg text-success border-transparent', - warn: 'bg-warn-bg text-warn border-transparent', - error: 'bg-error-bg text-error border-transparent', - info: 'bg-info-bg text-info border-transparent', -}; - -/** - * DS generic badge — `ui-badge` (sans, sentence-case label). For lifecycle - * enums (draft/review/published…) use the mono `app-status-badge` instead. - */ -@Component({ - selector: 'app-badge', - template: ` - - - - `, - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class BadgeComponent { - readonly tone = input('neutral'); - readonly testId = input(null); - - protected readonly classes = computed(() => - [ - 'inline-flex items-center gap-1.5 rounded-sm border px-2 py-[3px]', - 'font-sans text-[11px] leading-none font-medium', - TONE_CLASSES[this.tone()], - ].join(' '), - ); -} diff --git a/frontend/src/app/shared/components/breadcrumbs/breadcrumbs.component.spec.ts b/frontend/src/app/shared/components/breadcrumbs/breadcrumbs.component.spec.ts deleted file mode 100644 index fa9d5c5e..00000000 --- a/frontend/src/app/shared/components/breadcrumbs/breadcrumbs.component.spec.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BreadcrumbsComponent, BreadcrumbItem } from './breadcrumbs.component'; - -const THREE_ITEMS: readonly BreadcrumbItem[] = [ - { label: 'Home', href: '/' }, - { label: 'Users', href: '/users' }, - { label: 'Profile' }, -]; - -describe('BreadcrumbsComponent', () => { - let fixture: ComponentFixture; - let component: BreadcrumbsComponent; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [BreadcrumbsComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(BreadcrumbsComponent); - component = fixture.componentInstance; - }); - - it('should create', () => { - fixture.componentRef.setInput('items', THREE_ITEMS); - fixture.detectChanges(); - expect(component).toBeTruthy(); - }); - - it('should render a nav element with default aria-label when ariaLabel is not provided', () => { - fixture.componentRef.setInput('items', THREE_ITEMS); - fixture.detectChanges(); - - const nav = fixture.nativeElement.querySelector('nav'); - expect(nav).toBeTruthy(); - expect(nav.getAttribute('aria-label')).toBe('Breadcrumb'); - }); - - it('should apply custom ariaLabel when provided', () => { - fixture.componentRef.setInput('items', THREE_ITEMS); - fixture.componentRef.setInput('ariaLabel', 'Page navigation'); - fixture.detectChanges(); - - const nav = fixture.nativeElement.querySelector('nav'); - expect(nav.getAttribute('aria-label')).toBe('Page navigation'); - }); - - it('should apply testId to nav when testId input is set', () => { - fixture.componentRef.setInput('items', THREE_ITEMS); - fixture.componentRef.setInput('testId', 'main-breadcrumbs'); - fixture.detectChanges(); - - const nav = fixture.nativeElement.querySelector( - '[data-testid="main-breadcrumbs"]', - ); - expect(nav).toBeTruthy(); - }); - - it('should render the last item as a span with aria-current=page', () => { - fixture.componentRef.setInput('items', THREE_ITEMS); - fixture.detectChanges(); - - const lastCrumb = fixture.nativeElement.querySelector( - '[data-testid="crumb-2"]', - ); - expect(lastCrumb.tagName.toLowerCase()).toBe('span'); - expect(lastCrumb.getAttribute('aria-current')).toBe('page'); - expect(lastCrumb.textContent.trim()).toBe('Profile'); - }); - - it('should render non-last items with href as anchor links', () => { - fixture.componentRef.setInput('items', THREE_ITEMS); - fixture.detectChanges(); - - const homeLink = fixture.nativeElement.querySelector( - '[data-testid="crumb-0"]', - ); - expect(homeLink.tagName.toLowerCase()).toBe('a'); - expect(homeLink.getAttribute('href')).toBe('/'); - expect(homeLink.textContent.trim()).toBe('Home'); - }); - - it('should render non-last items without href as plain spans', () => { - const items: readonly BreadcrumbItem[] = [ - { label: 'Section' }, - { label: 'Current' }, - ]; - fixture.componentRef.setInput('items', items); - fixture.detectChanges(); - - const sectionCrumb = fixture.nativeElement.querySelector( - '[data-testid="crumb-0"]', - ); - expect(sectionCrumb.tagName.toLowerCase()).toBe('span'); - expect(sectionCrumb.getAttribute('aria-current')).toBeNull(); - }); - - it('should render separator slashes between non-last items', () => { - fixture.componentRef.setInput('items', THREE_ITEMS); - fixture.detectChanges(); - - // Two separators for three items (after item 0 and item 1) - const separators = fixture.nativeElement.querySelectorAll( - '[aria-hidden="true"]', - ); - expect(separators.length).toBe(2); - }); - - it('should not render a separator after the last item', () => { - fixture.componentRef.setInput('items', [{ label: 'Only' }]); - fixture.detectChanges(); - - const separators = fixture.nativeElement.querySelectorAll( - '[aria-hidden="true"]', - ); - expect(separators.length).toBe(0); - }); - - it('should render a single-item breadcrumb as aria-current=page with no link', () => { - fixture.componentRef.setInput('items', [ - { label: 'Dashboard', href: '/dashboard' }, - ]); - fixture.detectChanges(); - - const crumb = fixture.nativeElement.querySelector( - '[data-testid="crumb-0"]', - ); - // Even with href provided, single item is last — so span, not anchor - expect(crumb.tagName.toLowerCase()).toBe('span'); - expect(crumb.getAttribute('aria-current')).toBe('page'); - }); - - it('should display all labels in document order', () => { - fixture.componentRef.setInput('items', THREE_ITEMS); - fixture.detectChanges(); - - const labels = ['Home', 'Users', 'Profile']; - labels.forEach((label, i) => { - const crumb = fixture.nativeElement.querySelector( - `[data-testid="crumb-${i}"]`, - ); - expect(crumb.textContent.trim()).toBe(label); - }); - }); -}); diff --git a/frontend/src/app/shared/components/breadcrumbs/breadcrumbs.component.ts b/frontend/src/app/shared/components/breadcrumbs/breadcrumbs.component.ts deleted file mode 100644 index ae90ba9f..00000000 --- a/frontend/src/app/shared/components/breadcrumbs/breadcrumbs.component.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { Component, ChangeDetectionStrategy, input } from '@angular/core'; - -export interface BreadcrumbItem { - readonly label: string; - readonly href?: string; -} - -/** - * DS breadcrumbs — `ui-crumbs`. Mono 11px trail with `/` separators. Links are - * fg-subtle→fg on hover; the last item is the current page (fg-muted, - * `aria-current=page`) and never a link. - */ -@Component({ - selector: 'app-breadcrumbs', - template: ` -
- `, - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class BreadcrumbsComponent { - readonly items = input.required(); - readonly ariaLabel = input('Breadcrumb'); - readonly testId = input(null); -} diff --git a/frontend/src/app/shared/components/button/button.component.spec.ts b/frontend/src/app/shared/components/button/button.component.spec.ts deleted file mode 100644 index 59d714a9..00000000 --- a/frontend/src/app/shared/components/button/button.component.spec.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { Component } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ButtonComponent } from './button.component'; - -// Host used only for content-projection tests -@Component({ - template: `Projected`, - imports: [ButtonComponent], -}) -class ProjectionHostComponent {} - -describe('ButtonComponent', () => { - let fixture: ComponentFixture; - let component: ButtonComponent; - - function btn(): HTMLButtonElement { - return fixture.nativeElement.querySelector('button') as HTMLButtonElement; - } - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ButtonComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(ButtonComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should set type="button" by default', () => { - expect(btn().type).toBe('button'); - }); - - it('should set type="submit" when type input is submit', () => { - fixture.componentRef.setInput('type', 'submit'); - fixture.detectChanges(); - expect(btn().type).toBe('submit'); - }); - - it('should set type="reset" when type input is reset', () => { - fixture.componentRef.setInput('type', 'reset'); - fixture.detectChanges(); - expect(btn().type).toBe('reset'); - }); - - it('should disable the button when disabled input is true', () => { - fixture.componentRef.setInput('disabled', true); - fixture.detectChanges(); - expect(btn().disabled).toBe(true); - }); - - it('should not disable the button when disabled is false', () => { - fixture.componentRef.setInput('disabled', false); - fixture.detectChanges(); - expect(btn().disabled).toBe(false); - }); - - it('should disable the button when loading is true', () => { - fixture.componentRef.setInput('loading', true); - fixture.detectChanges(); - expect(btn().disabled).toBe(true); - }); - - it('should set aria-busy when loading is true', () => { - fixture.componentRef.setInput('loading', true); - fixture.detectChanges(); - expect(btn().getAttribute('aria-busy')).toBe('true'); - }); - - it('should not have aria-busy when loading is false', () => { - fixture.componentRef.setInput('loading', false); - fixture.detectChanges(); - expect(btn().getAttribute('aria-busy')).toBeNull(); - }); - - it('should render a spinner element when loading is true', () => { - fixture.componentRef.setInput('loading', true); - fixture.detectChanges(); - const spinner = fixture.nativeElement.querySelector('span.animate-spin'); - expect(spinner).toBeTruthy(); - }); - - it('should not render a spinner element when loading is false', () => { - fixture.componentRef.setInput('loading', false); - fixture.detectChanges(); - const spinner = fixture.nativeElement.querySelector('span.animate-spin'); - expect(spinner).toBeNull(); - }); - - it('should forward testId to data-testid attribute when provided', () => { - fixture.componentRef.setInput('testId', 'save-button'); - fixture.detectChanges(); - expect(btn().getAttribute('data-testid')).toBe('save-button'); - }); - - it('should have no data-testid attribute when testId is null', () => { - fixture.componentRef.setInput('testId', null); - fixture.detectChanges(); - expect(btn().getAttribute('data-testid')).toBeNull(); - }); - - it('should include w-full class when block is true', () => { - fixture.componentRef.setInput('block', true); - fixture.detectChanges(); - expect(btn().className).toContain('w-full'); - }); - - it('should not include w-full class when block is false', () => { - fixture.componentRef.setInput('block', false); - fixture.detectChanges(); - expect(btn().className).not.toContain('w-full'); - }); - - it('should apply primary variant class when variant is primary', () => { - fixture.componentRef.setInput('variant', 'primary'); - fixture.detectChanges(); - expect(btn().className).toContain('bg-primary'); - }); - - it('should apply danger variant class when variant is danger', () => { - fixture.componentRef.setInput('variant', 'danger'); - fixture.detectChanges(); - expect(btn().className).toContain('bg-error-bg'); - expect(btn().className).toContain('text-error'); - }); - - it('should apply ghost variant class when variant is ghost', () => { - fixture.componentRef.setInput('variant', 'ghost'); - fixture.detectChanges(); - expect(btn().className).toContain('bg-transparent'); - }); - - it('should apply secondary variant class by default', () => { - // secondary is the default variant - expect(btn().className).toContain('bg-elevated'); - }); - - it('should apply xs size padding class when size is xs', () => { - fixture.componentRef.setInput('size', 'xs'); - fixture.detectChanges(); - expect(btn().className).toContain('px-2'); - expect(btn().className).toContain('py-1'); - }); - - it('should apply lg size padding class when size is lg', () => { - fixture.componentRef.setInput('size', 'lg'); - fixture.detectChanges(); - expect(btn().className).toContain('py-2.5'); - }); - - describe('content projection', () => { - let hostFixture: ComponentFixture; - - beforeEach(async () => { - hostFixture = TestBed.createComponent(ProjectionHostComponent); - hostFixture.detectChanges(); - }); - - it('should render projected label text when content is provided', () => { - const innerBtn = hostFixture.nativeElement.querySelector('button'); - expect(innerBtn.textContent?.trim()).toBe('Projected'); - }); - }); -}); diff --git a/frontend/src/app/shared/components/button/button.component.ts b/frontend/src/app/shared/components/button/button.component.ts deleted file mode 100644 index 11504943..00000000 --- a/frontend/src/app/shared/components/button/button.component.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { - Component, - ChangeDetectionStrategy, - input, - computed, -} from '@angular/core'; - -export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger'; -export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg'; - -const VARIANT_CLASSES: Record = { - primary: 'bg-primary text-primary-foreground hover:bg-(--accent-strong)', - secondary: - 'bg-elevated text-fg border-border hover:bg-overlay hover:border-border-strong active:bg-panel', - ghost: - 'bg-transparent text-fg-muted hover:bg-overlay hover:text-fg active:bg-panel', - danger: 'bg-error-bg text-error hover:bg-error/20 active:bg-error/25', -}; - -const SIZE_CLASSES: Record = { - xs: 'gap-1.5 px-2 py-1 text-xs', - sm: 'gap-1.5 px-2.5 py-1.5 text-[13px]', - md: 'gap-2 px-3.5 py-2 text-[13px]', - lg: 'gap-2 px-4.5 py-2.5 text-sm', -}; - -/** - * DS button — `ui-btn`. Variants primary/secondary/ghost/danger, four sizes, - * loading spinner, icon-only, full-width. Radius is r-sm (no pill, per DS). - */ -@Component({ - selector: 'app-button', - template: ` - - `, - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class ButtonComponent { - readonly variant = input('secondary'); - readonly size = input('md'); - readonly type = input<'button' | 'submit' | 'reset'>('button'); - readonly disabled = input(false); - readonly loading = input(false); - readonly block = input(false); - readonly iconOnly = input(false); - readonly testId = input(null); - - protected readonly classes = computed(() => { - const icon = this.iconOnly() - ? this.size() === 'sm' - ? 'p-1.5' - : 'p-2' - : SIZE_CLASSES[this.size()]; - return [ - 'relative inline-flex items-center justify-center font-sans font-semibold leading-none', - 'rounded-sm border border-transparent cursor-pointer whitespace-nowrap no-underline', - 'transition-colors duration-[120ms] disabled:opacity-40 disabled:cursor-not-allowed disabled:pointer-events-none', - '[&_svg]:size-4', - this.block() ? 'w-full' : '', - VARIANT_CLASSES[this.variant()], - icon, - ] - .filter(Boolean) - .join(' '); - }); -} diff --git a/frontend/src/app/shared/components/callout/callout.component.spec.ts b/frontend/src/app/shared/components/callout/callout.component.spec.ts deleted file mode 100644 index 7ba3cde3..00000000 --- a/frontend/src/app/shared/components/callout/callout.component.spec.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { Component, signal } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { CalloutComponent, type CalloutVariant } from './callout.component'; - -@Component({ - template: ` - - {{ body() }} - - `, - imports: [CalloutComponent], -}) -class HostComponent { - readonly variant = signal('brand'); - readonly label = signal(null); - readonly testId = signal(null); - readonly body = signal('Callout body text.'); -} - -describe('CalloutComponent', () => { - let fixture: ComponentFixture; - let host: HostComponent; - - function containerEl(): HTMLElement { - return fixture.nativeElement.querySelector('div') as HTMLElement; - } - - function labelEl(): HTMLElement | null { - return fixture.nativeElement.querySelector( - 'div > div:first-child', - ) as HTMLElement | null; - } - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [HostComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(HostComponent); - host = fixture.componentInstance; - await fixture.whenStable(); - }); - - it('should create', () => { - expect(containerEl()).toBeTruthy(); - }); - - it('should project body text into the content area', async () => { - host.body.set('Important note here.'); - await fixture.whenStable(); - expect(containerEl().textContent).toContain('Important note here.'); - }); - - it('should not render label element when label input is null', async () => { - host.label.set(null); - await fixture.whenStable(); - const children = containerEl().querySelectorAll('div'); - const hasUppercaseLabel = Array.from(children).some((el) => - el.className.includes('uppercase'), - ); - expect(hasUppercaseLabel).toBe(false); - }); - - it('should render label text when label input is provided', async () => { - host.label.set('Note'); - await fixture.whenStable(); - const el = labelEl(); - expect(el).toBeTruthy(); - expect(el?.textContent?.trim()).toBe('Note'); - }); - - it('should forward testId to data-testid attribute when provided', async () => { - host.testId.set('my-callout'); - await fixture.whenStable(); - expect(containerEl().getAttribute('data-testid')).toBe('my-callout'); - }); - - it('should have no data-testid when testId is null', async () => { - await fixture.whenStable(); - expect(containerEl().getAttribute('data-testid')).toBeNull(); - }); - - it('should apply brand left-border class when variant is brand', async () => { - host.variant.set('brand'); - await fixture.whenStable(); - expect(containerEl().className).toContain('border-l-brand'); - }); - - it('should apply warn left-border class when variant is warn', async () => { - host.variant.set('warn'); - await fixture.whenStable(); - expect(containerEl().className).toContain('border-l-warn'); - }); - - it('should apply success left-border class when variant is success', async () => { - host.variant.set('success'); - await fixture.whenStable(); - expect(containerEl().className).toContain('border-l-success'); - }); - - it('should apply error left-border class when variant is error', async () => { - host.variant.set('error'); - await fixture.whenStable(); - expect(containerEl().className).toContain('border-l-error'); - }); - - it('should apply note left-border class when variant is note', async () => { - host.variant.set('note'); - await fixture.whenStable(); - expect(containerEl().className).toContain('border-l-fg-subtle'); - }); - - it('should apply brand label colour class when variant is brand and label is set', async () => { - host.variant.set('brand'); - host.label.set('Tip'); - await fixture.whenStable(); - const el = labelEl(); - expect(el?.className).toContain('text-brand'); - }); - - it('should apply error label colour class when variant is error and label is set', async () => { - host.variant.set('error'); - host.label.set('Error'); - await fixture.whenStable(); - const el = labelEl(); - expect(el?.className).toContain('text-error'); - }); - - it('should always include base structural classes on the container', async () => { - await fixture.whenStable(); - expect(containerEl().className).toContain('rounded-r-md'); - expect(containerEl().className).toContain('bg-panel'); - }); -}); diff --git a/frontend/src/app/shared/components/callout/callout.component.ts b/frontend/src/app/shared/components/callout/callout.component.ts deleted file mode 100644 index d0787a94..00000000 --- a/frontend/src/app/shared/components/callout/callout.component.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { - Component, - ChangeDetectionStrategy, - input, - computed, -} from '@angular/core'; - -export type CalloutVariant = 'brand' | 'note' | 'warn' | 'success' | 'error'; - -const RULE_CLASSES: Record = { - brand: 'border-l-brand', - note: 'border-l-fg-subtle', - warn: 'border-l-warn', - success: 'border-l-success', - error: 'border-l-error', -}; - -const LABEL_CLASSES: Record = { - brand: 'text-brand', - note: 'text-fg-subtle', - warn: 'text-warn', - success: 'text-success', - error: 'text-error', -}; - -/** - * DS callout / admonition — `ui-callout`. Left brand rule, mono label, serif - * body (the reading-surface voice). Project the body as content. - */ -@Component({ - selector: 'app-callout', - template: ` -
- @if (label()) { -
- {{ label() }} -
- } -
- -
-
- `, - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class CalloutComponent { - readonly variant = input('brand'); - readonly label = input(null); - readonly testId = input(null); - - protected readonly containerClasses = computed(() => - [ - 'rounded-r-md border border-l-[3px] border-border bg-panel px-[18px] py-3.5', - RULE_CLASSES[this.variant()], - ].join(' '), - ); - - protected readonly labelClasses = computed( - () => LABEL_CLASSES[this.variant()], - ); -} diff --git a/frontend/src/app/shared/components/checkbox/checkbox.component.spec.ts b/frontend/src/app/shared/components/checkbox/checkbox.component.spec.ts deleted file mode 100644 index 8e48a87b..00000000 --- a/frontend/src/app/shared/components/checkbox/checkbox.component.spec.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { Component, signal } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { CheckboxComponent } from './checkbox.component'; - -// Checkbox uses ng-content for its label — use a host component throughout. - -@Component({ - imports: [CheckboxComponent], - template: ` - - Accept terms - - `, - standalone: true, -}) -class HostCheckboxComponent { - readonly isChecked = signal(false); - readonly isDisabled = signal(false); - readonly isInvalid = signal(false); -} - -describe('CheckboxComponent', () => { - let hostFixture: ComponentFixture; - let host: HostCheckboxComponent; - - function nativeInput(): HTMLInputElement { - return hostFixture.nativeElement.querySelector( - '[data-testid="host-cb"]', - ) as HTMLInputElement; - } - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [HostCheckboxComponent], - }).compileComponents(); - - hostFixture = TestBed.createComponent(HostCheckboxComponent); - host = hostFixture.componentInstance; - await hostFixture.whenStable(); - }); - - it('should create', () => { - expect( - hostFixture.nativeElement.querySelector('app-checkbox'), - ).toBeTruthy(); - }); - - describe('checked model', () => { - it('should render unchecked by default', () => { - expect(nativeInput().checked).toBe(false); - }); - - it('should render checked when host sets isChecked to true', async () => { - host.isChecked.set(true); - await hostFixture.whenStable(); - expect(nativeInput().checked).toBe(true); - }); - - it('should update host isChecked to true when checkbox is checked by user', async () => { - const input = nativeInput(); - input.checked = true; - input.dispatchEvent(new Event('change')); - await hostFixture.whenStable(); - expect(host.isChecked()).toBe(true); - }); - - it('should update host isChecked to false when checkbox is unchecked by user', async () => { - host.isChecked.set(true); - await hostFixture.whenStable(); - const input = nativeInput(); - input.checked = false; - input.dispatchEvent(new Event('change')); - await hostFixture.whenStable(); - expect(host.isChecked()).toBe(false); - }); - }); - - describe('disabled input', () => { - it('should not be disabled by default', () => { - expect(nativeInput().disabled).toBe(false); - }); - - it('should disable the native input when disabled is true', async () => { - host.isDisabled.set(true); - await hostFixture.whenStable(); - expect(nativeInput().disabled).toBe(true); - }); - - it('should not change checked state when disabled checkbox receives change event', async () => { - host.isDisabled.set(true); - await hostFixture.whenStable(); - // Browsers don't fire change on disabled inputs, but guard the model - expect(host.isChecked()).toBe(false); - }); - }); - - describe('invalid input', () => { - it('should not have aria-invalid when invalid is false', () => { - expect(nativeInput().getAttribute('aria-invalid')).toBeNull(); - }); - - it('should set aria-invalid when invalid is true', async () => { - host.isInvalid.set(true); - await hostFixture.whenStable(); - expect(nativeInput().getAttribute('aria-invalid')).toBe('true'); - }); - }); - - describe('content projection', () => { - it('should render projected label text inside the label element', () => { - const label = hostFixture.nativeElement.querySelector( - 'label', - ) as HTMLLabelElement; - expect(label.textContent?.trim()).toContain('Accept terms'); - }); - }); - - describe('label wrapping', () => { - it('should wrap the input inside a label element so clicking the label toggles the checkbox', () => { - const label = hostFixture.nativeElement.querySelector( - 'label', - ) as HTMLLabelElement; - const input = nativeInput(); - // The input must be a descendant of the label for implicit association - expect(label.contains(input)).toBe(true); - }); - }); -}); diff --git a/frontend/src/app/shared/components/checkbox/checkbox.component.ts b/frontend/src/app/shared/components/checkbox/checkbox.component.ts deleted file mode 100644 index 074c8cdf..00000000 --- a/frontend/src/app/shared/components/checkbox/checkbox.component.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { - Component, - ChangeDetectionStrategy, - input, - model, -} from '@angular/core'; - -/** - * DS checkbox — `ui-checkbox`. Visually-hidden native input paired with a - * custom box (so it stays keyboard/AT operable and form-associable). Project - * the label as content. Two-way `checked` model. - */ -@Component({ - selector: 'app-checkbox', - template: ` - - `, - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class CheckboxComponent { - readonly checked = model(false); - readonly disabled = input(false); - readonly invalid = input(false); - readonly testId = input(null); - - protected onChange(event: Event): void { - this.checked.set((event.target as HTMLInputElement).checked); - } -} diff --git a/frontend/src/app/shared/components/chip/chip.component.spec.ts b/frontend/src/app/shared/components/chip/chip.component.spec.ts deleted file mode 100644 index 0cf8fd75..00000000 --- a/frontend/src/app/shared/components/chip/chip.component.spec.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ChipComponent } from './chip.component'; - -describe('ChipComponent', () => { - let fixture: ComponentFixture; - let component: ChipComponent; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ChipComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(ChipComponent); - component = fixture.componentInstance; - fixture.componentRef.setInput('testId', 'chip'); - fixture.detectChanges(); - }); - - function chip(): HTMLSpanElement { - return fixture.nativeElement.querySelector( - '[data-testid="chip"]', - ) as HTMLSpanElement; - } - - function removeButton(): HTMLButtonElement | null { - return fixture.nativeElement.querySelector( - 'button[aria-label="Remove"]', - ) as HTMLButtonElement | null; - } - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - describe('active input', () => { - it('should not set data-active attribute when active is false', () => { - fixture.componentRef.setInput('active', false); - fixture.detectChanges(); - expect(chip().getAttribute('data-active')).toBeNull(); - }); - - it('should set data-active="true" when active is true', () => { - fixture.componentRef.setInput('active', true); - fixture.detectChanges(); - expect(chip().getAttribute('data-active')).toBe('true'); - }); - }); - - describe('testId input', () => { - it('should not set data-testid when testId is null', () => { - fixture.componentRef.setInput('testId', null); - fixture.detectChanges(); - // span has no testid - const span = fixture.nativeElement.querySelector('span'); - expect(span.getAttribute('data-testid')).toBeNull(); - }); - - it('should set data-testid attribute when testId is provided', () => { - fixture.componentRef.setInput('testId', 'lang-chip'); - fixture.detectChanges(); - expect( - fixture.nativeElement - .querySelector('[data-testid="lang-chip"]') - .getAttribute('data-testid'), - ).toBe('lang-chip'); - }); - - it('should set data-testid on remove button derived from testId when removable', () => { - fixture.componentRef.setInput('testId', 'lang-chip'); - fixture.componentRef.setInput('removable', true); - fixture.detectChanges(); - const btn = fixture.nativeElement.querySelector( - '[data-testid="lang-chip-remove"]', - ); - expect(btn).toBeTruthy(); - expect(btn.getAttribute('data-testid')).toBe('lang-chip-remove'); - }); - }); - - describe('removable input', () => { - it('should not render remove button when removable is false', () => { - fixture.componentRef.setInput('removable', false); - fixture.detectChanges(); - expect(removeButton()).toBeNull(); - }); - - it('should render remove button when removable is true', () => { - fixture.componentRef.setInput('removable', true); - fixture.detectChanges(); - expect(removeButton()).toBeTruthy(); - }); - - it('should have aria-label="Remove" on the remove button', () => { - fixture.componentRef.setInput('removable', true); - fixture.detectChanges(); - expect(removeButton()?.getAttribute('aria-label')).toBe('Remove'); - }); - }); - - describe('removed output', () => { - it('should emit removed event when remove button is clicked', () => { - fixture.componentRef.setInput('removable', true); - fixture.detectChanges(); - - const spy = vi.fn(); - component.removed.subscribe(spy); - - removeButton()!.click(); - - expect(spy).toHaveBeenCalledTimes(1); - }); - - it('should not emit removed event when chip body is clicked (not remove button)', () => { - fixture.componentRef.setInput('removable', true); - fixture.detectChanges(); - - const spy = vi.fn(); - component.removed.subscribe(spy); - - chip().click(); - - expect(spy).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/frontend/src/app/shared/components/chip/chip.component.ts b/frontend/src/app/shared/components/chip/chip.component.ts deleted file mode 100644 index 8d8169db..00000000 --- a/frontend/src/app/shared/components/chip/chip.component.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { - Component, - ChangeDetectionStrategy, - input, - output, - computed, -} from '@angular/core'; - -/** - * DS filter chip — `ui-chip`. Mono, toggleable via `active` (reflected as - * `aria-pressed`), optionally `removable` with an `×` button that emits - * `removed`. The chip itself is a `role="button"` toggle. - */ -@Component({ - selector: 'app-chip', - template: ` - - - @if (removable()) { - - } - - `, - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class ChipComponent { - readonly active = input(false); - readonly removable = input(false); - readonly testId = input(null); - - readonly removed = output(); - - protected readonly classes = computed(() => - [ - 'inline-flex cursor-pointer items-center gap-1 rounded-sm border px-2.5 py-1', - 'font-mono text-[11px] leading-none transition-colors duration-[120ms]', - this.active() - ? 'border-transparent bg-brand-muted text-brand-strong' - : 'border-border-faint bg-elevated text-fg-subtle hover:border-border hover:text-fg-muted', - ].join(' '), - ); - - protected onRemove(event: Event): void { - event.stopPropagation(); - this.removed.emit(); - } -} diff --git a/frontend/src/app/shared/components/code-block/code-block.component.spec.ts b/frontend/src/app/shared/components/code-block/code-block.component.spec.ts deleted file mode 100644 index a7060b89..00000000 --- a/frontend/src/app/shared/components/code-block/code-block.component.spec.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { Clipboard } from '@angular/cdk/clipboard'; -import { CodeBlockComponent } from './code-block.component'; - -describe('CodeBlockComponent', () => { - let fixture: ComponentFixture; - let copySpy: ReturnType; - - beforeEach(async () => { - copySpy = vi.fn().mockReturnValue(true); - await TestBed.configureTestingModule({ - imports: [CodeBlockComponent], - providers: [{ provide: Clipboard, useValue: { copy: copySpy } }], - }).compileComponents(); - - fixture = TestBed.createComponent(CodeBlockComponent); - fixture.componentRef.setInput('code', 'const x = 1;'); - fixture.componentRef.setInput('lang', 'ts'); - await fixture.whenStable(); - }); - - function copyButton(): HTMLButtonElement { - return fixture.nativeElement.querySelector( - '[data-testid="code-block-copy"]', - ); - } - - it('should render the code and language', () => { - expect(fixture.nativeElement.querySelector('code').textContent).toContain( - 'const x = 1;', - ); - expect(fixture.nativeElement.textContent).toContain('ts'); - }); - - it('should show the Copy affordance before any copy', () => { - expect(copyButton().textContent).toContain('Copy'); - expect(copyButton().getAttribute('aria-label')).toBe('Copy code'); - }); - - it('should copy the code to the clipboard when the button is clicked', () => { - copyButton().click(); - expect(copySpy).toHaveBeenCalledWith('const x = 1;'); - }); - - it('should flip to the Copied state after a successful copy', async () => { - copyButton().click(); - await fixture.whenStable(); - expect(copyButton().textContent).toContain('Copied'); - expect(copyButton().getAttribute('aria-label')).toBe('Copied to clipboard'); - }); - - it('should revert to Copy after the reset timeout', async () => { - vi.useFakeTimers(); - try { - copyButton().click(); - fixture.detectChanges(); - expect(copyButton().textContent).toContain('Copied'); - await vi.advanceTimersByTimeAsync(2000); - fixture.detectChanges(); - expect(copyButton().textContent).toContain('Copy'); - expect(copyButton().textContent).not.toContain('Copied'); - } finally { - vi.useRealTimers(); - } - }); - - it('should not enter the Copied state when the clipboard copy fails', async () => { - copySpy.mockReturnValue(false); - copyButton().click(); - await fixture.whenStable(); - expect(copyButton().textContent).toContain('Copy'); - expect(copyButton().textContent).not.toContain('Copied'); - }); - - it('should suffix the copy button testid when a testId is provided', async () => { - fixture.componentRef.setInput('testId', 'snippet'); - await fixture.whenStable(); - expect( - fixture.nativeElement.querySelector('[data-testid="snippet-copy"]'), - ).not.toBeNull(); - expect( - fixture.nativeElement.querySelector('[data-testid="snippet"]'), - ).not.toBeNull(); - }); -}); diff --git a/frontend/src/app/shared/components/code-block/code-block.component.ts b/frontend/src/app/shared/components/code-block/code-block.component.ts deleted file mode 100644 index 260b3107..00000000 --- a/frontend/src/app/shared/components/code-block/code-block.component.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { - Component, - ChangeDetectionStrategy, - input, - signal, - inject, -} from '@angular/core'; -import { Clipboard } from '@angular/cdk/clipboard'; - -const COPIED_RESET_MS = 2000; - -/** - * DS code block — `ui-code-block`. Framed mono panel: a header bar shows the - * language tag and a copy button, the body renders the (non-highlighted) source - * in a horizontally scrollable `
`. Copy writes to the clipboard via the
- * CDK `Clipboard` service and flips the button to a transient "Copied" state
- * for ~2s.
- */
-@Component({
-  selector: 'app-code-block',
-  template: `
-    
-
- {{ lang() }} - -
-
{{ code() }}
-
- `, - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class CodeBlockComponent { - readonly lang = input(''); - readonly code = input.required(); - readonly testId = input(null); - - private readonly clipboard = inject(Clipboard); - - protected readonly copied = signal(false); - - protected copy(): void { - if (this.clipboard.copy(this.code())) { - this.copied.set(true); - setTimeout(() => this.copied.set(false), COPIED_RESET_MS); - } - } -} diff --git a/frontend/src/app/shared/components/content-type/content-type.component.spec.ts b/frontend/src/app/shared/components/content-type/content-type.component.spec.ts deleted file mode 100644 index 3f87c591..00000000 --- a/frontend/src/app/shared/components/content-type/content-type.component.spec.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ContentTypeComponent, ContentType } from './content-type.component'; - -describe('ContentTypeComponent', () => { - let fixture: ComponentFixture; - let component: ContentTypeComponent; - - // Helper: re-create fixture with a fresh type input each time - async function setup(type: ContentType): Promise { - await TestBed.configureTestingModule({ - imports: [ContentTypeComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(ContentTypeComponent); - component = fixture.componentInstance; - fixture.componentRef.setInput('type', type); - fixture.detectChanges(); - } - - it('should create when type is "article"', async () => { - await setup('article'); - expect(component).toBeTruthy(); - }); - - describe('data-testid', () => { - it('should set data-testid to "content-type-article" when type is article', async () => { - await setup('article'); - const el = fixture.nativeElement.querySelector( - '[data-testid="content-type-article"]', - ); - expect(el).toBeTruthy(); - }); - - it('should set data-testid to "content-type-til" when type is til', async () => { - await setup('til'); - const el = fixture.nativeElement.querySelector( - '[data-testid="content-type-til"]', - ); - expect(el).toBeTruthy(); - }); - - it('should set data-testid to "content-type-build-log" when type is build-log', async () => { - await setup('build-log'); - const el = fixture.nativeElement.querySelector( - '[data-testid="content-type-build-log"]', - ); - expect(el).toBeTruthy(); - }); - }); - - describe('label text', () => { - const types: ContentType[] = [ - 'article', - 'essay', - 'build-log', - 'til', - 'digest', - ]; - - for (const type of types) { - it(`should render lowercase type name "${type}" when type is ${type}`, async () => { - await setup(type); - const el = fixture.nativeElement.querySelector( - `[data-testid="content-type-${type}"]`, - ); - expect(el.textContent).toContain(type); - }); - } - }); - - describe('dot color', () => { - it('should apply CSS variable for dot color matching the type', async () => { - await setup('essay'); - const dot = fixture.nativeElement.querySelector( - '[data-testid="content-type-essay"] span[aria-hidden="true"]', - ) as HTMLElement; - expect(dot.style.backgroundColor).toBe( - 'var(--dot-essay, var(--fg-faint))', - ); - }); - - it('should apply different CSS variable when type changes', async () => { - await setup('digest'); - const dot = fixture.nativeElement.querySelector( - '[data-testid="content-type-digest"] span[aria-hidden="true"]', - ) as HTMLElement; - expect(dot.style.backgroundColor).toBe( - 'var(--dot-digest, var(--fg-faint))', - ); - }); - - it('should mark dot span as aria-hidden', async () => { - await setup('article'); - const dot = fixture.nativeElement.querySelector( - '[data-testid="content-type-article"] span[aria-hidden="true"]', - ); - expect(dot).toBeTruthy(); - expect(dot.getAttribute('aria-hidden')).toBe('true'); - }); - }); - - describe('type input update', () => { - it('should re-render with new type name when type input changes', async () => { - await setup('article'); - fixture.componentRef.setInput('type', 'til'); - fixture.detectChanges(); - await fixture.whenStable(); - - const newEl = fixture.nativeElement.querySelector( - '[data-testid="content-type-til"]', - ); - expect(newEl).toBeTruthy(); - expect(newEl.textContent).toContain('til'); - - // old testid should be gone - const oldEl = fixture.nativeElement.querySelector( - '[data-testid="content-type-article"]', - ); - expect(oldEl).toBeNull(); - }); - }); -}); diff --git a/frontend/src/app/shared/components/content-type/content-type.component.ts b/frontend/src/app/shared/components/content-type/content-type.component.ts deleted file mode 100644 index c250c610..00000000 --- a/frontend/src/app/shared/components/content-type/content-type.component.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { - Component, - ChangeDetectionStrategy, - input, - computed, -} from '@angular/core'; - -/** koopa0.dev content-type taxonomy (pack). The live, published surface. */ -export type ContentType = - | 'article' - | 'essay' - | 'build-log' - | 'til' - | 'digest'; - -/** - * DS koopa pack — content-type label (`ui-type`): a categorical dot + the - * lowercase, hyphenated type name. Colors come from the `--dot-*` tokens in - * styles.css. Voice rule: type labels are plain, lowercase, no icons. - */ -@Component({ - selector: 'app-content-type', - template: ` - - - {{ type() }} - - `, - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class ContentTypeComponent { - readonly type = input.required(); - - protected readonly dotColor = computed( - () => `var(--dot-${this.type()}, var(--fg-faint))`, - ); -} diff --git a/frontend/src/app/shared/components/description-list/description-list.component.spec.ts b/frontend/src/app/shared/components/description-list/description-list.component.spec.ts deleted file mode 100644 index 051f4614..00000000 --- a/frontend/src/app/shared/components/description-list/description-list.component.spec.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { - DescriptionListComponent, - type DescriptionRow, -} from './description-list.component'; - -const ROWS: DescriptionRow[] = [ - { term: 'Name', desc: 'Alice' }, - { term: 'Role', desc: 'Admin' }, - { term: 'Status', desc: 'Active' }, -]; - -describe('DescriptionListComponent', () => { - let fixture: ComponentFixture; - let component: DescriptionListComponent; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [DescriptionListComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(DescriptionListComponent); - component = fixture.componentInstance; - }); - - it('should create', () => { - fixture.componentRef.setInput('rows', ROWS); - fixture.detectChanges(); - expect(component).toBeTruthy(); - }); - - it('should render the data-testid="description-list" container', () => { - fixture.componentRef.setInput('rows', ROWS); - fixture.detectChanges(); - const dl = fixture.nativeElement.querySelector( - '[data-testid="description-list"]', - ); - expect(dl).toBeTruthy(); - expect(dl.tagName.toLowerCase()).toBe('dl'); - }); - - it('should render every term when rows are provided', () => { - fixture.componentRef.setInput('rows', ROWS); - fixture.detectChanges(); - const terms = fixture.nativeElement.querySelectorAll('dt'); - const termTexts = Array.from(terms).map((t) => - (t as HTMLElement).textContent?.trim(), - ); - expect(termTexts).toContain('Name'); - expect(termTexts).toContain('Role'); - expect(termTexts).toContain('Status'); - }); - - it('should render every description when rows are provided', () => { - fixture.componentRef.setInput('rows', ROWS); - fixture.detectChanges(); - const descs = fixture.nativeElement.querySelectorAll('dd'); - const descTexts = Array.from(descs).map((d) => - (d as HTMLElement).textContent?.trim(), - ); - expect(descTexts).toContain('Alice'); - expect(descTexts).toContain('Admin'); - expect(descTexts).toContain('Active'); - }); - - it('should render exactly as many term/description pairs as rows', () => { - fixture.componentRef.setInput('rows', ROWS); - fixture.detectChanges(); - const terms = fixture.nativeElement.querySelectorAll('dt'); - const descs = fixture.nativeElement.querySelectorAll('dd'); - expect(terms.length).toBe(ROWS.length); - expect(descs.length).toBe(ROWS.length); - }); - - it('should apply standard (non-inline) grid layout by default', () => { - fixture.componentRef.setInput('rows', ROWS); - fixture.detectChanges(); - const dl = fixture.nativeElement.querySelector( - '[data-testid="description-list"]', - ); - // Default mode wraps each pair in a div; inline does not. - const wrapperDivs = dl.querySelectorAll('div'); - expect(wrapperDivs.length).toBe(ROWS.length); - }); - - it('should NOT wrap pairs in divs when inline is true', () => { - fixture.componentRef.setInput('rows', ROWS); - fixture.componentRef.setInput('inline', true); - fixture.detectChanges(); - const dl = fixture.nativeElement.querySelector( - '[data-testid="description-list"]', - ); - const wrapperDivs = dl.querySelectorAll('div'); - expect(wrapperDivs.length).toBe(0); - }); - - it('should apply inline grid class when inline is true', () => { - fixture.componentRef.setInput('rows', ROWS); - fixture.componentRef.setInput('inline', true); - fixture.detectChanges(); - const dl = fixture.nativeElement.querySelector( - '[data-testid="description-list"]', - ) as HTMLElement; - expect(dl.className).toContain('grid-cols-[max-content_1fr]'); - }); - - it('should render an empty list when rows is an empty array', () => { - fixture.componentRef.setInput('rows', []); - fixture.detectChanges(); - const terms = fixture.nativeElement.querySelectorAll('dt'); - expect(terms.length).toBe(0); - }); - - it('should update rendered rows when the rows input changes', () => { - fixture.componentRef.setInput('rows', ROWS); - fixture.detectChanges(); - - const newRows: DescriptionRow[] = [{ term: 'Updated', desc: 'Value' }]; - fixture.componentRef.setInput('rows', newRows); - fixture.detectChanges(); - - const terms = fixture.nativeElement.querySelectorAll('dt'); - expect(terms.length).toBe(1); - expect((terms[0] as HTMLElement).textContent?.trim()).toBe('Updated'); - }); -}); diff --git a/frontend/src/app/shared/components/description-list/description-list.component.ts b/frontend/src/app/shared/components/description-list/description-list.component.ts deleted file mode 100644 index c09ddea9..00000000 --- a/frontend/src/app/shared/components/description-list/description-list.component.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { - Component, - ChangeDetectionStrategy, - input, - computed, -} from '@angular/core'; - -export interface DescriptionRow { - readonly term: string; - readonly desc: string; -} - -/** - * DS description list — `ui-description-list`. Renders term/description pairs in - * a `
` grid. Default: fixed 180px term column with hairline-separated rows. - * `inline` collapses each pair to a compact `max-content / 1fr` two-column row. - */ -@Component({ - selector: 'app-description-list', - template: ` -
- @for (row of rows(); track row.term) { - @if (inline()) { -
{{ row.term }}
-
{{ row.desc }}
- } @else { -
-
{{ row.term }}
-
{{ row.desc }}
-
- } - } -
- `, - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class DescriptionListComponent { - readonly rows = input.required(); - readonly inline = input(false); - - protected readonly listClasses = computed(() => - this.inline() - ? 'm-0 grid grid-cols-[max-content_1fr] gap-x-6 gap-y-2' - : 'm-0', - ); -} diff --git a/frontend/src/app/shared/components/drawer/drawer.component.spec.ts b/frontend/src/app/shared/components/drawer/drawer.component.spec.ts deleted file mode 100644 index cf5ebeab..00000000 --- a/frontend/src/app/shared/components/drawer/drawer.component.spec.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { Component, signal } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { DrawerComponent } from './drawer.component'; - -@Component({ - imports: [DrawerComponent], - template: ` - -
-

Drawer Title

-
-

Body content

- -
- `, -}) -class TestHostComponent { - readonly drawerOpen = signal(false); -} - -describe('DrawerComponent', () => { - let fixture: ComponentFixture; - let host: TestHostComponent; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [TestHostComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(TestHostComponent); - host = fixture.componentInstance; - await fixture.whenStable(); - }); - - function panel(): HTMLElement | null { - return fixture.nativeElement.querySelector('[data-testid="test-drawer"]'); - } - - function scrim(): HTMLElement | null { - return fixture.nativeElement.querySelector( - '[data-testid="test-drawer-scrim"]', - ); - } - - it('should create', () => { - expect(fixture.componentInstance).toBeTruthy(); - }); - - it('should not render panel when open is false', () => { - expect(panel()).toBeNull(); - }); - - it('should not render scrim when open is false', () => { - expect(scrim()).toBeNull(); - }); - - it('should render panel when open is set to true', async () => { - host.drawerOpen.set(true); - fixture.detectChanges(); - await fixture.whenStable(); - - expect(panel()).toBeTruthy(); - }); - - it('should render scrim when open is true', async () => { - host.drawerOpen.set(true); - fixture.detectChanges(); - await fixture.whenStable(); - - expect(scrim()).toBeTruthy(); - }); - - it('should have role=dialog on the panel', async () => { - host.drawerOpen.set(true); - fixture.detectChanges(); - await fixture.whenStable(); - - expect(panel()?.getAttribute('role')).toBe('dialog'); - }); - - it('should have aria-modal=true on the panel', async () => { - host.drawerOpen.set(true); - fixture.detectChanges(); - await fixture.whenStable(); - - expect(panel()?.getAttribute('aria-modal')).toBe('true'); - }); - - it('should set aria-label on the panel', async () => { - host.drawerOpen.set(true); - fixture.detectChanges(); - await fixture.whenStable(); - - expect(panel()?.getAttribute('aria-label')).toBe('Test drawer'); - }); - - it('should project header slot content', async () => { - host.drawerOpen.set(true); - fixture.detectChanges(); - await fixture.whenStable(); - - const heading = fixture.nativeElement.querySelector('#drawer-title'); - expect(heading?.textContent).toContain('Drawer Title'); - }); - - it('should project body content', async () => { - host.drawerOpen.set(true); - fixture.detectChanges(); - await fixture.whenStable(); - - const body = fixture.nativeElement.querySelector( - '[data-testid="drawer-body"]', - ); - expect(body?.textContent).toContain('Body content'); - }); - - it('should project footer slot content', async () => { - host.drawerOpen.set(true); - fixture.detectChanges(); - await fixture.whenStable(); - - const cancel = fixture.nativeElement.querySelector( - '[data-testid="drawer-cancel"]', - ); - expect(cancel?.textContent).toContain('Cancel'); - }); - - it('should close and update open model when scrim is clicked', async () => { - host.drawerOpen.set(true); - fixture.detectChanges(); - await fixture.whenStable(); - - scrim()?.click(); - fixture.detectChanges(); - await fixture.whenStable(); - - expect(host.drawerOpen()).toBe(false); - expect(panel()).toBeNull(); - }); - - it('should close when ESC key is pressed on the panel', async () => { - host.drawerOpen.set(true); - fixture.detectChanges(); - await fixture.whenStable(); - - const escEvent = new KeyboardEvent('keydown', { - key: 'Escape', - bubbles: true, - }); - panel()?.dispatchEvent(escEvent); - fixture.detectChanges(); - await fixture.whenStable(); - - expect(host.drawerOpen()).toBe(false); - }); - - it('should re-render panel when open is toggled back to true', async () => { - host.drawerOpen.set(true); - fixture.detectChanges(); - await fixture.whenStable(); - - scrim()?.click(); - fixture.detectChanges(); - await fixture.whenStable(); - expect(panel()).toBeNull(); - - host.drawerOpen.set(true); - fixture.detectChanges(); - await fixture.whenStable(); - expect(panel()).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/shared/components/drawer/drawer.component.ts b/frontend/src/app/shared/components/drawer/drawer.component.ts deleted file mode 100644 index ee57368d..00000000 --- a/frontend/src/app/shared/components/drawer/drawer.component.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { - Component, - ChangeDetectionStrategy, - model, - input, -} from '@angular/core'; -import { A11yModule } from '@angular/cdk/a11y'; - -/** - * DS right-side drawer — `ui-drawer`. Two-way `open` model drives a fixed - * scrim + sliding panel. Slots: `[drawer-header]`, default body (scrollable), - * `[drawer-footer]`. `role=dialog aria-modal`; focus is trapped while open and - * ESC / scrim click close it. Rendered only while open. - */ -@Component({ - selector: 'app-drawer', - imports: [A11yModule], - template: ` - @if (open()) { - - - - - - } - `, - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class DrawerComponent { - /** Two-way open state. */ - readonly open = model(false); - readonly ariaLabel = input(null); - /** Id of an element inside `[drawer-header]` that titles the dialog. */ - readonly labelledBy = input(null); - readonly testId = input(null); - - protected close(): void { - this.open.set(false); - } -} diff --git a/frontend/src/app/shared/components/hextile/hextile.component.spec.ts b/frontend/src/app/shared/components/hextile/hextile.component.spec.ts deleted file mode 100644 index 06af71cf..00000000 --- a/frontend/src/app/shared/components/hextile/hextile.component.spec.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { Component, signal } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { HextileComponent } from './hextile.component'; - -@Component({ - standalone: true, - imports: [HextileComponent], - template: ` - - - - `, -}) -class HextileHostComponent { - readonly testId = signal(null); -} - -describe('HextileComponent', () => { - let fixture: ComponentFixture; - let host: HextileHostComponent; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [HextileHostComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(HextileHostComponent); - host = fixture.componentInstance; - await fixture.whenStable(); - }); - - it('should create', () => { - expect(host).toBeTruthy(); - }); - - describe('aria-hidden', () => { - it('should be aria-hidden true so decorative tile is skipped by screen readers', () => { - const span = fixture.nativeElement.querySelector( - 'app-hextile span', - ) as HTMLElement; - expect(span.getAttribute('aria-hidden')).toBe('true'); - }); - }); - - describe('content projection', () => { - it('should render projected icon content inside the tile', () => { - const icon = fixture.nativeElement.querySelector( - '[data-testid="projected-icon"]', - ); - expect(icon).toBeTruthy(); - }); - }); - - describe('shape classes', () => { - it('should have rounded-md class for the tile container', () => { - const span = fixture.nativeElement.querySelector( - 'app-hextile span', - ) as HTMLElement; - expect(span.className).toContain('rounded-md'); - }); - - it('should have size-10 class making it a fixed square tile', () => { - const span = fixture.nativeElement.querySelector( - 'app-hextile span', - ) as HTMLElement; - expect(span.className).toContain('size-10'); - }); - - it('should have brand-muted background class', () => { - const span = fixture.nativeElement.querySelector( - 'app-hextile span', - ) as HTMLElement; - expect(span.className).toContain('bg-brand-muted'); - }); - - it('should have brand-strong text colour for AA-safe icon contrast', () => { - const span = fixture.nativeElement.querySelector( - 'app-hextile span', - ) as HTMLElement; - expect(span.className).toContain('text-brand-strong'); - }); - }); - - describe('testId', () => { - it('should set data-testid when testId input is provided', async () => { - host.testId.set('feature-tile'); - await fixture.whenStable(); - - const el = fixture.nativeElement.querySelector( - '[data-testid="feature-tile"]', - ); - expect(el).toBeTruthy(); - }); - - it('should not render data-testid when testId is null', async () => { - host.testId.set(null); - await fixture.whenStable(); - - const span = fixture.nativeElement.querySelector( - 'app-hextile span[data-testid]', - ); - expect(span).toBeNull(); - }); - }); -}); diff --git a/frontend/src/app/shared/components/hextile/hextile.component.ts b/frontend/src/app/shared/components/hextile/hextile.component.ts deleted file mode 100644 index 3dc7d04e..00000000 --- a/frontend/src/app/shared/components/hextile/hextile.component.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Component, ChangeDetectionStrategy, input } from '@angular/core'; - -/** - * DS hex tile — `ui-hextile`. Leading brand-tinted tile used to front an icon - * (project a ~22px icon as content). Approximated as a rounded brand square - * rather than a true hexagon SVG — visually a brand-muted chip with the - * AA-safe `text-brand-strong` foreground for the tint background. - */ -@Component({ - selector: 'app-hextile', - template: ` - - `, - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class HextileComponent { - readonly testId = input(null); -} diff --git a/frontend/src/app/shared/components/index.ts b/frontend/src/app/shared/components/index.ts index 05c571128..045872ed 100644 --- a/frontend/src/app/shared/components/index.ts +++ b/frontend/src/app/shared/components/index.ts @@ -6,100 +6,5 @@ export { FormFieldComponent } from './form-field/form-field.component'; export { DataTableComponent } from './data-table/data-table.component'; export { EnergyMeterComponent } from './energy-meter/energy-meter.component'; -/* ── DS core primitives (Claude Design "koopa.dev Design System" ingest) ── */ -export { - ButtonComponent, - type ButtonVariant, - type ButtonSize, -} from './button/button.component'; -export { BadgeComponent, type BadgeTone } from './badge/badge.component'; -export { - CalloutComponent, - type CalloutVariant, -} from './callout/callout.component'; -export { AlertComponent, type AlertVariant } from './alert/alert.component'; -export { TabsComponent, type TabItem } from './tabs/tabs.component'; - -/* nav */ -export { - SegmentedComponent, - type SegmentedItem, -} from './segmented/segmented.component'; -export { - BreadcrumbsComponent, - type BreadcrumbItem, -} from './breadcrumbs/breadcrumbs.component'; -export { PaginationComponent } from './pagination/pagination.component'; -export { NavItemComponent } from './nav-item/nav-item.component'; - -/* cards / data display */ -export { - StatCardComponent, - type StatTrend, -} from './stat-card/stat-card.component'; -export { - AvatarComponent, - type AvatarSize, - type AvatarActor, -} from './avatar/avatar.component'; -export { AvatarGroupComponent } from './avatar-group/avatar-group.component'; -export { - ProgressComponent, - type ProgressTone, -} from './progress/progress.component'; -export { HextileComponent } from './hextile/hextile.component'; - -/* more */ -export { AccordionComponent } from './accordion/accordion.component'; -export { AccordionItemComponent } from './accordion-item/accordion-item.component'; -export { - DescriptionListComponent, - type DescriptionRow, -} from './description-list/description-list.component'; -export { StepperComponent, type StepItem } from './stepper/stepper.component'; -export { - SeparatorComponent, - type SeparatorOrientation, -} from './separator/separator.component'; - -/* forms */ -export { InputComponent, type InputSize } from './input/input.component'; -export { - TextareaComponent, - type TextareaSize, -} from './textarea/textarea.component'; -export { - SelectComponent, - type SelectOption, - type SelectSize, -} from './select/select.component'; -export { CheckboxComponent } from './checkbox/checkbox.component'; -export { RadioComponent } from './radio/radio.component'; -export { SwitchComponent } from './switch/switch.component'; - /* small inline */ -export { ChipComponent } from './chip/chip.component'; -export { TagComponent } from './tag/tag.component'; export { KbdComponent } from './kbd/kbd.component'; - -/* overlays (CDK) */ -export { MenuComponent } from './menu/menu.component'; -export { - MenuItemComponent, - type MenuItemVariant, -} from './menu-item/menu-item.component'; -export { - TooltipDirective, - TooltipComponent, -} from './tooltip/tooltip.directive'; -export { DrawerComponent } from './drawer/drawer.component'; - -/* koopa pack */ -export { - ContentTypeComponent, - type ContentType, -} from './content-type/content-type.component'; - -/* interactive — toast & command palette already exist under src/app/shared/ - (app-wired); only the new, non-duplicated code block ships from here. */ -export { CodeBlockComponent } from './code-block/code-block.component'; diff --git a/frontend/src/app/shared/components/input/input.component.spec.ts b/frontend/src/app/shared/components/input/input.component.spec.ts deleted file mode 100644 index 28d7895a..00000000 --- a/frontend/src/app/shared/components/input/input.component.spec.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { Component } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { InputComponent } from './input.component'; - -describe('InputComponent', () => { - let fixture: ComponentFixture; - let component: InputComponent; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [InputComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(InputComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - describe('value model', () => { - it('should reflect initial empty value in the native input', () => { - const input = fixture.nativeElement.querySelector( - 'input', - ) as HTMLInputElement; - expect(input.value).toBe(''); - }); - - it('should reflect set value in the native input when value is set', () => { - fixture.componentRef.setInput('value', 'hello'); - fixture.detectChanges(); - const input = fixture.nativeElement.querySelector( - 'input', - ) as HTMLInputElement; - expect(input.value).toBe('hello'); - }); - - it('should update value model when user types into the input', () => { - const input = fixture.nativeElement.querySelector( - 'input', - ) as HTMLInputElement; - input.value = 'typed text'; - input.dispatchEvent(new Event('input')); - expect(component.value()).toBe('typed text'); - }); - }); - - describe('type input', () => { - it('should render type="text" by default', () => { - const input = fixture.nativeElement.querySelector( - 'input', - ) as HTMLInputElement; - expect(input.type).toBe('text'); - }); - - it('should render type="password" when type input is password', () => { - fixture.componentRef.setInput('type', 'password'); - fixture.detectChanges(); - const input = fixture.nativeElement.querySelector( - 'input', - ) as HTMLInputElement; - expect(input.type).toBe('password'); - }); - - it('should render type="email" when type input is email', () => { - fixture.componentRef.setInput('type', 'email'); - fixture.detectChanges(); - const input = fixture.nativeElement.querySelector( - 'input', - ) as HTMLInputElement; - expect(input.type).toBe('email'); - }); - }); - - describe('placeholder input', () => { - it('should render placeholder when placeholder input is set', () => { - fixture.componentRef.setInput('placeholder', 'Enter your name'); - fixture.detectChanges(); - const input = fixture.nativeElement.querySelector( - 'input', - ) as HTMLInputElement; - expect(input.placeholder).toBe('Enter your name'); - }); - }); - - describe('disabled input', () => { - it('should not be disabled by default', () => { - const input = fixture.nativeElement.querySelector( - 'input', - ) as HTMLInputElement; - expect(input.disabled).toBe(false); - }); - - it('should disable the native input when disabled is true', () => { - fixture.componentRef.setInput('disabled', true); - fixture.detectChanges(); - const input = fixture.nativeElement.querySelector( - 'input', - ) as HTMLInputElement; - expect(input.disabled).toBe(true); - }); - }); - - describe('invalid input', () => { - it('should not have aria-invalid when invalid is false', () => { - const input = fixture.nativeElement.querySelector( - 'input', - ) as HTMLInputElement; - expect(input.getAttribute('aria-invalid')).toBeNull(); - }); - - it('should set aria-invalid when invalid is true', () => { - fixture.componentRef.setInput('invalid', true); - fixture.detectChanges(); - const input = fixture.nativeElement.querySelector( - 'input', - ) as HTMLInputElement; - expect(input.getAttribute('aria-invalid')).toBe('true'); - }); - }); - - describe('testId input', () => { - it('should not set data-testid attribute when testId is null', () => { - const input = fixture.nativeElement.querySelector( - 'input', - ) as HTMLInputElement; - expect(input.getAttribute('data-testid')).toBeNull(); - }); - - it('should set data-testid attribute when testId is provided', () => { - fixture.componentRef.setInput('testId', 'my-input'); - fixture.detectChanges(); - const input = fixture.nativeElement.querySelector( - '[data-testid="my-input"]', - ) as HTMLInputElement; - expect(input).toBeTruthy(); - }); - }); - - describe('size input', () => { - it('should apply md size classes by default', () => { - const input = fixture.nativeElement.querySelector( - 'input', - ) as HTMLInputElement; - expect(input.className).toContain('px-2.5'); - expect(input.className).toContain('py-2'); - }); - - it('should apply sm size classes when size is sm', () => { - fixture.componentRef.setInput('size', 'sm'); - fixture.detectChanges(); - const input = fixture.nativeElement.querySelector( - 'input', - ) as HTMLInputElement; - expect(input.className).toContain('px-2'); - expect(input.className).toContain('py-1.5'); - }); - - it('should apply lg size classes when size is lg', () => { - fixture.componentRef.setInput('size', 'lg'); - fixture.detectChanges(); - const input = fixture.nativeElement.querySelector( - 'input', - ) as HTMLInputElement; - expect(input.className).toContain('px-3'); - expect(input.className).toContain('py-2.5'); - }); - }); - - describe('mono input', () => { - it('should apply font-sans class by default', () => { - const input = fixture.nativeElement.querySelector( - 'input', - ) as HTMLInputElement; - expect(input.className).toContain('font-sans'); - }); - - it('should apply font-mono class when mono is true', () => { - fixture.componentRef.setInput('mono', true); - fixture.detectChanges(); - const input = fixture.nativeElement.querySelector( - 'input', - ) as HTMLInputElement; - expect(input.className).toContain('font-mono'); - expect(input.className).not.toContain('font-sans'); - }); - }); -}); - -// Host-based integration test for content projection & model two-way binding -@Component({ - imports: [InputComponent], - template: ``, - standalone: true, -}) -class HostInputComponent { - name = 'initial'; -} - -describe('InputComponent (host integration)', () => { - let hostFixture: ComponentFixture; - let host: HostInputComponent; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [HostInputComponent], - }).compileComponents(); - - hostFixture = TestBed.createComponent(HostInputComponent); - host = hostFixture.componentInstance; - hostFixture.detectChanges(); - }); - - it('should reflect host value in the native input via two-way binding', () => { - const input = hostFixture.nativeElement.querySelector( - '[data-testid="host-input"]', - ) as HTMLInputElement; - expect(input.value).toBe('initial'); - }); - - it('should update host property when native input event fires', () => { - const input = hostFixture.nativeElement.querySelector( - '[data-testid="host-input"]', - ) as HTMLInputElement; - input.value = 'updated'; - input.dispatchEvent(new Event('input')); - expect(host.name).toBe('updated'); - }); -}); diff --git a/frontend/src/app/shared/components/input/input.component.ts b/frontend/src/app/shared/components/input/input.component.ts deleted file mode 100644 index cc9810a5..00000000 --- a/frontend/src/app/shared/components/input/input.component.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { - Component, - ChangeDetectionStrategy, - input, - model, - computed, -} from '@angular/core'; - -export type InputSize = 'sm' | 'md' | 'lg'; - -const SIZE_CLASSES: Record = { - sm: 'px-2 py-1.5 text-xs', - md: 'px-2.5 py-2 text-[13px]', - lg: 'px-3 py-2.5 text-sm', -}; - -/** - * DS text input — `ui-input`. Presentational, Signal-Forms friendly: forwards - * its value through a two-way `value` model and emits on native input. Set - * `invalid` to surface the error border (also reflected via aria-invalid). - */ -@Component({ - selector: 'app-input', - template: ` - - `, - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class InputComponent { - readonly value = model(''); - readonly type = input< - 'text' | 'email' | 'password' | 'search' | 'tel' | 'url' | 'number' - >('text'); - readonly placeholder = input(''); - readonly disabled = input(false); - readonly invalid = input(false); - readonly mono = input(false); - readonly size = input('md'); - readonly testId = input(null); - - protected readonly classes = computed(() => - [ - 'w-full rounded-sm border border-border bg-elevated text-fg leading-normal', - 'placeholder:text-fg-subtle transition-colors duration-[120ms]', - 'hover:border-border-strong focus:border-brand focus:bg-panel focus:outline-hidden', - 'aria-invalid:border-error', - 'disabled:opacity-40 disabled:cursor-not-allowed', - this.mono() ? 'font-mono' : 'font-sans', - SIZE_CLASSES[this.size()], - ].join(' '), - ); - - protected onInput(event: Event): void { - this.value.set((event.target as HTMLInputElement).value); - } -} diff --git a/frontend/src/app/shared/components/menu-item/menu-item.component.spec.ts b/frontend/src/app/shared/components/menu-item/menu-item.component.spec.ts deleted file mode 100644 index 7a0b4b09..00000000 --- a/frontend/src/app/shared/components/menu-item/menu-item.component.spec.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { Component, signal } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { MenuItemComponent, type MenuItemVariant } from './menu-item.component'; - -@Component({ - imports: [MenuItemComponent], - template: ` - - - {{ label() }} - - `, -}) -class TestHostComponent { - readonly variant = signal('default'); - readonly disabled = signal(false); - readonly testId = signal('test-menu-item'); - readonly label = signal('Edit item'); -} - -describe('MenuItemComponent', () => { - let fixture: ComponentFixture; - let host: TestHostComponent; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [TestHostComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(TestHostComponent); - host = fixture.componentInstance; - await fixture.whenStable(); - }); - - function button(): HTMLButtonElement { - return fixture.nativeElement.querySelector( - '[data-testid="test-menu-item"]', - ) as HTMLButtonElement; - } - - it('should create', () => { - expect(fixture.nativeElement.querySelector('app-menu-item')).toBeTruthy(); - }); - - it('should render label text when content is projected', () => { - expect(button().textContent).toContain('Edit item'); - }); - - it('should have role=menuitem', () => { - expect(button().getAttribute('role')).toBe('menuitem'); - }); - - it('should apply testId attribute when testId input is set', () => { - expect(button()).toBeTruthy(); - }); - - it('should not be disabled by default', () => { - expect(button().disabled).toBe(false); - }); - - it('should be disabled when disabled input is true', async () => { - host.disabled.set(true); - fixture.detectChanges(); - await fixture.whenStable(); - - expect(button().disabled).toBe(true); - }); - - it('should set aria-disabled when disabled', async () => { - host.disabled.set(true); - fixture.detectChanges(); - await fixture.whenStable(); - - expect(button().getAttribute('aria-disabled')).toBe('true'); - }); - - it('should not set aria-disabled when enabled', () => { - expect(button().getAttribute('aria-disabled')).toBeNull(); - }); - - it('should not apply danger text class for default variant', () => { - const btn = button(); - expect(btn.className).not.toContain('text-error'); - }); - - it('should apply danger text class when variant is danger', async () => { - host.variant.set('danger'); - fixture.detectChanges(); - await fixture.whenStable(); - - const btn = button(); - expect(btn.className).toContain('text-error'); - }); - - it('should change from default to danger variant at runtime', async () => { - const btnBefore = button(); - expect(btnBefore.className).not.toContain('text-error'); - - host.variant.set('danger'); - fixture.detectChanges(); - await fixture.whenStable(); - - expect(button().className).toContain('text-error'); - }); -}); diff --git a/frontend/src/app/shared/components/menu-item/menu-item.component.ts b/frontend/src/app/shared/components/menu-item/menu-item.component.ts deleted file mode 100644 index 88b684bd..00000000 --- a/frontend/src/app/shared/components/menu-item/menu-item.component.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { - Component, - ChangeDetectionStrategy, - input, - computed, -} from '@angular/core'; - -export type MenuItemVariant = 'default' | 'danger'; - -/** - * DS menu item — a single row inside `app-menu`. `role=menuitem`, icon slot + - * label content. `danger` variant tints the row red for destructive actions. - * Disabled rows are non-interactive and dimmed. - */ -@Component({ - selector: 'app-menu-item', - template: ` - - `, - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class MenuItemComponent { - readonly variant = input('default'); - readonly disabled = input(false); - readonly testId = input(null); - - protected readonly classes = computed(() => { - const tone = - this.variant() === 'danger' - ? 'text-error hover:bg-error-bg hover:text-error' - : 'text-fg-muted hover:bg-overlay hover:text-fg'; - return [ - 'flex w-full cursor-pointer items-center gap-2.5 rounded-sm border-none bg-transparent', - 'px-2.5 py-[7px] font-sans text-[13px] leading-none whitespace-nowrap', - 'transition-colors duration-[120ms]', - 'disabled:cursor-not-allowed disabled:opacity-40 disabled:pointer-events-none', - tone, - ].join(' '); - }); -} diff --git a/frontend/src/app/shared/components/menu/menu.component.spec.ts b/frontend/src/app/shared/components/menu/menu.component.spec.ts deleted file mode 100644 index 7927b8c9..00000000 --- a/frontend/src/app/shared/components/menu/menu.component.spec.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { Component, signal } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { OverlayContainer } from '@angular/cdk/overlay'; -import { MenuComponent } from './menu.component'; - -@Component({ - imports: [MenuComponent], - template: ` - - - - - - `, -}) -class TestHostComponent { - readonly menuOpen = signal(false); -} - -describe('MenuComponent', () => { - let fixture: ComponentFixture; - let host: TestHostComponent; - let overlayContainer: OverlayContainer; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [TestHostComponent], - }).compileComponents(); - - overlayContainer = TestBed.inject(OverlayContainer); - fixture = TestBed.createComponent(TestHostComponent); - host = fixture.componentInstance; - await fixture.whenStable(); - }); - - afterEach(() => { - overlayContainer.ngOnDestroy?.(); - }); - - it('should create', () => { - const menuEl = fixture.nativeElement.querySelector('app-menu'); - expect(menuEl).toBeTruthy(); - }); - - it('should render trigger content when closed', () => { - const trigger = fixture.nativeElement.querySelector( - '[data-testid="menu-trigger"]', - ); - expect(trigger).toBeTruthy(); - expect(trigger.textContent).toContain('Open'); - }); - - it('should not render panel when open is false', () => { - const panel = overlayContainer - .getContainerElement() - .querySelector('[data-testid="test-menu"]'); - expect(panel).toBeNull(); - }); - - it('should open panel when trigger is clicked', async () => { - const trigger = fixture.nativeElement.querySelector( - '[data-testid="menu-trigger"]', - ); - trigger.click(); - fixture.detectChanges(); - await fixture.whenStable(); - - const panel = overlayContainer - .getContainerElement() - .querySelector('[data-testid="test-menu"]'); - expect(panel).toBeTruthy(); - }); - - it('should set role=menu on the panel when open', async () => { - const trigger = fixture.nativeElement.querySelector( - '[data-testid="menu-trigger"]', - ); - trigger.click(); - fixture.detectChanges(); - await fixture.whenStable(); - - const panel = overlayContainer - .getContainerElement() - .querySelector('[data-testid="test-menu"]'); - expect(panel?.getAttribute('role')).toBe('menu'); - }); - - it('should set aria-label on the panel when open', async () => { - const trigger = fixture.nativeElement.querySelector( - '[data-testid="menu-trigger"]', - ); - trigger.click(); - fixture.detectChanges(); - await fixture.whenStable(); - - const panel = overlayContainer - .getContainerElement() - .querySelector('[data-testid="test-menu"]'); - expect(panel?.getAttribute('aria-label')).toBe('Actions menu'); - }); - - it('should close panel when ESC is pressed', async () => { - const trigger = fixture.nativeElement.querySelector( - '[data-testid="menu-trigger"]', - ); - trigger.click(); - fixture.detectChanges(); - await fixture.whenStable(); - - const panel = overlayContainer - .getContainerElement() - .querySelector('[data-testid="test-menu"]'); - expect(panel).toBeTruthy(); - - const escEvent = new KeyboardEvent('keydown', { - key: 'Escape', - bubbles: true, - }); - panel?.dispatchEvent(escEvent); - fixture.detectChanges(); - await fixture.whenStable(); - - const closedPanel = overlayContainer - .getContainerElement() - .querySelector('[data-testid="test-menu"]'); - expect(closedPanel).toBeNull(); - }); - - it('should update open model to true when menu opens', async () => { - expect(host.menuOpen()).toBe(false); - - const trigger = fixture.nativeElement.querySelector( - '[data-testid="menu-trigger"]', - ); - trigger.click(); - fixture.detectChanges(); - await fixture.whenStable(); - - expect(host.menuOpen()).toBe(true); - }); - - it('should update open model to false when menu closes via ESC', async () => { - host.menuOpen.set(true); - fixture.detectChanges(); - await fixture.whenStable(); - - const panel = overlayContainer - .getContainerElement() - .querySelector('[data-testid="test-menu"]'); - const escEvent = new KeyboardEvent('keydown', { - key: 'Escape', - bubbles: true, - }); - panel?.dispatchEvent(escEvent); - fixture.detectChanges(); - await fixture.whenStable(); - - expect(host.menuOpen()).toBe(false); - }); - - it('should open with ArrowDown key when closed', async () => { - const menuEl = fixture.nativeElement.querySelector( - 'app-menu', - ) as HTMLElement; - const arrowEvent = new KeyboardEvent('keydown', { - key: 'ArrowDown', - bubbles: true, - }); - menuEl.dispatchEvent(arrowEvent); - fixture.detectChanges(); - await fixture.whenStable(); - - const panel = overlayContainer - .getContainerElement() - .querySelector('[data-testid="test-menu"]'); - expect(panel).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/shared/components/menu/menu.component.ts b/frontend/src/app/shared/components/menu/menu.component.ts deleted file mode 100644 index fced2e71..00000000 --- a/frontend/src/app/shared/components/menu/menu.component.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { - Component, - ChangeDetectionStrategy, - input, - model, - computed, - viewChild, - ElementRef, -} from '@angular/core'; -import { A11yModule } from '@angular/cdk/a11y'; -import { OverlayModule, type ConnectedPosition } from '@angular/cdk/overlay'; - -const MENU_POSITIONS: ConnectedPosition[] = [ - { - originX: 'start', - originY: 'bottom', - overlayX: 'start', - overlayY: 'top', - offsetY: 6, - }, - { - originX: 'end', - originY: 'bottom', - overlayX: 'end', - overlayY: 'top', - offsetY: 6, - }, - { - originX: 'start', - originY: 'top', - overlayX: 'start', - overlayY: 'bottom', - offsetY: -6, - }, - { - originX: 'end', - originY: 'top', - overlayX: 'end', - overlayY: 'bottom', - offsetY: -6, - }, -]; - -/** - * DS dropdown menu — `ui-menu`. Project the trigger into `[menu-trigger]` and - * `app-menu-item` rows as default content. Clicking the trigger opens a CDK - * overlay panel (`role=menu`) anchored to the trigger; it closes on backdrop - * click, ESC, or selecting an item. Focus is trapped while open. - */ -@Component({ - selector: 'app-menu', - imports: [A11yModule, OverlayModule], - host: { - '(click)': 'toggle()', - '(keydown.arrowdown)': 'onArrowDown($event)', - }, - template: ` - -
- -
- - -
- -
-
- `, - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class MenuComponent { - /** Two-way open state — parents may control or observe it. */ - readonly open = model(false); - readonly ariaLabel = input(null); - readonly testId = input(null); - - protected readonly positions = MENU_POSITIONS; - protected readonly panelClasses = - 'flex min-w-[180px] flex-col gap-0.5 rounded-md border border-border bg-elevated p-1 shadow-[var(--shadow-1)]'; - - private readonly triggerRef = - viewChild.required>('trigger'); - - protected readonly isOpen = computed(() => this.open()); - - /** CDK origin: the bare element the overlay anchors to. */ - protected readonly triggerEl = computed( - () => this.triggerRef().nativeElement, - ); - - protected toggle(): void { - this.open.update((v) => !v); - } - - protected onArrowDown(event: Event): void { - event.preventDefault(); - if (!this.open()) { - this.open.set(true); - } - } - - protected close(): void { - if (this.open()) { - this.open.set(false); - } - } - - protected onOverlayKeydown(event: KeyboardEvent): void { - if (event.key === 'Escape') { - event.preventDefault(); - this.close(); - } - } - - /** Close after any enabled menuitem is activated (event delegation). */ - protected onItemSelect(event: Event): void { - const item = (event.target as HTMLElement).closest('[role="menuitem"]'); - if (item && !item.hasAttribute('disabled')) { - this.close(); - } - } -} diff --git a/frontend/src/app/shared/components/nav-item/nav-item.component.spec.ts b/frontend/src/app/shared/components/nav-item/nav-item.component.spec.ts deleted file mode 100644 index e85218ce..00000000 --- a/frontend/src/app/shared/components/nav-item/nav-item.component.spec.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { Component, signal } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { NavItemComponent } from './nav-item.component'; - -// --------------------------------------------------------------------------- -// Host component — NavItemComponent uses content projection for [nav-icon] -// --------------------------------------------------------------------------- -@Component({ - standalone: true, - imports: [NavItemComponent], - template: ` - - - - - - `, -}) -class HostComponent { - readonly label = signal('Dashboard'); - readonly active = signal(false); - readonly count = signal(null); - readonly href = signal(null); - readonly testId = signal('nav-dashboard'); -} - -describe('NavItemComponent', () => { - let hostFixture: ComponentFixture; - let host: HostComponent; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [HostComponent], - }).compileComponents(); - - hostFixture = TestBed.createComponent(HostComponent); - host = hostFixture.componentInstance; - await hostFixture.whenStable(); - }); - - it('should create', () => { - expect(host).toBeTruthy(); - }); - - describe('element type based on href', () => { - it('should render a button when href is null', async () => { - host.href.set(null); - await hostFixture.whenStable(); - - const el = hostFixture.nativeElement.querySelector( - '[data-testid="nav-dashboard"]', - ); - expect(el.tagName.toLowerCase()).toBe('button'); - }); - - it('should render an anchor when href is provided', async () => { - host.href.set('/dashboard'); - await hostFixture.whenStable(); - - const el = hostFixture.nativeElement.querySelector( - '[data-testid="nav-dashboard"]', - ); - expect(el.tagName.toLowerCase()).toBe('a'); - expect(el.getAttribute('href')).toBe('/dashboard'); - }); - }); - - describe('label', () => { - it('should render the label text', async () => { - host.label.set('Orders'); - await hostFixture.whenStable(); - - const el = hostFixture.nativeElement.querySelector( - '[data-testid="nav-dashboard"]', - ); - expect(el.textContent).toContain('Orders'); - }); - }); - - describe('active state', () => { - it('should not set aria-current when active is false', async () => { - host.active.set(false); - await hostFixture.whenStable(); - - const el = hostFixture.nativeElement.querySelector( - '[data-testid="nav-dashboard"]', - ); - expect(el.getAttribute('aria-current')).toBeNull(); - }); - - it('should set aria-current=page when active is true', async () => { - host.active.set(true); - await hostFixture.whenStable(); - - const el = hostFixture.nativeElement.querySelector( - '[data-testid="nav-dashboard"]', - ); - expect(el.getAttribute('aria-current')).toBe('page'); - }); - - it('should set aria-current=page on the anchor when href is set and active is true', async () => { - host.href.set('/orders'); - host.active.set(true); - await hostFixture.whenStable(); - - const el = hostFixture.nativeElement.querySelector( - '[data-testid="nav-dashboard"]', - ); - expect(el.tagName.toLowerCase()).toBe('a'); - expect(el.getAttribute('aria-current')).toBe('page'); - }); - }); - - describe('count badge', () => { - it('should not render a count span when count is null', async () => { - host.count.set(null); - await hostFixture.whenStable(); - - const monospanElements = - hostFixture.nativeElement.querySelectorAll('.ml-auto'); - expect(monospanElements.length).toBe(0); - }); - - it('should render the count value when count is provided', async () => { - host.count.set(42); - await hostFixture.whenStable(); - - const countSpan = hostFixture.nativeElement.querySelector('.ml-auto'); - expect(countSpan).toBeTruthy(); - expect(countSpan.textContent.trim()).toBe('42'); - }); - - it('should render count of 0 when count is 0', async () => { - host.count.set(0); - await hostFixture.whenStable(); - - const countSpan = hostFixture.nativeElement.querySelector('.ml-auto'); - expect(countSpan).toBeTruthy(); - expect(countSpan.textContent.trim()).toBe('0'); - }); - }); - - describe('content projection', () => { - it('should project the nav-icon slot content', () => { - const svg = hostFixture.nativeElement.querySelector( - '[data-testid="nav-icon-svg"]', - ); - expect(svg).toBeTruthy(); - }); - }); - - describe('testId', () => { - it('should apply testId attribute when testId is set', async () => { - host.testId.set('nav-settings'); - await hostFixture.whenStable(); - - const el = hostFixture.nativeElement.querySelector( - '[data-testid="nav-settings"]', - ); - expect(el).toBeTruthy(); - }); - - it('should not render data-testid attribute when testId is null', async () => { - host.testId.set(null); - await hostFixture.whenStable(); - - // Query specifically button or anchor — the projected svg has its own - // data-testid="nav-icon-svg" and must not be counted here. - const el = hostFixture.nativeElement.querySelector( - 'button[data-testid], a[data-testid]', - ); - expect(el).toBeNull(); - }); - }); -}); diff --git a/frontend/src/app/shared/components/nav-item/nav-item.component.ts b/frontend/src/app/shared/components/nav-item/nav-item.component.ts deleted file mode 100644 index aea79245..00000000 --- a/frontend/src/app/shared/components/nav-item/nav-item.component.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { - Component, - ChangeDetectionStrategy, - input, - computed, -} from '@angular/core'; -import { NgTemplateOutlet } from '@angular/common'; - -/** - * DS sidebar nav row — `ui-navitem`. Renders as an `` when `href` is set, - * otherwise a ` - } - - - - {{ label() }} - @if (count() !== undefined && count() !== null) { - - {{ count() }} - - } - - `, - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class NavItemComponent { - readonly label = input.required(); - readonly active = input(false); - readonly count = input(null); - readonly href = input(null); - readonly testId = input(null); - - protected readonly classes = computed(() => - [ - 'flex w-full items-center gap-2.5 rounded-sm px-2.5 py-[7px]', - 'cursor-pointer border-0 bg-transparent text-left no-underline', - 'font-sans text-[13px] leading-normal', - 'transition-colors duration-[120ms]', - this.active() - ? 'bg-brand-faint text-fg' - : 'text-fg-muted hover:bg-overlay hover:text-fg', - ].join(' '), - ); -} diff --git a/frontend/src/app/shared/components/pagination/pagination.component.spec.ts b/frontend/src/app/shared/components/pagination/pagination.component.spec.ts deleted file mode 100644 index 7ce5c4f3..00000000 --- a/frontend/src/app/shared/components/pagination/pagination.component.spec.ts +++ /dev/null @@ -1,250 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { PaginationComponent } from './pagination.component'; - -describe('PaginationComponent', () => { - let fixture: ComponentFixture; - let component: PaginationComponent; - - function prevBtn(): HTMLButtonElement { - return fixture.nativeElement.querySelector( - '[data-testid="pagination-prev"]', - ) as HTMLButtonElement; - } - - function nextBtn(): HTMLButtonElement { - return fixture.nativeElement.querySelector( - '[data-testid="pagination-next"]', - ) as HTMLButtonElement; - } - - function pageBtn(page: number): HTMLButtonElement { - return fixture.nativeElement.querySelector( - `[data-testid="pagination-page-${page}"]`, - ) as HTMLButtonElement; - } - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [PaginationComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(PaginationComponent); - component = fixture.componentInstance; - }); - - it('should create', () => { - fixture.componentRef.setInput('total', 50); - fixture.componentRef.setInput('page', 1); - fixture.detectChanges(); - expect(component).toBeTruthy(); - }); - - it('should render a nav element with default aria-label', () => { - fixture.componentRef.setInput('total', 50); - fixture.componentRef.setInput('page', 1); - fixture.detectChanges(); - - const nav = fixture.nativeElement.querySelector('nav'); - expect(nav.getAttribute('aria-label')).toBe('Pagination'); - }); - - it('should apply custom ariaLabel when provided', () => { - fixture.componentRef.setInput('total', 50); - fixture.componentRef.setInput('page', 1); - fixture.componentRef.setInput('ariaLabel', 'Results navigation'); - fixture.detectChanges(); - - const nav = fixture.nativeElement.querySelector('nav'); - expect(nav.getAttribute('aria-label')).toBe('Results navigation'); - }); - - it('should apply testId to nav when testId input is set', () => { - fixture.componentRef.setInput('total', 50); - fixture.componentRef.setInput('page', 1); - fixture.componentRef.setInput('testId', 'my-pagination'); - fixture.detectChanges(); - - const nav = fixture.nativeElement.querySelector( - '[data-testid="my-pagination"]', - ); - expect(nav).toBeTruthy(); - }); - - describe('prev/next button disabled state', () => { - it('should disable prev button when on first page', () => { - fixture.componentRef.setInput('total', 50); - fixture.componentRef.setInput('page', 1); - fixture.detectChanges(); - - expect(prevBtn().disabled).toBe(true); - }); - - it('should enable prev button when not on first page', () => { - fixture.componentRef.setInput('total', 50); - fixture.componentRef.setInput('page', 2); - fixture.detectChanges(); - - expect(prevBtn().disabled).toBe(false); - }); - - it('should disable next button when on last page', () => { - fixture.componentRef.setInput('total', 30); - fixture.componentRef.setInput('pageSize', 10); - fixture.componentRef.setInput('page', 3); - fixture.detectChanges(); - - expect(nextBtn().disabled).toBe(true); - }); - - it('should enable next button when not on last page', () => { - fixture.componentRef.setInput('total', 30); - fixture.componentRef.setInput('pageSize', 10); - fixture.componentRef.setInput('page', 2); - fixture.detectChanges(); - - expect(nextBtn().disabled).toBe(false); - }); - }); - - describe('aria-current on current page button', () => { - it('should mark current page button with aria-current=page', () => { - fixture.componentRef.setInput('total', 50); - fixture.componentRef.setInput('pageSize', 10); - fixture.componentRef.setInput('page', 3); - fixture.detectChanges(); - - const currentBtn = pageBtn(3); - expect(currentBtn.getAttribute('aria-current')).toBe('page'); - }); - - it('should not mark other page buttons with aria-current', () => { - fixture.componentRef.setInput('total', 50); - fixture.componentRef.setInput('pageSize', 10); - fixture.componentRef.setInput('page', 3); - fixture.detectChanges(); - - const page1 = pageBtn(1); - expect(page1.getAttribute('aria-current')).toBeNull(); - }); - }); - - describe('page model update', () => { - it('should advance page model when next button is clicked', () => { - fixture.componentRef.setInput('total', 50); - fixture.componentRef.setInput('pageSize', 10); - fixture.componentRef.setInput('page', 2); - fixture.detectChanges(); - - nextBtn().click(); - fixture.detectChanges(); - - expect(component.page()).toBe(3); - }); - - it('should decrement page model when prev button is clicked', () => { - fixture.componentRef.setInput('total', 50); - fixture.componentRef.setInput('pageSize', 10); - fixture.componentRef.setInput('page', 3); - fixture.detectChanges(); - - prevBtn().click(); - fixture.detectChanges(); - - expect(component.page()).toBe(2); - }); - - it('should set page model to clicked page number', () => { - fixture.componentRef.setInput('total', 50); - fixture.componentRef.setInput('pageSize', 10); - fixture.componentRef.setInput('page', 1); - fixture.detectChanges(); - - pageBtn(4).click(); - fixture.detectChanges(); - - expect(component.page()).toBe(4); - }); - - it('should not change page model when clicking the already-current page', () => { - fixture.componentRef.setInput('total', 50); - fixture.componentRef.setInput('pageSize', 10); - fixture.componentRef.setInput('page', 2); - fixture.detectChanges(); - - pageBtn(2).click(); - fixture.detectChanges(); - - expect(component.page()).toBe(2); - }); - }); - - describe('token window and gaps', () => { - it('should render all page buttons when total pages is small', () => { - fixture.componentRef.setInput('total', 30); - fixture.componentRef.setInput('pageSize', 10); - fixture.componentRef.setInput('page', 2); - fixture.detectChanges(); - - expect(pageBtn(1)).toBeTruthy(); - expect(pageBtn(2)).toBeTruthy(); - expect(pageBtn(3)).toBeTruthy(); - }); - - it('should render gap ellipsis when pages exceed visible window', () => { - fixture.componentRef.setInput('total', 100); - fixture.componentRef.setInput('pageSize', 10); - fixture.componentRef.setInput('page', 5); - fixture.detectChanges(); - - const gaps = fixture.nativeElement.querySelectorAll( - 'span[aria-hidden="true"]', - ); - expect(gaps.length).toBeGreaterThan(0); - }); - - it('should always render first and last page buttons when gap is shown', () => { - fixture.componentRef.setInput('total', 100); - fixture.componentRef.setInput('pageSize', 10); - fixture.componentRef.setInput('page', 5); - fixture.detectChanges(); - - expect(pageBtn(1)).toBeTruthy(); - expect(pageBtn(10)).toBeTruthy(); - }); - }); - - describe('pageSize input', () => { - it('should compute correct pageCount from total and pageSize', () => { - fixture.componentRef.setInput('total', 25); - fixture.componentRef.setInput('pageSize', 10); - fixture.componentRef.setInput('page', 1); - fixture.detectChanges(); - - // 3 pages: 10 + 10 + 5 - expect(pageBtn(3)).toBeTruthy(); - expect(pageBtn(4)).toBeNull(); - }); - }); - - describe('aria-label on prev/next buttons', () => { - it('should use default prev/next labels', () => { - fixture.componentRef.setInput('total', 50); - fixture.componentRef.setInput('page', 2); - fixture.detectChanges(); - - expect(prevBtn().getAttribute('aria-label')).toBe('Previous page'); - expect(nextBtn().getAttribute('aria-label')).toBe('Next page'); - }); - - it('should use custom prev/next labels when provided', () => { - fixture.componentRef.setInput('total', 50); - fixture.componentRef.setInput('page', 2); - fixture.componentRef.setInput('prevLabel', 'Go back'); - fixture.componentRef.setInput('nextLabel', 'Go forward'); - fixture.detectChanges(); - - expect(prevBtn().getAttribute('aria-label')).toBe('Go back'); - expect(nextBtn().getAttribute('aria-label')).toBe('Go forward'); - }); - }); -}); diff --git a/frontend/src/app/shared/components/pagination/pagination.component.ts b/frontend/src/app/shared/components/pagination/pagination.component.ts deleted file mode 100644 index e09a3b05..00000000 --- a/frontend/src/app/shared/components/pagination/pagination.component.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { - Component, - ChangeDetectionStrategy, - input, - model, - computed, -} from '@angular/core'; - -type PageToken = number | 'gap'; - -/** - * DS pagination — `ui-page`. Prev/next + a computed numbered window with `…` - * gaps. `page` is a 1-based two-way model. The current page is marked with - * `aria-current=page` and a brand-muted chip (text-brand-strong for AA). - */ -@Component({ - selector: 'app-pagination', - template: ` - - `, - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class PaginationComponent { - readonly total = input.required(); - readonly pageSize = input(10); - readonly page = model.required(); - /** Number of sibling pages to show on each side of the current page. */ - readonly siblings = input(1); - readonly ariaLabel = input('Pagination'); - readonly prevLabel = input('Previous page'); - readonly nextLabel = input('Next page'); - readonly testId = input(null); - - protected readonly pageCount = computed(() => - Math.max(1, Math.ceil(this.total() / Math.max(1, this.pageSize()))), - ); - - protected readonly tokens = computed(() => { - const count = this.pageCount(); - const current = Math.min(Math.max(1, this.page()), count); - const sib = Math.max(0, this.siblings()); - - // boundary (1) + boundary (last) + current + 2*siblings + 2 gaps - const slots = sib * 2 + 5; - if (count <= slots) { - return Array.from({ length: count }, (_, i) => i + 1); - } - - const left = Math.max(current - sib, 2); - const right = Math.min(current + sib, count - 1); - const showLeftGap = left > 2; - const showRightGap = right < count - 1; - - const result: PageToken[] = [1]; - if (showLeftGap) { - result.push('gap'); - } else { - for (let p = 2; p < left; p++) { - result.push(p); - } - } - for (let p = left; p <= right; p++) { - result.push(p); - } - if (showRightGap) { - result.push('gap'); - } else { - for (let p = right + 1; p < count; p++) { - result.push(p); - } - } - result.push(count); - return result; - }); - - protected go(target: number): void { - const clamped = Math.min(Math.max(1, target), this.pageCount()); - if (clamped !== this.page()) { - this.page.set(clamped); - } - } -} diff --git a/frontend/src/app/shared/components/progress/progress.component.spec.ts b/frontend/src/app/shared/components/progress/progress.component.spec.ts deleted file mode 100644 index aafe4d84..00000000 --- a/frontend/src/app/shared/components/progress/progress.component.spec.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ProgressComponent } from './progress.component'; - -describe('ProgressComponent', () => { - let fixture: ComponentFixture; - let component: ProgressComponent; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ProgressComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(ProgressComponent); - component = fixture.componentInstance; - }); - - it('should create', () => { - fixture.componentRef.setInput('value', 50); - fixture.detectChanges(); - expect(component).toBeTruthy(); - }); - - describe('ARIA progressbar contract', () => { - it('should render an element with role progressbar', () => { - fixture.componentRef.setInput('value', 40); - fixture.detectChanges(); - - const bar = fixture.nativeElement.querySelector( - '[role="progressbar"]', - ) as HTMLElement; - expect(bar).toBeTruthy(); - }); - - it('should set aria-valuenow to the clamped value', () => { - fixture.componentRef.setInput('value', 75); - fixture.detectChanges(); - - const bar = fixture.nativeElement.querySelector( - '[role="progressbar"]', - ) as HTMLElement; - expect(bar.getAttribute('aria-valuenow')).toBe('75'); - }); - - it('should set aria-valuemin to 0 and aria-valuemax to 100', () => { - fixture.componentRef.setInput('value', 50); - fixture.detectChanges(); - - const bar = fixture.nativeElement.querySelector( - '[role="progressbar"]', - ) as HTMLElement; - expect(bar.getAttribute('aria-valuemin')).toBe('0'); - expect(bar.getAttribute('aria-valuemax')).toBe('100'); - }); - - it('should use default aria-label "Progress" when label input is null', () => { - fixture.componentRef.setInput('value', 30); - fixture.componentRef.setInput('label', null); - fixture.detectChanges(); - - const bar = fixture.nativeElement.querySelector( - '[role="progressbar"]', - ) as HTMLElement; - expect(bar.getAttribute('aria-label')).toBe('Progress'); - }); - - it('should use provided label for aria-label when label input is set', () => { - fixture.componentRef.setInput('value', 60); - fixture.componentRef.setInput('label', 'Upload progress'); - fixture.detectChanges(); - - const bar = fixture.nativeElement.querySelector( - '[role="progressbar"]', - ) as HTMLElement; - expect(bar.getAttribute('aria-label')).toBe('Upload progress'); - }); - }); - - describe('value clamping', () => { - it('should clamp value to 0 when value input is negative', () => { - fixture.componentRef.setInput('value', -10); - fixture.detectChanges(); - - expect(component['clamped']()).toBe(0); - const bar = fixture.nativeElement.querySelector( - '[role="progressbar"]', - ) as HTMLElement; - expect(bar.getAttribute('aria-valuenow')).toBe('0'); - }); - - it('should clamp value to 100 when value input exceeds 100', () => { - fixture.componentRef.setInput('value', 150); - fixture.detectChanges(); - - expect(component['clamped']()).toBe(100); - const bar = fixture.nativeElement.querySelector( - '[role="progressbar"]', - ) as HTMLElement; - expect(bar.getAttribute('aria-valuenow')).toBe('100'); - }); - - it('should pass through value unchanged when value is within 0-100', () => { - fixture.componentRef.setInput('value', 42); - fixture.detectChanges(); - - expect(component['clamped']()).toBe(42); - }); - }); - - describe('fill width', () => { - it('should set fill div width to 60% when value is 60', () => { - fixture.componentRef.setInput('value', 60); - fixture.detectChanges(); - - const fill = fixture.nativeElement.querySelector( - '[role="progressbar"] div', - ) as HTMLElement; - expect(fill.style.width).toBe('60%'); - }); - - it('should set fill div width to 0% when value is clamped to 0', () => { - fixture.componentRef.setInput('value', -5); - fixture.detectChanges(); - - const fill = fixture.nativeElement.querySelector( - '[role="progressbar"] div', - ) as HTMLElement; - expect(fill.style.width).toBe('0%'); - }); - - it('should set fill div width to 100% when value is clamped to 100', () => { - fixture.componentRef.setInput('value', 200); - fixture.detectChanges(); - - const fill = fixture.nativeElement.querySelector( - '[role="progressbar"] div', - ) as HTMLElement; - expect(fill.style.width).toBe('100%'); - }); - }); - - describe('tone classes', () => { - it('should apply bg-brand fill class when tone is brand', () => { - fixture.componentRef.setInput('value', 50); - fixture.componentRef.setInput('tone', 'brand'); - fixture.detectChanges(); - - const fill = fixture.nativeElement.querySelector( - '[role="progressbar"] div', - ) as HTMLElement; - expect(fill.className).toContain('bg-brand'); - }); - - it('should apply bg-success fill class when tone is success', () => { - fixture.componentRef.setInput('value', 50); - fixture.componentRef.setInput('tone', 'success'); - fixture.detectChanges(); - - const fill = fixture.nativeElement.querySelector( - '[role="progressbar"] div', - ) as HTMLElement; - expect(fill.className).toContain('bg-success'); - }); - - it('should apply bg-warn fill class when tone is warn', () => { - fixture.componentRef.setInput('value', 50); - fixture.componentRef.setInput('tone', 'warn'); - fixture.detectChanges(); - - const fill = fixture.nativeElement.querySelector( - '[role="progressbar"] div', - ) as HTMLElement; - expect(fill.className).toContain('bg-warn'); - }); - - it('should default tone to brand when tone input is not provided', () => { - fixture.componentRef.setInput('value', 50); - fixture.detectChanges(); - - expect(component.tone()).toBe('brand'); - const fill = fixture.nativeElement.querySelector( - '[role="progressbar"] div', - ) as HTMLElement; - expect(fill.className).toContain('bg-brand'); - }); - }); - - describe('testId', () => { - it('should set data-testid on the progressbar element when testId is provided', () => { - fixture.componentRef.setInput('value', 70); - fixture.componentRef.setInput('testId', 'upload-progress'); - fixture.detectChanges(); - - const el = fixture.nativeElement.querySelector( - '[data-testid="upload-progress"]', - ); - expect(el).toBeTruthy(); - }); - - it('should not render data-testid when testId is null', () => { - fixture.componentRef.setInput('value', 70); - fixture.componentRef.setInput('testId', null); - fixture.detectChanges(); - - const el = fixture.nativeElement.querySelector('[data-testid]'); - expect(el).toBeNull(); - }); - }); -}); diff --git a/frontend/src/app/shared/components/progress/progress.component.ts b/frontend/src/app/shared/components/progress/progress.component.ts deleted file mode 100644 index 51960df9..00000000 --- a/frontend/src/app/shared/components/progress/progress.component.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { - Component, - ChangeDetectionStrategy, - input, - computed, -} from '@angular/core'; - -export type ProgressTone = 'brand' | 'success' | 'warn'; - -const FILL_CLASSES: Record = { - brand: 'bg-brand', - success: 'bg-success', - warn: 'bg-warn', -}; - -/** - * DS progress bar — `ui-progress`. Thin track with an animated fill whose - * colour follows the tone. Exposes the full progressbar ARIA contract. - */ -@Component({ - selector: 'app-progress', - template: ` -
-
-
- `, - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class ProgressComponent { - readonly value = input.required(); - readonly tone = input('brand'); - readonly label = input(null); - readonly testId = input(null); - - protected readonly clamped = computed(() => - Math.max(0, Math.min(100, this.value())), - ); - - protected readonly fillClasses = computed(() => FILL_CLASSES[this.tone()]); -} diff --git a/frontend/src/app/shared/components/radio/radio.component.spec.ts b/frontend/src/app/shared/components/radio/radio.component.spec.ts deleted file mode 100644 index 7fc21295..00000000 --- a/frontend/src/app/shared/components/radio/radio.component.spec.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { Component, signal } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { RadioComponent } from './radio.component'; - -// RadioComponent uses ng-content for its label and requires value + name inputs. -// Use a host component with multiple radios sharing the same groupValue model. - -@Component({ - imports: [RadioComponent], - template: ` - - Apple - - - Banana - - `, - standalone: true, -}) -class HostRadioComponent { - readonly selected = signal(''); - readonly isDisabled = signal(false); -} - -describe('RadioComponent', () => { - let hostFixture: ComponentFixture; - let host: HostRadioComponent; - - function appleInput(): HTMLInputElement { - return hostFixture.nativeElement.querySelector( - '[data-testid="radio-apple"]', - ) as HTMLInputElement; - } - - function bananaInput(): HTMLInputElement { - return hostFixture.nativeElement.querySelector( - '[data-testid="radio-banana"]', - ) as HTMLInputElement; - } - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [HostRadioComponent], - }).compileComponents(); - - hostFixture = TestBed.createComponent(HostRadioComponent); - host = hostFixture.componentInstance; - await hostFixture.whenStable(); - }); - - it('should create both radio elements', () => { - expect(appleInput()).toBeTruthy(); - expect(bananaInput()).toBeTruthy(); - }); - - describe('groupValue model', () => { - it('should render both radios as unchecked when groupValue is empty', () => { - expect(appleInput().checked).toBe(false); - expect(bananaInput().checked).toBe(false); - }); - - it('should render apple radio as checked when groupValue is apple', async () => { - host.selected.set('apple'); - await hostFixture.whenStable(); - expect(appleInput().checked).toBe(true); - expect(bananaInput().checked).toBe(false); - }); - - it('should render banana radio as checked when groupValue is banana', async () => { - host.selected.set('banana'); - await hostFixture.whenStable(); - expect(bananaInput().checked).toBe(true); - expect(appleInput().checked).toBe(false); - }); - - it('should update groupValue to apple when apple radio fires change event', async () => { - appleInput().dispatchEvent(new Event('change')); - await hostFixture.whenStable(); - expect(host.selected()).toBe('apple'); - }); - - it('should update groupValue to banana when banana radio fires change event', async () => { - host.selected.set('apple'); - await hostFixture.whenStable(); - bananaInput().dispatchEvent(new Event('change')); - await hostFixture.whenStable(); - expect(host.selected()).toBe('banana'); - }); - - it('should switch selection from apple to banana when banana change event fires', async () => { - host.selected.set('apple'); - await hostFixture.whenStable(); - bananaInput().dispatchEvent(new Event('change')); - await hostFixture.whenStable(); - expect(host.selected()).toBe('banana'); - }); - }); - - describe('name input', () => { - it('should set the name attribute on the native input', () => { - expect(appleInput().name).toBe('fruit'); - expect(bananaInput().name).toBe('fruit'); - }); - }); - - describe('value input', () => { - it('should set the value attribute on the native input', () => { - expect(appleInput().value).toBe('apple'); - expect(bananaInput().value).toBe('banana'); - }); - }); - - describe('disabled input', () => { - it('should not be disabled by default', () => { - expect(appleInput().disabled).toBe(false); - }); - - it('should disable the native input when disabled is true', async () => { - host.isDisabled.set(true); - await hostFixture.whenStable(); - expect(appleInput().disabled).toBe(true); - }); - - it('should not affect banana radio when only apple is disabled', async () => { - host.isDisabled.set(true); - await hostFixture.whenStable(); - expect(bananaInput().disabled).toBe(false); - }); - }); - - describe('content projection', () => { - it('should render projected label text inside each label element', () => { - const labels = hostFixture.nativeElement.querySelectorAll( - 'label', - ) as NodeListOf; - const texts = Array.from(labels).map((l) => l.textContent?.trim()); - expect(texts).toContain('Apple'); - expect(texts).toContain('Banana'); - }); - }); - - describe('label wrapping', () => { - it('should wrap each input inside its label for implicit association', () => { - const labels = hostFixture.nativeElement.querySelectorAll( - 'label', - ) as NodeListOf; - expect(labels[0].contains(appleInput())).toBe(true); - expect(labels[1].contains(bananaInput())).toBe(true); - }); - }); - - describe('input type', () => { - it('should render inputs with type radio', () => { - expect(appleInput().type).toBe('radio'); - expect(bananaInput().type).toBe('radio'); - }); - }); -}); diff --git a/frontend/src/app/shared/components/radio/radio.component.ts b/frontend/src/app/shared/components/radio/radio.component.ts deleted file mode 100644 index d5770649..00000000 --- a/frontend/src/app/shared/components/radio/radio.component.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { - Component, - ChangeDetectionStrategy, - input, - model, - computed, -} from '@angular/core'; - -/** - * DS radio — `ui-radio`. Visually-hidden native input + custom round box with - * an inner dot when selected. Group several by sharing `name` and binding the - * same two-way `groupValue` model; each radio carries its own `value`. - */ -@Component({ - selector: 'app-radio', - template: ` - - `, - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class RadioComponent { - readonly groupValue = model(''); - readonly value = input.required(); - readonly name = input.required(); - readonly disabled = input(false); - readonly testId = input(null); - - protected readonly selected = computed( - () => this.groupValue() === this.value(), - ); - - protected onChange(): void { - this.groupValue.set(this.value()); - } -} diff --git a/frontend/src/app/shared/components/segmented/segmented.component.spec.ts b/frontend/src/app/shared/components/segmented/segmented.component.spec.ts deleted file mode 100644 index 7ee51ab6..00000000 --- a/frontend/src/app/shared/components/segmented/segmented.component.spec.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { Component, signal } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { SegmentedComponent, SegmentedItem } from './segmented.component'; - -const ITEMS: readonly SegmentedItem[] = [ - { id: 'day', label: 'Day' }, - { id: 'week', label: 'Week' }, - { id: 'month', label: 'Month' }, -]; - -const ITEMS_WITH_DISABLED: readonly SegmentedItem[] = [ - { id: 'a', label: 'Alpha' }, - { id: 'b', label: 'Beta', disabled: true }, - { id: 'c', label: 'Gamma' }, -]; - -// --------------------------------------------------------------------------- -// Host for standard tests -// --------------------------------------------------------------------------- -@Component({ - standalone: true, - imports: [SegmentedComponent], - template: ` - - `, -}) -class HostComponent { - readonly items = signal(ITEMS); - readonly active = signal('day'); - readonly ariaLabel = signal('View mode'); - readonly testId = signal('seg-test'); -} - -// --------------------------------------------------------------------------- -// Host for disabled-item tests — uses ITEMS_WITH_DISABLED from construction -// --------------------------------------------------------------------------- -@Component({ - standalone: true, - imports: [SegmentedComponent], - template: ` `, -}) -class DisabledHostComponent { - readonly items = signal(ITEMS_WITH_DISABLED); - readonly active = signal('a'); -} - -describe('SegmentedComponent', () => { - let hostFixture: ComponentFixture; - let host: HostComponent; - - beforeEach(async () => { - // Import both host components in one configureTestingModule call so nested - // beforeEach blocks can create DisabledHostComponent without re-configuring. - await TestBed.configureTestingModule({ - imports: [HostComponent, DisabledHostComponent], - }).compileComponents(); - - hostFixture = TestBed.createComponent(HostComponent); - host = hostFixture.componentInstance; - await hostFixture.whenStable(); - }); - - it('should create', () => { - expect(host).toBeTruthy(); - }); - - it('should render all segment buttons when items are provided', () => { - const buttons = hostFixture.nativeElement.querySelectorAll('button'); - expect(buttons.length).toBe(3); - }); - - it('should apply aria-label to the group when ariaLabel input is set', () => { - const group = hostFixture.nativeElement.querySelector('[role="group"]'); - expect(group.getAttribute('aria-label')).toBe('View mode'); - }); - - it('should apply testId to the container when testId input is set', () => { - const group = hostFixture.nativeElement.querySelector( - '[data-testid="seg-test"]', - ); - expect(group).toBeTruthy(); - }); - - it('should mark the active segment with aria-pressed true', () => { - const dayBtn = hostFixture.nativeElement.querySelector( - '[data-testid="segmented-day"]', - ); - expect(dayBtn.getAttribute('aria-pressed')).toBe('true'); - }); - - it('should mark inactive segments with aria-pressed false', () => { - const weekBtn = hostFixture.nativeElement.querySelector( - '[data-testid="segmented-week"]', - ); - expect(weekBtn.getAttribute('aria-pressed')).toBe('false'); - }); - - it('should update active model when a segment button is clicked', async () => { - const weekBtn = hostFixture.nativeElement.querySelector( - '[data-testid="segmented-week"]', - ) as HTMLButtonElement; - weekBtn.click(); - await hostFixture.whenStable(); - - expect(host.active()).toBe('week'); - }); - - it('should reflect model change back to aria-pressed after click', async () => { - const weekBtn = hostFixture.nativeElement.querySelector( - '[data-testid="segmented-week"]', - ) as HTMLButtonElement; - weekBtn.click(); - await hostFixture.whenStable(); - - expect(weekBtn.getAttribute('aria-pressed')).toBe('true'); - - const dayBtn = hostFixture.nativeElement.querySelector( - '[data-testid="segmented-day"]', - ); - expect(dayBtn.getAttribute('aria-pressed')).toBe('false'); - }); - - it('should render each segment with data-testid based on item id', () => { - for (const item of ITEMS) { - const btn = hostFixture.nativeElement.querySelector( - `[data-testid="segmented-${item.id}"]`, - ); - expect(btn).toBeTruthy(); - expect(btn.textContent.trim()).toBe(item.label); - } - }); - - describe('disabled segment', () => { - let disabledFixture: ComponentFixture; - let disabledHost: DisabledHostComponent; - - beforeEach(async () => { - // TestBed is already configured by the outer beforeEach — create fixture only. - disabledFixture = TestBed.createComponent(DisabledHostComponent); - disabledHost = disabledFixture.componentInstance; - await disabledFixture.whenStable(); - }); - - it('should disable the disabled segment button', () => { - const betaBtn = disabledFixture.nativeElement.querySelector( - '[data-testid="segmented-b"]', - ) as HTMLButtonElement; - expect(betaBtn).toBeTruthy(); - expect(betaBtn.disabled).toBe(true); - }); - - it('should not change active when a disabled segment is clicked', async () => { - const betaBtn = disabledFixture.nativeElement.querySelector( - '[data-testid="segmented-b"]', - ) as HTMLButtonElement; - betaBtn.click(); - await disabledFixture.whenStable(); - - expect(disabledHost.active()).toBe('a'); - }); - }); -}); diff --git a/frontend/src/app/shared/components/segmented/segmented.component.ts b/frontend/src/app/shared/components/segmented/segmented.component.ts deleted file mode 100644 index 8ac0ba15..00000000 --- a/frontend/src/app/shared/components/segmented/segmented.component.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { - Component, - ChangeDetectionStrategy, - input, - model, -} from '@angular/core'; - -export interface SegmentedItem { - readonly id: string; - readonly label: string; - readonly disabled?: boolean; -} - -/** - * DS enclosed segmented control — `ui-segmented`. Signal-driven: `active` is a - * two-way model. Renders a `role=group` of toggle buttons; the active segment - * gets the overlay surface via `aria-pressed`. - */ -@Component({ - selector: 'app-segmented', - template: ` -
- @for (item of items(); track item.id) { - - } -
- `, - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class SegmentedComponent { - readonly items = input.required(); - readonly active = model.required(); - readonly ariaLabel = input(null); - readonly testId = input(null); - - protected select(item: SegmentedItem): void { - if (!item.disabled) { - this.active.set(item.id); - } - } -} diff --git a/frontend/src/app/shared/components/select/select.component.spec.ts b/frontend/src/app/shared/components/select/select.component.spec.ts deleted file mode 100644 index f3ad344c..00000000 --- a/frontend/src/app/shared/components/select/select.component.spec.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { Component } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { SelectComponent, SelectOption } from './select.component'; - -const MOCK_OPTIONS: SelectOption[] = [ - { value: 'apple', label: 'Apple' }, - { value: 'banana', label: 'Banana' }, - { value: 'cherry', label: 'Cherry' }, -]; - -describe('SelectComponent', () => { - let fixture: ComponentFixture; - let component: SelectComponent; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [SelectComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(SelectComponent); - component = fixture.componentInstance; - fixture.componentRef.setInput('options', MOCK_OPTIONS); - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - describe('options rendering', () => { - it('should render one option element per option when options are provided', () => { - const optionEls = fixture.nativeElement.querySelectorAll( - 'option', - ) as NodeListOf; - expect(optionEls.length).toBe(3); - }); - - it('should render option labels as text content', () => { - const optionEls = Array.from( - fixture.nativeElement.querySelectorAll( - 'option', - ) as NodeListOf, - ); - const labels = optionEls.map((o) => o.textContent?.trim()); - expect(labels).toContain('Apple'); - expect(labels).toContain('Banana'); - expect(labels).toContain('Cherry'); - }); - - it('should render option values correctly', () => { - const optionEls = Array.from( - fixture.nativeElement.querySelectorAll( - 'option', - ) as NodeListOf, - ); - const values = optionEls.map((o) => o.value); - expect(values).toContain('apple'); - expect(values).toContain('banana'); - expect(values).toContain('cherry'); - }); - }); - - describe('placeholder input', () => { - it('should not render a placeholder option when placeholder is not set', () => { - const optionEls = fixture.nativeElement.querySelectorAll( - 'option', - ) as NodeListOf; - expect(optionEls.length).toBe(3); - }); - - it('should render a disabled placeholder option when placeholder is provided', () => { - fixture.componentRef.setInput('placeholder', 'Choose a fruit'); - fixture.detectChanges(); - const optionEls = fixture.nativeElement.querySelectorAll( - 'option', - ) as NodeListOf; - // placeholder + 3 real options - expect(optionEls.length).toBe(4); - const placeholder = optionEls[0]; - expect(placeholder.disabled).toBe(true); - expect(placeholder.textContent?.trim()).toBe('Choose a fruit'); - }); - }); - - describe('value model', () => { - it('should reflect set value on the native select when value is set', () => { - fixture.componentRef.setInput('value', 'banana'); - fixture.detectChanges(); - const select = fixture.nativeElement.querySelector( - 'select', - ) as HTMLSelectElement; - expect(select.value).toBe('banana'); - }); - - it('should update value model when user changes selection', () => { - const select = fixture.nativeElement.querySelector( - 'select', - ) as HTMLSelectElement; - select.value = 'cherry'; - select.dispatchEvent(new Event('change')); - expect(component.value()).toBe('cherry'); - }); - }); - - describe('disabled input', () => { - it('should not be disabled by default', () => { - const select = fixture.nativeElement.querySelector( - 'select', - ) as HTMLSelectElement; - expect(select.disabled).toBe(false); - }); - - it('should disable the native select when disabled is true', () => { - fixture.componentRef.setInput('disabled', true); - fixture.detectChanges(); - const select = fixture.nativeElement.querySelector( - 'select', - ) as HTMLSelectElement; - expect(select.disabled).toBe(true); - }); - }); - - describe('invalid input', () => { - it('should not have aria-invalid when invalid is false', () => { - const select = fixture.nativeElement.querySelector( - 'select', - ) as HTMLSelectElement; - expect(select.getAttribute('aria-invalid')).toBeNull(); - }); - - it('should set aria-invalid when invalid is true', () => { - fixture.componentRef.setInput('invalid', true); - fixture.detectChanges(); - const select = fixture.nativeElement.querySelector( - 'select', - ) as HTMLSelectElement; - expect(select.getAttribute('aria-invalid')).toBe('true'); - }); - }); - - describe('ariaLabel input', () => { - it('should not set aria-label when ariaLabel is null and no placeholder', () => { - const select = fixture.nativeElement.querySelector( - 'select', - ) as HTMLSelectElement; - expect(select.getAttribute('aria-label')).toBeNull(); - }); - - it('should set aria-label when ariaLabel is provided', () => { - fixture.componentRef.setInput('ariaLabel', 'Fruit selector'); - fixture.detectChanges(); - const select = fixture.nativeElement.querySelector( - 'select', - ) as HTMLSelectElement; - expect(select.getAttribute('aria-label')).toBe('Fruit selector'); - }); - - it('should fall back to placeholder as aria-label when ariaLabel is null', () => { - fixture.componentRef.setInput('placeholder', 'Pick one'); - fixture.detectChanges(); - const select = fixture.nativeElement.querySelector( - 'select', - ) as HTMLSelectElement; - expect(select.getAttribute('aria-label')).toBe('Pick one'); - }); - }); - - describe('testId input', () => { - it('should not set data-testid when testId is null', () => { - const select = fixture.nativeElement.querySelector( - 'select', - ) as HTMLSelectElement; - expect(select.getAttribute('data-testid')).toBeNull(); - }); - - it('should set data-testid when testId is provided', () => { - fixture.componentRef.setInput('testId', 'fruit-select'); - fixture.detectChanges(); - const select = fixture.nativeElement.querySelector( - '[data-testid="fruit-select"]', - ); - expect(select).toBeTruthy(); - }); - }); - - describe('size input', () => { - it('should apply md size classes by default', () => { - const select = fixture.nativeElement.querySelector( - 'select', - ) as HTMLSelectElement; - expect(select.className).toContain('pl-2.5'); - expect(select.className).toContain('py-2'); - }); - - it('should apply sm size classes when size is sm', () => { - fixture.componentRef.setInput('size', 'sm'); - fixture.detectChanges(); - const select = fixture.nativeElement.querySelector( - 'select', - ) as HTMLSelectElement; - expect(select.className).toContain('pl-2'); - expect(select.className).toContain('py-1.5'); - }); - - it('should apply lg size classes when size is lg', () => { - fixture.componentRef.setInput('size', 'lg'); - fixture.detectChanges(); - const select = fixture.nativeElement.querySelector( - 'select', - ) as HTMLSelectElement; - expect(select.className).toContain('pl-3'); - expect(select.className).toContain('py-2.5'); - }); - }); -}); - -// Host-based integration test for two-way model binding -@Component({ - imports: [SelectComponent], - template: ``, - standalone: true, -}) -class HostSelectComponent { - fruit = 'apple'; - readonly opts: SelectOption[] = MOCK_OPTIONS; -} - -describe('SelectComponent (host integration)', () => { - let hostFixture: ComponentFixture; - let host: HostSelectComponent; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [HostSelectComponent], - }).compileComponents(); - - hostFixture = TestBed.createComponent(HostSelectComponent); - host = hostFixture.componentInstance; - hostFixture.detectChanges(); - }); - - it('should reflect host value on the native select via two-way binding', () => { - const select = hostFixture.nativeElement.querySelector( - '[data-testid="host-select"]', - ) as HTMLSelectElement; - expect(select.value).toBe('apple'); - }); - - it('should update host property when native change event fires', () => { - const select = hostFixture.nativeElement.querySelector( - '[data-testid="host-select"]', - ) as HTMLSelectElement; - select.value = 'cherry'; - select.dispatchEvent(new Event('change')); - expect(host.fruit).toBe('cherry'); - }); -}); diff --git a/frontend/src/app/shared/components/select/select.component.ts b/frontend/src/app/shared/components/select/select.component.ts deleted file mode 100644 index a427f920..00000000 --- a/frontend/src/app/shared/components/select/select.component.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { - Component, - ChangeDetectionStrategy, - input, - model, - computed, -} from '@angular/core'; - -export interface SelectOption { - readonly value: string; - readonly label: string; -} - -export type SelectSize = 'sm' | 'md' | 'lg'; - -const SIZE_CLASSES: Record = { - sm: 'py-1.5 pl-2 pr-8 text-xs', - md: 'py-2 pl-2.5 pr-9 text-[13px]', - lg: 'py-2.5 pl-3 pr-9 text-sm', -}; - -/** - * DS native select — `ui-select`. A styled native ` - @if (placeholder()) { - - } - @for (opt of options(); track opt.value) { - - } - - -
- `, - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class SelectComponent { - readonly value = model(''); - readonly options = input([]); - readonly placeholder = input(''); - readonly ariaLabel = input(null); - readonly disabled = input(false); - readonly invalid = input(false); - readonly size = input('md'); - readonly testId = input(null); - - protected readonly classes = computed(() => - [ - 'w-full appearance-none rounded-sm border border-border bg-elevated text-fg font-sans leading-normal', - 'transition-colors duration-[120ms] cursor-pointer', - 'hover:border-border-strong focus:border-brand focus:bg-panel focus:outline-hidden', - 'aria-invalid:border-error', - 'disabled:opacity-40 disabled:cursor-not-allowed', - SIZE_CLASSES[this.size()], - ].join(' '), - ); - - protected onChange(event: Event): void { - this.value.set((event.target as HTMLSelectElement).value); - } -} diff --git a/frontend/src/app/shared/components/separator/separator.component.spec.ts b/frontend/src/app/shared/components/separator/separator.component.spec.ts deleted file mode 100644 index 5217cafc..00000000 --- a/frontend/src/app/shared/components/separator/separator.component.spec.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { SeparatorComponent } from './separator.component'; - -describe('SeparatorComponent', () => { - let fixture: ComponentFixture; - let component: SeparatorComponent; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [SeparatorComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(SeparatorComponent); - component = fixture.componentInstance; - }); - - it('should create', () => { - fixture.detectChanges(); - expect(component).toBeTruthy(); - }); - - it('should render a data-testid="separator" element', () => { - fixture.detectChanges(); - const el = fixture.nativeElement.querySelector('[data-testid="separator"]'); - expect(el).toBeTruthy(); - }); - - describe('horizontal (default)', () => { - it('should have role="separator" and aria-orientation="horizontal" when no label', () => { - fixture.detectChanges(); - const el = fixture.nativeElement.querySelector( - '[data-testid="separator"]', - ) as HTMLElement; - expect(el.getAttribute('role')).toBe('separator'); - expect(el.getAttribute('aria-orientation')).toBe('horizontal'); - }); - - it('should apply the horizontal line class when orientation is "h"', () => { - fixture.detectChanges(); - const el = fixture.nativeElement.querySelector( - '[data-testid="separator"]', - ) as HTMLElement; - expect(el.className).toContain('h-px'); - expect(el.className).toContain('w-full'); - }); - - it('should NOT apply vertical class when orientation is "h"', () => { - fixture.detectChanges(); - const el = fixture.nativeElement.querySelector( - '[data-testid="separator"]', - ) as HTMLElement; - expect(el.className).not.toContain('self-stretch'); - }); - }); - - describe('vertical orientation', () => { - it('should have aria-orientation="vertical" when orientation is "v"', () => { - fixture.componentRef.setInput('orientation', 'v'); - fixture.detectChanges(); - const el = fixture.nativeElement.querySelector( - '[data-testid="separator"]', - ) as HTMLElement; - expect(el.getAttribute('aria-orientation')).toBe('vertical'); - }); - - it('should apply the vertical class when orientation is "v"', () => { - fixture.componentRef.setInput('orientation', 'v'); - fixture.detectChanges(); - const el = fixture.nativeElement.querySelector( - '[data-testid="separator"]', - ) as HTMLElement; - expect(el.className).toContain('w-px'); - expect(el.className).toContain('self-stretch'); - }); - }); - - describe('label (horizontal only)', () => { - it('should render the label text when label is provided', () => { - fixture.componentRef.setInput('label', 'OR'); - fixture.detectChanges(); - const el = fixture.nativeElement.querySelector( - '[data-testid="separator"]', - ) as HTMLElement; - expect(el.textContent).toContain('OR'); - }); - - it('should set aria-label to the label value when label is provided', () => { - fixture.componentRef.setInput('label', 'OR'); - fixture.detectChanges(); - const el = fixture.nativeElement.querySelector( - '[data-testid="separator"]', - ) as HTMLElement; - expect(el.getAttribute('aria-label')).toBe('OR'); - }); - - it('should render two decorative spans flanking the label', () => { - fixture.componentRef.setInput('label', 'OR'); - fixture.detectChanges(); - const el = fixture.nativeElement.querySelector( - '[data-testid="separator"]', - ) as HTMLElement; - const decorativeSpans = el.querySelectorAll('[aria-hidden="true"]'); - expect(decorativeSpans.length).toBe(2); - }); - - it('should NOT render the labelled variant when label is null', () => { - fixture.detectChanges(); - // Without a label the separator is a plain div — no flanking spans - const el = fixture.nativeElement.querySelector( - '[data-testid="separator"]', - ) as HTMLElement; - const decorativeSpans = el.querySelectorAll('[aria-hidden="true"]'); - expect(decorativeSpans.length).toBe(0); - }); - }); -}); diff --git a/frontend/src/app/shared/components/separator/separator.component.ts b/frontend/src/app/shared/components/separator/separator.component.ts deleted file mode 100644 index 3dc5477a..00000000 --- a/frontend/src/app/shared/components/separator/separator.component.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { - Component, - ChangeDetectionStrategy, - input, - computed, -} from '@angular/core'; - -export type SeparatorOrientation = 'h' | 'v'; - -/** - * DS separator — `ui-separator`. A hairline divider. Horizontal (default) spans - * full width; vertical stretches to its container's height. An optional `label` - * (horizontal only) centers a mono caption flanked by hairlines. - */ -@Component({ - selector: 'app-separator', - template: ` - @if (orientation() === 'h' && label()) { - - } @else { -
- } - `, - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class SeparatorComponent { - readonly orientation = input('h'); - readonly label = input(null); - - protected readonly lineClasses = computed(() => - this.orientation() === 'v' - ? 'w-px self-stretch bg-border' - : 'h-px w-full bg-border', - ); -} diff --git a/frontend/src/app/shared/components/stat-card/stat-card.component.spec.ts b/frontend/src/app/shared/components/stat-card/stat-card.component.spec.ts deleted file mode 100644 index e34fc0b6..00000000 --- a/frontend/src/app/shared/components/stat-card/stat-card.component.spec.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { StatCardComponent } from './stat-card.component'; - -describe('StatCardComponent', () => { - let fixture: ComponentFixture; - let component: StatCardComponent; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [StatCardComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(StatCardComponent); - component = fixture.componentInstance; - }); - - it('should create', () => { - fixture.componentRef.setInput('label', 'Revenue'); - fixture.componentRef.setInput('value', '1,234'); - fixture.detectChanges(); - expect(component).toBeTruthy(); - }); - - describe('label and value rendering', () => { - it('should display the label when label input is provided', () => { - fixture.componentRef.setInput('label', 'Total Orders'); - fixture.componentRef.setInput('value', '42'); - fixture.detectChanges(); - - const el = fixture.nativeElement as HTMLElement; - expect(el.textContent).toContain('Total Orders'); - }); - - it('should display a string value when value input is a string', () => { - fixture.componentRef.setInput('label', 'Revenue'); - fixture.componentRef.setInput('value', '$9,999'); - fixture.detectChanges(); - - const el = fixture.nativeElement as HTMLElement; - expect(el.textContent).toContain('$9,999'); - }); - - it('should display a numeric value when value input is a number', () => { - fixture.componentRef.setInput('label', 'Active Users'); - fixture.componentRef.setInput('value', 512); - fixture.detectChanges(); - - const el = fixture.nativeElement as HTMLElement; - expect(el.textContent).toContain('512'); - }); - }); - - describe('delta rendering', () => { - it('should not render delta element when delta input is null', () => { - fixture.componentRef.setInput('label', 'Metric'); - fixture.componentRef.setInput('value', '100'); - fixture.componentRef.setInput('delta', null); - fixture.detectChanges(); - - // No delta spans beyond label and value - const spans = (fixture.nativeElement as HTMLElement).querySelectorAll( - 'span', - ); - // Only label span and value span should exist - expect(spans.length).toBe(2); - }); - - it('should render delta text when delta input is provided', () => { - fixture.componentRef.setInput('label', 'Revenue'); - fixture.componentRef.setInput('value', '1,000'); - fixture.componentRef.setInput('delta', '+12%'); - fixture.detectChanges(); - - const el = fixture.nativeElement as HTMLElement; - expect(el.textContent).toContain('+12%'); - }); - - it('should not render delta text when delta transitions from value to null', () => { - fixture.componentRef.setInput('label', 'Revenue'); - fixture.componentRef.setInput('value', '1,000'); - fixture.componentRef.setInput('delta', '+12%'); - fixture.detectChanges(); - - fixture.componentRef.setInput('delta', null); - fixture.detectChanges(); - - const el = fixture.nativeElement as HTMLElement; - expect(el.textContent).not.toContain('+12%'); - }); - }); - - describe('trend colour classes', () => { - it('should apply text-success class when trend is up', () => { - fixture.componentRef.setInput('label', 'Sales'); - fixture.componentRef.setInput('value', '200'); - fixture.componentRef.setInput('delta', '+5%'); - fixture.componentRef.setInput('trend', 'up'); - fixture.detectChanges(); - - const deltaSpan = (fixture.nativeElement as HTMLElement).querySelectorAll( - 'span', - )[2]; - expect(deltaSpan.className).toContain('text-success'); - }); - - it('should apply text-error class when trend is down', () => { - fixture.componentRef.setInput('label', 'Churn'); - fixture.componentRef.setInput('value', '80'); - fixture.componentRef.setInput('delta', '-3%'); - fixture.componentRef.setInput('trend', 'down'); - fixture.detectChanges(); - - const deltaSpan = (fixture.nativeElement as HTMLElement).querySelectorAll( - 'span', - )[2]; - expect(deltaSpan.className).toContain('text-error'); - }); - - it('should apply text-fg-subtle class when trend is flat', () => { - fixture.componentRef.setInput('label', 'Visits'); - fixture.componentRef.setInput('value', '500'); - fixture.componentRef.setInput('delta', '0%'); - fixture.componentRef.setInput('trend', 'flat'); - fixture.detectChanges(); - - const deltaSpan = (fixture.nativeElement as HTMLElement).querySelectorAll( - 'span', - )[2]; - expect(deltaSpan.className).toContain('text-fg-subtle'); - }); - - it('should default trend to flat when trend input is not provided', () => { - fixture.componentRef.setInput('label', 'Visits'); - fixture.componentRef.setInput('value', '500'); - fixture.componentRef.setInput('delta', 'stable'); - fixture.detectChanges(); - - expect(component.trend()).toBe('flat'); - const deltaSpan = (fixture.nativeElement as HTMLElement).querySelectorAll( - 'span', - )[2]; - expect(deltaSpan.className).toContain('text-fg-subtle'); - }); - }); - - describe('testId', () => { - it('should set data-testid attribute when testId input is provided', () => { - fixture.componentRef.setInput('label', 'MRR'); - fixture.componentRef.setInput('value', '$4,200'); - fixture.componentRef.setInput('testId', 'mrr-card'); - fixture.detectChanges(); - - const container = (fixture.nativeElement as HTMLElement).querySelector( - '[data-testid="mrr-card"]', - ); - expect(container).toBeTruthy(); - }); - - it('should not render data-testid when testId input is null', () => { - fixture.componentRef.setInput('label', 'MRR'); - fixture.componentRef.setInput('value', '$4,200'); - fixture.componentRef.setInput('testId', null); - fixture.detectChanges(); - - const container = (fixture.nativeElement as HTMLElement).querySelector( - '[data-testid]', - ); - expect(container).toBeNull(); - }); - }); -}); diff --git a/frontend/src/app/shared/components/stat-card/stat-card.component.ts b/frontend/src/app/shared/components/stat-card/stat-card.component.ts deleted file mode 100644 index aac2cae7..00000000 --- a/frontend/src/app/shared/components/stat-card/stat-card.component.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { - Component, - ChangeDetectionStrategy, - input, - computed, -} from '@angular/core'; - -export type StatTrend = 'up' | 'down' | 'flat'; - -const TREND_CLASSES: Record = { - up: 'text-success', - down: 'text-error', - flat: 'text-fg-subtle', -}; - -/** - * DS stat / metric card — `ui-stat-card`. Mono uppercase label, oversized - * display value, optional delta whose colour follows the trend direction. - */ -@Component({ - selector: 'app-stat-card', - template: ` -
- - {{ label() }} - - - {{ value() }} - - @if (delta(); as d) { - - {{ d }} - - } -
- `, - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class StatCardComponent { - readonly label = input.required(); - readonly value = input.required(); - readonly delta = input(null); - readonly trend = input('flat'); - readonly testId = input(null); - - protected readonly trendClasses = computed(() => TREND_CLASSES[this.trend()]); -} diff --git a/frontend/src/app/shared/components/stepper/stepper.component.spec.ts b/frontend/src/app/shared/components/stepper/stepper.component.spec.ts deleted file mode 100644 index 9c5b28f9..00000000 --- a/frontend/src/app/shared/components/stepper/stepper.component.spec.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { StepperComponent, type StepItem } from './stepper.component'; - -const STEPS: StepItem[] = [ - { label: 'Account' }, - { label: 'Profile' }, - { label: 'Review' }, -]; - -describe('StepperComponent', () => { - let fixture: ComponentFixture; - let component: StepperComponent; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [StepperComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(StepperComponent); - component = fixture.componentInstance; - }); - - it('should create', () => { - fixture.componentRef.setInput('steps', STEPS); - fixture.detectChanges(); - expect(component).toBeTruthy(); - }); - - it('should render the data-testid="stepper" container', () => { - fixture.componentRef.setInput('steps', STEPS); - fixture.detectChanges(); - const ol = fixture.nativeElement.querySelector('[data-testid="stepper"]'); - expect(ol).toBeTruthy(); - expect(ol.tagName.toLowerCase()).toBe('ol'); - }); - - it('should render a dot for each step', () => { - fixture.componentRef.setInput('steps', STEPS); - fixture.detectChanges(); - const dots = fixture.nativeElement.querySelectorAll( - '[data-testid^="stepper-dot-"]', - ); - expect(dots.length).toBe(STEPS.length); - }); - - it('should render step labels', () => { - fixture.componentRef.setInput('steps', STEPS); - fixture.detectChanges(); - const text = fixture.nativeElement.textContent as string; - expect(text).toContain('Account'); - expect(text).toContain('Profile'); - expect(text).toContain('Review'); - }); - - it('should mark the first dot as aria-current="step" when current is 0', () => { - fixture.componentRef.setInput('steps', STEPS); - fixture.componentRef.setInput('current', 0); - fixture.detectChanges(); - - const dot0 = fixture.nativeElement.querySelector( - '[data-testid="stepper-dot-0"]', - ); - expect(dot0.getAttribute('aria-current')).toBe('step'); - }); - - it('should mark only the current dot as aria-current="step"', () => { - fixture.componentRef.setInput('steps', STEPS); - fixture.componentRef.setInput('current', 1); - fixture.detectChanges(); - - const dot0 = fixture.nativeElement.querySelector( - '[data-testid="stepper-dot-0"]', - ); - const dot1 = fixture.nativeElement.querySelector( - '[data-testid="stepper-dot-1"]', - ); - const dot2 = fixture.nativeElement.querySelector( - '[data-testid="stepper-dot-2"]', - ); - - expect(dot0.getAttribute('aria-current')).toBeNull(); - expect(dot1.getAttribute('aria-current')).toBe('step'); - expect(dot2.getAttribute('aria-current')).toBeNull(); - }); - - it('should show a checkmark svg in done steps and numbers in upcoming steps', () => { - // current=1 → step 0 is done, step 1 is current, step 2 is upcoming - fixture.componentRef.setInput('steps', STEPS); - fixture.componentRef.setInput('current', 1); - fixture.detectChanges(); - - const dot0 = fixture.nativeElement.querySelector( - '[data-testid="stepper-dot-0"]', - ) as HTMLElement; - const dot2 = fixture.nativeElement.querySelector( - '[data-testid="stepper-dot-2"]', - ) as HTMLElement; - - // done step should contain the check SVG, not a plain number - expect(dot0.querySelector('svg')).toBeTruthy(); - // upcoming step should show a number (3 for index 2) - expect(dot2.textContent?.trim()).toBe('3'); - }); - - it('should not render a checkmark for the current step', () => { - fixture.componentRef.setInput('steps', STEPS); - fixture.componentRef.setInput('current', 1); - fixture.detectChanges(); - - const dot1 = fixture.nativeElement.querySelector( - '[data-testid="stepper-dot-1"]', - ) as HTMLElement; - expect(dot1.querySelector('svg')).toBeNull(); - expect(dot1.textContent?.trim()).toBe('2'); - }); - - it('should update aria-current when current input changes', () => { - fixture.componentRef.setInput('steps', STEPS); - fixture.componentRef.setInput('current', 0); - fixture.detectChanges(); - - expect( - fixture.nativeElement - .querySelector('[data-testid="stepper-dot-0"]') - ?.getAttribute('aria-current'), - ).toBe('step'); - - fixture.componentRef.setInput('current', 2); - fixture.detectChanges(); - - expect( - fixture.nativeElement - .querySelector('[data-testid="stepper-dot-0"]') - ?.getAttribute('aria-current'), - ).toBeNull(); - expect( - fixture.nativeElement - .querySelector('[data-testid="stepper-dot-2"]') - ?.getAttribute('aria-current'), - ).toBe('step'); - }); -}); diff --git a/frontend/src/app/shared/components/stepper/stepper.component.ts b/frontend/src/app/shared/components/stepper/stepper.component.ts deleted file mode 100644 index 0c92196c..00000000 --- a/frontend/src/app/shared/components/stepper/stepper.component.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { Component, ChangeDetectionStrategy, input } from '@angular/core'; - -export interface StepItem { - readonly label: string; -} - -type StepState = 'done' | 'current' | 'upcoming'; - -const DOT_CLASSES: Record = { - done: 'bg-primary text-primary-foreground border-2 border-transparent', - current: 'bg-(--accent-faint) border-2 border-primary text-brand-strong', - upcoming: 'bg-elevated border-2 border-border text-fg-subtle', -}; - -/** - * DS stepper — `ui-stepper`. Horizontal progress indicator: 28px round dots - * connected by a rule that fills with the accent up to the current step. - * `current` is the zero-based index of the active step (aria-current=step). - */ -@Component({ - selector: 'app-stepper', - template: ` -
    - @for ( - step of steps(); - track step.label; - let i = $index; - let last = $last - ) { -
  1. -
    - - @if (i > 0) { - - } @else { - - } - - @if (stateOf(i) === 'done') { - - } @else { - {{ i + 1 }} - } - - - @if (!last) { - - } @else { - - } -
    - - {{ step.label }} - -
  2. - } -
- `, - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class StepperComponent { - readonly steps = input.required(); - readonly current = input(0); - - protected stateOf(index: number): StepState { - const c = this.current(); - if (index < c) { - return 'done'; - } - return index === c ? 'current' : 'upcoming'; - } - - protected dotClasses(index: number): string { - return DOT_CLASSES[this.stateOf(index)]; - } -} diff --git a/frontend/src/app/shared/components/switch/switch.component.spec.ts b/frontend/src/app/shared/components/switch/switch.component.spec.ts deleted file mode 100644 index 9c378f19..00000000 --- a/frontend/src/app/shared/components/switch/switch.component.spec.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { Component, signal } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { SwitchComponent } from './switch.component'; - -// SwitchComponent uses ng-content for its label — use a host component throughout. -// isOn is a WritableSignal so Angular's twoWayBindingSet wires [(checked)] correctly -// in zoneless mode. isDisabled is called explicitly in the [disabled] binding. - -@Component({ - imports: [SwitchComponent], - template: ` - - Enable notifications - - `, - standalone: true, -}) -class HostSwitchComponent { - readonly isOn = signal(false); - readonly isDisabled = signal(false); -} - -describe('SwitchComponent', () => { - let hostFixture: ComponentFixture; - let host: HostSwitchComponent; - - function switchButton(): HTMLButtonElement { - return hostFixture.nativeElement.querySelector( - '[data-testid="host-switch"]', - ) as HTMLButtonElement; - } - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [HostSwitchComponent], - }).compileComponents(); - - hostFixture = TestBed.createComponent(HostSwitchComponent); - host = hostFixture.componentInstance; - await hostFixture.whenStable(); - }); - - it('should create', () => { - expect(switchButton()).toBeTruthy(); - }); - - describe('role and ARIA', () => { - it('should have role="switch" on the button element', () => { - expect(switchButton().getAttribute('role')).toBe('switch'); - }); - - it('should have aria-checked="false" when checked is false', () => { - expect(switchButton().getAttribute('aria-checked')).toBe('false'); - }); - - it('should have aria-checked="true" when checked is true', async () => { - host.isOn.set(true); - await hostFixture.whenStable(); - expect(switchButton().getAttribute('aria-checked')).toBe('true'); - }); - }); - - describe('checked model', () => { - it('should render unchecked by default', () => { - expect(host.isOn()).toBe(false); - }); - - it('should toggle checked to true when button is clicked while unchecked', async () => { - switchButton().click(); - await hostFixture.whenStable(); - expect(host.isOn()).toBe(true); - }); - - it('should toggle checked back to false when button is clicked while checked', async () => { - host.isOn.set(true); - await hostFixture.whenStable(); - switchButton().click(); - await hostFixture.whenStable(); - expect(host.isOn()).toBe(false); - }); - - it('should reflect host value in aria-checked after toggle', async () => { - switchButton().click(); - await hostFixture.whenStable(); - expect(switchButton().getAttribute('aria-checked')).toBe('true'); - }); - }); - - describe('disabled input', () => { - it('should not be disabled by default', () => { - expect(switchButton().disabled).toBe(false); - }); - - it('should disable the button when disabled is true', async () => { - host.isDisabled.set(true); - await hostFixture.whenStable(); - expect(switchButton().disabled).toBe(true); - }); - - it('should not toggle checked when button is disabled and clicked', async () => { - host.isDisabled.set(true); - await hostFixture.whenStable(); - switchButton().click(); - await hostFixture.whenStable(); - expect(host.isOn()).toBe(false); - }); - }); - - describe('track visual state', () => { - it('should apply the off-state track classes when unchecked', () => { - const track = switchButton().querySelector('span') as HTMLSpanElement; - expect(track.className).toContain('bg-overlay'); - }); - - it('should apply the on-state track classes when checked', async () => { - host.isOn.set(true); - await hostFixture.whenStable(); - const track = switchButton().querySelector('span') as HTMLSpanElement; - expect(track.className).toContain('bg-brand'); - }); - }); - - describe('content projection', () => { - it('should render projected label text inside the button element', () => { - expect(switchButton().textContent?.trim()).toContain( - 'Enable notifications', - ); - }); - }); - - describe('button type', () => { - it('should have type="button" to prevent accidental form submission', () => { - expect(switchButton().type).toBe('button'); - }); - }); -}); diff --git a/frontend/src/app/shared/components/switch/switch.component.ts b/frontend/src/app/shared/components/switch/switch.component.ts deleted file mode 100644 index 441a9ecd..00000000 --- a/frontend/src/app/shared/components/switch/switch.component.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { - Component, - ChangeDetectionStrategy, - input, - model, - computed, -} from '@angular/core'; - -/** - * DS switch / toggle — `ui-switch`. A `role="switch"` button (not a checkbox) - * with track + sliding thumb; space/enter toggle it. Two-way `checked` model; - * project the visible label as content. - */ -@Component({ - selector: 'app-switch', - template: ` - - `, - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class SwitchComponent { - readonly checked = model(false); - readonly disabled = input(false); - readonly testId = input(null); - - protected readonly trackClasses = computed(() => - [ - 'relative inline-flex h-5 w-[34px] shrink-0 cursor-pointer items-center rounded-full border', - 'transition-colors duration-[120ms] disabled:cursor-not-allowed', - this.checked() ? 'border-brand bg-brand' : 'border-border bg-overlay', - ].join(' '), - ); - - protected readonly thumbClasses = computed(() => - [ - 'absolute left-px size-[14px] rounded-full transition-transform duration-[120ms]', - this.checked() - ? 'translate-x-[14px] bg-primary-foreground' - : 'bg-fg-muted', - ].join(' '), - ); - - protected toggle(): void { - if (!this.disabled()) { - this.checked.update((v) => !v); - } - } -} diff --git a/frontend/src/app/shared/components/tabs/tabs.component.spec.ts b/frontend/src/app/shared/components/tabs/tabs.component.spec.ts deleted file mode 100644 index 7f86588b..00000000 --- a/frontend/src/app/shared/components/tabs/tabs.component.spec.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { Component, signal } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { TabItem, TabsComponent } from './tabs.component'; - -const SAMPLE_TABS: readonly TabItem[] = [ - { id: 'overview', label: 'Overview' }, - { id: 'settings', label: 'Settings' }, - { id: 'billing', label: 'Billing', disabled: true }, -]; - -@Component({ - template: ` `, - imports: [TabsComponent], -}) -class HostComponent { - readonly items = signal(SAMPLE_TABS); - readonly activeId = signal('overview'); -} - -describe('TabsComponent', () => { - let fixture: ComponentFixture; - let host: HostComponent; - - function tabBtn(id: string): HTMLButtonElement { - return fixture.nativeElement.querySelector( - `[data-testid="tab-${id}"]`, - ) as HTMLButtonElement; - } - - function allTabBtns(): NodeListOf { - return fixture.nativeElement.querySelectorAll('[role="tab"]'); - } - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [HostComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(HostComponent); - host = fixture.componentInstance; - await fixture.whenStable(); - }); - - it('should create', () => { - expect( - fixture.nativeElement.querySelector('[role="tablist"]'), - ).toBeTruthy(); - }); - - it('should render a tab button for each item', () => { - expect(allTabBtns().length).toBe(3); - }); - - it('should render tab labels from items input', () => { - expect(tabBtn('overview').textContent?.trim()).toBe('Overview'); - expect(tabBtn('settings').textContent?.trim()).toBe('Settings'); - expect(tabBtn('billing').textContent?.trim()).toBe('Billing'); - }); - - it('should set aria-selected="true" on the initially active tab', () => { - expect(tabBtn('overview').getAttribute('aria-selected')).toBe('true'); - }); - - it('should set aria-selected="false" on inactive tabs', () => { - expect(tabBtn('settings').getAttribute('aria-selected')).toBe('false'); - }); - - it('should set tabindex="0" on the active tab', () => { - expect(tabBtn('overview').getAttribute('tabindex')).toBe('0'); - }); - - it('should set tabindex="-1" on non-active tabs', () => { - expect(tabBtn('settings').getAttribute('tabindex')).toBe('-1'); - }); - - it('should update active model when an enabled tab is clicked', async () => { - tabBtn('settings').click(); - await fixture.whenStable(); - expect(host.activeId()).toBe('settings'); - }); - - it('should reflect new active tab via aria-selected after click', async () => { - tabBtn('settings').click(); - await fixture.whenStable(); - expect(tabBtn('settings').getAttribute('aria-selected')).toBe('true'); - expect(tabBtn('overview').getAttribute('aria-selected')).toBe('false'); - }); - - it('should not change active when a disabled tab is clicked', async () => { - tabBtn('billing').click(); - await fixture.whenStable(); - expect(host.activeId()).toBe('overview'); - }); - - it('should disable the button element for a disabled tab item', () => { - expect(tabBtn('billing').disabled).toBe(true); - }); - - it('should not disable button elements for enabled tab items', () => { - expect(tabBtn('overview').disabled).toBe(false); - expect(tabBtn('settings').disabled).toBe(false); - }); - - it('should use data-testid="tab-{id}" on each button', () => { - expect(tabBtn('overview')).toBeTruthy(); - expect(tabBtn('settings')).toBeTruthy(); - expect(tabBtn('billing')).toBeTruthy(); - }); - - it('should render tablist with role="tablist" wrapping the buttons', () => { - const tablist = fixture.nativeElement.querySelector('[role="tablist"]'); - expect(tablist).toBeTruthy(); - const tabs = tablist.querySelectorAll('[role="tab"]'); - expect(tabs.length).toBe(3); - }); - - it('should update aria-selected when active model is changed externally', async () => { - host.activeId.set('settings'); - await fixture.whenStable(); - expect(tabBtn('settings').getAttribute('aria-selected')).toBe('true'); - expect(tabBtn('overview').getAttribute('aria-selected')).toBe('false'); - }); - - it('should render updated tabs when items input changes', async () => { - host.items.set([ - { id: 'a', label: 'Alpha' }, - { id: 'b', label: 'Beta' }, - ]); - host.activeId.set('a'); - await fixture.whenStable(); - expect(allTabBtns().length).toBe(2); - expect( - fixture.nativeElement - .querySelector('[data-testid="tab-a"]') - .textContent?.trim(), - ).toBe('Alpha'); - }); -}); diff --git a/frontend/src/app/shared/components/tabs/tabs.component.ts b/frontend/src/app/shared/components/tabs/tabs.component.ts deleted file mode 100644 index 086846ab..00000000 --- a/frontend/src/app/shared/components/tabs/tabs.component.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { - Component, - ChangeDetectionStrategy, - input, - model, -} from '@angular/core'; - -export interface TabItem { - readonly id: string; - readonly label: string; - readonly disabled?: boolean; -} - -/** - * DS underline tabs — `ui-tabs`. Signal-driven: `active` is a two-way model. - * Renders an ARIA tablist; the brand underline marks the selected tab. - */ -@Component({ - selector: 'app-tabs', - template: ` -
- @for (tab of items(); track tab.id) { - - } -
- `, - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class TabsComponent { - readonly items = input.required(); - readonly active = model.required(); - - protected select(tab: TabItem): void { - if (!tab.disabled) { - this.active.set(tab.id); - } - } -} diff --git a/frontend/src/app/shared/components/tag/tag.component.spec.ts b/frontend/src/app/shared/components/tag/tag.component.spec.ts deleted file mode 100644 index e49e6a74..00000000 --- a/frontend/src/app/shared/components/tag/tag.component.spec.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { Component, signal } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { TagComponent } from './tag.component'; - -// --------------------------------------------------------------------------- -// Host component for content-projection tests -// --------------------------------------------------------------------------- -@Component({ - imports: [TagComponent], - template: `{{ - label() - }}`, -}) -class HostComponent { - readonly href = signal(null); - readonly testId = signal(null); - readonly label = signal('#angular'); -} - -describe('TagComponent', () => { - let fixture: ComponentFixture; - let host: HostComponent; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [HostComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(HostComponent); - host = fixture.componentInstance; - await fixture.whenStable(); - }); - - function anchor(): HTMLAnchorElement { - return fixture.nativeElement.querySelector('a') as HTMLAnchorElement; - } - - it('should create', () => { - expect(host).toBeTruthy(); - }); - - it('should project label text into the tag', () => { - expect(anchor().textContent).toContain('#angular'); - }); - - describe('href input', () => { - it('should not set href attribute when href is null', async () => { - host.href.set(null); - await fixture.whenStable(); - // Angular sets attr to null — the attribute should be absent - expect(anchor().getAttribute('href')).toBeNull(); - }); - - it('should set href attribute when href is provided', async () => { - host.href.set('/tags/angular'); - await fixture.whenStable(); - expect(anchor().getAttribute('href')).toBe('/tags/angular'); - }); - - it('should always render an element regardless of href', async () => { - // The template always uses — verify the element type - host.href.set(null); - await fixture.whenStable(); - expect(anchor()).toBeTruthy(); - expect(anchor().tagName).toBe('A'); - }); - }); - - describe('testId input', () => { - it('should not set data-testid when testId is null', async () => { - host.testId.set(null); - await fixture.whenStable(); - expect(anchor().getAttribute('data-testid')).toBeNull(); - }); - - it('should set data-testid attribute when testId is provided', async () => { - host.testId.set('tag-angular'); - await fixture.whenStable(); - expect(anchor().getAttribute('data-testid')).toBe('tag-angular'); - }); - }); - - describe('label content', () => { - it('should display updated label when projected content changes', async () => { - host.label.set('#vitest'); - await fixture.whenStable(); - expect(anchor().textContent).toContain('#vitest'); - }); - }); -}); diff --git a/frontend/src/app/shared/components/tag/tag.component.ts b/frontend/src/app/shared/components/tag/tag.component.ts deleted file mode 100644 index 3e5e41ac..00000000 --- a/frontend/src/app/shared/components/tag/tag.component.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Component, ChangeDetectionStrategy, input } from '@angular/core'; - -/** - * DS inline `#hashtag` tag — `ui-tag`. Mono, subtle. Renders an `` when - * `href` is set (brand on hover), otherwise a plain ``. The label is - * the projected content. - */ -@Component({ - selector: 'app-tag', - template: ` - - `, - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class TagComponent { - readonly href = input(null); - readonly testId = input(null); -} diff --git a/frontend/src/app/shared/components/textarea/textarea.component.spec.ts b/frontend/src/app/shared/components/textarea/textarea.component.spec.ts deleted file mode 100644 index f12d0ef6..00000000 --- a/frontend/src/app/shared/components/textarea/textarea.component.spec.ts +++ /dev/null @@ -1,222 +0,0 @@ -import { Component } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { TextareaComponent } from './textarea.component'; - -describe('TextareaComponent', () => { - let fixture: ComponentFixture; - let component: TextareaComponent; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [TextareaComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(TextareaComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - describe('value model', () => { - it('should reflect initial empty value in the native textarea', () => { - const ta = fixture.nativeElement.querySelector( - 'textarea', - ) as HTMLTextAreaElement; - expect(ta.value).toBe(''); - }); - - it('should reflect set value in the native textarea when value is set', () => { - fixture.componentRef.setInput('value', 'hello world'); - fixture.detectChanges(); - const ta = fixture.nativeElement.querySelector( - 'textarea', - ) as HTMLTextAreaElement; - expect(ta.value).toBe('hello world'); - }); - - it('should update value model when user types into the textarea', () => { - const ta = fixture.nativeElement.querySelector( - 'textarea', - ) as HTMLTextAreaElement; - ta.value = 'user typed'; - ta.dispatchEvent(new Event('input')); - expect(component.value()).toBe('user typed'); - }); - }); - - describe('placeholder input', () => { - it('should render placeholder when placeholder input is set', () => { - fixture.componentRef.setInput('placeholder', 'Describe your issue'); - fixture.detectChanges(); - const ta = fixture.nativeElement.querySelector( - 'textarea', - ) as HTMLTextAreaElement; - expect(ta.placeholder).toBe('Describe your issue'); - }); - }); - - describe('disabled input', () => { - it('should not be disabled by default', () => { - const ta = fixture.nativeElement.querySelector( - 'textarea', - ) as HTMLTextAreaElement; - expect(ta.disabled).toBe(false); - }); - - it('should disable the native textarea when disabled is true', () => { - fixture.componentRef.setInput('disabled', true); - fixture.detectChanges(); - const ta = fixture.nativeElement.querySelector( - 'textarea', - ) as HTMLTextAreaElement; - expect(ta.disabled).toBe(true); - }); - }); - - describe('invalid input', () => { - it('should not have aria-invalid when invalid is false', () => { - const ta = fixture.nativeElement.querySelector( - 'textarea', - ) as HTMLTextAreaElement; - expect(ta.getAttribute('aria-invalid')).toBeNull(); - }); - - it('should set aria-invalid when invalid is true', () => { - fixture.componentRef.setInput('invalid', true); - fixture.detectChanges(); - const ta = fixture.nativeElement.querySelector( - 'textarea', - ) as HTMLTextAreaElement; - expect(ta.getAttribute('aria-invalid')).toBe('true'); - }); - }); - - describe('rows input', () => { - it('should default to 3 rows', () => { - const ta = fixture.nativeElement.querySelector( - 'textarea', - ) as HTMLTextAreaElement; - expect(ta.rows).toBe(3); - }); - - it('should render the specified number of rows when rows input is set', () => { - fixture.componentRef.setInput('rows', 8); - fixture.detectChanges(); - const ta = fixture.nativeElement.querySelector( - 'textarea', - ) as HTMLTextAreaElement; - expect(ta.rows).toBe(8); - }); - }); - - describe('testId input', () => { - it('should not set data-testid attribute when testId is null', () => { - const ta = fixture.nativeElement.querySelector( - 'textarea', - ) as HTMLTextAreaElement; - expect(ta.getAttribute('data-testid')).toBeNull(); - }); - - it('should set data-testid attribute when testId is provided', () => { - fixture.componentRef.setInput('testId', 'my-textarea'); - fixture.detectChanges(); - const ta = fixture.nativeElement.querySelector( - '[data-testid="my-textarea"]', - ) as HTMLTextAreaElement; - expect(ta).toBeTruthy(); - }); - }); - - describe('size input', () => { - it('should apply md size classes by default', () => { - const ta = fixture.nativeElement.querySelector( - 'textarea', - ) as HTMLTextAreaElement; - expect(ta.className).toContain('px-2.5'); - expect(ta.className).toContain('py-2'); - }); - - it('should apply sm size classes when size is sm', () => { - fixture.componentRef.setInput('size', 'sm'); - fixture.detectChanges(); - const ta = fixture.nativeElement.querySelector( - 'textarea', - ) as HTMLTextAreaElement; - expect(ta.className).toContain('px-2'); - expect(ta.className).toContain('py-1.5'); - }); - - it('should apply lg size classes when size is lg', () => { - fixture.componentRef.setInput('size', 'lg'); - fixture.detectChanges(); - const ta = fixture.nativeElement.querySelector( - 'textarea', - ) as HTMLTextAreaElement; - expect(ta.className).toContain('px-3'); - expect(ta.className).toContain('py-2.5'); - }); - }); - - describe('mono input', () => { - it('should apply font-sans class by default', () => { - const ta = fixture.nativeElement.querySelector( - 'textarea', - ) as HTMLTextAreaElement; - expect(ta.className).toContain('font-sans'); - }); - - it('should apply font-mono class when mono is true', () => { - fixture.componentRef.setInput('mono', true); - fixture.detectChanges(); - const ta = fixture.nativeElement.querySelector( - 'textarea', - ) as HTMLTextAreaElement; - expect(ta.className).toContain('font-mono'); - expect(ta.className).not.toContain('font-sans'); - }); - }); -}); - -// Host-based integration test for two-way model binding -@Component({ - imports: [TextareaComponent], - template: ``, - standalone: true, -}) -class HostTextareaComponent { - notes = 'initial notes'; -} - -describe('TextareaComponent (host integration)', () => { - let hostFixture: ComponentFixture; - let host: HostTextareaComponent; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [HostTextareaComponent], - }).compileComponents(); - - hostFixture = TestBed.createComponent(HostTextareaComponent); - host = hostFixture.componentInstance; - hostFixture.detectChanges(); - }); - - it('should reflect host value in the native textarea via two-way binding', () => { - const ta = hostFixture.nativeElement.querySelector( - '[data-testid="host-ta"]', - ) as HTMLTextAreaElement; - expect(ta.value).toBe('initial notes'); - }); - - it('should update host property when native input event fires', () => { - const ta = hostFixture.nativeElement.querySelector( - '[data-testid="host-ta"]', - ) as HTMLTextAreaElement; - ta.value = 'updated notes'; - ta.dispatchEvent(new Event('input')); - expect(host.notes).toBe('updated notes'); - }); -}); diff --git a/frontend/src/app/shared/components/textarea/textarea.component.ts b/frontend/src/app/shared/components/textarea/textarea.component.ts deleted file mode 100644 index 4350bb45..00000000 --- a/frontend/src/app/shared/components/textarea/textarea.component.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { - Component, - ChangeDetectionStrategy, - input, - model, - computed, -} from '@angular/core'; - -export type TextareaSize = 'sm' | 'md' | 'lg'; - -const SIZE_CLASSES: Record = { - sm: 'px-2 py-1.5 text-xs', - md: 'px-2.5 py-2 text-[13px]', - lg: 'px-3 py-2.5 text-sm', -}; - -/** - * DS multi-line input — `ui-textarea`. Mirrors `app-input` (same surface, - * border, focus + invalid states) but vertically resizable with a sensible - * min height. Two-way `value` model; presentational/Signal-Forms friendly. - */ -@Component({ - selector: 'app-textarea', - template: ` - - `, - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class TextareaComponent { - readonly value = model(''); - readonly placeholder = input(''); - readonly disabled = input(false); - readonly invalid = input(false); - readonly mono = input(false); - readonly rows = input(3); - readonly size = input('md'); - readonly testId = input(null); - - protected readonly classes = computed(() => - [ - 'w-full min-h-20 resize-y rounded-sm border border-border bg-elevated text-fg leading-normal', - 'placeholder:text-fg-subtle transition-colors duration-[120ms]', - 'hover:border-border-strong focus:border-brand focus:bg-panel focus:outline-hidden', - 'aria-invalid:border-error', - 'disabled:opacity-40 disabled:cursor-not-allowed disabled:resize-none', - this.mono() ? 'font-mono' : 'font-sans', - SIZE_CLASSES[this.size()], - ].join(' '), - ); - - protected onInput(event: Event): void { - this.value.set((event.target as HTMLTextAreaElement).value); - } -} diff --git a/frontend/src/app/shared/components/tooltip/tooltip.directive.spec.ts b/frontend/src/app/shared/components/tooltip/tooltip.directive.spec.ts deleted file mode 100644 index 290bcb00..00000000 --- a/frontend/src/app/shared/components/tooltip/tooltip.directive.spec.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { Component, signal } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { OverlayContainer } from '@angular/cdk/overlay'; -import { TooltipDirective } from './tooltip.directive'; - -@Component({ - imports: [TooltipDirective], - template: ` - - `, -}) -class TestHostComponent { - readonly tooltipText = signal('Hello tooltip'); -} - -describe('TooltipDirective', () => { - let fixture: ComponentFixture; - let host: TestHostComponent; - let overlayContainer: OverlayContainer; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [TestHostComponent], - }).compileComponents(); - - overlayContainer = TestBed.inject(OverlayContainer); - fixture = TestBed.createComponent(TestHostComponent); - host = fixture.componentInstance; - await fixture.whenStable(); - }); - - afterEach(() => { - overlayContainer.ngOnDestroy?.(); - }); - - function anchor(): HTMLElement { - return fixture.nativeElement.querySelector( - '[data-testid="anchor"]', - ) as HTMLElement; - } - - function tooltipEl(): Element | null { - return overlayContainer - .getContainerElement() - .querySelector('[role="tooltip"]'); - } - - it('should create the host component', () => { - expect(fixture.componentInstance).toBeTruthy(); - }); - - it('should not render tooltip bubble when idle', () => { - expect(tooltipEl()).toBeNull(); - }); - - it('should show tooltip bubble on mouseenter', async () => { - anchor().dispatchEvent(new MouseEvent('mouseenter', { bubbles: true })); - fixture.detectChanges(); - await fixture.whenStable(); - - expect(tooltipEl()).toBeTruthy(); - }); - - it('should display the correct tooltip text on hover', async () => { - anchor().dispatchEvent(new MouseEvent('mouseenter', { bubbles: true })); - fixture.detectChanges(); - await fixture.whenStable(); - - expect(tooltipEl()?.textContent?.trim()).toBe('Hello tooltip'); - }); - - it('should hide tooltip bubble on mouseleave', async () => { - anchor().dispatchEvent(new MouseEvent('mouseenter', { bubbles: true })); - fixture.detectChanges(); - await fixture.whenStable(); - - anchor().dispatchEvent(new MouseEvent('mouseleave', { bubbles: true })); - fixture.detectChanges(); - await fixture.whenStable(); - - expect(tooltipEl()).toBeNull(); - }); - - it('should show tooltip on focusin', async () => { - anchor().dispatchEvent(new FocusEvent('focusin', { bubbles: true })); - fixture.detectChanges(); - await fixture.whenStable(); - - expect(tooltipEl()).toBeTruthy(); - }); - - it('should hide tooltip on focusout', async () => { - anchor().dispatchEvent(new FocusEvent('focusin', { bubbles: true })); - fixture.detectChanges(); - await fixture.whenStable(); - - anchor().dispatchEvent(new FocusEvent('focusout', { bubbles: true })); - fixture.detectChanges(); - await fixture.whenStable(); - - expect(tooltipEl()).toBeNull(); - }); - - it('should hide tooltip when ESC key is pressed', async () => { - anchor().dispatchEvent(new MouseEvent('mouseenter', { bubbles: true })); - fixture.detectChanges(); - await fixture.whenStable(); - - anchor().dispatchEvent( - new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }), - ); - fixture.detectChanges(); - await fixture.whenStable(); - - expect(tooltipEl()).toBeNull(); - }); - - it('should set aria-describedby on anchor while tooltip is visible', async () => { - anchor().dispatchEvent(new MouseEvent('mouseenter', { bubbles: true })); - fixture.detectChanges(); - await fixture.whenStable(); - - expect(anchor().getAttribute('aria-describedby')).toMatch( - /^app-tooltip-\d+$/, - ); - }); - - it('should remove aria-describedby after tooltip hides', async () => { - anchor().dispatchEvent(new MouseEvent('mouseenter', { bubbles: true })); - fixture.detectChanges(); - await fixture.whenStable(); - - anchor().dispatchEvent(new MouseEvent('mouseleave', { bubbles: true })); - fixture.detectChanges(); - await fixture.whenStable(); - - expect(anchor().getAttribute('aria-describedby')).toBeNull(); - }); - - it('should update tooltip text when binding changes while visible', async () => { - anchor().dispatchEvent(new MouseEvent('mouseenter', { bubbles: true })); - fixture.detectChanges(); - await fixture.whenStable(); - - host.tooltipText.set('Updated text'); - fixture.detectChanges(); - await fixture.whenStable(); - - expect(tooltipEl()?.textContent?.trim()).toBe('Updated text'); - }); - - it('should apply tooltipTestId to the overlay wrapper when provided', async () => { - anchor().dispatchEvent(new MouseEvent('mouseenter', { bubbles: true })); - fixture.detectChanges(); - await fixture.whenStable(); - - const wrapper = overlayContainer - .getContainerElement() - .querySelector('[data-testid="test-tooltip"]'); - expect(wrapper).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/shared/components/tooltip/tooltip.directive.ts b/frontend/src/app/shared/components/tooltip/tooltip.directive.ts deleted file mode 100644 index abac212a..00000000 --- a/frontend/src/app/shared/components/tooltip/tooltip.directive.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { - Directive, - ElementRef, - inject, - input, - signal, - effect, - OnDestroy, -} from '@angular/core'; -import { - Overlay, - OverlayRef, - type ConnectedPosition, -} from '@angular/cdk/overlay'; -import { ComponentPortal } from '@angular/cdk/portal'; -import { - Component, - ChangeDetectionStrategy, - input as cmpInput, -} from '@angular/core'; - -const TOOLTIP_POSITIONS: readonly ConnectedPosition[] = [ - { - originX: 'center', - originY: 'top', - overlayX: 'center', - overlayY: 'bottom', - offsetY: -6, - }, - { - originX: 'center', - originY: 'bottom', - overlayX: 'center', - overlayY: 'top', - offsetY: 6, - }, -]; - -let tooltipUid = 0; - -/** - * Internal bubble rendered into the CDK overlay by {@link TooltipDirective}. - * `role=tooltip`; the host wires `aria-describedby` to its id. - */ -@Component({ - selector: 'app-tooltip', - template: ` - - `, - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class TooltipComponent { - readonly text = cmpInput(''); - readonly tooltipId = cmpInput(''); -} - -/** - * DS tooltip — `[appTooltip]`. Shows a CDK overlay bubble on hover/focus, - * positioned above the host with a below fallback. Hidden on - * blur/mouseleave/ESC. Wires `aria-describedby` while visible so screen - * readers announce the description. - */ -@Directive({ - selector: '[appTooltip]', - host: { - '(mouseenter)': 'show()', - '(mouseleave)': 'hide()', - '(focusin)': 'show()', - '(focusout)': 'hide()', - '(keydown.escape)': 'hide()', - '[attr.aria-describedby]': 'describedBy()', - }, -}) -export class TooltipDirective implements OnDestroy { - readonly appTooltip = input.required(); - readonly tooltipTestId = input(null); - - private readonly overlay = inject(Overlay); - private readonly host = inject(ElementRef); - - private overlayRef: OverlayRef | null = null; - private readonly id = `app-tooltip-${tooltipUid++}`; - - /** Non-null only while the bubble is visible — drives aria-describedby. */ - protected readonly describedBy = signal(null); - - constructor() { - // Keep a live bubble's text in sync if the binding changes mid-hover. - effect(() => { - const text = this.appTooltip(); - if (this.overlayRef?.hasAttached()) { - this.attachBubble(text); - } - }); - } - - protected show(): void { - const text = this.appTooltip(); - if (!text || this.overlayRef?.hasAttached()) return; - - if (!this.overlayRef) { - const positionStrategy = this.overlay - .position() - .flexibleConnectedTo(this.host) - .withPush(true) - .withPositions([...TOOLTIP_POSITIONS]); - - this.overlayRef = this.overlay.create({ - positionStrategy, - scrollStrategy: this.overlay.scrollStrategies.reposition(), - hasBackdrop: false, - }); - } - - this.attachBubble(text); - this.describedBy.set(this.id); - } - - protected hide(): void { - if (this.overlayRef?.hasAttached()) { - this.overlayRef.detach(); - } - this.describedBy.set(null); - } - - private attachBubble(text: string): void { - if (!this.overlayRef) return; - if (this.overlayRef.hasAttached()) { - this.overlayRef.detach(); - } - const portal = new ComponentPortal(TooltipComponent); - const ref = this.overlayRef.attach(portal); - ref.setInput('text', text); - ref.setInput('tooltipId', this.id); - if (this.tooltipTestId()) { - ref.location.nativeElement.setAttribute( - 'data-testid', - this.tooltipTestId(), - ); - } - } - - ngOnDestroy(): void { - this.overlayRef?.dispose(); - this.overlayRef = null; - } -} From 4d94c5a22479017d4e2d68dcb2ce4f79626e9f0b Mon Sep 17 00:00:00 2001 From: Koopa Date: Wed, 24 Jun 2026 16:17:42 +0800 Subject: [PATCH 16/31] test(frontend): add markdown XSS sanitization tests; drop hollow static-page specs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit markdown.service binds DOMPurify output to [innerHTML] but had no sanitization test — add cases asserting a '); + expect(result).not.toContain(' payload', () => { + const result = service.parse(''); + // The img element itself is permitted, but the event handler must be gone. + expect(result.toLowerCase()).not.toContain('onerror'); + expect(result).not.toContain('alert(1)'); + }); + + it('should remove a javascript: scheme from a link href', () => { + const result = service.parse('[click me](javascript:alert(1))'); + expect(result.toLowerCase()).not.toContain('javascript:'); + expect(result).not.toContain('alert(1)'); + }); }); diff --git a/frontend/src/app/pages/about/about.spec.ts b/frontend/src/app/pages/about/about.spec.ts deleted file mode 100644 index c942be12..00000000 --- a/frontend/src/app/pages/about/about.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { provideNoopAnimations } from '@angular/platform-browser/animations'; -import { AboutComponent } from './about'; - -describe('AboutComponent', () => { - let component: AboutComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [AboutComponent], - providers: [provideNoopAnimations()], - }).compileComponents(); - - fixture = TestBed.createComponent(AboutComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/pages/privacy/privacy.spec.ts b/frontend/src/app/pages/privacy/privacy.spec.ts deleted file mode 100644 index a0225c32..00000000 --- a/frontend/src/app/pages/privacy/privacy.spec.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { provideHttpClient, withXhr } from '@angular/common/http'; -import { provideHttpClientTesting } from '@angular/common/http/testing'; -import { provideRouter } from '@angular/router'; -import { PrivacyComponent } from './privacy'; - -describe('PrivacyComponent', () => { - let component: PrivacyComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [PrivacyComponent], - providers: [ - provideHttpClient(withXhr()), - provideHttpClientTesting(), - provideRouter([]), - ], - }).compileComponents(); - - fixture = TestBed.createComponent(PrivacyComponent); - component = fixture.componentInstance; - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/pages/terms/terms.spec.ts b/frontend/src/app/pages/terms/terms.spec.ts deleted file mode 100644 index 0829938d..00000000 --- a/frontend/src/app/pages/terms/terms.spec.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { provideHttpClient, withXhr } from '@angular/common/http'; -import { provideHttpClientTesting } from '@angular/common/http/testing'; -import { provideRouter } from '@angular/router'; -import { TermsComponent } from './terms'; - -describe('TermsComponent', () => { - let component: TermsComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [TermsComponent], - providers: [ - provideHttpClient(withXhr()), - provideHttpClientTesting(), - provideRouter([]), - ], - }).compileComponents(); - - fixture = TestBed.createComponent(TermsComponent); - component = fixture.componentInstance; - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); From f2b18a5cc478fe1029c9067c439bf7d8ae1fc611 Mon Sep 17 00:00:00 2001 From: Koopa Date: Wed, 24 Jun 2026 16:26:44 +0800 Subject: [PATCH 17/31] test(mcp): cover validateProposeContent rejection branches + fuzz MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit validateProposeContent gates untrusted agent input (required title/type/body, content-type enum, C0/C1 control-char rejection per field, slug derivation, topic-UUID parsing) and had no direct test. Add a table-driven test naming the bug each case catches, plus fuzz over the slug-derivation and parseTopicIDs paths (must not panic). The C1 case uses string(rune(0x80)) — a raw \x80 byte is invalid UTF-8 and would decode to RuneError, never exercising the C1 check. --- internal/mcp/proposal_test.go | 301 ++++++++++++++++++++++++++++++++++ 1 file changed, 301 insertions(+) create mode 100644 internal/mcp/proposal_test.go diff --git a/internal/mcp/proposal_test.go b/internal/mcp/proposal_test.go new file mode 100644 index 00000000..b136b989 --- /dev/null +++ b/internal/mcp/proposal_test.go @@ -0,0 +1,301 @@ +// Copyright 2026 Koopa. All rights reserved. + +// proposal_test.go covers validateProposeContent (white-box, package mcp): +// every rejection branch named in the comment block in proposal.go, plus one +// accept case, plus a Fuzz for the slug-derivation + parseTopicIDs path. +package mcp + +import ( + "strings" + "testing" + + "github.com/google/uuid" +) + +// TestValidateProposeContent names the implementation bug each case would catch: +// +// - "empty title" → title check missing / swapped order +// - "whitespace-only title" → TrimSpace absent on title check +// - "empty type" → type check missing +// - "whitespace-only type" → TrimSpace absent on type check +// - "invalid type" → contentType.Valid() not called +// - "empty body" → body check missing +// - "whitespace-only body" → TrimSpace absent on body check +// - "control char in title" → ContainsControlChars not called on title +// - "control char in body" → containsProseControlChars not called on body +// - "control char in excerpt" → ContainsControlChars not called on excerpt +// - "control char in rationale" → ContainsControlChars not called on rationale +// - "all-punctuation title → empty slug" → empty-slug guard missing +// - "invalid topic UUID" → parseTopicIDs not called +// - "slug derived from title" → slug derivation logic removed +// - "explicit slug passed through" → explicit-slug pass-through missing +// - "accept valid input" → any regression in the happy path +func TestValidateProposeContent(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input ProposeContentInput + wantErr bool + wantErrContains string + // When non-nil, assert the returned slug equals this string. + wantSlug *string + // When non-nil, assert the returned type equals this string. + wantTypStr *string + }{ + // ── required-field checks ────────────────────────────────────────────── + { + name: "empty title", + input: ProposeContentInput{Title: "", Type: "article", Body: "body"}, + wantErr: true, + wantErrContains: "title is required", + }, + { + name: "whitespace-only title", + input: ProposeContentInput{Title: " ", Type: "article", Body: "body"}, + wantErr: true, + wantErrContains: "title is required", + }, + { + name: "empty type", + input: ProposeContentInput{Title: "T", Type: "", Body: "body"}, + wantErr: true, + wantErrContains: "type is required", + }, + { + name: "whitespace-only type", + input: ProposeContentInput{Title: "T", Type: " ", Body: "body"}, + wantErr: true, + wantErrContains: "type is required", + }, + { + name: "empty body", + input: ProposeContentInput{Title: "T", Type: "article", Body: ""}, + wantErr: true, + wantErrContains: "body is required", + }, + { + name: "whitespace-only body", + input: ProposeContentInput{Title: "T", Type: "article", Body: " "}, + wantErr: true, + wantErrContains: "body is required", + }, + // ── content-type enum validation ─────────────────────────────────────── + { + name: "invalid type: podcast", + input: ProposeContentInput{Title: "T", Type: "podcast", Body: "body"}, + wantErr: true, + wantErrContains: "type must be one of", + }, + { + name: "invalid type: uppercase Article", + input: ProposeContentInput{Title: "T", Type: "Article", Body: "body"}, + wantErr: true, + wantErrContains: "type must be one of", + }, + // ── control-character rejection ──────────────────────────────────────── + { + name: "control char C0 in title", + input: ProposeContentInput{Title: "bad\x01title", Type: "article", Body: "body"}, + wantErr: true, + wantErrContains: "title must not contain control characters", + }, + { + // U+0080 is a C1 control rune. It must be written as string(rune(0x80)) (valid + // UTF-8, 0xC2 0x80), not the raw byte \x80 — a raw 0x80 is invalid + // UTF-8 and decodes to RuneError when ranged, never matching the + // C1 range that ContainsControlChars checks. + name: "control char C1 in title", + input: ProposeContentInput{Title: "bad" + string(rune(0x80)) + "title", Type: "article", Body: "body"}, + wantErr: true, + wantErrContains: "title must not contain control characters", + }, + { + name: "control char C0 in body (not HT/LF/CR)", + // \x01 is a C0 control char that prose-check rejects (not HT/LF/CR) + input: ProposeContentInput{Title: "T", Type: "article", Body: "bad\x01body"}, + wantErr: true, + wantErrContains: "body must not contain control characters", + }, + { + // Prose check must allow HT/LF/CR — rejecting LF would break a + // multi-line Markdown body. Asserts a multi-line body is accepted. + name: "LF in body is allowed by prose check", + input: ProposeContentInput{Title: "T", Type: "til", Body: "line1\nline2"}, + wantErr: false, + }, + { + name: "control char in excerpt", + input: ProposeContentInput{Title: "T", Type: "article", Body: "body", Excerpt: "ex\x01cerpt"}, + wantErr: true, + wantErrContains: "excerpt must not contain control characters", + }, + { + name: "control char in proposal_rationale", + input: ProposeContentInput{Title: "T", Type: "article", Body: "body", ProposalRationale: "rat\x01ionale"}, + wantErr: true, + wantErrContains: "proposal_rationale must not contain control characters", + }, + // ── slug derivation ──────────────────────────────────────────────────── + { + name: "all-punctuation title yields empty slug", + input: ProposeContentInput{Title: "!!!---", Type: "article", Body: "body"}, + wantErr: true, + wantErrContains: "must contain at least one letter or number", + }, + { + name: "slug derived from title when omitted", + input: ProposeContentInput{ + Title: "Go Is Great", + Type: "article", + Body: "body", + }, + wantErr: false, + wantSlug: ptr("go-is-great"), + }, + { + name: "explicit slug passes through unchanged", + input: ProposeContentInput{ + Title: "Any Title", + Type: "essay", + Body: "body", + Slug: "my-custom-slug", + }, + wantErr: false, + wantSlug: ptr("my-custom-slug"), + }, + // ── topic UUID parsing ───────────────────────────────────────────────── + { + name: "invalid topic UUID", + input: ProposeContentInput{ + Title: "T", + Type: "article", + Body: "body", + TopicIDs: []string{"not-a-uuid"}, + }, + wantErr: true, + wantErrContains: "not a valid uuid", + }, + // ── accept cases ─────────────────────────────────────────────────────── + { + name: "valid article", + input: ProposeContentInput{ + Title: "Understanding Interfaces in Go", + Type: "article", + Body: "Markdown body here.\n\n## Section\n\nContent.", + Excerpt: "A short summary.", + }, + wantErr: false, + wantTypStr: ptr("article"), + wantSlug: ptr("understanding-interfaces-in-go"), + }, + { + name: "valid til", + input: ProposeContentInput{ + Title: "TIL: range over func", + Type: "til", + Body: "Go 1.23 adds range over func.\n", + }, + wantErr: false, + wantTypStr: ptr("til"), + }, + { + name: "valid with topic UUIDs", + input: ProposeContentInput{ + Title: "T", + Type: "digest", + Body: "body", + TopicIDs: []string{"550e8400-e29b-41d4-a716-446655440000"}, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + gotType, gotSlug, gotIDs, err := validateProposeContent(tt.input) + + if tt.wantErr { + if err == nil { + t.Fatalf("validateProposeContent() error = nil, want error containing %q", tt.wantErrContains) + } + if tt.wantErrContains != "" && !strings.Contains(err.Error(), tt.wantErrContains) { + t.Errorf("validateProposeContent() error = %q, want containing %q", err.Error(), tt.wantErrContains) + } + return + } + if err != nil { + t.Fatalf("validateProposeContent() unexpected error: %v", err) + } + if tt.wantTypStr != nil && string(gotType) != *tt.wantTypStr { + t.Errorf("validateProposeContent() type = %q, want %q", gotType, *tt.wantTypStr) + } + if tt.wantSlug != nil && gotSlug != *tt.wantSlug { + t.Errorf("validateProposeContent() slug = %q, want %q", gotSlug, *tt.wantSlug) + } + // gotIDs may be nil or non-nil; just check it doesn't panic. + _ = gotIDs + }) + } +} + +// ptr returns a pointer to the given string — helper for table fields. +func ptr(s string) *string { return &s } + +// FuzzValidateProposeContent_SlugAndTopics fuzzes the slug-derivation + +// parseTopicIDs path. The seed corpus drives toward the cases most likely to +// panic: all-punctuation titles, malformed UUIDs, control characters. +func FuzzValidateProposeContent_SlugAndTopics(f *testing.F) { + // Seed: valid + f.Add("Go Interfaces", "article", "body", "", "") + // Seed: all punctuation → empty slug + f.Add("!!!---", "article", "body", "", "") + // Seed: control chars + f.Add("title\x01here", "article", "body", "", "") + // Seed: invalid topic UUID + f.Add("T", "article", "body", "", "not-a-uuid") + // Seed: valid UUID + f.Add("T", "article", "body", "", "550e8400-e29b-41d4-a716-446655440000") + // Seed: empty + f.Add("", "", "", "", "") + // Seed: long title + f.Add(strings.Repeat("x", 200), "article", "body", "", "") + + f.Fuzz(func(t *testing.T, title, typ, body, slug, topicID string) { + var topicIDs []string + if topicID != "" { + topicIDs = []string{topicID} + } + input := ProposeContentInput{ + Title: title, + Type: typ, + Body: body, + Slug: slug, + TopicIDs: topicIDs, + } + // Must not panic on any input. + _, _, _, _ = validateProposeContent(input) + }) +} + +// FuzzParseTopicIDs fuzzes the UUID-parsing path with arbitrary string slices. +func FuzzParseTopicIDs(f *testing.F) { + f.Add("") + f.Add("550e8400-e29b-41d4-a716-446655440000") + f.Add("not-a-uuid") + f.Add("'; DROP TABLE topics; --") + f.Add("\x00") + f.Add(strings.Repeat("f", 36)) + f.Add(uuid.New().String()) + + f.Fuzz(func(t *testing.T, raw string) { + var rawSlice []string + if raw != "" { + rawSlice = []string{raw} + } + // Must not panic on any input. + _, _ = parseTopicIDs(rawSlice) + }) +} From 7b45070ea2982acd64b07306c4f9ad5bd260118d Mon Sep 17 00:00:00 2001 From: Koopa Date: Wed, 24 Jun 2026 16:46:54 +0800 Subject: [PATCH 18/31] test(feed): replace fictional handler tests with real integration coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scheduler_test.go drove a hand-rolled feedTestHandler — a parallel reimplementation of the production handler (via a test-only feedHandlerStore interface) that had diverged, so the real handler's behavior was asserted nowhere. Delete feedTestHandler/feedHandlerStore and the TestFeedHandler_* tests, plus the TestMaxConsecutiveFailures const==5 tautology. Add real *feed.Handler Create/Update/Delete/Fetch tests and an IncrementFailure auto-disable test against a testcontainers store. Extract the scheduler's due predicate into a pure due() function (zero behaviour change — the skip condition is identical) and unit-test it directly via TestDue. --- internal/feed/integration_test.go | 377 ++++++++++++++++- internal/feed/scheduler.go | 14 +- internal/feed/scheduler_test.go | 674 ++---------------------------- 3 files changed, 419 insertions(+), 646 deletions(-) diff --git a/internal/feed/integration_test.go b/internal/feed/integration_test.go index 51144ecc..d1461962 100644 --- a/internal/feed/integration_test.go +++ b/internal/feed/integration_test.go @@ -17,15 +17,22 @@ package feed_test import ( "context" + "encoding/json" "errors" + "io" "log/slog" + "net/http" + "net/http/httptest" "os" + "strconv" + "strings" "testing" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" "github.com/Koopa0/koopa/internal/agent" + "github.com/Koopa0/koopa/internal/api" "github.com/Koopa0/koopa/internal/feed" "github.com/Koopa0/koopa/internal/testdb" ) @@ -54,7 +61,7 @@ func TestMain(m *testing.M) { func truncate(t *testing.T) { t.Helper() if _, err := testPool.Exec(t.Context(), - `TRUNCATE feeds, feed_topics, activity_events CASCADE`); err != nil { + `TRUNCATE feeds, feed_topics, topics, activity_events CASCADE`); err != nil { t.Fatalf("truncate: %v", err) } } @@ -135,3 +142,371 @@ func TestIntegration_Feed_InvalidInput(t *testing.T) { }) } } + +// fakeFetcher is a hand-written fake for the feed.ManualFetcher consumer +// interface. The real fetcher (collector) makes outbound HTTP, which is not +// containerizable; per rules/testing.md § Test Doubles a plain-struct fake of +// an existing consumer interface is allowed here. It is asserted on the +// handler's OUTPUT (the new_items count), never on call order. +type fakeFetcher struct { + ids []uuid.UUID + err error +} + +func (f *fakeFetcher) FetchFeed(context.Context, *feed.Feed) ([]uuid.UUID, error) { + return f.ids, f.err +} + +// newFeedHandler wires a real *feed.Handler against the shared test pool. +func newFeedHandler(fetcher feed.ManualFetcher) *feed.Handler { + return feed.NewHandler(feed.NewStore(testPool, slog.Default()), fetcher, slog.Default()) +} + +// serveAdmin runs an admin mutation request through api.ActorMiddleware +// (actor="human") into the handler, exactly like the production adminMid chain. +// Feed Create/Update need the per-request tx the middleware binds (atomic +// feed+feed_topics write) and the audit trigger reads koopa.actor from it. +func serveAdmin(t *testing.T, h http.HandlerFunc, req *http.Request) *httptest.ResponseRecorder { + t.Helper() + mid := api.ActorMiddleware(testPool, "human", slog.Default()) + rec := httptest.NewRecorder() + mid(h).ServeHTTP(rec, req) + return rec +} + +// seedTopic inserts a topic and returns its id, for the topic_id parsing path. +func seedTopic(t *testing.T, slug, name string) uuid.UUID { + t.Helper() + var id uuid.UUID + if err := testPool.QueryRow(t.Context(), + `INSERT INTO topics (slug, name) VALUES ($1, $2) RETURNING id`, + slug, name, + ).Scan(&id); err != nil { + t.Fatalf("seeding topic %q: %v", slug, err) + } + return id +} + +// errEnvelope decodes the standard api error envelope. +func errEnvelope(t *testing.T, body []byte) string { + t.Helper() + var env struct { + Error struct { + Code string `json:"code"` + } `json:"error"` + } + if err := json.Unmarshal(body, &env); err != nil { + t.Fatalf("decode error envelope: %v (body=%s)", err, body) + } + return env.Error.Code +} + +// TestIntegration_FeedHandler_Create drives POST /api/admin/feeds through the +// real *feed.Handler + ActorMiddleware against a real store. It pins the +// topic_id parse path: a valid topic_id is written into feed_topics, a malformed +// topic_id is a 400 INVALID-form rejection before the store, and a well-formed +// but non-existent topic_id surfaces the FK (23503 → ErrTopicNotFound) as 400. +func TestIntegration_FeedHandler_Create(t *testing.T) { + truncate(t) + h := newFeedHandler(nil) + topicID := seedTopic(t, "go", "Go") + + tests := []struct { + name string + body string + wantStatus int + wantErrCode string + // wantTopicLinked, when set, asserts exactly one feed_topics row exists + // for the created feed after a 201. + wantTopicLinked bool + }{ + { + name: "valid topic_id is linked", + body: `{"url":"https://example.com/go.xml","name":"Go feed","schedule":"daily","topic_ids":["` + topicID.String() + `"]}`, + wantStatus: http.StatusCreated, + wantTopicLinked: true, + }, + { + name: "malformed topic_id is rejected before the store", + body: `{"url":"https://example.com/bad.xml","name":"Bad topic","schedule":"daily","topic_ids":["not-a-uuid"]}`, + wantStatus: http.StatusBadRequest, + wantErrCode: "BAD_REQUEST", + }, + { + name: "non-existent topic_id surfaces FK as 400", + body: `{"url":"https://example.com/missing.xml","name":"Missing topic","schedule":"daily","topic_ids":["` + uuid.New().String() + `"]}`, + wantStatus: http.StatusBadRequest, + wantErrCode: "TOPIC_NOT_FOUND", + }, + { + name: "invalid schedule rejected at handler", + body: `{"url":"https://example.com/yearly.xml","name":"Bad sched","schedule":"yearly"}`, + wantStatus: http.StatusBadRequest, + wantErrCode: "BAD_REQUEST", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/api/admin/feeds", strings.NewReader(tt.body)) + req.Header.Set("Content-Type", "application/json") + rec := serveAdmin(t, h.Create, req) + + resp := rec.Result() + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != tt.wantStatus { + t.Fatalf("Create status = %d, want %d (body=%s)", resp.StatusCode, tt.wantStatus, body) + } + if tt.wantErrCode != "" { + if code := errEnvelope(t, body); code != tt.wantErrCode { + t.Errorf("Create error.code = %q, want %q (body=%s)", code, tt.wantErrCode, body) + } + return + } + if tt.wantTopicLinked { + var env struct { + Data struct { + ID uuid.UUID `json:"id"` + } `json:"data"` + } + if err := json.Unmarshal(body, &env); err != nil { + t.Fatalf("decode create response: %v (body=%s)", err, body) + } + var n int + if err := testPool.QueryRow(t.Context(), + `SELECT COUNT(*) FROM feed_topics WHERE feed_id = $1 AND topic_id = $2`, + env.Data.ID, topicID, + ).Scan(&n); err != nil { + t.Fatalf("counting feed_topics: %v", err) + } + if n != 1 { + t.Errorf("feed_topics rows for created feed = %d, want 1", n) + } + } + }) + } +} + +// TestIntegration_FeedHandler_Update pins the present-but-empty rejection +// asymmetry: a PUT carrying url:"" or name:"" is a 400 at the handler boundary +// (mirroring chk_feed_url_scheme / chk_feed_name_not_blank) instead of a 500 at +// the DB, while an omitted field leaves the value unchanged. It drives the real +// *feed.Handler + ActorMiddleware against a seeded feed. +func TestIntegration_FeedHandler_Update(t *testing.T) { + truncate(t) + h := newFeedHandler(nil) + + tests := []struct { + name string + body string + wantStatus int + wantErrCode string + // wantName, when non-empty, asserts the persisted name after a 200. + wantName string + }{ + { + name: "present-but-empty url is a 400", + body: `{"url":""}`, + wantStatus: http.StatusBadRequest, + wantErrCode: "BAD_REQUEST", + }, + { + name: "present-but-empty name is a 400", + body: `{"name":""}`, + wantStatus: http.StatusBadRequest, + wantErrCode: "BAD_REQUEST", + }, + { + name: "invalid schedule is a 400", + body: `{"schedule":"yearly"}`, + wantStatus: http.StatusBadRequest, + wantErrCode: "BAD_REQUEST", + }, + { + name: "omitted url, valid name change persists", + body: `{"name":"Renamed feed"}`, + wantStatus: http.StatusOK, + wantName: "Renamed feed", + }, + } + + for i, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + id := seedFeed(t, "https://example.com/upd-"+strconv.Itoa(i)+".xml", "Update target") + req := httptest.NewRequest(http.MethodPut, "/api/admin/feeds/"+id.String(), strings.NewReader(tt.body)) + req.Header.Set("Content-Type", "application/json") + req.SetPathValue("id", id.String()) + rec := serveAdmin(t, h.Update, req) + + resp := rec.Result() + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != tt.wantStatus { + t.Fatalf("Update status = %d, want %d (body=%s)", resp.StatusCode, tt.wantStatus, body) + } + if tt.wantErrCode != "" { + if code := errEnvelope(t, body); code != tt.wantErrCode { + t.Errorf("Update error.code = %q, want %q (body=%s)", code, tt.wantErrCode, body) + } + // The seeded url must survive a rejected update. + var url string + if err := testPool.QueryRow(t.Context(), `SELECT url FROM feeds WHERE id = $1`, id).Scan(&url); err != nil { + t.Fatalf("reading url after rejected update: %v", err) + } + if !strings.HasPrefix(url, "https://example.com/upd-") { + t.Errorf("url changed to %q after a rejected update, want unchanged", url) + } + return + } + var name string + if err := testPool.QueryRow(t.Context(), `SELECT name FROM feeds WHERE id = $1`, id).Scan(&name); err != nil { + t.Fatalf("reading name after update: %v", err) + } + if name != tt.wantName { + t.Errorf("persisted name = %q, want %q", name, tt.wantName) + } + }) + } +} + +// TestIntegration_FeedHandler_Delete drives DELETE /api/admin/feeds/{id}: a +// valid id returns 204 and removes the row; a malformed id is a 400. +func TestIntegration_FeedHandler_Delete(t *testing.T) { + truncate(t) + h := newFeedHandler(nil) + + t.Run("valid id deletes the row", func(t *testing.T) { + id := seedFeed(t, "https://example.com/del.xml", "Delete me") + req := httptest.NewRequest(http.MethodDelete, "/api/admin/feeds/"+id.String(), http.NoBody) + req.SetPathValue("id", id.String()) + rec := serveAdmin(t, h.Delete, req) + if rec.Code != http.StatusNoContent { + t.Fatalf("Delete status = %d, want 204 (body=%s)", rec.Code, rec.Body.String()) + } + var n int + if err := testPool.QueryRow(t.Context(), `SELECT COUNT(*) FROM feeds WHERE id = $1`, id).Scan(&n); err != nil { + t.Fatalf("counting feed after delete: %v", err) + } + if n != 0 { + t.Errorf("feed rows after delete = %d, want 0", n) + } + }) + + t.Run("malformed id is a 400", func(t *testing.T) { + req := httptest.NewRequest(http.MethodDelete, "/api/admin/feeds/not-a-uuid", http.NoBody) + req.SetPathValue("id", "not-a-uuid") + rec := serveAdmin(t, h.Delete, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("Delete(bad id) status = %d, want 400", rec.Code) + } + if code := errEnvelope(t, rec.Body.Bytes()); code != "BAD_REQUEST" { + t.Errorf("Delete(bad id) error.code = %q, want BAD_REQUEST", code) + } + }) +} + +// TestIntegration_FeedHandler_Fetch drives POST /api/admin/feeds/{id}/fetch +// through the real handler with a fake fetcher. The store reads the feed by id +// (real DB), then the handler returns the fetcher's new_items count. A 404 is +// returned when the feed id does not exist, exercising the real storeErrors map. +func TestIntegration_FeedHandler_Fetch(t *testing.T) { + truncate(t) + + t.Run("existing feed returns fetcher item count", func(t *testing.T) { + id := seedFeed(t, "https://example.com/fetch.xml", "Fetch me") + h := newFeedHandler(&fakeFetcher{ids: []uuid.UUID{uuid.New(), uuid.New(), uuid.New()}}) + req := httptest.NewRequest(http.MethodPost, "/api/admin/feeds/"+id.String()+"/fetch", http.NoBody) + req.SetPathValue("id", id.String()) + rec := httptest.NewRecorder() + h.Fetch(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("Fetch status = %d, want 200 (body=%s)", rec.Code, rec.Body.String()) + } + var env struct { + Data struct { + NewItems int `json:"new_items"` + } `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil { + t.Fatalf("decode fetch response: %v (body=%s)", err, rec.Body.String()) + } + if env.Data.NewItems != 3 { + t.Errorf("new_items = %d, want 3", env.Data.NewItems) + } + }) + + t.Run("unknown feed id is a 404", func(t *testing.T) { + h := newFeedHandler(&fakeFetcher{}) + missing := uuid.New() + req := httptest.NewRequest(http.MethodPost, "/api/admin/feeds/"+missing.String()+"/fetch", http.NoBody) + req.SetPathValue("id", missing.String()) + rec := httptest.NewRecorder() + h.Fetch(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("Fetch(unknown id) status = %d, want 404 (body=%s)", rec.Code, rec.Body.String()) + } + if code := errEnvelope(t, rec.Body.Bytes()); code != "NOT_FOUND" { + t.Errorf("Fetch(unknown id) error.code = %q, want NOT_FOUND", code) + } + }) +} + +// TestIntegration_Feed_IncrementFailure_AutoDisable proves the auto-disable +// invariant against real DB state: Store.IncrementFailure called exactly +// MaxConsecutiveFailures (5) times leaves the feed disabled with +// disabled_reason set, and the counter at the threshold. The fourth call must +// NOT yet disable — the boundary is the behavior under test, not just the final +// state. +func TestIntegration_Feed_IncrementFailure_AutoDisable(t *testing.T) { + truncate(t) + store := feed.NewStore(testPool, slog.Default()) + ctx := t.Context() + + id := seedFeed(t, "https://example.com/failing.xml", "Flaky feed") + + // readState reads the persisted auto-disable state. + readState := func() (enabled bool, reason *string, failures int) { + t.Helper() + if err := testPool.QueryRow(ctx, + `SELECT enabled, disabled_reason, consecutive_failures FROM feeds WHERE id = $1`, id, + ).Scan(&enabled, &reason, &failures); err != nil { + t.Fatalf("reading feed state: %v", err) + } + return enabled, reason, failures + } + + // After 4 failures (one below MaxConsecutiveFailures), the feed must still + // be enabled with no disabled_reason — the threshold is not yet reached. + for i := range 4 { + if err := store.IncrementFailure(ctx, id, "fetch timeout"); err != nil { + t.Fatalf("IncrementFailure #%d: %v", i+1, err) + } + } + if enabled, reason, failures := readState(); !enabled || reason != nil || failures != 4 { + t.Fatalf("after 4 failures: enabled=%v reason=%v failures=%d, want enabled=true reason=nil failures=4", + enabled, reason, failures) + } + + // The 5th failure reaches MaxConsecutiveFailures and must auto-disable. + if err := store.IncrementFailure(ctx, id, "fetch timeout"); err != nil { + t.Fatalf("IncrementFailure #5: %v", err) + } + enabled, reason, failures := readState() + if enabled { + t.Errorf("after 5 failures: enabled = true, want false (auto-disabled)") + } + if failures != 5 { + t.Errorf("after 5 failures: consecutive_failures = %d, want 5", failures) + } + if reason == nil { + t.Fatalf("after 5 failures: disabled_reason = nil, want a recorded reason") + } + if !strings.Contains(*reason, "5 consecutive failures") { + t.Errorf("disabled_reason = %q, want it to mention 5 consecutive failures", *reason) + } +} diff --git a/internal/feed/scheduler.go b/internal/feed/scheduler.go index 5fc450dd..0e791c4f 100644 --- a/internal/feed/scheduler.go +++ b/internal/feed/scheduler.go @@ -130,6 +130,18 @@ func (s *Scheduler) fetchDueFeeds(ctx context.Context) { } } +// due reports whether a feed is due for a fetch on this tick. A feed that has +// never been fetched (lastFetched == nil) is always due; otherwise it is due +// once its age (now - *lastFetched) reaches the schedule interval. The boundary +// is inclusive — age exactly equal to interval is due — because the comparison +// skips only when the age is strictly less than the interval. +func due(lastFetched *time.Time, now time.Time, interval time.Duration) bool { + if lastFetched == nil { + return true + } + return now.Sub(*lastFetched) >= interval +} + // fetchSchedule fetches all enabled feeds for a given schedule that are past their interval. func (s *Scheduler) fetchSchedule(ctx context.Context, schedule string, interval time.Duration) error { feeds, err := s.feeds.EnabledFeedsBySchedule(ctx, schedule) @@ -146,7 +158,7 @@ func (s *Scheduler) fetchSchedule(ctx context.Context, schedule string, interval } f := &feeds[i] - if f.LastFetchedAt != nil && now.Sub(*f.LastFetchedAt) < interval { + if !due(f.LastFetchedAt, now, interval) { skipped++ continue } diff --git a/internal/feed/scheduler_test.go b/internal/feed/scheduler_test.go index 5d335d51..6305c05b 100644 --- a/internal/feed/scheduler_test.go +++ b/internal/feed/scheduler_test.go @@ -3,20 +3,11 @@ package feed import ( - "context" "encoding/json" - "errors" - "io" - "log/slog" - "net/http" - "net/http/httptest" - "strings" "testing" + "time" "github.com/google/go-cmp/cmp" - "github.com/google/uuid" - - "github.com/Koopa0/koopa/internal/api" ) // --------------------------------------------------------------------------- @@ -491,659 +482,54 @@ func TestValidSchedule_Adversarial(t *testing.T) { } // --------------------------------------------------------------------------- -// Stub store and fetcher for handler tests +// due — scheduler skip-vs-fetch decision (pure) // --------------------------------------------------------------------------- -type stubFeedStore struct { - feeds []Feed - feedsErr error - feed *Feed - feedErr error - created *Feed - createErr error - updated *Feed - updateErr error - deleteErr error -} - -func (s *stubFeedStore) Feeds(_ context.Context, _ *string) ([]Feed, error) { - return s.feeds, s.feedsErr -} - -func (s *stubFeedStore) Feed(_ context.Context, _ uuid.UUID) (*Feed, error) { - return s.feed, s.feedErr -} - -func (s *stubFeedStore) CreateFeed(_ context.Context, _ *CreateParams) (*Feed, error) { - return s.created, s.createErr -} - -func (s *stubFeedStore) UpdateFeed(_ context.Context, _ uuid.UUID, _ *UpdateParams) (*Feed, error) { - return s.updated, s.updateErr -} - -func (s *stubFeedStore) DeleteFeed(_ context.Context, _ uuid.UUID) error { - return s.deleteErr -} - -// feedHandlerStore is a test-only interface (violates interface-golden-rule.md). -// Kept because 30+ handler behavior tests depend on it. Refactoring to -// testcontainers would make these integration-only (require Docker). -type feedHandlerStore interface { - Feeds(ctx context.Context, schedule *string) ([]Feed, error) - Feed(ctx context.Context, id uuid.UUID) (*Feed, error) - CreateFeed(ctx context.Context, p *CreateParams) (*Feed, error) - UpdateFeed(ctx context.Context, id uuid.UUID, p *UpdateParams) (*Feed, error) - DeleteFeed(ctx context.Context, id uuid.UUID) error -} - -type stubManualFetcher struct { - ids []uuid.UUID - err error -} - -func (s *stubManualFetcher) FetchFeed(_ context.Context, _ *Feed) ([]uuid.UUID, error) { - return s.ids, s.err -} - -// feedTestHandler wraps Handler and injects stub store. -type feedTestHandler struct { - store feedHandlerStore - fetcher ManualFetcher - logger *slog.Logger -} - -func newFeedTestHandler(store feedHandlerStore, fetcher ManualFetcher) *feedTestHandler { - return &feedTestHandler{ - store: store, - fetcher: fetcher, - logger: slog.New(slog.NewTextHandler(io.Discard, nil)), - } -} - -func (h *feedTestHandler) List(w http.ResponseWriter, r *http.Request) { - var schedule *string - if s := r.URL.Query().Get("schedule"); s != "" { - schedule = &s - } - feeds, err := h.store.Feeds(r.Context(), schedule) - if err != nil { - h.logger.Error("listing feeds", "error", err) - api.Error(w, http.StatusInternalServerError, "INTERNAL", "failed to list feeds") - return - } - api.Encode(w, http.StatusOK, api.Response{Data: feeds}) -} - -func (h *feedTestHandler) Create(w http.ResponseWriter, r *http.Request) { - p, err := api.Decode[CreateParams](w, r) - if err != nil { - api.Error(w, http.StatusBadRequest, "BAD_REQUEST", "invalid request body") - return - } - if p.URL == "" || p.Name == "" || p.Schedule == "" { - api.Error(w, http.StatusBadRequest, "BAD_REQUEST", "url, name, and schedule are required") - return - } - if !ValidSchedule(p.Schedule) { - api.Error(w, http.StatusBadRequest, "BAD_REQUEST", "invalid schedule value") - return - } - f, err := h.store.CreateFeed(r.Context(), &p) - if err != nil { - storeErrors := []api.ErrMap{ - {Target: ErrNotFound, Status: http.StatusNotFound, Code: "NOT_FOUND", Message: "feed not found"}, - {Target: ErrConflict, Status: http.StatusConflict, Code: "CONFLICT", Message: "feed conflict"}, - } - api.HandleError(w, h.logger, err, storeErrors...) - return - } - api.Encode(w, http.StatusCreated, api.Response{Data: f}) -} - -func (h *feedTestHandler) Update(w http.ResponseWriter, r *http.Request) { - id, err := uuid.Parse(r.PathValue("id")) - if err != nil { - api.Error(w, http.StatusBadRequest, "BAD_REQUEST", "invalid feed id") - return - } - p, err := api.Decode[UpdateParams](w, r) - if err != nil { - api.Error(w, http.StatusBadRequest, "BAD_REQUEST", "invalid request body") - return - } - if p.Schedule != nil && !ValidSchedule(*p.Schedule) { - api.Error(w, http.StatusBadRequest, "BAD_REQUEST", "invalid schedule value") - return - } - f, err := h.store.UpdateFeed(r.Context(), id, &p) - if err != nil { - storeErrors := []api.ErrMap{ - {Target: ErrNotFound, Status: http.StatusNotFound, Code: "NOT_FOUND", Message: "feed not found"}, - {Target: ErrConflict, Status: http.StatusConflict, Code: "CONFLICT", Message: "feed conflict"}, - } - api.HandleError(w, h.logger, err, storeErrors...) - return - } - api.Encode(w, http.StatusOK, api.Response{Data: f}) -} - -func (h *feedTestHandler) Delete(w http.ResponseWriter, r *http.Request) { - id, err := uuid.Parse(r.PathValue("id")) - if err != nil { - api.Error(w, http.StatusBadRequest, "BAD_REQUEST", "invalid feed id") - return - } - if err := h.store.DeleteFeed(r.Context(), id); err != nil { - h.logger.Error("deleting feed", "id", id, "error", err) - api.Error(w, http.StatusInternalServerError, "INTERNAL", "failed to delete feed") - return - } - w.WriteHeader(http.StatusNoContent) -} - -func (h *feedTestHandler) Fetch(w http.ResponseWriter, r *http.Request) { - if h.fetcher == nil { - api.Error(w, http.StatusNotImplemented, "NOT_IMPLEMENTED", "feed fetcher not available") - return - } - id, err := uuid.Parse(r.PathValue("id")) - if err != nil { - api.Error(w, http.StatusBadRequest, "BAD_REQUEST", "invalid feed id") - return - } - f, err := h.store.Feed(r.Context(), id) - if err != nil { - storeErrors := []api.ErrMap{ - {Target: ErrNotFound, Status: http.StatusNotFound, Code: "NOT_FOUND", Message: "feed not found"}, - {Target: ErrConflict, Status: http.StatusConflict, Code: "CONFLICT", Message: "feed conflict"}, - } - api.HandleError(w, h.logger, err, storeErrors...) - return - } - ids, err := h.fetcher.FetchFeed(r.Context(), f) - if err != nil { - h.logger.Error("fetching feed", "id", id, "error", err) - api.Error(w, http.StatusInternalServerError, "INTERNAL", "failed to fetch feed") - return - } - type fetchResponse struct { - NewItems int `json:"new_items"` - } - api.Encode(w, http.StatusOK, api.Response{Data: fetchResponse{NewItems: len(ids)}}) -} - -func assertFeedErrorCode(t *testing.T, w *httptest.ResponseRecorder, wantStatus int, wantCode string) { - t.Helper() - if w.Code != wantStatus { - t.Errorf("status = %d, want %d (body: %s)", w.Code, wantStatus, w.Body.String()) - } - var eb api.ErrorBody - if err := json.NewDecoder(w.Body).Decode(&eb); err != nil { - t.Fatalf("decoding error body: %v", err) - } - if diff := cmp.Diff(wantCode, eb.Error.Code); diff != "" { - t.Errorf("error code mismatch (-want +got):\n%s", diff) - } -} - -// fixtureFeed returns a stable test Feed. -func fixtureFeed() *Feed { - return &Feed{ - ID: uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), - URL: "https://example.com/feed.xml", - Name: "Example Feed", - Schedule: ScheduleDaily, - Topics: []string{"go"}, - Enabled: true, - Priority: "normal", - } -} - -// --------------------------------------------------------------------------- -// Handler.List tests -// --------------------------------------------------------------------------- - -func TestFeedHandler_List(t *testing.T) { +// TestDue exercises the scheduler's per-feed skip-vs-fetch predicate +// (scheduler.go::due, called from fetchSchedule). The decision is a pure +// function of (lastFetched, now, interval): a never-fetched feed is always due; +// a fetched feed is due once its age reaches the interval, with the boundary +// inclusive. Expected values are hand-computed against a fixed `now`. +// +// Mutation it catches: flipping the comparison to `>` (excluding the exact +// boundary) breaks "exactly interval ago" → due; dropping the nil guard panics +// on a never-fetched feed; using `<=` instead of `<` in the original inline +// skip check would make "exactly interval ago" skip — each surfaces here. +func TestDue(t *testing.T) { t.Parallel() - tests := []struct { - name string - stub *stubFeedStore - query string - wantStatus int - wantLen int - wantCode string - }{ - { - name: "returns feeds", - stub: &stubFeedStore{feeds: []Feed{*fixtureFeed()}}, - wantStatus: http.StatusOK, - wantLen: 1, - }, - { - name: "returns empty list", - stub: &stubFeedStore{feeds: []Feed{}}, - wantStatus: http.StatusOK, - wantLen: 0, - }, - { - name: "nil slice from store", - stub: &stubFeedStore{feeds: nil}, - wantStatus: http.StatusOK, - }, - { - name: "store error returns 500", - stub: &stubFeedStore{feedsErr: errors.New("db down")}, - wantStatus: http.StatusInternalServerError, - wantCode: "INTERNAL", - }, - { - name: "with schedule filter", - stub: &stubFeedStore{feeds: []Feed{*fixtureFeed()}}, - query: "?schedule=daily", - wantStatus: http.StatusOK, - wantLen: 1, - }, - } + now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC) + const interval = time.Hour - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - h := newFeedTestHandler(tt.stub, nil) - req := httptest.NewRequest(http.MethodGet, "/api/admin/feeds"+tt.query, http.NoBody) - w := httptest.NewRecorder() - h.List(w, req) - - if tt.wantCode != "" { - assertFeedErrorCode(t, w, tt.wantStatus, tt.wantCode) - return - } - if w.Code != tt.wantStatus { - t.Fatalf("List() status = %d, want %d", w.Code, tt.wantStatus) - } - }) + ago := func(d time.Duration) *time.Time { + ts := now.Add(-d) + return &ts } -} - -// --------------------------------------------------------------------------- -// Handler.Create tests -// --------------------------------------------------------------------------- - -func TestFeedHandler_Create(t *testing.T) { - t.Parallel() tests := []struct { - name string - body string - stub *stubFeedStore - wantStatus int - wantCode string + name string + lastFetched *time.Time + want bool }{ - { - name: "happy path creates feed", - body: `{"url":"https://example.com/feed.xml","name":"Example","schedule":"daily","topics":["go"]}`, - stub: &stubFeedStore{created: fixtureFeed()}, - wantStatus: http.StatusCreated, - }, - { - name: "missing url returns 400", - body: `{"name":"Example","schedule":"daily"}`, - stub: &stubFeedStore{}, - wantStatus: http.StatusBadRequest, - wantCode: "BAD_REQUEST", - }, - { - name: "missing name returns 400", - body: `{"url":"https://example.com/feed.xml","schedule":"daily"}`, - stub: &stubFeedStore{}, - wantStatus: http.StatusBadRequest, - wantCode: "BAD_REQUEST", - }, - { - name: "missing schedule returns 400", - body: `{"url":"https://example.com/feed.xml","name":"Example"}`, - stub: &stubFeedStore{}, - wantStatus: http.StatusBadRequest, - wantCode: "BAD_REQUEST", - }, - { - name: "invalid schedule returns 400", - body: `{"url":"https://example.com/feed.xml","name":"Example","schedule":"yearly"}`, - stub: &stubFeedStore{}, - wantStatus: http.StatusBadRequest, - wantCode: "BAD_REQUEST", - }, - { - name: "duplicate URL returns 409", - body: `{"url":"https://example.com/feed.xml","name":"Example","schedule":"daily"}`, - stub: &stubFeedStore{createErr: ErrConflict}, - wantStatus: http.StatusConflict, - wantCode: "CONFLICT", - }, - { - name: "malformed JSON returns 400", - body: `{not valid`, - stub: &stubFeedStore{}, - wantStatus: http.StatusBadRequest, - wantCode: "BAD_REQUEST", - }, - { - name: "empty body returns 400", - body: ``, - stub: &stubFeedStore{}, - wantStatus: http.StatusBadRequest, - wantCode: "BAD_REQUEST", - }, - { - name: "hourly schedule is valid", - body: `{"url":"https://example.com/feed.xml","name":"Example","schedule":"hourly"}`, - stub: &stubFeedStore{created: fixtureFeed()}, - wantStatus: http.StatusCreated, - }, - { - name: "weekly schedule is valid", - body: `{"url":"https://example.com/feed.xml","name":"Example","schedule":"weekly"}`, - stub: &stubFeedStore{created: fixtureFeed()}, - wantStatus: http.StatusCreated, - }, - { - name: "biweekly schedule is valid", - body: `{"url":"https://example.com/feed.xml","name":"Example","schedule":"biweekly"}`, - stub: &stubFeedStore{created: fixtureFeed()}, - wantStatus: http.StatusCreated, - }, - { - name: "monthly schedule is valid", - body: `{"url":"https://example.com/feed.xml","name":"Example","schedule":"monthly"}`, - stub: &stubFeedStore{created: fixtureFeed()}, - wantStatus: http.StatusCreated, - }, - { - name: "XSS in name is forwarded to store", - body: `{"url":"https://example.com/feed.xml","name":"","schedule":"daily"}`, - stub: &stubFeedStore{created: fixtureFeed()}, - wantStatus: http.StatusCreated, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - h := newFeedTestHandler(tt.stub, nil) - req := httptest.NewRequest(http.MethodPost, "/api/admin/feeds", strings.NewReader(tt.body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - h.Create(w, req) - - if tt.wantCode != "" { - assertFeedErrorCode(t, w, tt.wantStatus, tt.wantCode) - return - } - if w.Code != tt.wantStatus { - t.Fatalf("Create() status = %d, want %d (body: %s)", w.Code, tt.wantStatus, w.Body.String()) - } - }) - } -} - -// --------------------------------------------------------------------------- -// Handler.Update tests -// --------------------------------------------------------------------------- - -func TestFeedHandler_Update(t *testing.T) { - t.Parallel() - - validID := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") - sched := ScheduleWeekly - - tests := []struct { - name string - id string - body string - stub *stubFeedStore - wantStatus int - wantCode string - }{ - { - name: "happy path updates feed", - id: validID.String(), - body: `{"schedule":"weekly"}`, - stub: &stubFeedStore{updated: fixtureFeed()}, - wantStatus: http.StatusOK, - }, - { - name: "invalid uuid returns 400", - id: "not-a-uuid", - body: `{"schedule":"daily"}`, - stub: &stubFeedStore{}, - wantStatus: http.StatusBadRequest, - wantCode: "BAD_REQUEST", - }, - { - name: "invalid schedule returns 400", - id: validID.String(), - body: `{"schedule":"yearly"}`, - stub: &stubFeedStore{}, - wantStatus: http.StatusBadRequest, - wantCode: "BAD_REQUEST", - }, - { - name: "not found returns 404", - id: validID.String(), - body: `{"schedule":"daily"}`, - stub: &stubFeedStore{updateErr: ErrNotFound}, - wantStatus: http.StatusNotFound, - wantCode: "NOT_FOUND", - }, - { - name: "conflict returns 409", - id: validID.String(), - body: `{"url":"https://other.com/feed.xml"}`, - stub: &stubFeedStore{updateErr: ErrConflict}, - wantStatus: http.StatusConflict, - wantCode: "CONFLICT", - }, - { - name: "valid schedule pointer accepted", - id: validID.String(), - body: `{"schedule":"weekly"}`, - stub: &stubFeedStore{updated: fixtureFeed()}, - wantStatus: http.StatusOK, - }, - } - _ = sched // suppress unused variable - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - h := newFeedTestHandler(tt.stub, nil) - req := httptest.NewRequest(http.MethodPut, "/api/admin/feeds/"+tt.id, strings.NewReader(tt.body)) - req.Header.Set("Content-Type", "application/json") - req.SetPathValue("id", tt.id) - w := httptest.NewRecorder() - h.Update(w, req) - - if tt.wantCode != "" { - assertFeedErrorCode(t, w, tt.wantStatus, tt.wantCode) - return - } - if w.Code != tt.wantStatus { - t.Fatalf("Update(%q) status = %d, want %d (body: %s)", tt.id, w.Code, tt.wantStatus, w.Body.String()) - } - }) - } -} - -// --------------------------------------------------------------------------- -// Handler.Delete tests -// --------------------------------------------------------------------------- - -func TestFeedHandler_Delete(t *testing.T) { - t.Parallel() - - validID := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") - - tests := []struct { - name string - id string - stub *stubFeedStore - wantStatus int - wantCode string - }{ - { - name: "deletes feed", - id: validID.String(), - stub: &stubFeedStore{}, - wantStatus: http.StatusNoContent, - }, - { - name: "invalid uuid returns 400", - id: "not-a-uuid", - stub: &stubFeedStore{}, - wantStatus: http.StatusBadRequest, - wantCode: "BAD_REQUEST", - }, - { - name: "store error returns 500", - id: validID.String(), - stub: &stubFeedStore{deleteErr: errors.New("db error")}, - wantStatus: http.StatusInternalServerError, - wantCode: "INTERNAL", - }, - { - name: "nil UUID is valid UUID", - id: uuid.Nil.String(), - stub: &stubFeedStore{}, - wantStatus: http.StatusNoContent, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - h := newFeedTestHandler(tt.stub, nil) - req := httptest.NewRequest(http.MethodDelete, "/api/admin/feeds/"+tt.id, http.NoBody) - req.SetPathValue("id", tt.id) - w := httptest.NewRecorder() - h.Delete(w, req) - - if tt.wantCode != "" { - assertFeedErrorCode(t, w, tt.wantStatus, tt.wantCode) - return - } - if w.Code != tt.wantStatus { - t.Fatalf("Delete(%q) status = %d, want %d", tt.id, w.Code, tt.wantStatus) - } - }) - } -} - -// --------------------------------------------------------------------------- -// Handler.Fetch tests -// --------------------------------------------------------------------------- - -func TestFeedHandler_Fetch(t *testing.T) { - t.Parallel() - - validID := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") - - tests := []struct { - name string - id string - stub *stubFeedStore - fetcher ManualFetcher - wantStatus int - wantCode string - wantItems int - }{ - { - name: "nil fetcher returns 501", - id: validID.String(), - stub: &stubFeedStore{feed: fixtureFeed()}, - fetcher: nil, - wantStatus: http.StatusNotImplemented, - wantCode: "NOT_IMPLEMENTED", - }, - { - name: "invalid uuid returns 400", - id: "not-a-uuid", - stub: &stubFeedStore{}, - fetcher: &stubManualFetcher{}, - wantStatus: http.StatusBadRequest, - wantCode: "BAD_REQUEST", - }, - { - name: "feed not found returns 404", - id: validID.String(), - stub: &stubFeedStore{feedErr: ErrNotFound}, - fetcher: &stubManualFetcher{}, - wantStatus: http.StatusNotFound, - wantCode: "NOT_FOUND", - }, - { - name: "fetcher returns new items", - id: validID.String(), - stub: &stubFeedStore{feed: fixtureFeed()}, - fetcher: &stubManualFetcher{ids: []uuid.UUID{uuid.New(), uuid.New()}}, - wantStatus: http.StatusOK, - wantItems: 2, - }, - { - name: "fetcher error returns 500", - id: validID.String(), - stub: &stubFeedStore{feed: fixtureFeed()}, - fetcher: &stubManualFetcher{err: errors.New("timeout")}, - wantStatus: http.StatusInternalServerError, - wantCode: "INTERNAL", - }, - { - name: "fetcher returns zero new items", - id: validID.String(), - stub: &stubFeedStore{feed: fixtureFeed()}, - fetcher: &stubManualFetcher{ids: []uuid.UUID{}}, - wantStatus: http.StatusOK, - wantItems: 0, - }, + {name: "never fetched is due", lastFetched: nil, want: true}, + {name: "fetched 30m ago, 1h interval — not due", lastFetched: ago(30 * time.Minute), want: false}, + {name: "fetched 59m59s ago — not due (just under interval)", lastFetched: ago(59*time.Minute + 59*time.Second), want: false}, + {name: "fetched exactly 1h ago — due (inclusive boundary)", lastFetched: ago(interval), want: true}, + {name: "fetched 2h ago — due (well past interval)", lastFetched: ago(2 * time.Hour), want: true}, + {name: "future last-fetched — not due (negative age)", lastFetched: ago(-time.Minute), want: false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - h := newFeedTestHandler(tt.stub, tt.fetcher) - req := httptest.NewRequest(http.MethodPost, "/api/admin/feeds/"+tt.id+"/fetch", http.NoBody) - req.SetPathValue("id", tt.id) - w := httptest.NewRecorder() - h.Fetch(w, req) - - if tt.wantCode != "" { - assertFeedErrorCode(t, w, tt.wantStatus, tt.wantCode) - return - } - if w.Code != tt.wantStatus { - t.Fatalf("Fetch(%q) status = %d, want %d (body: %s)", tt.id, w.Code, tt.wantStatus, w.Body.String()) - } - if tt.wantItems > 0 { - var resp api.Response - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("decoding response: %v", err) - } + got := due(tt.lastFetched, now, interval) + if got != tt.want { + t.Errorf("due(%v, now, %v) = %v, want %v", tt.lastFetched, interval, got, tt.want) } }) } } -// --------------------------------------------------------------------------- -// IncrementFailure threshold logic (pure logic via mock counter) -// --------------------------------------------------------------------------- - -func TestMaxConsecutiveFailures(t *testing.T) { - t.Parallel() - if MaxConsecutiveFailures != 5 { - t.Errorf("MaxConsecutiveFailures = %d, want 5", MaxConsecutiveFailures) - } -} - // --------------------------------------------------------------------------- // Fuzz tests // --------------------------------------------------------------------------- From 6e168373a5ee6eb2eb81eefe633c3ae33b814351 Mon Sep 17 00:00:00 2001 From: Koopa Date: Wed, 24 Jun 2026 16:46:54 +0800 Subject: [PATCH 19/31] test(stats): replace DB-mock handler tests with testcontainers integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stats_test.go hand-rolled a db.DBTX fake (stubDBTX/emptyRows/zeroRow) and the handler tests asserted zero-in/zero-out through it — a forbidden DB mock that verified nothing about the real SQL aggregation. Delete the fake and those tests; add an integration test seeding real rows and asserting Overview/ SystemHealth/ProcessRuns/Drift produce correct non-zero counts. Extract the days-clamp into a pure parseDays() (zero behaviour change) and keep the clamp/fuzz + computeAreaDrift + state-mapper unit tests against it. --- internal/stats/handler.go | 26 +- internal/stats/integration_test.go | 420 +++++++++++++++++++++++++++++ internal/stats/stats_test.go | 412 ++++++++-------------------- 3 files changed, 550 insertions(+), 308 deletions(-) create mode 100644 internal/stats/integration_test.go diff --git a/internal/stats/handler.go b/internal/stats/handler.go index 56bd7c1c..f7b34757 100644 --- a/internal/stats/handler.go +++ b/internal/stats/handler.go @@ -33,15 +33,29 @@ func (h *Handler) Overview(w http.ResponseWriter, r *http.Request) { api.Encode(w, http.StatusOK, api.Response{Data: overview}) } +// driftDefaultDays / driftMaxDays bound the drift window. A request outside +// [1, driftMaxDays] (or unparseable) falls back to the default. +const ( + driftDefaultDays = 30 + driftMaxDays = 90 +) + +// parseDays parses the drift window's days query parameter, clamping to the +// valid range [1, driftMaxDays]. Any value that is empty, non-numeric, ≤ 0, or +// above driftMaxDays falls back to driftDefaultDays. Pure so the bounds logic is +// testable without a request or a store. +func parseDays(raw string) int { + d, err := strconv.Atoi(raw) + if err != nil || d <= 0 || d > driftMaxDays { + return driftDefaultDays + } + return d +} + // Drift handles GET /api/admin/stats/drift. // Query params: days (default 30, max 90). func (h *Handler) Drift(w http.ResponseWriter, r *http.Request) { - days := 30 - if v := r.URL.Query().Get("days"); v != "" { - if d, err := strconv.Atoi(v); err == nil && d > 0 && d <= 90 { - days = d - } - } + days := parseDays(r.URL.Query().Get("days")) report, err := h.store.Drift(r.Context(), days) if err != nil { diff --git a/internal/stats/integration_test.go b/internal/stats/integration_test.go new file mode 100644 index 00000000..3fe0139a --- /dev/null +++ b/internal/stats/integration_test.go @@ -0,0 +1,420 @@ +// Copyright 2026 Koopa. All rights reserved. + +//go:build integration + +// integration_test.go exercises the stats aggregators against a real +// PostgreSQL container (testcontainers). The store does nothing but run SQL and +// shape the rows, so the only honest test is one that seeds real rows across +// contents / feeds / process_runs / activity_events / goals and asserts the +// aggregator returns the NON-ZERO counts that data implies. A hand-rolled +// db.DBTX fake would assert only that the Go control flow runs — never that the +// queries are correct. +// +// activity_events rows are produced organically by the AFTER triggers on +// covered tables (a seeded content + goal), not by direct INSERT — that mirrors +// production and keeps the "no direct INSERT into activity_events" invariant. +// +// Run with: +// +// go test -tags=integration ./internal/stats/... +package stats_test + +import ( + "context" + "log/slog" + "os" + "testing" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Koopa0/koopa/internal/agent" + "github.com/Koopa0/koopa/internal/stats" + "github.com/Koopa0/koopa/internal/testdb" +) + +var testPool *pgxpool.Pool + +func TestMain(m *testing.M) { + pool, cleanup := testdb.NewPool() + testPool = pool + + // The audit triggers on contents / goals write activity_events.actor, which + // FKs onto agents. Reconcile the builtin registry once per suite exactly as + // cmd/app/main.go does at startup, or every audited insert fails 23503. + registry := agent.NewBuiltinRegistry() + if _, err := agent.SyncToTable(context.Background(), registry, agent.NewStore(pool), nil, slog.Default()); err != nil { + slog.Default().Error("agent.SyncToTable", "error", err) + cleanup() + os.Exit(1) + } + + code := m.Run() + cleanup() + os.Exit(code) +} + +// truncate clears every table the stats aggregators read so each test starts +// from a known-empty baseline (and the non-zero assertions are unambiguous). +func truncate(t *testing.T) { + t.Helper() + if _, err := testPool.Exec(t.Context(), + `TRUNCATE contents, feeds, process_runs, activity_events, goals, projects, areas, todos CASCADE`, + ); err != nil { + t.Fatalf("truncate: %v", err) + } +} + +// execActor runs the given write inside a transaction with koopa.actor set, so +// the AFTER triggers on covered tables resolve current_actor() to a real agent +// and emit the activity_events audit row. fn receives the tx. +func execActor(t *testing.T, actor string, fn func(tx pgx.Tx)) { + t.Helper() + ctx := t.Context() + tx, err := testPool.Begin(ctx) + if err != nil { + t.Fatalf("begin: %v", err) + } + defer func() { _ = tx.Rollback(ctx) }() + if _, err := tx.Exec(ctx, "SELECT set_config('koopa.actor', $1, true)", actor); err != nil { + t.Fatalf("set koopa.actor: %v", err) + } + fn(tx) + if err := tx.Commit(ctx); err != nil { + t.Fatalf("commit: %v", err) + } +} + +// TestIntegration_Stats_Overview seeds rows in every source Overview aggregates +// and asserts the counts are correct and non-zero. +func TestIntegration_Stats_Overview(t *testing.T) { + truncate(t) + ctx := t.Context() + + // 3 contents: 2 published, 1 draft (2 distinct types). Published rows need + // published_at (chk_content_publication) and may be public; the draft stays + // private with a NULL published_at. + execActor(t, "human", func(tx pgx.Tx) { + _, err := tx.Exec(ctx, ` + INSERT INTO contents (slug, title, type, status, is_public, published_at) VALUES + ('a', 'A', 'article'::content_type, 'published'::content_status, true, now()), + ('b', 'B', 'article'::content_type, 'published'::content_status, true, now()), + ('c', 'C', 'til'::content_type, 'draft'::content_status, false, NULL)`) + if err != nil { + t.Fatalf("seeding contents: %v", err) + } + }) + + // 2 feeds, 1 enabled / 1 disabled. + if _, err := testPool.Exec(ctx, ` + INSERT INTO feeds (url, name, schedule, enabled) VALUES + ('https://x.test/a.xml', 'Feed A', 'daily', true), + ('https://x.test/b.xml', 'Feed B', 'daily', false)`); err != nil { + t.Fatalf("seeding feeds: %v", err) + } + + // 2 crawl process_runs: 1 completed / 1 failed. + if _, err := testPool.Exec(ctx, ` + INSERT INTO process_runs (kind, name, status, error, ended_at) VALUES + ('crawl', 'feed_fetch', 'completed', NULL, now()), + ('crawl', 'feed_fetch', 'failed', 'boom', now())`); err != nil { + t.Fatalf("seeding process_runs: %v", err) + } + + // 1 goal (fires the goal audit trigger → 1 activity_events row, entity_type=goal). + execActor(t, "human", func(tx pgx.Tx) { + if _, err := tx.Exec(ctx, + `INSERT INTO goals (title, status) VALUES ('Ship stats coverage', 'in_progress'::goal_status)`); err != nil { + t.Fatalf("seeding goal: %v", err) + } + }) + + store := stats.NewStore(testPool) + o, err := store.Overview(ctx) + if err != nil { + t.Fatalf("Overview: %v", err) + } + + if o.Contents.Total != 3 { + t.Errorf("Contents.Total = %d, want 3", o.Contents.Total) + } + if o.Contents.Published != 2 { + t.Errorf("Contents.Published = %d, want 2", o.Contents.Published) + } + if o.Contents.ByStatus["published"] != 2 { + t.Errorf("Contents.ByStatus[published] = %d, want 2", o.Contents.ByStatus["published"]) + } + if o.Contents.ByType["article"] != 2 { + t.Errorf("Contents.ByType[article] = %d, want 2", o.Contents.ByType["article"]) + } + + if o.Feeds.Total != 2 { + t.Errorf("Feeds.Total = %d, want 2", o.Feeds.Total) + } + if o.Feeds.Enabled != 1 { + t.Errorf("Feeds.Enabled = %d, want 1", o.Feeds.Enabled) + } + + crawl := o.ProcessRuns["crawl"] + if crawl.Total != 2 { + t.Errorf("ProcessRuns[crawl].Total = %d, want 2", crawl.Total) + } + if crawl.ByStatus["completed"] != 1 || crawl.ByStatus["failed"] != 1 { + t.Errorf("ProcessRuns[crawl].ByStatus = %v, want completed=1 failed=1", crawl.ByStatus) + } + + // Both seeded content (3 created) and the goal (1 created) emit audit rows. + if o.Activity.Total < 4 { + t.Errorf("Activity.Total = %d, want >= 4 (3 content + 1 goal created events)", o.Activity.Total) + } + if o.Activity.BySource["content"] != 3 { + t.Errorf("Activity.BySource[content] = %d, want 3", o.Activity.BySource["content"]) + } + if o.Activity.BySource["goal"] != 1 { + t.Errorf("Activity.BySource[goal] = %d, want 1", o.Activity.BySource["goal"]) + } +} + +// TestIntegration_Stats_SystemHealth seeds a failing feed plus recent +// process_runs and asserts the health snapshot reports the failing feed by +// name/error and the recent-run counts. +func TestIntegration_Stats_SystemHealth(t *testing.T) { + truncate(t) + ctx := t.Context() + + // 1 healthy, 1 failing feed. + if _, err := testPool.Exec(ctx, ` + INSERT INTO feeds (url, name, schedule, consecutive_failures, last_error) VALUES + ('https://x.test/ok.xml', 'Healthy Feed', 'daily', 0, NULL), + ('https://x.test/bad.xml', 'Broken Feed', 'daily', 3, 'connection refused')`); err != nil { + t.Fatalf("seeding feeds: %v", err) + } + + // 2 recent process_runs in the last 24h, 1 failed. + if _, err := testPool.Exec(ctx, ` + INSERT INTO process_runs (kind, name, status, error, ended_at) VALUES + ('crawl', 'feed_fetch', 'completed', NULL, now()), + ('crawl', 'feed_fetch', 'failed', 'timeout', now())`); err != nil { + t.Fatalf("seeding process_runs: %v", err) + } + + // 1 content + 1 todo for the database-counts section. + execActor(t, "human", func(tx pgx.Tx) { + if _, err := tx.Exec(ctx, + `INSERT INTO contents (slug, title, type, status) VALUES ('hc', 'Health Content', 'article'::content_type, 'draft'::content_status)`); err != nil { + t.Fatalf("seeding content: %v", err) + } + if _, err := tx.Exec(ctx, + `INSERT INTO todos (title, state, created_by) VALUES ('Health todo', 'inbox'::todo_state, 'human')`); err != nil { + t.Fatalf("seeding todo: %v", err) + } + }) + + store := stats.NewStore(testPool) + snap, err := store.SystemHealth(ctx) + if err != nil { + t.Fatalf("SystemHealth: %v", err) + } + + if snap.Feeds.Total != 2 { + t.Errorf("Feeds.Total = %d, want 2", snap.Feeds.Total) + } + if snap.Feeds.Healthy != 1 { + t.Errorf("Feeds.Healthy = %d, want 1", snap.Feeds.Healthy) + } + if snap.Feeds.Failing != 1 { + t.Errorf("Feeds.Failing = %d, want 1", snap.Feeds.Failing) + } + if len(snap.Feeds.FailingFeeds) != 1 { + t.Fatalf("FailingFeeds len = %d, want 1 (%+v)", len(snap.Feeds.FailingFeeds), snap.Feeds.FailingFeeds) + } + if got := snap.Feeds.FailingFeeds[0]; got.Name != "Broken Feed" || got.Error != "connection refused" { + t.Errorf("FailingFeeds[0] = {Name:%q Error:%q}, want {Broken Feed, connection refused}", got.Name, got.Error) + } + + if snap.Pipelines.RecentRuns != 2 { + t.Errorf("Pipelines.RecentRuns = %d, want 2", snap.Pipelines.RecentRuns) + } + if snap.Pipelines.Failed != 1 { + t.Errorf("Pipelines.Failed = %d, want 1", snap.Pipelines.Failed) + } + if snap.Pipelines.LastRunAt == nil { + t.Error("Pipelines.LastRunAt = nil, want a timestamp (there were recent runs)") + } + + if snap.Database.ContentsCount != 1 { + t.Errorf("Database.ContentsCount = %d, want 1", snap.Database.ContentsCount) + } + if snap.Database.TodosCount != 1 { + t.Errorf("Database.TodosCount = %d, want 1", snap.Database.TodosCount) + } +} + +// TestIntegration_Stats_ProcessRuns seeds crawl runs across statuses and time +// windows, then asserts ProcessRunsSince counts each status and RecentProcessRuns +// returns the newest-first window correctly — including the name/status filters. +func TestIntegration_Stats_ProcessRuns(t *testing.T) { + truncate(t) + ctx := t.Context() + + // created_at is DEFAULT now(); seed an OLD run (>24h) that must fall outside + // a 1h/24h window, and four recent runs across statuses. + if _, err := testPool.Exec(ctx, ` + INSERT INTO process_runs (kind, name, status, error, started_at, ended_at, created_at) VALUES + ('crawl', 'feed_fetch', 'completed', NULL, now(), now(), now()), + ('crawl', 'feed_fetch', 'completed', NULL, now(), now(), now()), + ('crawl', 'feed_fetch', 'failed', 'boom', now(), now(), now()), + ('crawl', 'other_job', 'pending', NULL, NULL, NULL, now()), + ('crawl', 'feed_fetch', 'completed', NULL, now(), now(), now() - interval '30 hours')`); err != nil { + t.Fatalf("seeding process_runs: %v", err) + } + + store := stats.NewStore(testPool) + since := time.Now().Add(-24 * time.Hour) + + // Summary over the last 24h, all names: 4 recent rows (the 30h-old one is excluded). + sum, err := store.ProcessRunsSince(ctx, since, "crawl", nil, nil) + if err != nil { + t.Fatalf("ProcessRunsSince: %v", err) + } + if sum.Total != 4 { + t.Errorf("ProcessRunsSince Total = %d, want 4 (30h-old row excluded)", sum.Total) + } + if sum.Completed != 2 { + t.Errorf("ProcessRunsSince Completed = %d, want 2", sum.Completed) + } + if sum.Failed != 1 { + t.Errorf("ProcessRunsSince Failed = %d, want 1", sum.Failed) + } + if sum.Pending != 1 { + t.Errorf("ProcessRunsSince Pending = %d, want 1", sum.Pending) + } + + // Name filter: only the 'other_job' pending row matches. + otherName := "other_job" + sumOther, err := store.ProcessRunsSince(ctx, since, "crawl", &otherName, nil) + if err != nil { + t.Fatalf("ProcessRunsSince(name=other_job): %v", err) + } + if sumOther.Total != 1 || sumOther.Pending != 1 { + t.Errorf("ProcessRunsSince(name=other_job) = {Total:%d Pending:%d}, want {1,1}", sumOther.Total, sumOther.Pending) + } + + // Recent list over 24h: 4 rows, newest first. + recent, err := store.RecentProcessRuns(ctx, since, "crawl", nil, nil, 100) + if err != nil { + t.Fatalf("RecentProcessRuns: %v", err) + } + if len(recent) != 4 { + t.Fatalf("RecentProcessRuns len = %d, want 4", len(recent)) + } + // Status filter: only failed runs. + failed := "failed" + recentFailed, err := store.RecentProcessRuns(ctx, since, "crawl", nil, &failed, 100) + if err != nil { + t.Fatalf("RecentProcessRuns(status=failed): %v", err) + } + if len(recentFailed) != 1 { + t.Fatalf("RecentProcessRuns(status=failed) len = %d, want 1", len(recentFailed)) + } + if recentFailed[0].Status != "failed" || recentFailed[0].Error == nil || *recentFailed[0].Error != "boom" { + t.Errorf("RecentProcessRuns(status=failed)[0] = {Status:%q Error:%v}, want {failed, boom}", + recentFailed[0].Status, recentFailed[0].Error) + } +} + +// TestIntegration_Stats_Drift seeds active goals by area and content activity by +// the same areas, then asserts the drift report computes a non-trivial, +// correctly-keyed area distribution from real rows. +func TestIntegration_Stats_Drift(t *testing.T) { + truncate(t) + ctx := t.Context() + + // Two areas; a goal in each; a project in each (so content events join to an + // area via project.area_id). + var backendID, frontendID uuid.UUID + if err := testPool.QueryRow(ctx, + `INSERT INTO areas (slug, name) VALUES ('backend', 'Backend') RETURNING id`).Scan(&backendID); err != nil { + t.Fatalf("seeding backend area: %v", err) + } + if err := testPool.QueryRow(ctx, + `INSERT INTO areas (slug, name) VALUES ('frontend', 'Frontend') RETURNING id`).Scan(&frontendID); err != nil { + t.Fatalf("seeding frontend area: %v", err) + } + + var backendProj uuid.UUID + if err := testPool.QueryRow(ctx, + `INSERT INTO projects (slug, title, area_id) VALUES ('be-proj', 'BE Project', $1) RETURNING id`, backendID). + Scan(&backendProj); err != nil { + t.Fatalf("seeding backend project: %v", err) + } + + // Active goals: 2 in backend, 1 in frontend (only not_started/in_progress count). + if _, err := testPool.Exec(ctx, ` + INSERT INTO goals (title, status, area_id) VALUES + ('BE goal 1', 'in_progress'::goal_status, $1), + ('BE goal 2', 'not_started'::goal_status, $1), + ('FE goal 1', 'in_progress'::goal_status, $2), + ('Done goal', 'done'::goal_status, $1)`, backendID, frontendID); err != nil { + t.Fatalf("seeding goals: %v", err) + } + + // Two content created events tied to the backend project → backend area + // events. (Frontend has goals but no events — that asymmetry is the drift.) + execActor(t, "human", func(tx pgx.Tx) { + _, err := tx.Exec(ctx, ` + INSERT INTO contents (slug, title, type, status, project_id) VALUES + ('d1', 'D1', 'article'::content_type, 'draft'::content_status, $1), + ('d2', 'D2', 'article'::content_type, 'draft'::content_status, $1)`, backendProj) + if err != nil { + t.Fatalf("seeding content: %v", err) + } + }) + + store := stats.NewStore(testPool) + report, err := store.Drift(ctx, 30) + if err != nil { + t.Fatalf("Drift: %v", err) + } + if report.Period != "last 30 days" { + t.Errorf("Drift.Period = %q, want %q", report.Period, "last 30 days") + } + + byArea := make(map[string]stats.AreaDrift, len(report.Areas)) + for _, a := range report.Areas { + byArea[a.Area] = a + } + + be, ok := byArea["Backend"] + if !ok { + t.Fatalf("Drift missing Backend area; got %+v", report.Areas) + } + if be.ActiveGoals != 2 { + t.Errorf("Backend ActiveGoals = %d, want 2 (done goal excluded)", be.ActiveGoals) + } + // Backend events join via project.area_id: the project-created audit row + // (1) plus two content-created audit rows (2) all carry project_id = + // backendProj, which resolves to the Backend area. + if be.EventCount != 3 { + t.Errorf("Backend EventCount = %d, want 3 (1 project + 2 content created events)", be.EventCount) + } + + fe, ok := byArea["Frontend"] + if !ok { + t.Fatalf("Drift missing Frontend area; got %+v", report.Areas) + } + if fe.ActiveGoals != 1 { + t.Errorf("Frontend ActiveGoals = %d, want 1", fe.ActiveGoals) + } + if fe.EventCount != 0 { + t.Errorf("Frontend EventCount = %d, want 0 (goals but no events)", fe.EventCount) + } + // Frontend has goal focus but zero activity → negative drift; backend the + // opposite sign. The exact magnitudes are covered by the computeAreaDrift + // unit tests; here we only assert the real join produced the right sign. + if fe.DriftPercent >= 0 { + t.Errorf("Frontend DriftPercent = %f, want negative (goals but no events)", fe.DriftPercent) + } +} diff --git a/internal/stats/stats_test.go b/internal/stats/stats_test.go index a7242fd9..a95a8059 100644 --- a/internal/stats/stats_test.go +++ b/internal/stats/stats_test.go @@ -4,40 +4,31 @@ package stats // Tests for internal/stats. // -// Scope: -// - computeAreaDrift: pure business logic — division-by-zero guards, empty maps, -// single-side data (goals but no events, events but no goals), sort order. -// - Handler.Overview / Handler.Drift: HTTP handler tests via -// httptest with a stub db.DBTX that controls which queries succeed or fail. -// - days param parsing in Handler.Drift: boundary clamping (0, negative, >90, exact -// boundaries 1 and 90) plus a fuzz test for the raw string path. +// Scope (unit, no DB): +// - computeAreaDrift: pure business logic — division-by-zero guards, empty +// maps, single-side data (goals but no events, events but no goals), sort +// order. +// - parseDays: drift-window bounds clamping (0, negative, >90, exact +// boundaries 1 and 90, non-numeric) plus a fuzz test on arbitrary input. +// - successRateState / nonZeroState: the pure cell-state mappers consumed by +// the process-runs summary. +// - SystemHealthSnapshot wire contract: marshaling-only pins on the nested +// field names the Today fan-out consumes. // -// Integration tests (real DB via testcontainers) are out of scope here. -// The store-level SQL is exercised by the handler tests through the stub DBTX, -// which validates the control-flow paths inside the store methods without a live DB. +// The store aggregators (Overview / SystemHealth / ProcessRunsSince / +// RecentProcessRuns) run against a real PostgreSQL container in +// internal/stats/integration_test.go — never a hand-rolled db.DBTX, which would +// prove nothing about the SQL. import ( "bytes" - "context" "encoding/json" - "errors" - "fmt" - "io" - "log/slog" "math" - "net/http" - "net/http/httptest" - "net/url" "strconv" - "strings" "testing" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgconn" - - "github.com/Koopa0/koopa/internal/api" ) // ── computeAreaDrift unit tests ──────────────────────────────────────────────── @@ -196,281 +187,136 @@ func TestComputeAreaDrift_SortInvariant(t *testing.T) { } } -// ── Stub DBTX implementation ─────────────────────────────────────────────────── - -// stubDBTX implements db.DBTX. Each method is controlled by a function field so -// individual tests can inject targeted failures or canned results. -type stubDBTX struct { - queryFn func(ctx context.Context, sql string, args ...any) (pgx.Rows, error) - queryRowFn func(ctx context.Context, sql string, args ...any) pgx.Row -} +// ── parseDays clamp unit tests ───────────────────────────────────────────────── -func (s *stubDBTX) Exec(_ context.Context, _ string, _ ...any) (pgconn.CommandTag, error) { - return pgconn.CommandTag{}, nil -} +// TestParseDays pins the drift-window bounds: valid in-range values pass +// through, everything else (≤0, >driftMaxDays, non-numeric, empty) falls back to +// driftDefaultDays. Expected values are hand-computed against the [1,90]/30 +// contract. +// +// Mutation it catches: changing `d <= 0` to `d < 0` would let 0 through; +// dropping the upper bound would let 91 through; swapping the fallback constant +// would break every out-of-range case. +func TestParseDays(t *testing.T) { + t.Parallel() -func (s *stubDBTX) Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) { - if s.queryFn != nil { - return s.queryFn(ctx, sql, args...) + tests := []struct { + name string + raw string + want int + }{ + {name: "empty falls back to default", raw: "", want: 30}, + {name: "valid 7", raw: "7", want: 7}, + {name: "lower boundary 1", raw: "1", want: 1}, + {name: "upper boundary 90", raw: "90", want: 90}, + {name: "zero falls back", raw: "0", want: 30}, + {name: "negative falls back", raw: "-5", want: 30}, + {name: "91 exceeds max, falls back", raw: "91", want: 30}, + {name: "non-numeric falls back", raw: "abc", want: 30}, + {name: "float string falls back", raw: "7.5", want: 30}, + {name: "trailing space falls back (Atoi rejects)", raw: "7 ", want: 30}, + {name: "overflow falls back", raw: "9999999999999999999", want: 30}, } - return &emptyRows{}, nil -} -func (s *stubDBTX) QueryRow(ctx context.Context, sql string, args ...any) pgx.Row { - if s.queryRowFn != nil { - return s.queryRowFn(ctx, sql, args...) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := parseDays(tt.raw); got != tt.want { + t.Errorf("parseDays(%q) = %d, want %d", tt.raw, got, tt.want) + } + }) } - return &zeroRow{} } -// emptyRows is a pgx.Rows that immediately signals no rows. -type emptyRows struct{} - -func (e *emptyRows) Next() bool { return false } -func (e *emptyRows) Scan(_ ...any) error { return nil } -func (e *emptyRows) Err() error { return nil } -func (e *emptyRows) Close() {} -func (e *emptyRows) CommandTag() pgconn.CommandTag { return pgconn.CommandTag{} } -func (e *emptyRows) FieldDescriptions() []pgconn.FieldDescription { - return nil -} -func (e *emptyRows) Values() ([]any, error) { return nil, nil } -func (e *emptyRows) RawValues() [][]byte { return nil } -func (e *emptyRows) Conn() *pgx.Conn { return nil } - -// zeroRow scans all destination pointers to their zero value. -type zeroRow struct{} - -func (z *zeroRow) Scan(dest ...any) error { - for _, d := range dest { - switch v := d.(type) { - case *int: - *v = 0 - case *int64: - *v = 0 - case *string: - *v = "" - case **string: - *v = nil +// FuzzParseDays verifies parseDays never panics and always returns a value +// inside the valid window [1, driftMaxDays] for arbitrary input. It also +// re-derives the expected result independently (Atoi on the RAW string — no +// trimming, matching parseDays) so a drift in the bounds logic surfaces. +func FuzzParseDays(f *testing.F) { + for _, seed := range []string{"30", "1", "90", "0", "-1", "91", "", "abc", "7.5", "1e2", "9999999999999999999", " 30 "} { + f.Add(seed) + } + f.Fuzz(func(t *testing.T, raw string) { + got := parseDays(raw) // must not panic + if got < 1 || got > driftMaxDays { + t.Errorf("parseDays(%q) = %d, outside [1, %d]", raw, got, driftMaxDays) } - } - return nil -} - -// errRow always returns a configurable error from Scan. -type errRow struct{ err error } -func (e *errRow) Scan(_ ...any) error { return e.err } - -// silentLogger discards all log output from the handler. -func silentLogger() *slog.Logger { - return slog.New(slog.NewTextHandler(io.Discard, nil)) + // Independent expectation: parseDays does NOT trim, so classify against + // the raw string exactly as strconv.Atoi sees it. + d, err := strconv.Atoi(raw) + want := driftDefaultDays + if err == nil && d > 0 && d <= driftMaxDays { + want = d + } + if got != want { + t.Errorf("parseDays(%q) = %d, want %d", raw, got, want) + } + }) } -// ── Handler.Overview tests ───────────────────────────────────────────────────── - -func TestHandler_Overview_Success(t *testing.T) { - t.Parallel() - - dbtx := &stubDBTX{ - // All Query calls return empty rows — store accumulates zeros. - queryFn: func(_ context.Context, _ string, _ ...any) (pgx.Rows, error) { - return &emptyRows{}, nil - }, - // All QueryRow calls scan zeros — no error. - queryRowFn: func(_ context.Context, _ string, _ ...any) pgx.Row { - return &zeroRow{} - }, - } - - h := NewHandler(NewStore(dbtx), silentLogger()) - - req := httptest.NewRequest(http.MethodGet, "/api/admin/stats", http.NoBody) - w := httptest.NewRecorder() - h.Overview(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("Overview() status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String()) - } - if ct := w.Header().Get("Content-Type"); ct != "application/json" { - t.Errorf("Content-Type = %q, want application/json", ct) - } - - var resp api.Response - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("decoding response: %v", err) - } - if resp.Data == nil { - t.Fatal("Overview() response.data is nil, want overview object") - } -} +// ── cell-state mapper unit tests ─────────────────────────────────────────────── -func TestHandler_Overview_StoreError(t *testing.T) { +// TestSuccessRateState pins the three-band success-rate cell state: ≥95 ok, +// ≥80 warn, below error. Boundary values are the ones the UI colour-codes on. +// +// Mutation it catches: using `>` instead of `>=` at a boundary would flip 95 to +// warn and 80 to error. +func TestSuccessRateState(t *testing.T) { t.Parallel() - boom := errors.New("db unavailable") - - // Make every Query call fail — errgroup will return this error. - dbtx := &stubDBTX{ - queryFn: func(_ context.Context, _ string, _ ...any) (pgx.Rows, error) { - return nil, boom - }, - queryRowFn: func(_ context.Context, _ string, _ ...any) pgx.Row { - return &errRow{err: boom} - }, + tests := []struct { + name string + pct float64 + want string + }{ + {name: "100 is ok", pct: 100, want: "ok"}, + {name: "95 boundary is ok", pct: 95, want: "ok"}, + {name: "94.9 is warn", pct: 94.9, want: "warn"}, + {name: "80 boundary is warn", pct: 80, want: "warn"}, + {name: "79.9 is error", pct: 79.9, want: "error"}, + {name: "0 is error", pct: 0, want: "error"}, } - h := NewHandler(NewStore(dbtx), silentLogger()) - - req := httptest.NewRequest(http.MethodGet, "/api/admin/stats", http.NoBody) - w := httptest.NewRecorder() - h.Overview(w, req) - - if w.Code != http.StatusInternalServerError { - t.Fatalf("Overview() on DB error: status = %d, want %d", w.Code, http.StatusInternalServerError) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := successRateState(tt.pct); got != tt.want { + t.Errorf("successRateState(%v) = %q, want %q", tt.pct, got, tt.want) + } + }) } - assertErrorCode(t, w, "INTERNAL") } -// ── Handler.Drift tests ──────────────────────────────────────────────────────── - -func TestHandler_Drift_DaysParamClamping(t *testing.T) { +// TestNonZeroState pins the zero-vs-elevated cell state: a zero count is always +// "ok", any non-zero count yields the caller-supplied elevated label. +// +// Mutation it catches: returning the elevated label for n==0, or always +// returning "ok", would break the retry/failure warning surfaces. +func TestNonZeroState(t *testing.T) { t.Parallel() tests := []struct { name string - query string - wantDays int // verified via DriftReport.Period field + n int + elevated string + want string }{ - {name: "default — no param", query: "", wantDays: 30}, - {name: "valid 7", query: "days=7", wantDays: 7}, - {name: "valid 90 — upper boundary", query: "days=90", wantDays: 90}, - {name: "valid 1 — lower boundary", query: "days=1", wantDays: 1}, - {name: "0 — rejected, falls back to 30", query: "days=0", wantDays: 30}, - {name: "negative — rejected, falls back to 30", query: "days=-5", wantDays: 30}, - {name: "91 — exceeds max, falls back to 30", query: "days=91", wantDays: 30}, - {name: "non-numeric — rejected, falls back to 30", query: "days=abc", wantDays: 30}, - {name: "empty string — falls back to 30", query: "days=", wantDays: 30}, - {name: "float string — rejected, falls back to 30", query: "days=7.5", wantDays: 30}, + {name: "zero is ok regardless of elevated label", n: 0, elevated: "warn", want: "ok"}, + {name: "one with warn label", n: 1, elevated: "warn", want: "warn"}, + {name: "many with error label", n: 17, elevated: "error", want: "error"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - - // The Drift store method calls queryGoalsByArea and queryEventsByArea. - // Both use Query; return empty rows so the handler succeeds and we can - // inspect the period string in the response. - dbtx := &stubDBTX{ - queryFn: func(_ context.Context, _ string, _ ...any) (pgx.Rows, error) { - return &emptyRows{}, nil - }, - } - - h := NewHandler(NewStore(dbtx), silentLogger()) - - reqURL := "/api/admin/stats/drift" - if tt.query != "" { - reqURL += "?" + tt.query - } - req := httptest.NewRequest(http.MethodGet, reqURL, http.NoBody) - w := httptest.NewRecorder() - h.Drift(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("Drift(%q) status = %d, want %d; body: %s", - tt.query, w.Code, http.StatusOK, w.Body.String()) - } - - var resp struct { - Data DriftReport `json:"data"` - } - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("decoding response: %v", err) - } - - wantPeriod := fmt.Sprintf("last %d days", tt.wantDays) - if resp.Data.Period != wantPeriod { - t.Errorf("Drift(%q).Period = %q, want %q", tt.query, resp.Data.Period, wantPeriod) + if got := nonZeroState(tt.n, tt.elevated); got != tt.want { + t.Errorf("nonZeroState(%d, %q) = %q, want %q", tt.n, tt.elevated, got, tt.want) } }) } } -func TestHandler_Drift_StoreError(t *testing.T) { - t.Parallel() - - boom := errors.New("query failed") - dbtx := &stubDBTX{ - queryFn: func(_ context.Context, _ string, _ ...any) (pgx.Rows, error) { - return nil, boom - }, - } - - h := NewHandler(NewStore(dbtx), silentLogger()) - - req := httptest.NewRequest(http.MethodGet, "/api/admin/stats/drift", http.NoBody) - w := httptest.NewRecorder() - h.Drift(w, req) - - if w.Code != http.StatusInternalServerError { - t.Fatalf("Drift() on DB error: status = %d, want %d", w.Code, http.StatusInternalServerError) - } - assertErrorCode(t, w, "INTERNAL") -} - -// ── Drift days param fuzz test ───────────────────────────────────────────────── - -// FuzzDriftDaysParam verifies that the days-parsing logic in Handler.Drift never -// panics and always falls back safely on arbitrary input. -func FuzzDriftDaysParam(f *testing.F) { - // Seed corpus — boundaries and tricky inputs. - f.Add("30") - f.Add("1") - f.Add("90") - f.Add("0") - f.Add("-1") - f.Add("91") - f.Add("") - f.Add("abc") - f.Add("7.5") - f.Add("1e2") - f.Add("9999999999999999999") // overflow - f.Add(" 30 ") // whitespace - f.Add("30 ") // trailing space — not parseable by strconv.Atoi - - dbtx := &stubDBTX{ - queryFn: func(_ context.Context, _ string, _ ...any) (pgx.Rows, error) { - return &emptyRows{}, nil - }, - } - h := NewHandler(NewStore(dbtx), silentLogger()) - - f.Fuzz(func(t *testing.T, rawDays string) { - // httptest.NewRequest panics when the URL contains bytes that make - // it structurally invalid (spaces, control chars). Use - // url.QueryEscape so the fuzz corpus can exercise arbitrary byte - // sequences through the handler's Atoi / bounds-check path without - // triggering the HTTP library's URL validator. - escaped := url.QueryEscape(rawDays) - req := httptest.NewRequest(http.MethodGet, "/api/admin/stats/drift?days="+escaped, http.NoBody) - w := httptest.NewRecorder() - - // Must not panic. - h.Drift(w, req) - - // The handler must always return a valid HTTP status. - if w.Code != http.StatusOK && w.Code != http.StatusInternalServerError { - t.Errorf("unexpected status %d for days=%q", w.Code, rawDays) - } - - // If successful, Period must match "last N days" where N is in [1, 90]. - if w.Code == http.StatusOK { - assertDriftFuzzResponse(t, w, rawDays) - } - }) -} - // assertAreaDriftResults checks the computed drift results against expected values. func assertAreaDriftResults(t *testing.T, testName string, got, wantAreas []AreaDrift) { t.Helper() @@ -496,44 +342,6 @@ func assertAreaDriftResults(t *testing.T, testName string, got, wantAreas []Area } } -// assertDriftFuzzResponse validates a successful drift fuzz response: period format and range clamping. -func assertDriftFuzzResponse(t *testing.T, w *httptest.ResponseRecorder, rawDays string) { - t.Helper() - var resp struct { - Data DriftReport `json:"data"` - } - if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { - t.Fatalf("decoding fuzz response: %v", err) - } - var n int - if _, err := fmt.Sscanf(resp.Data.Period, "last %d days", &n); err != nil { - t.Errorf("Period %q doesn't match 'last N days': %v", resp.Data.Period, err) - return - } - if n < 1 || n > 90 { - t.Errorf("Period N = %d, want [1, 90]", n) - } - d, err := strconv.Atoi(strings.TrimSpace(rawDays)) - outOfRange := err != nil || d <= 0 || d > 90 - if outOfRange && n != 30 { - t.Errorf("out-of-range input %q produced days=%d, want 30", rawDays, n) - } -} - -// ── helpers ──────────────────────────────────────────────────────────────────── - -// assertErrorCode decodes the response body and asserts the error code field. -func assertErrorCode(t *testing.T, w *httptest.ResponseRecorder, wantCode string) { - t.Helper() - var body api.ErrorBody - if err := json.NewDecoder(w.Body).Decode(&body); err != nil { - t.Fatalf("decoding error body: %v", err) - } - if diff := cmp.Diff(wantCode, body.Error.Code); diff != "" { - t.Errorf("error code mismatch (-want +got):\n%s", diff) - } -} - // Track 1B — Today fan-out wire contract. // // GET /api/admin/system/health is one of the six Today fan-out sources. From e229806991eabdc2f24100513bc251bf3eefe9aa Mon Sep 17 00:00:00 2001 From: Koopa Date: Wed, 24 Jun 2026 16:46:54 +0800 Subject: [PATCH 20/31] test(todo): cover interval-mode recurrence arithmetic The only interval-recurrence seed had last_completed_on=NULL (the trivial branch), so the interval-boundary computation was untested. Add cases with last_completed_on exactly interval-days ago (due), interval-1 days ago (not due), and weeks/months units, with hand-computed dates. --- internal/todo/integration_test.go | 80 +++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/internal/todo/integration_test.go b/internal/todo/integration_test.go index 1b6c01bb..8b938be3 100644 --- a/internal/todo/integration_test.go +++ b/internal/todo/integration_test.go @@ -235,6 +235,86 @@ func TestIntegration_Todo_Recurring(t *testing.T) { } } +// TestIntegration_Todo_Recurring_Interval exercises the interval-mode +// recurrence arithmetic in RecurringTodoItemsDueToday: +// +// @today >= last_completed_on + (recur_interval || ' ' || recur_unit)::interval +// +// The weekday-mode test only covers last_completed_on=NULL for interval rows; +// this test drives the actual date arithmetic with hand-computed +// last_completed_on values relative to today, so a wrong interval addition +// (e.g. off-by-one, or wrong unit) surfaces as an inclusion/exclusion failure. +func TestIntegration_Todo_Recurring_Interval(t *testing.T) { + truncate(t) + store := todo.NewStore(testPool) + ctx := t.Context() + + // today is the date the query is evaluated against (date-only, UTC). + today := time.Now().UTC().Truncate(24 * time.Hour) + daysAgo := func(n int) *string { + s := today.AddDate(0, 0, -n).Format(time.DateOnly) + return &s + } + + // seedInterval inserts an active interval-mode recurring todo with the given + // last_completed_on and returns its id. + seedInterval := func(title string, interval int, unit string, lastCompleted *string) uuid.UUID { + t.Helper() + var id uuid.UUID + if err := testPool.QueryRow(ctx, + `INSERT INTO todos (title, state, recur_interval, recur_unit, last_completed_on, created_by) + VALUES ($1, 'todo', $2::int, $3, $4::date, 'human') RETURNING id`, + title, interval, unit, lastCompleted, + ).Scan(&id); err != nil { + t.Fatalf("seeding %q: %v", title, err) + } + return id + } + + // every 3 days, completed exactly 3 days ago → today == lastCompleted + 3d → due. + dueExact := seedInterval("3d, completed exactly 3 days ago", 3, "days", daysAgo(3)) + // every 3 days, completed 2 days ago (interval-1) → today < lastCompleted + 3d → NOT due. + notDueYet := seedInterval("3d, completed 2 days ago", 3, "days", daysAgo(2)) + // every 3 days, completed 5 days ago (well past) → due. + dueOverdue := seedInterval("3d, completed 5 days ago", 3, "days", daysAgo(5)) + // every 2 weeks, completed exactly 14 days ago → today == lastCompleted + 14d → due. + dueWeeks := seedInterval("2w, completed exactly 14 days ago", 2, "weeks", daysAgo(14)) + // every 2 weeks, completed 13 days ago → NOT due yet. + notDueWeeks := seedInterval("2w, completed 13 days ago", 2, "weeks", daysAgo(13)) + // every 1 month, completed 20 days ago → NOT due (a month is >= 28 days). + notDueMonth := seedInterval("1mo, completed 20 days ago", 1, "months", daysAgo(20)) + + items, err := store.RecurringItemsDueToday(ctx, today) + if err != nil { + t.Fatalf("RecurringItemsDueToday: %v", err) + } + due := make(map[uuid.UUID]struct{}, len(items)) + for i := range items { + due[items[i].ID] = struct{}{} + } + + wantDue := map[string]uuid.UUID{ + "3d completed exactly 3 days ago": dueExact, + "3d completed 5 days ago": dueOverdue, + "2w completed exactly 14 days ago": dueWeeks, + } + for name, id := range wantDue { + if _, ok := due[id]; !ok { + t.Errorf("%s (%s) missing from due_today", name, id) + } + } + wantNotDue := map[string]uuid.UUID{ + "3d completed 2 days ago (interval-1)": notDueYet, + "2w completed 13 days ago": notDueWeeks, + "1mo completed 20 days ago": notDueMonth, + } + for name, id := range wantNotDue { + if _, ok := due[id]; ok { + t.Errorf("%s (%s) must NOT be in due_today", name, id) + } + } +} + // TestIntegration_Todo_History seeds a completed todo and asserts it appears // in the default (completed-since) history view. func TestIntegration_Todo_History(t *testing.T) { From 04ed8f574ee8cbefb101a7a6fb1a3bfaa451c132 Mon Sep 17 00:00:00 2001 From: Koopa Date: Wed, 24 Jun 2026 16:46:54 +0800 Subject: [PATCH 21/31] test(goal): assert a blank title returns ErrInvalidInput Pins the CHECK-violation mapping fix: a whitespace-only title hits chk_goal_title_not_blank (23514) and must surface as ErrInvalidInput (400), not a wrapped 500. --- internal/goal/integration_test.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/internal/goal/integration_test.go b/internal/goal/integration_test.go index c2d9b162..ebd9ac23 100644 --- a/internal/goal/integration_test.go +++ b/internal/goal/integration_test.go @@ -1365,6 +1365,18 @@ func TestIntegration_Goal_InvalidInput(t *testing.T) { return err }, }, + { + // A whitespace-only title violates chk_goal_title_not_blank + // (btrim(title) <> '') → 23514, which mapWriteError classifies as + // ErrInvalidInput so the handler renders 400, not an opaque 500. + name: "create goal with whitespace-only title (check 23514)", + run: func() error { + _, err := store.Create(ctx, &goal.CreateParams{ + Title: " ", + }) + return err + }, + }, { name: "update goal with non-existent area_id (foreign key 23503)", run: func() error { From 287d8268ac35a59e61644ea28d038adf07186988 Mon Sep 17 00:00:00 2001 From: Koopa Date: Wed, 24 Jun 2026 16:46:54 +0800 Subject: [PATCH 22/31] test(auth): cover the refresh-token consume cycle; drop skipped stub The security-critical persistence half of auth had no test. Add consume happy-path, double-consume returns ErrNotFound (single-use), expired-token rejection, and rotation (old hash gone). Delete the skipped stub TestRefresh_InvalidToken_ReturnsUnauthorized, which asserted nothing while claiming coverage that now actually exists. --- internal/auth/handler_test.go | 16 ---- internal/auth/integration_test.go | 137 ++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+), 16 deletions(-) diff --git a/internal/auth/handler_test.go b/internal/auth/handler_test.go index 90da324d..c38ea864 100644 --- a/internal/auth/handler_test.go +++ b/internal/auth/handler_test.go @@ -266,22 +266,6 @@ func TestRefresh_MissingRefreshToken(t *testing.T) { assertErrorCode(t, w, "BAD_REQUEST") } -func TestRefresh_InvalidToken_ReturnsUnauthorized(t *testing.T) { - t.Parallel() - - // The handler calls h.store.ConsumeRefreshToken which will fail because - // h.store is nil. That returns a nil dereference — which reveals a bug: - // the handler doesn't guard against nil store. However in production the - // store is always non-nil (wired by NewHandler). We test here that a valid - // JSON payload with a non-empty token results in 401 when no store is - // configured (store is nil — panic guard should not be needed but let's - // check with a real-looking but invalid token). - // - // We skip this particular sub-case here and cover it in integration tests. - // This keeps the unit test layer clean. - t.Skip("covered by store integration tests — requires real or stub store") -} - // ─── jsRedirect ─────────────────────────────────────────────────────────────── func TestJsRedirect(t *testing.T) { diff --git a/internal/auth/integration_test.go b/internal/auth/integration_test.go index 2287b828..762330c4 100644 --- a/internal/auth/integration_test.go +++ b/internal/auth/integration_test.go @@ -5,6 +5,7 @@ package auth_test import ( + "errors" "os" "testing" "time" @@ -74,3 +75,139 @@ func TestStore_DeleteExpiredRefreshTokens(t *testing.T) { t.Errorf("ConsumeRefreshToken(live) token_hash = %q, want %q", row.TokenHash, liveHash) } } + +// seedUser truncates the auth tables and returns a fresh user to own tokens. +func seedUser(t *testing.T, store *auth.Store, email string) *auth.User { + t.Helper() + if err := testdb.TruncateCtx(t.Context(), testPool, "refresh_tokens", "users"); err != nil { + t.Fatal(err) + } + user, err := store.UpsertUserByEmail(t.Context(), email) + if err != nil { + t.Fatalf("UpsertUserByEmail(%q): %v", email, err) + } + return user +} + +// TestStore_ConsumeRefreshToken_HappyPath proves the create→consume round-trip: +// a freshly created token is consumed exactly once and the returned row carries +// the right user and hash. +func TestStore_ConsumeRefreshToken_HappyPath(t *testing.T) { + store := auth.NewStore(testPool) + ctx := t.Context() + user := seedUser(t, store, "happy@example.com") + + const hash = "happy-token-hash" + expires := time.Now().Add(time.Hour) + if err := store.CreateRefreshToken(ctx, user.ID, hash, expires); err != nil { + t.Fatalf("CreateRefreshToken: %v", err) + } + + row, err := store.ConsumeRefreshToken(ctx, hash) + if err != nil { + t.Fatalf("ConsumeRefreshToken: %v", err) + } + if row.UserID != user.ID { + t.Errorf("consumed token user_id = %v, want %v", row.UserID, user.ID) + } + if row.TokenHash != hash { + t.Errorf("consumed token_hash = %q, want %q", row.TokenHash, hash) + } + if row.ExpiresAt.Unix() != expires.Unix() { + t.Errorf("consumed expires_at = %v, want ~%v", row.ExpiresAt, expires) + } +} + +// TestStore_ConsumeRefreshToken_DoubleConsume proves single-use: the DELETE ... +// RETURNING removes the row on first consume, so a second consume of the same +// hash returns ErrNotFound. This is the property that stops a leaked-then- +// replayed refresh token from minting tokens twice. +func TestStore_ConsumeRefreshToken_DoubleConsume(t *testing.T) { + store := auth.NewStore(testPool) + ctx := t.Context() + user := seedUser(t, store, "double@example.com") + + const hash = "single-use-hash" + if err := store.CreateRefreshToken(ctx, user.ID, hash, time.Now().Add(time.Hour)); err != nil { + t.Fatalf("CreateRefreshToken: %v", err) + } + + if _, err := store.ConsumeRefreshToken(ctx, hash); err != nil { + t.Fatalf("first ConsumeRefreshToken: %v", err) + } + _, err := store.ConsumeRefreshToken(ctx, hash) + if !errors.Is(err, auth.ErrNotFound) { + t.Fatalf("second ConsumeRefreshToken err = %v, want auth.ErrNotFound", err) + } +} + +// TestStore_ConsumeRefreshToken_Expired proves the store still returns an +// expired-but-unconsumed token (the handler — not the store — enforces expiry +// via ExpiresAt), and that consuming it removes it. The store's contract is +// "return the row if it exists"; this pins that an expired row is returned with +// a past ExpiresAt, so the handler's time check has something to reject. +func TestStore_ConsumeRefreshToken_Expired(t *testing.T) { + store := auth.NewStore(testPool) + ctx := t.Context() + user := seedUser(t, store, "expired@example.com") + + const hash = "expired-hash" + past := time.Now().Add(-time.Hour) + if err := store.CreateRefreshToken(ctx, user.ID, hash, past); err != nil { + t.Fatalf("CreateRefreshToken: %v", err) + } + + row, err := store.ConsumeRefreshToken(ctx, hash) + if err != nil { + t.Fatalf("ConsumeRefreshToken(expired): %v", err) + } + if !row.ExpiresAt.Before(time.Now()) { + t.Errorf("consumed token expires_at = %v, want in the past (so the handler rejects it)", row.ExpiresAt) + } + // Consuming it removed the row — a second consume is ErrNotFound. + if _, err := store.ConsumeRefreshToken(ctx, hash); !errors.Is(err, auth.ErrNotFound) { + t.Errorf("re-consume of expired token err = %v, want auth.ErrNotFound", err) + } +} + +// TestStore_RefreshToken_Rotation models the store half of refresh-token +// rotation the handler performs: consume the presented token (its hash is +// permanently gone) and create a new one (the new hash is now consumable). After +// rotation the OLD hash must not consume, and the NEW hash must consume exactly +// once to the same user. +func TestStore_RefreshToken_Rotation(t *testing.T) { + store := auth.NewStore(testPool) + ctx := t.Context() + user := seedUser(t, store, "rotate@example.com") + + const oldHash = "rotation-old-hash" + const newHash = "rotation-new-hash" + if err := store.CreateRefreshToken(ctx, user.ID, oldHash, time.Now().Add(time.Hour)); err != nil { + t.Fatalf("CreateRefreshToken(old): %v", err) + } + + // Rotate: consume old, issue new. + if _, err := store.ConsumeRefreshToken(ctx, oldHash); err != nil { + t.Fatalf("ConsumeRefreshToken(old) during rotation: %v", err) + } + if err := store.CreateRefreshToken(ctx, user.ID, newHash, time.Now().Add(time.Hour)); err != nil { + t.Fatalf("CreateRefreshToken(new): %v", err) + } + + // The old hash is gone for good. + if _, err := store.ConsumeRefreshToken(ctx, oldHash); !errors.Is(err, auth.ErrNotFound) { + t.Errorf("post-rotation old-hash consume err = %v, want auth.ErrNotFound", err) + } + // The new hash consumes once, to the same user. + row, err := store.ConsumeRefreshToken(ctx, newHash) + if err != nil { + t.Fatalf("ConsumeRefreshToken(new): %v", err) + } + if row.UserID != user.ID { + t.Errorf("rotated token user_id = %v, want %v", row.UserID, user.ID) + } + // And the new hash is now single-use too. + if _, err := store.ConsumeRefreshToken(ctx, newHash); !errors.Is(err, auth.ErrNotFound) { + t.Errorf("post-rotation new-hash second consume err = %v, want auth.ErrNotFound", err) + } +} From e8b940e9803c591d71860cdc41081c0bfa0f2180 Mon Sep 17 00:00:00 2001 From: Koopa Date: Wed, 24 Jun 2026 16:46:54 +0800 Subject: [PATCH 23/31] test: remove tautological tests in content and api MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - content: TestRegression_BySlug_PrivateContentReturns404 only asserted a Content zero value has IsPublic==false — a struct-zero tautology that never invokes the handler (real coverage is TestHandler_PublicBySlug_HidesNonPublic). - api: TestStatusCapturingWriter_Decision re-implemented the commit predicate in the test body and compared it to itself; TestActorMiddleware_TxAvailableInContext asserted only that context.WithValue round-trips, not the middleware (the real wiring is covered by the integration test's ActorMiddleware+TxFromContext path). --- internal/api/middleware_test.go | 84 ++++++----------------------- internal/content/validation_test.go | 28 ---------- 2 files changed, 15 insertions(+), 97 deletions(-) diff --git a/internal/api/middleware_test.go b/internal/api/middleware_test.go index 2c1c0787..acb5d727 100644 --- a/internal/api/middleware_test.go +++ b/internal/api/middleware_test.go @@ -143,72 +143,18 @@ func TestStatusCapturingWriter_UnwrapReturnsInner(t *testing.T) { } } -// TestStatusCapturingWriter_Decision exercises the same status thresholds -// the middleware uses to decide commit vs rollback: [200, 400) commits, -// everything else rolls back. This locks the boundary so a future -// refactor to "success := sw.status < 400" does not drift. -// Scene: middleware dispatch logic depends on this range being exact. -func TestStatusCapturingWriter_Decision(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - status int - wantCommit bool - }{ - {name: "200 commits", status: 200, wantCommit: true}, - {name: "201 commits", status: 201, wantCommit: true}, - {name: "301 commits", status: 301, wantCommit: true}, - {name: "399 commits", status: 399, wantCommit: true}, - {name: "400 rolls back", status: 400, wantCommit: false}, - {name: "404 rolls back", status: 404, wantCommit: false}, - {name: "500 rolls back", status: 500, wantCommit: false}, - {name: "0 (no write) rolls back", status: 0, wantCommit: false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - // Mirror the middleware's commit predicate exactly. - gotCommit := tt.status >= 200 && tt.status < 400 - if gotCommit != tt.wantCommit { - t.Errorf("commit-range(%d) = %v, want %v", tt.status, gotCommit, tt.wantCommit) - } - }) - } -} - -// TestActorMiddleware_TxAvailableInContext verifies that a handler wrapped -// by ActorMiddleware (through its constructor signature) can retrieve a -// pgx.Tx via TxFromContext. We stage the context manually here to avoid -// spinning up a pool for a unit test -- the full begin/commit/rollback -// assertions live in the commit 25 integration test per brief §8.3. -// Scene: regression guard that the key used for injection matches the -// key used for extraction; a typo in txKey{} would silently break every -// admin mutation. -func TestActorMiddleware_TxAvailableInContext(t *testing.T) { - t.Parallel() - - // Sentinel value standing in for a real pgx.Tx. The type assertion in - // TxFromContext requires pgx.Tx, so we use nil here and assert on ok. - // (A nil pgx.Tx interface value still satisfies the type assertion - // when explicitly stored as pgx.Tx(nil), but for this test we only - // care that the key contract is honored.) - ctx := context.WithValue(t.Context(), txKey{}, pgxTxSentinel{}) - - // Direct assertion using the same key exposes the contract without - // requiring a real pool. - v := ctx.Value(txKey{}) - if v == nil { - t.Fatalf("ctx.Value(txKey{}) = nil, want stored sentinel") - } - if _, ok := v.(pgxTxSentinel); !ok { - t.Fatalf("ctx.Value(txKey{}) underlying type = %T, want pgxTxSentinel", v) - } -} - -// pgxTxSentinel is a test-local stand-in used only to prove that the -// unexported txKey{} is the key the middleware and helper agree on. -// A real pgx.Tx cannot be constructed without a pool connection; the -// integration test in commit 25 covers the round-trip end to end. -type pgxTxSentinel struct{} +// The status thresholds the middleware uses to decide commit vs rollback +// ([200, 400) commits, everything else rolls back) are exercised end to end — +// against a real pool, asserting the audit row actually commits/rolls back — by +// TestActorMiddleware_PropagatesHumanActor in api integration_test.go. A unit +// test that re-implements the `status >= 200 && status < 400` predicate and +// compares it to itself would be a tautology (testing.md Low-Value #1/#2), so it +// is intentionally absent here. +// +// Likewise, the tx-in-context round-trip (ActorMiddleware injects a pgx.Tx that +// the handler retrieves via TxFromContext) is covered by that same integration +// test, which drives content.Handler.Create — a real handler that reads +// api.TxFromContext — through ActorMiddleware against a real pool. The unexported +// txKey{} contract is therefore proven by exercising the real code path rather +// than by a context.WithValue/Value round-trip that never touches the +// middleware. diff --git a/internal/content/validation_test.go b/internal/content/validation_test.go index 38468241..42ee93bb 100644 --- a/internal/content/validation_test.go +++ b/internal/content/validation_test.go @@ -832,34 +832,6 @@ func keysOf(m map[string]json.RawMessage) []string { // Regression tests // ============================================================================= -// TestRegression_BySlug_PrivateContentReturns404 documents the visibility gate: -// a private content hit by slug must return 404, not 200. -// This was a design decision — the gate lives in the handler, not the store. -// -// Visibility is modelled as Content.IsPublic bool (true = public, false = private). -// The handler checks c.IsPublic before returning content via the public slug route. -// This test verifies the zero-value semantics: a newly created Content is private -// by default (IsPublic == false), so the gate fires correctly without explicit setup. -// -// Full end-to-end enforcement is covered by the integration tests in -// integration_test.go. -func TestRegression_BySlug_PrivateContentReturns404(t *testing.T) { - t.Parallel() - - // Verify that the zero value of Content has IsPublic == false (private by default). - // The visibility gate checks c.IsPublic; a false value must cause a 404. - var c Content - if c.IsPublic { - t.Error("Content zero value has IsPublic = true, want false (private by default)") - } - - // Verify that setting IsPublic to true makes it public. - c.IsPublic = true - if !c.IsPublic { - t.Error("Content.IsPublic = true did not set the field correctly") - } -} - // TestRegression_ErrNotFound_SentinelIdentity verifies that ErrNotFound and // ErrConflict use errors.Is correctly and are distinct. func TestRegression_ErrNotFound_SentinelIdentity(t *testing.T) { From c240db493b6c0d62d729e82c5cc6608ee0eb52ad Mon Sep 17 00:00:00 2001 From: Koopa Date: Wed, 24 Jun 2026 16:53:04 +0800 Subject: [PATCH 24/31] docs: correct stale and wrong code comments Comment-only fixes where the comment contradicted the code (code is authority): - stats/topic/todo handler doc comments named wrong routes / query params (per_page not limit; /api/admin/system/stats*; /api/admin/knowledge/topics). - ops/types.go Destructive example referenced the removed learning-plan feature. - stats process_runs.kind comments claimed 'agent_schedule' but the CHECK allows only 'crawl'. - migrations refresh_tokens.token_hash comment said 'Bcrypt or SHA256'; the code only stores a SHA256 base64url hash. - mcp.go package doc described a per-mutation authz gate that does not exist (Option B is attribution-only; access control is the MCP transport). - brief.go/catalog.go 'tasks' section docs omitted the emitted recurring_todos. - api integration test request path literal matched the real content route. --- internal/api/integration_test.go | 2 +- internal/mcp/brief.go | 4 ++-- internal/mcp/mcp.go | 5 +++-- internal/mcp/ops/catalog.go | 2 +- internal/mcp/ops/types.go | 4 ++-- internal/stats/handler.go | 6 +++--- internal/stats/query.sql | 2 +- internal/stats/stats.go | 6 +++--- internal/todo/handler.go | 2 +- internal/topic/handler.go | 6 +++--- migrations/001_initial.up.sql | 2 +- 11 files changed, 21 insertions(+), 20 deletions(-) diff --git a/internal/api/integration_test.go b/internal/api/integration_test.go index 08bbc761..e2ab34d1 100644 --- a/internal/api/integration_test.go +++ b/internal/api/integration_test.go @@ -147,7 +147,7 @@ func postContent(t *testing.T, title string) *http.Request { if err != nil { t.Fatalf("marshal content body: %v", err) } - req := httptest.NewRequest(http.MethodPost, "/api/admin/contents", bytes.NewReader(buf)) + req := httptest.NewRequest(http.MethodPost, "/api/admin/knowledge/content", bytes.NewReader(buf)) req.Header.Set("Content-Type", "application/json") return req } diff --git a/internal/mcp/brief.go b/internal/mcp/brief.go index 80353f00..df7421a4 100644 --- a/internal/mcp/brief.go +++ b/internal/mcp/brief.go @@ -70,7 +70,7 @@ func resolveDefaultSections(caller string) []string { // // Morning group → response field mapping: // -// "tasks" → overdue_todos, today_todos, committed_todos, upcoming_todos +// "tasks" → overdue_todos, today_todos, recurring_todos, committed_todos, upcoming_todos // "goals" → active_goals // "rss" → rss_highlights // "content_pipeline" → content_pipeline @@ -79,7 +79,7 @@ func resolveDefaultSections(caller string) []string { type BriefInput struct { As string `json:"as,omitempty" jsonschema_description:"Caller agent identity (e.g. planner, learning-studio)."` Mode string `json:"mode" jsonschema_description:"Briefing mode (required): 'morning' = daily-planning pull (todos/goals/rss/content_pipeline); 'reflection' = end-of-day plan-vs-actual retrospective (daily plan items + completion counts). brief is a pure planning-state pull and carries no agent memory."` - Sections FlexStringSlice `json:"sections,omitempty" jsonschema_description:"MORNING-ONLY strict filter on which groups to populate (default: all). Ignored in reflection mode. Omit or pass [] to get the full morning briefing. Group key → response fields: 'tasks' → overdue_todos/today_todos/committed_todos/upcoming_todos; 'goals' → active_goals; 'rss' → rss_highlights; 'content_pipeline' → content_pipeline. Unknown keys silently ignored."` + Sections FlexStringSlice `json:"sections,omitempty" jsonschema_description:"MORNING-ONLY strict filter on which groups to populate (default: all). Ignored in reflection mode. Omit or pass [] to get the full morning briefing. Group key → response fields: 'tasks' → overdue_todos/today_todos/recurring_todos/committed_todos/upcoming_todos; 'goals' → active_goals; 'rss' → rss_highlights; 'content_pipeline' → content_pipeline. Unknown keys silently ignored."` Date *string `json:"date,omitempty" jsonschema_description:"Target date YYYY-MM-DD (default: today)"` } diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index 9915b5fe..226106ff 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -5,6 +5,7 @@ // planning briefs, GTD capture, and knowledge search over the content // corpus. Tools are organized by workflow rather than // entity CRUD; the canonical tool catalog lives in internal/mcp/ops. -// Callers self-identify per call and every mutation is authorized -// against the caller's agent identity. +// Callers self-identify per call; that identity is recorded as attribution +// (created_by / activity actor) and scopes caller-owned reads and writes. +// Access control is the MCP transport, not a tool-layer authorization gate. package mcp diff --git a/internal/mcp/ops/catalog.go b/internal/mcp/ops/catalog.go index 48553fb6..b152418c 100644 --- a/internal/mcp/ops/catalog.go +++ b/internal/mcp/ops/catalog.go @@ -32,7 +32,7 @@ func Brief() Meta { Writability: ReadOnly, Stability: StabilityStable, Since: since, - Description: "Read-only planning-state pull. Pick a mode (required): 'morning' = single-call daily-planning briefing (overdue/today/committed/upcoming todos, active_goals, rss_highlights, content_pipeline); 'reflection' = end-of-day plan-vs-actual retrospective (planned_items + completed/deferred/planned counts + completion_rate). brief is a pure planning-state pull and carries no agent memory. Morning mode is filterable via the sections parameter (ignored in reflection mode) — valid keys (omit or pass [] for all): 'tasks' (overdue/today/committed/upcoming todos), 'goals' (active_goals), 'rss' (rss_highlights — feeds tagged priority=high, NOT relevance-ranked; use search_knowledge for ranked retrieval), 'content_pipeline' (content_pipeline). Every caller gets all sections by default; pass an explicit sections list to narrow the briefing. Scope is the target date (default today), not since-last-session.", + Description: "Read-only planning-state pull. Pick a mode (required): 'morning' = single-call daily-planning briefing (overdue/today/recurring/committed/upcoming todos, active_goals, rss_highlights, content_pipeline); 'reflection' = end-of-day plan-vs-actual retrospective (planned_items + completed/deferred/planned counts + completion_rate). brief is a pure planning-state pull and carries no agent memory. Morning mode is filterable via the sections parameter (ignored in reflection mode) — valid keys (omit or pass [] for all): 'tasks' (overdue/today/recurring/committed/upcoming todos), 'goals' (active_goals), 'rss' (rss_highlights — feeds tagged priority=high, NOT relevance-ranked; use search_knowledge for ranked retrieval), 'content_pipeline' (content_pipeline). Every caller gets all sections by default; pass an explicit sections list to narrow the briefing. Scope is the target date (default today), not since-last-session.", FieldEnums: map[string][]string{ "mode": {"morning", "reflection"}, }, diff --git a/internal/mcp/ops/types.go b/internal/mcp/ops/types.go index 9992b779..a2514de3 100644 --- a/internal/mcp/ops/types.go +++ b/internal/mcp/ops/types.go @@ -34,8 +34,8 @@ const ( Additive Writability = "additive" // Idempotent tools may write, but repeating the same call is a no-op. Idempotent Writability = "idempotent" - // Destructive tools transition state in ways that matter (e.g. mutating - // a learning plan's entries). + // Destructive tools transition state in ways that matter (e.g. resolving + // a todo to a terminal state, or revising review-queue content). Destructive Writability = "destructive" ) diff --git a/internal/stats/handler.go b/internal/stats/handler.go index f7b34757..9d9f4780 100644 --- a/internal/stats/handler.go +++ b/internal/stats/handler.go @@ -22,7 +22,7 @@ func NewHandler(store *Store, logger *slog.Logger) *Handler { return &Handler{store: store, logger: logger} } -// Overview handles GET /api/admin/stats. +// Overview handles GET /api/admin/system/stats. func (h *Handler) Overview(w http.ResponseWriter, r *http.Request) { overview, err := h.store.Overview(r.Context()) if err != nil { @@ -52,7 +52,7 @@ func parseDays(raw string) int { return d } -// Drift handles GET /api/admin/stats/drift. +// Drift handles GET /api/admin/system/stats/drift. // Query params: days (default 30, max 90). func (h *Handler) Drift(w http.ResponseWriter, r *http.Request) { days := parseDays(r.URL.Query().Get("days")) @@ -98,7 +98,7 @@ type ProcessRunsSummary struct { FailedLastHour ProcessRunsCell `json:"failed_last_hour"` } -// ProcessRunsResponse is the wire shape for GET /coordination/process-runs. +// ProcessRunsResponse is the wire shape for GET /api/admin/system/process-runs. type ProcessRunsResponse struct { Summary ProcessRunsSummary `json:"summary"` Stages []any `json:"stages"` diff --git a/internal/stats/query.sql b/internal/stats/query.sql index 33eff1eb..12386c82 100644 --- a/internal/stats/query.sql +++ b/internal/stats/query.sql @@ -18,7 +18,7 @@ FROM feeds; -- name: StatsProcessRunsByStatus :many -- Count process_runs grouped by status within a single kind --- (one of: crawl, agent_schedule). +-- (one of: crawl). SELECT status::text AS status, COUNT(*)::int AS count FROM process_runs WHERE kind = @kind::text diff --git a/internal/stats/stats.go b/internal/stats/stats.go index 2ab9f9f4..aa173446 100644 --- a/internal/stats/stats.go +++ b/internal/stats/stats.go @@ -18,8 +18,8 @@ package stats // Overview contains aggregated stats across all platform data sources. // -// ProcessRuns is a map keyed by process_runs.kind — currently one of -// "crawl", "agent_schedule". The map is always populated with every valid +// ProcessRuns is a map keyed by process_runs.kind — currently only +// "crawl". The map is always populated with every valid // kind (zero-valued stats when no rows exist) so the frontend does not // need to distinguish "missing key" from "zero runs". type Overview struct { @@ -52,7 +52,7 @@ type FeedStats struct { } // ProcessRunStats holds process_runs counts by status for a single kind -// (crawl or agent_schedule). Used as the value type in Overview.ProcessRuns map. +// (crawl). Used as the value type in Overview.ProcessRuns map. type ProcessRunStats struct { Total int `json:"total"` ByStatus map[string]int `json:"by_status"` diff --git a/internal/todo/handler.go b/internal/todo/handler.go index e9e92e23..b998e78d 100644 --- a/internal/todo/handler.go +++ b/internal/todo/handler.go @@ -72,7 +72,7 @@ type listResponse struct { // List handles GET /api/admin/commitment/todos. // Query params: state (single value or comma-separated list, every // element validated against the state enum), project (uuid), priority, -// energy, q, limit, due_before (YYYY-MM-DD), sort. due_before is applied +// energy, q, per_page, due_before (YYYY-MM-DD), sort. due_before is applied // in Go after the SQL query returns. Unknown sort values silently fall // back to the default ordering (due → priority → created_at). func (h *Handler) List(w http.ResponseWriter, r *http.Request) { diff --git a/internal/topic/handler.go b/internal/topic/handler.go index e9a8c426..b1fc3712 100644 --- a/internal/topic/handler.go +++ b/internal/topic/handler.go @@ -128,7 +128,7 @@ func (h *Handler) BySlug(w http.ResponseWriter, r *http.Request) { )) } -// Create handles POST /api/admin/topics. +// Create handles POST /api/admin/knowledge/topics. func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { p, err := api.Decode[CreateParams](w, r) if err != nil { @@ -153,7 +153,7 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { api.Encode(w, http.StatusCreated, api.Response{Data: t}) } -// Update handles PUT /api/admin/topics/{id}. +// Update handles PUT /api/admin/knowledge/topics/{id}. func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { id, err := uuid.Parse(r.PathValue("id")) if err != nil { @@ -184,7 +184,7 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { api.Encode(w, http.StatusOK, api.Response{Data: t}) } -// Delete handles DELETE /api/admin/topics/{id}. +// Delete handles DELETE /api/admin/knowledge/topics/{id}. func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { id, err := uuid.Parse(r.PathValue("id")) if err != nil { diff --git a/migrations/001_initial.up.sql b/migrations/001_initial.up.sql index 7a7d3479..cf58f480 100644 --- a/migrations/001_initial.up.sql +++ b/migrations/001_initial.up.sql @@ -110,7 +110,7 @@ CREATE TABLE refresh_tokens ( COMMENT ON TABLE refresh_tokens IS 'JWT refresh token hashes. One user may have multiple active tokens (multi-device).'; COMMENT ON COLUMN refresh_tokens.user_id IS 'Token owner. CASCADE — user deletion invalidates all tokens.'; -COMMENT ON COLUMN refresh_tokens.token_hash IS 'Bcrypt or SHA256 hash of the actual token. Never store plaintext.'; +COMMENT ON COLUMN refresh_tokens.token_hash IS 'SHA256 (base64url) hash of the opaque refresh token. Never store plaintext.'; COMMENT ON COLUMN refresh_tokens.expires_at IS 'Absolute expiration. Tokens past this time are invalid and eligible for cleanup.'; CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id); From f0169e4b82df59e8290f2139b52e37810388a4ec Mon Sep 17 00:00:00 2001 From: Koopa Date: Wed, 24 Jun 2026 16:55:28 +0800 Subject: [PATCH 25/31] test(mcp): drop the obsolete project-filter integration test TestIntegration_SearchKnowledge_ProjectRejected exercised the search_knowledge project field removed earlier; it is dead now that the field is gone. The field-removal commit dropped the unit test but missed this integration one (behind the integration build tag, so the unit gate did not catch it). --- internal/mcp/integration_test.go | 33 -------------------------------- 1 file changed, 33 deletions(-) diff --git a/internal/mcp/integration_test.go b/internal/mcp/integration_test.go index faa639ec..2341069a 100644 --- a/internal/mcp/integration_test.go +++ b/internal/mcp/integration_test.go @@ -658,39 +658,6 @@ func TestIntegration_SearchKnowledge_DateBoundaryInclusive(t *testing.T) { // --- project filter rejection: end-to-end (Track 1I) --- -// TestIntegration_SearchKnowledge_ProjectRejected pins that a non-empty project -// filter is rejected at the MCP handler boundary with an unsupported_filter -// error, against a corpus that WOULD match the query — proving the rejection is -// the project field, not an empty corpus. An empty project value is ignored -// (treated as absent) and the search succeeds. -func TestIntegration_SearchKnowledge_ProjectRejected(t *testing.T) { - s := setupServer(t) - const term = "zqxproj" - seedSearchContent(t, "sk-proj-content", term, "draft") - - t.Run("non-empty project rejected", func(t *testing.T) { - p := "koopa" - _, _, err := callHandler(t, s.searchKnowledge, SearchKnowledgeInput{Query: term, Project: &p}) - if err == nil { - t.Fatal("non-empty project must be rejected as unsupported_filter") - } - if !strings.Contains(err.Error(), "unsupported_filter") { - t.Errorf("error = %q, want containing %q", err, "unsupported_filter") - } - }) - - t.Run("empty project ignored, search succeeds", func(t *testing.T) { - empty := "" - _, out, err := callHandler(t, s.searchKnowledge, SearchKnowledgeInput{Query: term, Project: &empty}) - if err != nil { - t.Fatalf("empty project must be treated as absent: %v", err) - } - if len(out.Results) == 0 { - t.Error("empty project must not filter out the matching content row") - } - }) -} - // --- plan_day position bounds (#13) --- // seedTodoState inserts a todo in the given state and returns its id. plan_day From 2e3c96c9677a3ca90ec670eb6fed948775a13d62 Mon Sep 17 00:00:00 2001 From: Koopa Date: Wed, 24 Jun 2026 17:07:04 +0800 Subject: [PATCH 26/31] fix: surface both listener and shutdown errors on exit The error path overwrote a captured listener error with a shutdown error only when the former was nil, silently dropping one of two independent failures. Use errors.Join so both surface (errors.Join(nil, x) is just x). --- cmd/app/main.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cmd/app/main.go b/cmd/app/main.go index ee0eb0c7..b8e92542 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -371,8 +371,10 @@ func run(logger *slog.Logger) error { shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - if err := srv.Shutdown(shutdownCtx); err != nil && runErr == nil { - runErr = fmt.Errorf("server shutdown: %w", err) + if err := srv.Shutdown(shutdownCtx); err != nil { + // Surface both: a listener error (runErr) and a shutdown error are + // independent failures. errors.Join(nil, x) is just x. + runErr = errors.Join(runErr, fmt.Errorf("server shutdown: %w", err)) } wg.Wait() logger.Info("server stopped") From 7aca7e2a56e5aafe82a6ade879d741084b63fcd8 Mon Sep 17 00:00:00 2001 From: Koopa Date: Wed, 24 Jun 2026 17:07:04 +0800 Subject: [PATCH 27/31] fix(content): reject control characters in the admin slug field Create/Update validated control chars in title/excerpt/body but not slug, which becomes a URL path segment. The DB chk_content_slug_format CHECK rejects whitespace and slashes but not non-whitespace control chars (C0-non-ws, DEL, C1), so a control-char slug passed both the handler and the DB. Add slug to the strict control-char check (which still allows the intentionally-supported Unicode/CJK slugs). Surfaced by L2 review; tests cover slug rejection in Create and Update. --- internal/content/admin.go | 25 ++++++++++++++++--------- internal/content/validation_test.go | 13 +++++++++++++ 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/internal/content/admin.go b/internal/content/admin.go index 80f86668..26f8df76 100644 --- a/internal/content/admin.go +++ b/internal/content/admin.go @@ -41,13 +41,20 @@ func containsControlChars(s string) bool { } // checkContentControlChars rejects control characters in the content write -// fields, mirroring the MCP write path (propose_content / revise_content): -// title and excerpt are single-line fields validated with the strict check -// (every control char), while body is multi-line Markdown validated with the -// prose check (HT/LF/CR permitted). A nil argument is skipped so the same -// check serves Create (all present) and partial Update (only changed fields). -// It returns the field name of the first offending field, or "" when clean. -func checkContentControlChars(title, excerpt, body *string) string { +// fields. slug, title, and excerpt are single-line fields validated with the +// strict check (every control char), while body is multi-line Markdown +// validated with the prose check (HT/LF/CR permitted). This mirrors the MCP +// write path for title/excerpt/body (propose_content / revise_content); slug is +// admin-only — the MCP path derives the slug server-side, and the DB +// slug-format CHECK rejects whitespace and slashes but NOT non-whitespace +// control chars, so this is the boundary that keeps them out of the URL path +// segment. A nil argument is skipped so the same check serves Create (all +// present) and partial Update (only changed fields). Returns the first +// offending field name, or "" when clean. +func checkContentControlChars(slug, title, excerpt, body *string) string { + if slug != nil && containsControlChars(*slug) { + return "slug" + } if title != nil && containsControlChars(*title) { return "title" } @@ -156,7 +163,7 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { api.Error(w, http.StatusBadRequest, "BAD_REQUEST", err.Error()) return } - if field := checkContentControlChars(&p.Title, &p.Excerpt, &p.Body); field != "" { + if field := checkContentControlChars(&p.Slug, &p.Title, &p.Excerpt, &p.Body); field != "" { api.Error(w, http.StatusBadRequest, "BAD_REQUEST", field+" must not contain control characters") return } @@ -209,7 +216,7 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { api.Error(w, http.StatusBadRequest, "BAD_REQUEST", err.Error()) return } - if field := checkContentControlChars(p.Title, p.Excerpt, p.Body); field != "" { + if field := checkContentControlChars(p.Slug, p.Title, p.Excerpt, p.Body); field != "" { api.Error(w, http.StatusBadRequest, "BAD_REQUEST", field+" must not contain control characters") return } diff --git a/internal/content/validation_test.go b/internal/content/validation_test.go index 42ee93bb..d741913e 100644 --- a/internal/content/validation_test.go +++ b/internal/content/validation_test.go @@ -431,6 +431,14 @@ func TestHandler_Create_RejectsControlChars(t *testing.T) { body string wantField string }{ + { + // Slug becomes a URL path segment; the DB slug-format CHECK rejects + // whitespace/slash but not non-whitespace control chars, so the + // handler is the boundary; the body uses a JSON escape decoding to byte 0x01. + name: "control char in slug", + body: "{\"slug\":\"bad\\u0001slug\",\"title\":\"T\",\"type\":\"article\"}", + wantField: "slug", + }, { name: "control char in title", body: `{"slug":"s","title":"bad\u0001title","type":"article"}`, @@ -487,6 +495,11 @@ func TestHandler_Update_RejectsControlChars(t *testing.T) { body string wantField string }{ + { + name: "control char in slug", + body: "{\"slug\":\"bad\\u0001slug\"}", + wantField: "slug", + }, { name: "control char in title", body: `{"title":"bad\u0001title"}`, From 850c8a8f933cafeb278a444f2ad3c8e9f9c24b00 Mon Sep 17 00:00:00 2001 From: Koopa Date: Wed, 24 Jun 2026 17:17:41 +0800 Subject: [PATCH 28/31] refactor: address review nits - feed: rename the unexported due() predicate to isDue() (clearer boolean predicate; avoids a future name collision in the package). - content: co-locate containsProseControlChars with containsControlChars and checkContentControlChars so the three control-char helpers sit together (they cross-reference each other; this stops them drifting 375 lines apart). - content: drop the dead wantTag table field and the obsolete 'tag' filter test case + benchmark param (tags were removed in favour of topics; parsePublicFilter no longer parses tag). --- internal/content/admin.go | 34 ++++++++++++++--------------- internal/content/validation_test.go | 9 +------- internal/feed/scheduler.go | 6 ++--- internal/feed/scheduler_test.go | 8 +++---- 4 files changed, 25 insertions(+), 32 deletions(-) diff --git a/internal/content/admin.go b/internal/content/admin.go index 26f8df76..90f51afa 100644 --- a/internal/content/admin.go +++ b/internal/content/admin.go @@ -67,6 +67,23 @@ func checkContentControlChars(slug, title, excerpt, body *string) string { return "" } +// containsProseControlChars reports whether s contains a control character +// forbidden in multi-line free-text prose: every control char EXCEPT HT (0x09), +// LF (0x0A), and CR (0x0D). Body and review_note are short, possibly multi-line +// free text where line breaks are legitimate, so they are validated with this +// rather than the strict containsControlChars above. Mirrors the MCP prose check. +func containsProseControlChars(s string) bool { + for _, r := range s { + switch { + case r == 0x09, r == 0x0a, r == 0x0d: + continue + case r < 0x20, r == 0x7f, r >= 0x80 && r <= 0x9f: + return true + } + } + return false +} + // Contents returns a paginated list across all statuses / visibilities. // The authenticated admin listing route consumes this; the public-facing // variant is PublicContents. @@ -405,23 +422,6 @@ type sendBackBody struct { ReviewNote string `json:"review_note"` } -// containsProseControlChars reports whether s contains a control character -// forbidden in multi-line free-text prose: every control char EXCEPT HT (0x09), -// LF (0x0A), and CR (0x0D). A review_note is a short, possibly multi-line -// revision reason where line breaks are legitimate, so SendBack validates it -// with this rather than rejecting every C0 control. Mirrors the MCP prose check. -func containsProseControlChars(s string) bool { - for _, r := range s { - switch { - case r == 0x09, r == 0x0a, r == 0x0d: - continue - case r < 0x20, r == 0x7f, r >= 0x80 && r <= 0x9f: - return true - } - } - return false -} - // SendBack handles POST /api/admin/knowledge/content/{id}/send-back. // Transitions review → changes_requested with the owner's review_note reason. // Rejects an empty/whitespace review_note (400) and any control characters diff --git a/internal/content/validation_test.go b/internal/content/validation_test.go index d741913e..720de65f 100644 --- a/internal/content/validation_test.go +++ b/internal/content/validation_test.go @@ -55,7 +55,6 @@ func TestParseFilter_Adversarial(t *testing.T) { name string query string wantType *Type - wantTag *string wantSinceNil bool }{ { @@ -64,12 +63,6 @@ func TestParseFilter_Adversarial(t *testing.T) { wantType: nil, wantSinceNil: true, }, - { - name: "xss payload in tag is passed through (no sanitisation at this layer)", - query: "tag=", - wantType: nil, - wantSinceNil: true, - }, { name: "null byte in type is rejected", query: "type=article%00malicious", @@ -806,7 +799,7 @@ func TestHandler_ErrorResponse_Contract(t *testing.T) { func BenchmarkParseFilter(b *testing.B) { h := &Handler{} req := httptest.NewRequest(http.MethodGet, - "/?type=article&tag=golang&since=2026-03-20&page=1&per_page=20", + "/?type=article&since=2026-03-20&page=1&per_page=20", http.NoBody) b.ReportAllocs() for b.Loop() { diff --git a/internal/feed/scheduler.go b/internal/feed/scheduler.go index 0e791c4f..41663db2 100644 --- a/internal/feed/scheduler.go +++ b/internal/feed/scheduler.go @@ -130,12 +130,12 @@ func (s *Scheduler) fetchDueFeeds(ctx context.Context) { } } -// due reports whether a feed is due for a fetch on this tick. A feed that has +// isDue reports whether a feed is due for a fetch on this tick. A feed that has // never been fetched (lastFetched == nil) is always due; otherwise it is due // once its age (now - *lastFetched) reaches the schedule interval. The boundary // is inclusive — age exactly equal to interval is due — because the comparison // skips only when the age is strictly less than the interval. -func due(lastFetched *time.Time, now time.Time, interval time.Duration) bool { +func isDue(lastFetched *time.Time, now time.Time, interval time.Duration) bool { if lastFetched == nil { return true } @@ -158,7 +158,7 @@ func (s *Scheduler) fetchSchedule(ctx context.Context, schedule string, interval } f := &feeds[i] - if !due(f.LastFetchedAt, now, interval) { + if !isDue(f.LastFetchedAt, now, interval) { skipped++ continue } diff --git a/internal/feed/scheduler_test.go b/internal/feed/scheduler_test.go index 6305c05b..7916d3ee 100644 --- a/internal/feed/scheduler_test.go +++ b/internal/feed/scheduler_test.go @@ -485,7 +485,7 @@ func TestValidSchedule_Adversarial(t *testing.T) { // due — scheduler skip-vs-fetch decision (pure) // --------------------------------------------------------------------------- -// TestDue exercises the scheduler's per-feed skip-vs-fetch predicate +// TestIsDue exercises the scheduler's per-feed skip-vs-fetch predicate // (scheduler.go::due, called from fetchSchedule). The decision is a pure // function of (lastFetched, now, interval): a never-fetched feed is always due; // a fetched feed is due once its age reaches the interval, with the boundary @@ -495,7 +495,7 @@ func TestValidSchedule_Adversarial(t *testing.T) { // boundary) breaks "exactly interval ago" → due; dropping the nil guard panics // on a never-fetched feed; using `<=` instead of `<` in the original inline // skip check would make "exactly interval ago" skip — each surfaces here. -func TestDue(t *testing.T) { +func TestIsDue(t *testing.T) { t.Parallel() now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC) @@ -522,9 +522,9 @@ func TestDue(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - got := due(tt.lastFetched, now, interval) + got := isDue(tt.lastFetched, now, interval) if got != tt.want { - t.Errorf("due(%v, now, %v) = %v, want %v", tt.lastFetched, interval, got, tt.want) + t.Errorf("isDue(%v, now, %v) = %v, want %v", tt.lastFetched, interval, got, tt.want) } }) } From 9dc4751df36f1a7f9c096a980bd4242ad4a9348f Mon Sep 17 00:00:00 2001 From: Koopa Date: Wed, 24 Jun 2026 17:19:12 +0800 Subject: [PATCH 29/31] docs(readme): correct the authz model, tool count, and missing tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both READMEs described a three-axis authorization gate in a non-existent internal/mcp/authz.go — the removed Option-A design. The actual model is Option B: as is attribution only (created_by / activity actor / caller-scope), and access control is the MCP transport (admin-email OAuth + bearer on HTTP /mcp; OS process boundary on stdio); a fabricated as is caught by the created_by FK. Also: the tool count was stale (fourteen → fifteen) and the toolset table omitted set_todo_recurrence. --- README.md | 5 +++-- README.zh-TW.md | 7 ++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 0c6ad49b..29fa9ead 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ The working roster (`internal/agent/registry.go::BuiltinAgents()`): Cowork agents run on declared cadences — the planner at 8 a.m., pinned in the registry — but execution is driven by external runners, not by this repo; the backend owns the registry metadata, the schema, and the `process_runs` table that audits each external run. -Writes are gated by **identity**. Every MCP call self-identifies via an `as` field; the server resolves it against the registry and applies three-axis authorization (`internal/mcp/authz.go`): an **author** allowlist (a human is always permitted), **registration** (a known, non-anonymous caller), and **self** (you may only act on your own rows). An unknown caller fails closed on every mutating tool. +Writes carry an **actor**, not a tool-layer gate. Every MCP call self-identifies via an `as` field; the server (`internal/mcp/server.go::callerIdentity`) records it as attribution — it sets `created_by`, the `activity_events` actor, and the caller-scope of an agent's own rows — but no tool checks it for permission. Access control is the MCP transport itself: the HTTP `/mcp` endpoint sits behind admin-email OAuth and a bearer token, and stdio is an OS process boundary. A fabricated `as` is caught downstream by the `created_by` foreign key to the agent roster, so an unknown caller's writes are attributed to `unknown`, never to you. Two structural invariants hold: @@ -84,7 +84,7 @@ Any agent queries the corpus through MCP via `search_knowledge` — published co ## The agent toolset -Fourteen MCP tools — small on purpose. Everything an agent can do is a workflow step with valid transitions and invariant checks, never raw table access: +Fifteen MCP tools — small on purpose. Everything an agent can do is a workflow step with valid transitions and invariant checks, never raw table access: | Tool | What it does | |---|---| @@ -96,6 +96,7 @@ Fourteen MCP tools — small on purpose. Everything an agent can do is a workflo | `plan_day` | Set today's plan as one atomic replacement. No auto-carryover. | | `propose_area` / `propose_goal` / `propose_project` | Draft an inert PARA proposal (`status=proposed`) for you to activate or reject in admin triage. | | `list_tasks` / `resolve_task` | Read back the disposition of the todos an agent created, and self-clear the ones it has finished. | +| `set_todo_recurrence` | Make a todo the agent created recurring — by weekday (e.g. Mon–Sat) or interval (every N days/weeks/months) — or clear it; recurring todos resurface in the brief on each matching day, computed on read. | | `propose_content` | Push a finished content piece into the editorial review queue (`status=review`); you publish it or send it back for revision. | | `list_content` / `revise_content` | Read back the disposition of the content an agent proposed — including your revision note when you send a draft back — and revise a sent-back draft back into review. | diff --git a/README.zh-TW.md b/README.zh-TW.md index 8b251d0e..1cecd621 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -53,7 +53,7 @@ actor 的軸線是**流程 vs. 決策**,不是人類 vs. agent: Cowork agent 跑在宣告好的節奏上 — 規劃者早 8 點,釘在 registry 裡 — 但執行由外部 runner 驅動,不是這個 repo 自己跑的;backend 持有的是 registry metadata、schema,以及記錄每次外部執行的 `process_runs` audit 表。 -把守寫入的是**身分**。每一次 MCP call 都透過 `as` 欄位自我表明身分;server 對著 registry 解析它,套用三軸授權(`internal/mcp/authz.go`):一個 **author** 白名單(人類永遠被允許)、**registration**(已知、非匿名的 caller),以及 **self**(你只能操作自己的 row)。未知的 caller 對每一個會 mutation 的工具都 fail closed。 +寫入帶的是 **actor**,不是 tool 層的授權閘。每一次 MCP call 都透過 `as` 欄位自我表明身分;server(`internal/mcp/server.go::callerIdentity`)把它當 attribution 記下來 — 設定 `created_by`、`activity_events` 的 actor,以及該 agent 自己 row 的 caller-scope — 但沒有任何工具拿它來檢查權限。存取控制是 MCP transport 本身:HTTP `/mcp` 端點走 admin-email OAuth + bearer token,stdio 則是 OS 行程邊界。偽造的 `as` 會在下游被 `created_by` 對 agent roster 的外鍵擋下,所以未知 caller 的寫入被歸給 `unknown`,絕不會算成你。 兩個結構性 invariant 成立: @@ -83,7 +83,7 @@ Agent 可以把一個 raw todo 丟進你的 inbox、起草一份惰性的 area / ## Agent 工具集 -十四個 MCP 工具 — 刻意做得小。agent 能做的每一件事都是一個工作流步驟,帶合法轉換與不變量檢查,絕不是原始的 table 存取: +十五個 MCP 工具 — 刻意做得小。agent 能做的每一件事都是一個工作流步驟,帶合法轉換與不變量檢查,絕不是原始的 table 存取: | 工具 | 它做什麼 | |---|---| @@ -95,6 +95,7 @@ Agent 可以把一個 raw todo 丟進你的 inbox、起草一份惰性的 area / | `plan_day` | 把今天的 plan 設定為一次 atomic 的整體替換。沒有 auto-carryover。 | | `propose_area` / `propose_goal` / `propose_project` | 起草一份惰性的 PARA 提案(`status=proposed`),讓你在 admin triage 啟用或拒絕。 | | `list_tasks` / `resolve_task` | 讀回 agent 建立的 todo 的處置,並自清它已處理完的。 | +| `set_todo_recurrence` | 把 agent 建立的 todo 設成循環(週幾型如 Mon–Sat,或間隔型每 N 天/週/月)或清掉;循環 todo 每逢符合的日子在 brief 重新浮現,compute-on-read。 | | `propose_content` | 把完成的內容推進 editorial 審核佇列(`status=review`);由你 publish 或退回要求修改。 | | `list_content` / `revise_content` | 讀回 agent 提的內容的處置 — 包含你退件時寫的修改原因 — 並把被退回的稿子改好、送回 review。 | @@ -123,7 +124,7 @@ Agent 可以把一個 raw todo 丟進你的 inbox、起草一份惰性的 area / | Embedding | `gemini-embedding-2`(1536d Matryoshka);背景 reconciler 維持搜尋語料庫的 embedding 最新 | | 排程 | Agent 節奏在 `internal/agent/registry.go` 宣告;執行由外部 Cowork/Desktop runner 驅動;以 `process_runs` 留 audit | | 前端 | Angular 22(SSR、zoneless、Signal Forms)、Tailwind CSS v4 | -| AI 協作 | Claude(Cowork + Code)、Codex CLI、MCP(14 個工作流工具) | +| AI 協作 | Claude(Cowork + Code)、Codex CLI、MCP(15 個工作流工具) | | Cache | Ristretto(in-memory,單機) | | Object 儲存 | Cloudflare R2(S3 相容) | From a48927216097d4b93b78b4741d43e0c43f725ea3 Mon Sep 17 00:00:00 2001 From: Koopa Date: Wed, 24 Jun 2026 17:26:54 +0800 Subject: [PATCH 30/31] chore: regenerate sqlc after SQL comment fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The doc-comment pass edited the refresh_tokens.token_hash column comment and the StatsProcessRunsByStatus query comment; sqlc propagates SQL comments into the generated Go doc comments, so the generated files needed regeneration. Comment-only — no type or query change. --- internal/db/models.go | 2 +- internal/db/query.sql.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/db/models.go b/internal/db/models.go index c2eff451..9326ff99 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -631,7 +631,7 @@ type RefreshToken struct { ID uuid.UUID `json:"id"` // Token owner. CASCADE — user deletion invalidates all tokens. UserID uuid.UUID `json:"user_id"` - // Bcrypt or SHA256 hash of the actual token. Never store plaintext. + // SHA256 (base64url) hash of the opaque refresh token. Never store plaintext. TokenHash string `json:"token_hash"` // Absolute expiration. Tokens past this time are invalid and eligible for cleanup. ExpiresAt time.Time `json:"expires_at"` diff --git a/internal/db/query.sql.go b/internal/db/query.sql.go index fb70f6f1..a2072329 100644 --- a/internal/db/query.sql.go +++ b/internal/db/query.sql.go @@ -5702,7 +5702,7 @@ type StatsProcessRunsByStatusRow struct { } // Count process_runs grouped by status within a single kind -// (one of: crawl, agent_schedule). +// (one of: crawl). func (q *Queries) StatsProcessRunsByStatus(ctx context.Context, kind string) ([]StatsProcessRunsByStatusRow, error) { rows, err := q.db.Query(ctx, statsProcessRunsByStatus, kind) if err != nil { From 1d00864990aae5b13aa74ab9c42a95e25a4fe0db Mon Sep 17 00:00:00 2001 From: Koopa Date: Wed, 24 Jun 2026 17:27:41 +0800 Subject: [PATCH 31/31] docs(readme): reframe the agent narrative around the active drivers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hero narrative led with a planner-on-Cowork morning briefing as if it ran daily; the actual drivers today are Koopa (decisions/router), Claude Code (dev sessions), and hermes (Obsidian curation). Lead with those three, present the planner as the daily-driver the system is designed around — wired but executed by an external scheduler, not yet a daily habit — and reorder the roster to put the active actors first. Roster facts unchanged (all agents are registered); this is emphasis, matching the 'README describes what-is' rule. EN + zh-TW. --- README.md | 14 +++++++------- README.zh-TW.md | 14 +++++++------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 29fa9ead..902f12b9 100644 --- a/README.md +++ b/README.md @@ -26,13 +26,13 @@ **koopa** is a private-by-default personal OS where AI agents share a semantic runtime — so the AI reads your state, not your prompts. -It's 8 a.m. You ask for the day. The planner doesn't ask what's on your plate — it reads yesterday's unfinished daily plan, this week's goal progress, the projects that have gone quiet, and the RSS highlights the ingest pipeline collected overnight, and hands you one briefing. You skim it, set today's plan, and start. Through the day the agents stay in their lane: the planner sets the day and drafts a goal or project proposal, any agent searches the corpus, and a finished article gets pushed into your review queue — all in conversation with you. Nothing high-stakes happens behind your back: every goal, project, milestone, and published article is **your** decision, made in the admin UI. The agents surface structure; you make the call. +Three actors drive it today, and a fourth is what it's built for. **You** make every call — each goal, project, milestone, and published article is committed by you in the admin UI. **Claude Code** runs development sessions in this repo: it searches the corpus, logs what it built, and pushes a finished draft into your review queue — reading the same state any agent sees, never re-explained. **hermes** curates your Obsidian vault on a schedule. The fourth is the **planner**: a daily driver that reads yesterday's unfinished plan, this week's goal progress, the projects that have gone quiet, and the overnight RSS highlights, and hands you one morning briefing — the flow is wired and runs on an external scheduler, the piece still becoming a daily habit. Nothing high-stakes happens behind your back: the agents surface structure; you make the call. ## Why this exists Most AI integrations are stateless: every conversation starts from zero, every agent is a fresh amnesiac, and you spend your time re-explaining context. The more agents you add — Claude Code in your editor, Cowork agents on schedulers, background summarizers — the worse it gets, because each produces output the others never see. -koopa models the work instead. Areas, goals, projects, milestones, todos, daily plans, content — all first-class entities with precise schemas and their own lifecycles, in one store every agent reads through MCP and writes through bounded workflow steps. When the planner assembles your morning briefing, it reads yesterday's daily plan and surfaces what didn't get done — not because you summarized it, but because the state is there. When it proposes a new goal, the draft lands inert in your triage queue with the milestones already laid out. Understanding is queried, not reconstructed; there is no drift between agents and no "I think you mentioned…". +koopa models the work instead. Areas, goals, projects, milestones, todos, daily plans, content — all first-class entities with precise schemas and their own lifecycles, in one store every agent reads through MCP and writes through bounded workflow steps. When an agent reads your state — Claude Code opening a session, or the planner assembling a briefing — it pulls yesterday's daily plan and goal progress through MCP, not because you summarized it, but because the state is there. When an agent proposes a new goal, the draft lands inert in your triage queue with the milestones already laid out. Understanding is queried, not reconstructed; there is no drift between agents and no "I think you mentioned…". ## How it works @@ -46,13 +46,13 @@ The working roster (`internal/agent/registry.go::BuiltinAgents()`): | Identity | Runs as | Role | |---|---|---| -| `planner` | Claude Cowork | Morning briefing, candidate day plans, inbox capture, search, PARA proposals | -| `koopa0-dev` / `go-spec` | Claude Code | Development sessions in this repo | -| `codex` | Codex CLI | Dev collaborator — repo work and cross-review sessions | -| `hermes` | Claude Code (scheduled) | Curates the personal Obsidian vault on assigned cron jobs | | `human` | — | Koopa: the only decision-maker, the only router | +| `koopa0-dev` / `go-spec` | Claude Code | Development sessions in this repo — search, build-logs, content drafts | +| `hermes` | Claude Code (scheduled) | Curates the personal Obsidian vault on assigned cron jobs | +| `planner` | Claude Cowork | The intended daily driver — morning briefing, candidate day plans, inbox capture, PARA proposals | +| `codex` | Codex CLI | Dev collaborator — repo work and cross-review sessions | -Cowork agents run on declared cadences — the planner at 8 a.m., pinned in the registry — but execution is driven by external runners, not by this repo; the backend owns the registry metadata, the schema, and the `process_runs` table that audits each external run. +The active drivers today are you, Claude Code, and hermes. The `planner` is the daily-driver the system is designed around; its cadence (a morning briefing, pinned in the registry) is **declared in the backend but executed by an external runner**, not by this repo — the backend owns the registry metadata, the schema, and the `process_runs` table that audits each external run. Writes carry an **actor**, not a tool-layer gate. Every MCP call self-identifies via an `as` field; the server (`internal/mcp/server.go::callerIdentity`) records it as attribution — it sets `created_by`, the `activity_events` actor, and the caller-scope of an agent's own rows — but no tool checks it for permission. Access control is the MCP transport itself: the HTTP `/mcp` endpoint sits behind admin-email OAuth and a bearer token, and stdio is an OS process boundary. A fabricated `as` is caught downstream by the `created_by` foreign key to the agent roster, so an unknown caller's writes are attributed to `unknown`, never to you. diff --git a/README.zh-TW.md b/README.zh-TW.md index 1cecd621..7e0a57b2 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -25,13 +25,13 @@ **koopa** 是一個預設私有的個人作業系統,讓多個 AI agent 共享同一套語意運行時 — AI 讀取的是你的狀態,不是你的 prompt。 -早上 8 點。你問今天怎麼安排。規劃者不會反問你手上有什麼 — 它讀昨天未完成的 daily plan、這週的目標進度、安靜下來的 project,還有夜裡 ingest pipeline 收集的 RSS 重點,然後遞給你一份 briefing。你掃過一遍,定下今天的 plan,開始動工。一整天裡 agent 各守本分:規劃者規劃當天、起草一份 goal 或 project 提案、任何 agent 都能搜尋語料庫,完成的文章被推進你的審核佇列 — 全都在跟你的對話裡。沒有任何高風險的事在你背後發生:每一個 goal、project、milestone、發佈的文章,都是**你**的決定,在 admin UI 裡做的。Agent 浮現結構;你下判斷。 +今天驅動它的有三個 actor,還有它為之設計的第四個。**你**下每一個判斷 — 每一個 goal、project、milestone、發佈的文章,都是你在 admin UI 裡 commit 的。**Claude Code** 在這個 repo 跑開發 session:搜尋語料庫、記錄它做了什麼、把完成的草稿推進你的審核佇列 — 讀的是任何 agent 都看得到的同一份狀態,不必重講。**hermes** 按排程整理你的 Obsidian vault。第四個是 **planner**:一個日常驅動者,讀昨天未完成的 plan、這週的目標進度、安靜下來的 project、還有夜裡的 RSS 重點,遞給你一份晨間 briefing — 這條流程已經接好、跑在外部 scheduler 上,是還在變成日常習慣的那一塊。沒有任何高風險的事在你背後發生:agent 浮現結構;你下判斷。 ## 為什麼存在 大多數 AI 整合是無狀態的:每次對話從零開始、每個 agent 都是新鮮的失憶者,你把時間花在重複解釋脈絡。你加的 agent 越多 — 編輯器裡的 Claude Code、scheduler 上的 Cowork agent、背景跑的 summarizer — 問題就越嚴重,因為每個 agent 產出的東西,別人從來看不到。 -koopa 改成把工作本身建模。Area、goal、project、milestone、todo、daily plan、content — 全都是一等公民實體,有精確的 schema 和各自的 lifecycle,存在同一份儲存裡,每個 agent 都透過 MCP 讀取,並透過有界的工作流步驟寫入。規劃者組裝晨間 briefing 時,它讀昨天的 daily plan 並浮現未完成的項目 — 不是因為你總結了,而是因為狀態就在那裡。它提一個新 goal 時,草稿帶著排好的 milestone,惰性地落進你的 triage 佇列。理解是查詢出來的,不是重建出來的;agent 之間沒有漂移,也沒有「我好像記得你提過⋯」。 +koopa 改成把工作本身建模。Area、goal、project、milestone、todo、daily plan、content — 全都是一等公民實體,有精確的 schema 和各自的 lifecycle,存在同一份儲存裡,每個 agent 都透過 MCP 讀取,並透過有界的工作流步驟寫入。一個 agent 讀你的狀態時 — Claude Code 開一個 session,或 planner 組裝 briefing — 它透過 MCP 拉昨天的 daily plan 與目標進度,不是因為你總結了,而是因為狀態就在那裡。一個 agent 提新 goal 時,草稿帶著排好的 milestone,惰性地落進你的 triage 佇列。理解是查詢出來的,不是重建出來的;agent 之間沒有漂移,也沒有「我好像記得你提過⋯」。 ## 運作方式 @@ -45,13 +45,13 @@ actor 的軸線是**流程 vs. 決策**,不是人類 vs. agent: | 身分 | 執行環境 | 角色 | |---|---|---| -| `planner` | Claude Cowork | 晨間 briefing、候選日計畫、inbox capture、搜尋、PARA 提案 | -| `koopa0-dev` / `go-spec` | Claude Code | 這個 repo 的開發 session | -| `codex` | Codex CLI | 開發協作者 — repo 工作與 cross-review session | -| `hermes` | Claude Code(排程) | 按指派的 cron job 整理個人 Obsidian vault | | `human` | — | Koopa:唯一的決策者、唯一的 router | +| `koopa0-dev` / `go-spec` | Claude Code | 這個 repo 的開發 session — 搜尋、build-log、content 草稿 | +| `hermes` | Claude Code(排程) | 按指派的 cron job 整理個人 Obsidian vault | +| `planner` | Claude Cowork | 系統為之設計的日常驅動者 — 晨間 briefing、候選日計畫、inbox capture、PARA 提案 | +| `codex` | Codex CLI | 開發協作者 — repo 工作與 cross-review session | -Cowork agent 跑在宣告好的節奏上 — 規劃者早 8 點,釘在 registry 裡 — 但執行由外部 runner 驅動,不是這個 repo 自己跑的;backend 持有的是 registry metadata、schema,以及記錄每次外部執行的 `process_runs` audit 表。 +今天實際在驅動的是你、Claude Code 和 hermes。`planner` 是系統設計所圍繞的日常驅動者;它的節奏(一份晨間 briefing,釘在 registry 裡)**在 backend 宣告,但由外部 runner 執行**,不是這個 repo 自己跑的 — backend 持有的是 registry metadata、schema,以及記錄每次外部執行的 `process_runs` audit 表。 寫入帶的是 **actor**,不是 tool 層的授權閘。每一次 MCP call 都透過 `as` 欄位自我表明身分;server(`internal/mcp/server.go::callerIdentity`)把它當 attribution 記下來 — 設定 `created_by`、`activity_events` 的 actor,以及該 agent 自己 row 的 caller-scope — 但沒有任何工具拿它來檢查權限。存取控制是 MCP transport 本身:HTTP `/mcp` 端點走 admin-email OAuth + bearer token,stdio 則是 OS 行程邊界。偽造的 `as` 會在下游被 `created_by` 對 agent roster 的外鍵擋下,所以未知 caller 的寫入被歸給 `unknown`,絕不會算成你。