mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-27 06:47:07 +00:00
47ca9e521a
* fix(server): refuse a collection schema that repeats or empties a field key or option (TASK-3539, PR B) Lead ruling: a repeated or empty field key or select option is a broken schema, and the web renders fields and options as lists keyed by them, so one repeat blanked the view (BUG-3538). PR A made the web render such a schema; this stops new ones being written. - Collection create and update refuse, with 400 validation_error naming it, a schema in which a field has no key, two fields share a key, or a select field lists an empty option or the same option twice (models.ValidateSchemaKeysAndOptions, beside ValidateAbandonedOptions). WRITE check only: a stored schema with a repeat still reads, and an update that sends no schema still applies. Live census on :7777: 0 of 213 collections affected. - Dynamic Client Registration registers a repeated redirect URI once (it is unauthenticated, and the connected-apps page lists URIs keyed by value). - ListWorkspaceTags groups tags as TEXT: on SQLite json_each kept [1] and ["1"] apart while both scanned to "1". - Pins that convention commands and surfaces are deduped on read: they are already (normalizeItemConventionMetadata); the census verdict that said otherwise was wrong. Not done, deliberately: refusing repeated app-manifest event names or companion field keys. appmanifest.Parse also re-reads a STORED manifest on upgrade, so a new refusal there would fail a read of an installed app; PR A made both web lists render repeats. ToolSurfaceVersion 0.82 -> 0.83: pad_collection create/update refuse calls they used to accept (the v0.43 / v0.48 grounds). docs/api.md states the 400. * fix(server): hold companion and template schemas to the key/option rule (TASK-3539) Codex round 1: writers that bypass the collection handlers could still store a schema that repeats or empties a key or option. - An app's companion collection schemas are checked in the install/upgrade preview, on the NEW manifest beside the reserved-key check, and refused 422 invalid_manifest. Not in appmanifest.Parse, which also re-reads an installed app's stored manifest. - Every template and default collection schema the repo ships is checked by a test (they seed through the store, not the handlers). - The legacy-schema test also reads and creates items in a collection stored with a repeat. Workspace and artifact import, and the library option widening (which already dedupes), are unchanged: refuse on write only. * docs(mcp): the v0.83 entry in docs/mcp.md's version history (TASK-3539) Codex round 2: the current-version labels said v0.83, the history began at v0.82. * docs(mcp): docs/mcp.md's intro names the current catalog, v0.83 (TASK-3539) Codex round 3: the intro still said v0.81, stale since before this change. * fix(server): the option check covers select and multi_select only (TASK-3539) Codex round 4: it refused empty or repeated options on every field type, but only a select or multi_select's options are a rendered list. Another type can carry a legacy options list (the old field DSL put a third part there), and refusing it would block an unrelated schema save. Tested both ways. * test(collections): the template schema check is independent of the validator (TASK-3539) Codex round 5: calling ValidateSchemaKeysAndOptions from the template test was circular. It now uses its own check. Also records, at the write, why the library option widening is not held to the validator: it only appends missing words (never a repeat), and refusing would fail an item create over a legacy schema.
747 lines
29 KiB
Go
747 lines
29 KiB
Go
package models
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestExtractItemCodeContextFromGitHubPRFields(t *testing.T) {
|
|
context := ExtractItemCodeContext(`{"github_pr":{"number":42,"url":"https://github.com/PerpetualSoftware/pad/pull/42","title":"Add branch metadata","state":"OPEN","branch":"feat/task-123-branch-pr-metadata","repo":"PerpetualSoftware/pad","updated_at":"2026-04-02T14:00:00Z"}}`)
|
|
if context == nil {
|
|
t.Fatal("expected code context")
|
|
}
|
|
if context.Provider != "github" {
|
|
t.Fatalf("expected provider github, got %q", context.Provider)
|
|
}
|
|
if context.Branch != "feat/task-123-branch-pr-metadata" {
|
|
t.Fatalf("expected branch metadata, got %q", context.Branch)
|
|
}
|
|
if context.Repo != "PerpetualSoftware/pad" {
|
|
t.Fatalf("expected repo PerpetualSoftware/pad, got %q", context.Repo)
|
|
}
|
|
if context.PullRequest == nil {
|
|
t.Fatal("expected pull request metadata")
|
|
}
|
|
if context.PullRequest.Number != 42 {
|
|
t.Fatalf("expected PR #42, got #%d", context.PullRequest.Number)
|
|
}
|
|
}
|
|
|
|
func TestExtractItemCodeContextReturnsNilForUnrelatedFields(t *testing.T) {
|
|
if got := ExtractItemCodeContext(`{"status":"open"}`); got != nil {
|
|
t.Fatal("expected nil code context for unrelated fields")
|
|
}
|
|
}
|
|
|
|
func TestExtractItemConventionMetadataFromStructuredFields(t *testing.T) {
|
|
metadata := ExtractItemConventionMetadata(`{"status":"active","convention":{"category":"build","trigger":"on-pr-create","surfaces":["backend","docs"],"enforcement":"must","commands":["go test ./...","make install"]}}`)
|
|
if metadata == nil {
|
|
t.Fatal("expected convention metadata")
|
|
}
|
|
if metadata.Category != "build" {
|
|
t.Fatalf("expected category build, got %q", metadata.Category)
|
|
}
|
|
if metadata.Trigger != "on-pr-create" {
|
|
t.Fatalf("expected trigger on-pr-create, got %q", metadata.Trigger)
|
|
}
|
|
if metadata.Enforcement != "must" {
|
|
t.Fatalf("expected enforcement must, got %q", metadata.Enforcement)
|
|
}
|
|
if len(metadata.Surfaces) != 2 || metadata.Surfaces[0] != "backend" || metadata.Surfaces[1] != "docs" {
|
|
t.Fatalf("expected surfaces backend/docs, got %#v", metadata.Surfaces)
|
|
}
|
|
if len(metadata.Commands) != 2 || metadata.Commands[0] != "go test ./..." {
|
|
t.Fatalf("expected commands to be preserved, got %#v", metadata.Commands)
|
|
}
|
|
}
|
|
|
|
func TestExtractItemConventionMetadataFallsBackToLegacyFields(t *testing.T) {
|
|
metadata := ExtractItemConventionMetadata(`{"status":"active","category":"quality","trigger":"on-commit","scope":"all","priority":"should"}`)
|
|
if metadata == nil {
|
|
t.Fatal("expected convention metadata")
|
|
}
|
|
if metadata.Category != "quality" || metadata.Trigger != "on-commit" || metadata.Enforcement != "should" {
|
|
t.Fatalf("unexpected convention metadata %#v", metadata)
|
|
}
|
|
if len(metadata.Surfaces) != 1 || metadata.Surfaces[0] != "all" {
|
|
t.Fatalf("expected legacy scope to map to surfaces, got %#v", metadata.Surfaces)
|
|
}
|
|
}
|
|
|
|
// TestExtractItemConventionMetadata_NoLeakOnNonConventionItems is the
|
|
// regression test for BUG-987 bug 13. Previously every Task / Idea /
|
|
// Plan with a `priority` field got a phantom
|
|
// `convention.enforcement: <priority>` surfaced on its response,
|
|
// because the legacy fallback in ExtractItemConventionMetadata
|
|
// unconditionally treated `priority` as the Convention enforcement
|
|
// tier. Tasks have priority but aren't Conventions; the metadata
|
|
// must NOT be synthesized for them.
|
|
func TestExtractItemConventionMetadata_NoLeakOnNonConventionItems(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
fields string
|
|
}{
|
|
{"task with priority", `{"status":"open","priority":"high"}`},
|
|
{"task with priority and category", `{"status":"open","priority":"high","category":"frontend"}`},
|
|
{"idea with priority", `{"status":"new","priority":"medium","impact":"high"}`},
|
|
{"plan with start_date and priority", `{"status":"active","priority":"high","start_date":"2026-01-01"}`},
|
|
{"category alone is not a Convention signal", `{"category":"agent-integration","status":"new"}`},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := ExtractItemConventionMetadata(tc.fields)
|
|
if got != nil {
|
|
t.Errorf("expected nil metadata for non-Convention item; got %+v", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestExtractItemConventionMetadata_ConventionWithLegacyPriority
|
|
// exercises the path where priority→enforcement legacy fallback IS
|
|
// expected to fire — items that carry Convention-specific markers
|
|
// (trigger, scope, etc.) but use the legacy `priority` field for
|
|
// enforcement. The bug 13 fix preserves this path.
|
|
func TestExtractItemConventionMetadata_ConventionWithLegacyPriority(t *testing.T) {
|
|
got := ExtractItemConventionMetadata(`{"status":"active","trigger":"on-commit","scope":"all","priority":"must"}`)
|
|
if got == nil {
|
|
t.Fatal("expected metadata for Convention with legacy priority field")
|
|
}
|
|
if got.Enforcement != "must" {
|
|
t.Errorf("Enforcement = %q, want must (priority legacy fallback)", got.Enforcement)
|
|
}
|
|
if got.Trigger != "on-commit" {
|
|
t.Errorf("Trigger = %q, want on-commit", got.Trigger)
|
|
}
|
|
}
|
|
|
|
// TestExtractItemConventionMetadata_LegacyConvention_ScopeOnly is
|
|
// the regression test for Codex's PR #361 round-1 finding: a legacy
|
|
// Convention carrying only `{scope, priority}` (no trigger, no
|
|
// commands, no structured convention field) must still resolve
|
|
// priority→enforcement. Pre-fix, the fallback ran BEFORE scope had
|
|
// flipped hasConventionShape, so enforcement got silently dropped.
|
|
func TestExtractItemConventionMetadata_LegacyConvention_ScopeOnly(t *testing.T) {
|
|
got := ExtractItemConventionMetadata(`{"status":"active","scope":"all","priority":"must"}`)
|
|
if got == nil {
|
|
t.Fatal("expected metadata for legacy Convention with scope+priority")
|
|
}
|
|
if got.Enforcement != "must" {
|
|
t.Errorf("Enforcement = %q, want must (priority fallback after scope flips shape)",
|
|
got.Enforcement)
|
|
}
|
|
if len(got.Surfaces) != 1 || got.Surfaces[0] != "all" {
|
|
t.Errorf("Surfaces = %v, want [all]", got.Surfaces)
|
|
}
|
|
}
|
|
|
|
// TestExtractItemConventionMetadata_LegacyConvention_CommandsOnly
|
|
// covers the equivalent path for the commands marker.
|
|
func TestExtractItemConventionMetadata_LegacyConvention_CommandsOnly(t *testing.T) {
|
|
got := ExtractItemConventionMetadata(`{"status":"active","commands":["go test"],"priority":"should"}`)
|
|
if got == nil {
|
|
t.Fatal("expected metadata for legacy Convention with commands+priority")
|
|
}
|
|
if got.Enforcement != "should" {
|
|
t.Errorf("Enforcement = %q, want should (priority fallback after commands flips shape)",
|
|
got.Enforcement)
|
|
}
|
|
}
|
|
|
|
func TestExtractItemImplementationNotes(t *testing.T) {
|
|
notes := ExtractItemImplementationNotes(`{"status":"open","implementation_notes":[{"id":"note-1","summary":"Used SSE refresh","details":"Reload phase tasks on visibility resume","created_at":"2026-04-02T15:00:00Z","created_by":"agent"}]}`)
|
|
if len(notes) != 1 {
|
|
t.Fatalf("expected 1 implementation note, got %d", len(notes))
|
|
}
|
|
if notes[0].Summary != "Used SSE refresh" {
|
|
t.Fatalf("expected note summary, got %q", notes[0].Summary)
|
|
}
|
|
if notes[0].CreatedBy != "agent" {
|
|
t.Fatalf("expected created_by agent, got %q", notes[0].CreatedBy)
|
|
}
|
|
}
|
|
|
|
func TestExtractItemDecisionLog(t *testing.T) {
|
|
log := ExtractItemDecisionLog(`{"status":"open","decision_log":[{"id":"decision-1","decision":"Use explicit setup state","rationale":"needs_setup was too ambiguous","created_at":"2026-04-02T15:05:00Z","created_by":"user"}]}`)
|
|
if len(log) != 1 {
|
|
t.Fatalf("expected 1 decision entry, got %d", len(log))
|
|
}
|
|
if log[0].Decision != "Use explicit setup state" {
|
|
t.Fatalf("expected decision text, got %q", log[0].Decision)
|
|
}
|
|
if log[0].Rationale == "" {
|
|
t.Fatal("expected rationale to be preserved")
|
|
}
|
|
}
|
|
|
|
func TestAppendImplementationNotePreservesExistingFields(t *testing.T) {
|
|
fields, err := AppendImplementationNote(`{"status":"open","priority":"high"}`, ItemImplementationNote{
|
|
ID: "note-1",
|
|
Summary: "Added setup guidance",
|
|
Details: "Updated the login screen copy",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("AppendImplementationNote error: %v", err)
|
|
}
|
|
if got := ExtractItemImplementationNotes(fields); len(got) != 1 {
|
|
t.Fatalf("expected 1 note after append, got %#v", got)
|
|
}
|
|
if ExtractItemCodeContext(fields) != nil {
|
|
t.Fatal("did not expect code context")
|
|
}
|
|
}
|
|
|
|
func TestAppendDecisionLogEntryPreservesExistingNotes(t *testing.T) {
|
|
fields, err := AppendDecisionLogEntry(`{"implementation_notes":[{"id":"note-1","summary":"Keep docker as external-managed"}]}`, ItemDecisionLogEntry{
|
|
ID: "decision-1",
|
|
Decision: "Keep docker in external-managed mode",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("AppendDecisionLogEntry error: %v", err)
|
|
}
|
|
if got := ExtractItemImplementationNotes(fields); len(got) != 1 {
|
|
t.Fatalf("expected implementation notes to be preserved, got %#v", got)
|
|
}
|
|
if got := ExtractItemDecisionLog(fields); len(got) != 1 {
|
|
t.Fatalf("expected 1 decision log entry, got %#v", got)
|
|
}
|
|
}
|
|
|
|
// BUG-2627. The Append* helpers assigned the rebuilt slice over the key
|
|
// unconditionally, so an existing value that Extract* could not decode was
|
|
// overwritten and destroyed while the call reported success. Reproduced live: an
|
|
// item whose implementation_notes held a JSON-ENCODED STRING lost its stored
|
|
// note to one `pad item note` call, silently.
|
|
//
|
|
// The refusal is asserted on the ERROR *and* on the returned fields being empty
|
|
// — "returns an error" alone would still pass if the helper handed back a
|
|
// mutated fields blob that a caller then wrote (CONVE-12: assert what the wrong
|
|
// behaviour would DO, not what the right one leaves looking unchanged).
|
|
func TestAppendRefusesWhenStructuredFieldIsUnreadable(t *testing.T) {
|
|
const encodedNotes = `{"implementation_notes":"[{\"summary\":\"stored note\"}]","status":"done"}`
|
|
const encodedDecisions = `{"decision_log":"[{\"decision\":\"stored decision\"}]"}`
|
|
|
|
t.Run("implementation notes", func(t *testing.T) {
|
|
fields, err := AppendImplementationNote(encodedNotes, ItemImplementationNote{ID: "note-new", Summary: "new"})
|
|
if !errors.Is(err, ErrStructuredFieldUnreadable) {
|
|
t.Fatalf("expected ErrStructuredFieldUnreadable, got %v", err)
|
|
}
|
|
if fields != "" {
|
|
t.Fatalf("refused append must return no fields to write, got %q", fields)
|
|
}
|
|
})
|
|
|
|
t.Run("decision log", func(t *testing.T) {
|
|
fields, err := AppendDecisionLogEntry(encodedDecisions, ItemDecisionLogEntry{ID: "decision-new", Decision: "new"})
|
|
if !errors.Is(err, ErrStructuredFieldUnreadable) {
|
|
t.Fatalf("expected ErrStructuredFieldUnreadable, got %v", err)
|
|
}
|
|
if fields != "" {
|
|
t.Fatalf("refused append must return no fields to write, got %q", fields)
|
|
}
|
|
})
|
|
|
|
// Shapes other than the double-encoded string that must also refuse. Each
|
|
// asserts the empty fields return too, not just the error — the refusal is
|
|
// only useful if the caller is left with nothing to write.
|
|
for _, tc := range []struct {
|
|
name string
|
|
fields string
|
|
}{
|
|
{"object instead of list", `{"implementation_notes":{"summary":"not a list"}}`},
|
|
{"list of wrong element type", `{"implementation_notes":[1,2,3]}`},
|
|
{"list of strings", `{"implementation_notes":["a note"]}`},
|
|
{"bare number", `{"implementation_notes":42}`},
|
|
{"known key holding an incompatible nested value", `{"implementation_notes":[{"summary":{"nested":"object"}}]}`},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
fields, err := AppendImplementationNote(tc.fields, ItemImplementationNote{ID: "note-new"})
|
|
if !errors.Is(err, ErrStructuredFieldUnreadable) {
|
|
t.Fatalf("expected ErrStructuredFieldUnreadable, got %v", err)
|
|
}
|
|
if fields != "" {
|
|
t.Fatalf("refused append must return no fields to write, got %q", fields)
|
|
}
|
|
})
|
|
}
|
|
|
|
// The message is the only thing a human sees at the moment of refusal, and
|
|
// PATTE-135 requires its suggested remedy to work in THIS state. It names a
|
|
// read-only inspection command deliberately — every append path is what is
|
|
// being refused, so none of them is a safe suggestion here.
|
|
t.Run("message names the field and a remedy that is not an append", func(t *testing.T) {
|
|
_, err := AppendImplementationNote(encodedNotes, ItemImplementationNote{ID: "note-new"})
|
|
msg := err.Error()
|
|
for _, want := range []string{"implementation_notes", "pad item show", "BUG-2627"} {
|
|
if !strings.Contains(msg, want) {
|
|
t.Errorf("refusal message missing %q: %s", want, msg)
|
|
}
|
|
}
|
|
if strings.Contains(msg, "pad item note") {
|
|
t.Errorf("refusal must not suggest an append path — it is destructive in this state: %s", msg)
|
|
}
|
|
})
|
|
}
|
|
|
|
// The guard must key on "present and undecodable", NOT on Extract* returning
|
|
// nil — Extract* also returns nil for an absent key and for a well-formed empty
|
|
// list, and refusing either would break every first append. These are the
|
|
// control legs: without them a guard of the form `if Extract(...) == nil` would
|
|
// pass the refusal test above and still be wrong.
|
|
func TestAppendProceedsWhenStructuredFieldIsAbsentEmptyOrValid(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
fields string
|
|
wantNotes int
|
|
}{
|
|
{"absent key", `{"status":"open"}`, 1},
|
|
{"empty list", `{"implementation_notes":[]}`, 1},
|
|
{"explicit null", `{"implementation_notes":null}`, 1},
|
|
{"existing entry is preserved", `{"implementation_notes":[{"id":"note-1","summary":"stored"}]}`, 2},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
fields, err := AppendImplementationNote(tc.fields, ItemImplementationNote{ID: "note-new", Summary: "new"})
|
|
if err != nil {
|
|
t.Fatalf("append must proceed, got %v", err)
|
|
}
|
|
got := ExtractItemImplementationNotes(fields)
|
|
if len(got) != tc.wantNotes {
|
|
t.Fatalf("expected %d note(s), got %#v", tc.wantNotes, got)
|
|
}
|
|
if got[len(got)-1].ID != "note-new" {
|
|
t.Fatalf("new note should be appended last, got %#v", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// decision_log gets its own control legs rather than riding on the notes ones.
|
|
// The two helpers carry independent guard calls, so coverage on one says
|
|
// nothing about the other — a guard omitted from AppendDecisionLogEntry, or
|
|
// pointed at the wrong field key, would pass every implementation_notes test.
|
|
func TestAppendDecisionLogProceedsWhenFieldIsAbsentEmptyOrValid(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
fields string
|
|
wantEntries int
|
|
}{
|
|
{"absent key", `{"status":"open"}`, 1},
|
|
{"empty list", `{"decision_log":[]}`, 1},
|
|
{"explicit null", `{"decision_log":null}`, 1},
|
|
{"existing entry is preserved", `{"decision_log":[{"id":"decision-1","decision":"stored"}]}`, 2},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
fields, err := AppendDecisionLogEntry(tc.fields, ItemDecisionLogEntry{ID: "decision-new", Decision: "new"})
|
|
if err != nil {
|
|
t.Fatalf("append must proceed, got %v", err)
|
|
}
|
|
got := ExtractItemDecisionLog(fields)
|
|
if len(got) != tc.wantEntries {
|
|
t.Fatalf("expected %d entr(y|ies), got %#v", tc.wantEntries, got)
|
|
}
|
|
// Ordering, not just count: a helper that PREPENDED would satisfy
|
|
// the length assertion on every leg above.
|
|
if got[len(got)-1].ID != "decision-new" {
|
|
t.Fatalf("new entry should be appended last, got %#v", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The decision-log guard must be instantiated with ItemDecisionLogEntry, not
|
|
// merely with SOME entry type. A wrong-but-compiling type parameter passes every
|
|
// other decision test, because the two structs disagree only on shapes that no
|
|
// other case exercises: `{"decision":{...}}` is ACCEPTED by the notes struct
|
|
// (unknown key, ignored) and REJECTED by the log struct (Decision is a string).
|
|
// The guard would then permit an append that ExtractItemDecisionLog reads as
|
|
// empty — which is the exact guard/extractor divergence this whole change exists
|
|
// to prevent, reintroduced one type parameter away. Codex round 2.
|
|
func TestAppendDecisionLogRefusesEntriesOnlyItsOwnTypeRejects(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
fields string
|
|
}{
|
|
{"decision holding an object", `{"decision_log":[{"decision":{"nested":"object"}}]}`},
|
|
{"rationale holding a list", `{"decision_log":[{"decision":"ok","rationale":["a","b"]}]}`},
|
|
{"double-encoded string", `{"decision_log":"[{\"decision\":\"stored\"}]"}`},
|
|
{"list of wrong element type", `{"decision_log":[1,2,3]}`},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
fields, err := AppendDecisionLogEntry(tc.fields, ItemDecisionLogEntry{ID: "decision-new", Decision: "new"})
|
|
if !errors.Is(err, ErrStructuredFieldUnreadable) {
|
|
t.Fatalf("expected ErrStructuredFieldUnreadable, got %v", err)
|
|
}
|
|
if fields != "" {
|
|
t.Fatalf("refused append must return no fields to write, got %q", fields)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The mirror of the above: shapes only the NOTES struct rejects, so a
|
|
// decision-log type parameter on the notes guard is caught too. `{"summary":{}}`
|
|
// is rejected by ItemImplementationNote (Summary is a string) and accepted by
|
|
// ItemDecisionLogEntry (unknown key, ignored).
|
|
func TestAppendImplementationNoteRefusesEntriesOnlyItsOwnTypeRejects(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
fields string
|
|
}{
|
|
{"summary holding an object", `{"implementation_notes":[{"summary":{"nested":"object"}}]}`},
|
|
{"details holding a list", `{"implementation_notes":[{"summary":"ok","details":["a","b"]}]}`},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
fields, err := AppendImplementationNote(tc.fields, ItemImplementationNote{ID: "note-new", Summary: "new"})
|
|
if !errors.Is(err, ErrStructuredFieldUnreadable) {
|
|
t.Fatalf("expected ErrStructuredFieldUnreadable, got %v", err)
|
|
}
|
|
if fields != "" {
|
|
t.Fatalf("refused append must return no fields to write, got %q", fields)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A successful append must not disturb the item's other reserved metadata.
|
|
// github_pr lives in the same fields blob and is rebuilt from the same map, so
|
|
// a helper that assigned a fresh map instead of mutating the parsed one would
|
|
// drop it while every notes assertion stayed green.
|
|
func TestAppendPreservesSiblingReservedFields(t *testing.T) {
|
|
const withPR = `{"github_pr":{"number":42,"url":"https://github.com/PerpetualSoftware/pad/pull/42","state":"OPEN"},"status":"open"}`
|
|
|
|
fields, err := AppendImplementationNote(withPR, ItemImplementationNote{ID: "note-new", Summary: "new"})
|
|
if err != nil {
|
|
t.Fatalf("AppendImplementationNote error: %v", err)
|
|
}
|
|
if ctx := ExtractItemCodeContext(fields); ctx == nil {
|
|
t.Fatalf("github_pr must survive an implementation-note append, got %q", fields)
|
|
}
|
|
|
|
fields, err = AppendDecisionLogEntry(fields, ItemDecisionLogEntry{ID: "decision-new", Decision: "new"})
|
|
if err != nil {
|
|
t.Fatalf("AppendDecisionLogEntry error: %v", err)
|
|
}
|
|
if ctx := ExtractItemCodeContext(fields); ctx == nil {
|
|
t.Fatalf("github_pr must survive a decision-log append, got %q", fields)
|
|
}
|
|
if got := ExtractItemImplementationNotes(fields); len(got) != 1 {
|
|
t.Fatalf("notes must survive a decision-log append, got %#v", got)
|
|
}
|
|
}
|
|
|
|
func TestApplyItemConventionMetadataPreservesStatusAndWritesAliases(t *testing.T) {
|
|
fields, err := ApplyItemConventionMetadata(`{"status":"active"}`, &ItemConventionMetadata{
|
|
Category: "pm",
|
|
Trigger: "on-task-start",
|
|
Surfaces: []string{"all"},
|
|
Enforcement: "must",
|
|
Commands: []string{"pad item update <ref> --status in-progress"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ApplyItemConventionMetadata error: %v", err)
|
|
}
|
|
|
|
metadata := ExtractItemConventionMetadata(fields)
|
|
if metadata == nil || metadata.Category != "pm" || metadata.Trigger != "on-task-start" {
|
|
t.Fatalf("expected structured convention metadata, got %#v", metadata)
|
|
}
|
|
|
|
if got := ExtractItemImplementationNotes(fields); got != nil {
|
|
t.Fatalf("did not expect implementation notes, got %#v", got)
|
|
}
|
|
}
|
|
|
|
// TestItemUpdateUnmarshalFlexFields covers BUG-1144: PATCH /items
|
|
// must accept `fields` and `tags` as either a JSON-encoded string
|
|
// (the canonical historical shape) or the natural nested object /
|
|
// array shape any reasonable HTTP client would send.
|
|
func TestItemUpdateUnmarshalFlexFields(t *testing.T) {
|
|
t.Run("fields as nested object", func(t *testing.T) {
|
|
var u ItemUpdate
|
|
if err := json.Unmarshal([]byte(`{"fields":{"reading_time":6,"status":"open"}}`), &u); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if u.Fields == nil {
|
|
t.Fatal("expected u.Fields to be set")
|
|
}
|
|
// Re-decode the canonical string and check semantic equality.
|
|
var got map[string]any
|
|
if err := json.Unmarshal([]byte(*u.Fields), &got); err != nil {
|
|
t.Fatalf("u.Fields not valid JSON: %v (was %q)", err, *u.Fields)
|
|
}
|
|
if got["reading_time"].(float64) != 6 || got["status"].(string) != "open" {
|
|
t.Fatalf("round-trip lost data: %#v", got)
|
|
}
|
|
})
|
|
|
|
t.Run("fields as JSON-encoded string (back-compat)", func(t *testing.T) {
|
|
var u ItemUpdate
|
|
if err := json.Unmarshal([]byte(`{"fields":"{\"reading_time\":6}"}`), &u); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if u.Fields == nil || *u.Fields != `{"reading_time":6}` {
|
|
t.Fatalf("expected stringified fields passthrough, got %v", u.Fields)
|
|
}
|
|
})
|
|
|
|
t.Run("fields null leaves pointer nil", func(t *testing.T) {
|
|
var u ItemUpdate
|
|
if err := json.Unmarshal([]byte(`{"fields":null}`), &u); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if u.Fields != nil {
|
|
t.Fatalf("expected nil pointer for null fields, got %q", *u.Fields)
|
|
}
|
|
})
|
|
|
|
t.Run("fields absent leaves pointer nil", func(t *testing.T) {
|
|
var u ItemUpdate
|
|
if err := json.Unmarshal([]byte(`{"title":"hi"}`), &u); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if u.Fields != nil {
|
|
t.Fatal("expected u.Fields nil when key absent")
|
|
}
|
|
if u.Title == nil || *u.Title != "hi" {
|
|
t.Fatal("title should still decode normally")
|
|
}
|
|
})
|
|
|
|
t.Run("fields wrong shape returns domain error", func(t *testing.T) {
|
|
var u ItemUpdate
|
|
err := json.Unmarshal([]byte(`{"fields":42}`), &u)
|
|
if err == nil {
|
|
t.Fatal("expected error for non-object non-string fields")
|
|
}
|
|
if !errors.Is(err, ErrInvalidFieldsType) {
|
|
t.Fatalf("expected ErrInvalidFieldsType, got %v", err)
|
|
}
|
|
if strings.Contains(err.Error(), "Go struct field") {
|
|
t.Fatalf("error leaked Go internals: %q", err.Error())
|
|
}
|
|
})
|
|
|
|
t.Run("fields as array is invalid (object expected)", func(t *testing.T) {
|
|
var u ItemUpdate
|
|
err := json.Unmarshal([]byte(`{"fields":["a","b"]}`), &u)
|
|
if !errors.Is(err, ErrInvalidFieldsType) {
|
|
t.Fatalf("expected ErrInvalidFieldsType for array fields, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("tags as nested array", func(t *testing.T) {
|
|
var u ItemUpdate
|
|
if err := json.Unmarshal([]byte(`{"tags":["foo","bar"]}`), &u); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if u.Tags == nil {
|
|
t.Fatal("expected u.Tags to be set")
|
|
}
|
|
var got []string
|
|
if err := json.Unmarshal([]byte(*u.Tags), &got); err != nil {
|
|
t.Fatalf("u.Tags not valid JSON: %v (was %q)", err, *u.Tags)
|
|
}
|
|
if len(got) != 2 || got[0] != "foo" || got[1] != "bar" {
|
|
t.Fatalf("round-trip lost data: %#v", got)
|
|
}
|
|
})
|
|
|
|
t.Run("tags as JSON-encoded string (back-compat)", func(t *testing.T) {
|
|
var u ItemUpdate
|
|
if err := json.Unmarshal([]byte(`{"tags":"[\"foo\"]"}`), &u); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if u.Tags == nil || *u.Tags != `["foo"]` {
|
|
t.Fatalf("expected stringified tags passthrough, got %v", u.Tags)
|
|
}
|
|
})
|
|
|
|
t.Run("tags as object is invalid (array expected)", func(t *testing.T) {
|
|
var u ItemUpdate
|
|
err := json.Unmarshal([]byte(`{"tags":{"x":1}}`), &u)
|
|
if !errors.Is(err, ErrInvalidTagsType) {
|
|
t.Fatalf("expected ErrInvalidTagsType for object tags, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("other fields decode normally", func(t *testing.T) {
|
|
var u ItemUpdate
|
|
body := `{"title":"new","content":"body","pinned":true,"source":"web","fields":{"x":1}}`
|
|
if err := json.Unmarshal([]byte(body), &u); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if u.Title == nil || *u.Title != "new" {
|
|
t.Fatal("title not decoded")
|
|
}
|
|
if u.Content == nil || *u.Content != "body" {
|
|
t.Fatal("content not decoded")
|
|
}
|
|
if u.Pinned == nil || !*u.Pinned {
|
|
t.Fatal("pinned not decoded")
|
|
}
|
|
if u.Source != "web" {
|
|
t.Fatal("source not decoded")
|
|
}
|
|
if u.Fields == nil || *u.Fields != `{"x":1}` {
|
|
t.Fatalf("fields not normalized, got %v", u.Fields)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestItemCreateUnmarshalFlexFields covers BUG-1432: POST /items must
|
|
// accept `fields` and `tags` as either a JSON-encoded string (the
|
|
// canonical historical shape ItemCreate stored internally) or the
|
|
// natural nested object / array shape any reasonable HTTP client —
|
|
// including the MCP HTTP dispatcher — would send.
|
|
//
|
|
// Pre-fix, ItemCreate.Tags rejected `["foo","bar"]` with "cannot
|
|
// unmarshal array into Go struct field ItemCreate.tags of type
|
|
// string" — agents over MCP saw this as a generic validation_failed
|
|
// envelope and (per BUG-1409) blamed "tags is the culprit." The Codex
|
|
// investigation called out the asymmetry with ItemUpdate (which already
|
|
// handled both shapes per BUG-1144) as the root architectural fix.
|
|
func TestItemCreateUnmarshalFlexFields(t *testing.T) {
|
|
t.Run("tags as nested array (agent-natural shape)", func(t *testing.T) {
|
|
var c ItemCreate
|
|
if err := json.Unmarshal([]byte(`{"title":"X","tags":["foo","bar"]}`), &c); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
var got []string
|
|
if err := json.Unmarshal([]byte(c.Tags), &got); err != nil {
|
|
t.Fatalf("c.Tags not valid JSON: %v (was %q)", err, c.Tags)
|
|
}
|
|
if len(got) != 2 || got[0] != "foo" || got[1] != "bar" {
|
|
t.Fatalf("round-trip lost data: %#v", got)
|
|
}
|
|
})
|
|
|
|
t.Run("tags as JSON-encoded string (back-compat with CLI)", func(t *testing.T) {
|
|
var c ItemCreate
|
|
if err := json.Unmarshal([]byte(`{"title":"X","tags":"[\"foo\"]"}`), &c); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if c.Tags != `["foo"]` {
|
|
t.Fatalf("expected stringified tags passthrough, got %q", c.Tags)
|
|
}
|
|
})
|
|
|
|
t.Run("tags absent leaves empty string", func(t *testing.T) {
|
|
var c ItemCreate
|
|
if err := json.Unmarshal([]byte(`{"title":"X"}`), &c); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if c.Tags != "" {
|
|
t.Fatalf("expected empty tags when absent, got %q", c.Tags)
|
|
}
|
|
})
|
|
|
|
t.Run("tags null leaves empty string", func(t *testing.T) {
|
|
var c ItemCreate
|
|
if err := json.Unmarshal([]byte(`{"title":"X","tags":null}`), &c); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if c.Tags != "" {
|
|
t.Fatalf("expected empty tags for null, got %q", c.Tags)
|
|
}
|
|
})
|
|
|
|
t.Run("tags as object is invalid (array expected)", func(t *testing.T) {
|
|
var c ItemCreate
|
|
err := json.Unmarshal([]byte(`{"title":"X","tags":{"x":1}}`), &c)
|
|
if !errors.Is(err, ErrInvalidTagsType) {
|
|
t.Fatalf("expected ErrInvalidTagsType for object tags, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("tags as number is invalid (array/string expected)", func(t *testing.T) {
|
|
var c ItemCreate
|
|
err := json.Unmarshal([]byte(`{"title":"X","tags":42}`), &c)
|
|
if !errors.Is(err, ErrInvalidTagsType) {
|
|
t.Fatalf("expected ErrInvalidTagsType for numeric tags, got %v", err)
|
|
}
|
|
if strings.Contains(err.Error(), "Go struct field") {
|
|
t.Fatalf("error leaked Go internals: %q", err.Error())
|
|
}
|
|
})
|
|
|
|
t.Run("fields as nested object (agent-natural shape)", func(t *testing.T) {
|
|
var c ItemCreate
|
|
if err := json.Unmarshal([]byte(`{"title":"X","fields":{"status":"open","priority":"high"}}`), &c); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
var got map[string]any
|
|
if err := json.Unmarshal([]byte(c.Fields), &got); err != nil {
|
|
t.Fatalf("c.Fields not valid JSON: %v (was %q)", err, c.Fields)
|
|
}
|
|
if got["status"].(string) != "open" || got["priority"].(string) != "high" {
|
|
t.Fatalf("round-trip lost data: %#v", got)
|
|
}
|
|
})
|
|
|
|
t.Run("fields as JSON-encoded string (back-compat)", func(t *testing.T) {
|
|
var c ItemCreate
|
|
if err := json.Unmarshal([]byte(`{"title":"X","fields":"{\"status\":\"open\"}"}`), &c); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if c.Fields != `{"status":"open"}` {
|
|
t.Fatalf("expected stringified fields passthrough, got %q", c.Fields)
|
|
}
|
|
})
|
|
|
|
t.Run("fields as array is invalid (object expected)", func(t *testing.T) {
|
|
var c ItemCreate
|
|
err := json.Unmarshal([]byte(`{"title":"X","fields":["a","b"]}`), &c)
|
|
if !errors.Is(err, ErrInvalidFieldsType) {
|
|
t.Fatalf("expected ErrInvalidFieldsType for array fields, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("other fields decode normally", func(t *testing.T) {
|
|
var c ItemCreate
|
|
body := `{"title":"new","content":"body","pinned":true,"source":"web","fields":{"x":1},"tags":["a"]}`
|
|
if err := json.Unmarshal([]byte(body), &c); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if c.Title != "new" {
|
|
t.Fatal("title not decoded")
|
|
}
|
|
if c.Content != "body" {
|
|
t.Fatal("content not decoded")
|
|
}
|
|
if !c.Pinned {
|
|
t.Fatal("pinned not decoded")
|
|
}
|
|
if c.Source != "web" {
|
|
t.Fatal("source not decoded")
|
|
}
|
|
if c.Fields != `{"x":1}` {
|
|
t.Fatalf("fields not normalized, got %q", c.Fields)
|
|
}
|
|
if c.Tags != `["a"]` {
|
|
t.Fatalf("tags not normalized, got %q", c.Tags)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TASK-3539: a workspace import stores a convention blob verbatim, so its
|
|
// commands and surfaces can repeat. normalizeItemConventionMetadata already
|
|
// dedupes them on read; the web keys both lists by the value, so this pins it.
|
|
func TestExtractItemConventionMetadataDedupesOnRead(t *testing.T) {
|
|
metadata := ExtractItemConventionMetadata(`{"convention":{"trigger":"on-commit","surfaces":["backend","backend"],"commands":["make test","make test","go vet ./..."]}}`)
|
|
if metadata == nil {
|
|
t.Fatal("no metadata")
|
|
}
|
|
if got := metadata.Commands; len(got) != 2 || got[0] != "make test" || got[1] != "go vet ./..." {
|
|
t.Errorf("commands = %q", got)
|
|
}
|
|
if got := metadata.Surfaces; len(got) != 1 || got[0] != "backend" {
|
|
t.Errorf("surfaces = %q", got)
|
|
}
|
|
}
|