package core import ( "errors" "strings" "testing" ) func TestParseStatus(t *testing.T) { tests := []struct { in string ok bool }{ {"draft", true}, {"review", true}, {"superseded", true}, // "approved" is a property of the branch, never of the frontmatter. // This case is the whole reason the enum is validated at all. {"approved", false}, {"Approved", false}, {"APPROVED", false}, {"merged", false}, {"Draft", false}, {"draft ", false}, {" draft", false}, {"", false}, {"черновик", false}, } for _, tc := range tests { st, err := ParseStatus(tc.in) if (err == nil) != tc.ok { t.Errorf("ParseStatus(%q) = %q, %v, want ok=%v", tc.in, st, err, tc.ok) } if err != nil && !errors.Is(err, ErrInvalidStatus) { t.Errorf("ParseStatus(%q) error %v is not ErrInvalidStatus", tc.in, err) } } } func TestSplitFrontmatter(t *testing.T) { tests := []struct { name string in string wantFront string wantBody string ok bool }{ {"minimal", "---\nid: SPEC-1\n---\n# Title\n", "id: SPEC-1\n", "# Title\n", true}, {"empty block", "---\n---\n", "", "", true}, {"empty body", "---\nid: SPEC-1\n---\n", "id: SPEC-1\n", "", true}, {"no trailing newline after fence", "---\nid: SPEC-1\n---", "id: SPEC-1\n", "", true}, {"crlf", "---\r\nid: SPEC-1\r\n---\r\nbody\r\n", "id: SPEC-1\r\n", "body\r\n", true}, {"dot fence", "---\nid: SPEC-1\n...\nbody\n", "id: SPEC-1\n", "body\n", true}, {"hr in body is not a fence", "---\nid: SPEC-1\n---\ntext\n\n---\n\nmore\n", "id: SPEC-1\n", "text\n\n---\n\nmore\n", true}, {"empty document", "", "", "", false}, {"no frontmatter", "# Just markdown\n", "", "", false}, {"leading blank line", "\n---\nid: SPEC-1\n---\n", "", "", false}, {"fence only", "---", "", "", false}, {"unterminated", "---\nid: SPEC-1\nbody\n", "", "", false}, {"indented fence", " ---\nid: SPEC-1\n---\n", "", "", false}, {"four dashes", "----\nid: SPEC-1\n----\n", "", "", false}, {"utf-8 bom", "\ufeff---\nid: SPEC-1\n---\n", "", "", false}, // A fence with trailing whitespace is not a fence; treating it as one // would make an unterminated block parse as a body-only document. {"fence with trailing space", "--- \nid: SPEC-1\n---\n", "", "", false}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { front, body, err := SplitFrontmatter([]byte(tc.in)) if (err == nil) != tc.ok { t.Fatalf("SplitFrontmatter(%q) = %q, %q, %v, want ok=%v", tc.in, front, body, err, tc.ok) } if !tc.ok { if !errors.Is(err, ErrMalformedFrontmatter) { t.Fatalf("error %v is not ErrMalformedFrontmatter", err) } return } if string(front) != tc.wantFront { t.Errorf("front = %q, want %q", front, tc.wantFront) } if string(body) != tc.wantBody { t.Errorf("body = %q, want %q", body, tc.wantBody) } }) } } func TestParseFrontmatter(t *testing.T) { full := "id: SPEC-0007\n" + "title: Proposal storage model\n" + "status: draft\n" + "supersedes: SPEC-0003\n" + "owners: [~bigbes]\n" + "tags: [storage, review]\n" + "type: spec\n" + "summary: how proposals are stored\n" fm, err := ParseFrontmatter([]byte(full)) if err != nil { t.Fatalf("ParseFrontmatter: %v", err) } if fm.ID != "SPEC-0007" || fm.Title != "Proposal storage model" || fm.Status != StatusDraft { t.Fatalf("unexpected frontmatter: %+v", fm) } if fm.Supersedes != "SPEC-0003" || fm.Type != "spec" || fm.Summary != "how proposals are stored" { t.Fatalf("unexpected frontmatter: %+v", fm) } if len(fm.Owners) != 1 || fm.Owners[0] != "~bigbes" { t.Fatalf("owners = %v", fm.Owners) } if len(fm.Tags) != 2 || fm.Tags[0] != "storage" { t.Fatalf("tags = %v", fm.Tags) } for _, key := range []string{"id", "title", "status", "supersedes", "owners", "tags", "type", "summary"} { if !fm.Has(key) { t.Errorf("Has(%q) = false, want true", key) } } if fm.Has("approved") { t.Error(`Has("approved") = true for a key that is not in the source`) } } func TestParseFrontmatterPresenceVsEmptiness(t *testing.T) { // `title:` with no value is present-but-blank, which is a different error // from an absent title. The distinction is the reason Present exists. fm, err := ParseFrontmatter([]byte("id: SPEC-1\ntitle:\n")) if err != nil { t.Fatalf("ParseFrontmatter: %v", err) } if !fm.Has("title") { t.Fatal(`Has("title") = false for a key that is present with a null value`) } if fm.Title != "" { t.Fatalf("Title = %q, want empty", fm.Title) } if fm.Has("status") { t.Fatal(`Has("status") = true for an absent key`) } } func TestParseFrontmatterUnknownKeysArePreserved(t *testing.T) { // Unknown keys are authored metadata we do not model; they must not fail // the parse, and they must be reportable so a schema can require them. fm, err := ParseFrontmatter([]byte("id: SPEC-1\nreviewers: [~someone]\n")) if err != nil { t.Fatalf("ParseFrontmatter: %v", err) } if !fm.Has("reviewers") { t.Fatal(`Has("reviewers") = false, want true`) } } func TestParseFrontmatterErrors(t *testing.T) { tests := []struct { name string in string ok bool }{ {"empty block", "", true}, {"whitespace only", "\n \n", true}, {"comment only", "# nothing here\n", true}, {"duplicate id", "id: SPEC-1\nid: SPEC-2\n", false}, {"duplicate title far apart", "id: SPEC-1\ntitle: a\nstatus: draft\ntitle: b\n", false}, {"sequence not mapping", "- id: SPEC-1\n", false}, {"scalar not mapping", "just a string\n", false}, {"tab indentation", "id: SPEC-1\n\ttitle: x\n", false}, {"unclosed flow seq", "tags: [a, b\n", false}, {"owners as scalar", "owners: bigbes\n", false}, {"tags as mapping", "tags: {a: 1}\n", false}, {"complex key", "? [a, b]\n: value\n", false}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { fm, err := ParseFrontmatter([]byte(tc.in)) if (err == nil) != tc.ok { t.Fatalf("ParseFrontmatter(%q) = %+v, %v, want ok=%v", tc.in, fm, err, tc.ok) } if err != nil && !errors.Is(err, ErrMalformedFrontmatter) { t.Fatalf("error %v is not ErrMalformedFrontmatter", err) } if err == nil && fm.Present == nil { t.Fatal("Present must never be nil on a successful parse") } }) } } func TestParseDocument(t *testing.T) { src := "---\nid: SPEC-0007\ntitle: T\nstatus: draft\n---\n\n# Heading\n\nbody\n" fm, body, err := ParseDocument([]byte(src)) if err != nil { t.Fatalf("ParseDocument: %v", err) } if fm.ID != "SPEC-0007" || fm.Status != StatusDraft { t.Fatalf("frontmatter = %+v", fm) } if string(body) != "\n# Heading\n\nbody\n" { t.Fatalf("body = %q", body) } if _, _, err := ParseDocument([]byte("# no frontmatter\n")); !errors.Is(err, ErrMalformedFrontmatter) { t.Fatalf("ParseDocument without frontmatter = %v, want ErrMalformedFrontmatter", err) } } func TestSchemaValidate(t *testing.T) { tests := []struct { name string in Schema ok bool }{ {"default", DefaultSchema(), true}, {"narrowed status", Schema{Required: []string{"id"}, Status: []Status{StatusDraft}}, true}, {"no required keys", Schema{Required: nil, Status: DefaultStatuses()}, true}, {"custom required key", Schema{Required: []string{"id", "reviewers"}, Status: DefaultStatuses()}, true}, {"empty status list", Schema{Required: []string{"id"}, Status: nil}, false}, // A per-space config file must not be able to reintroduce "approved". {"approved status", Schema{Required: []string{"id"}, Status: []Status{StatusDraft, "approved"}}, false}, {"unknown status", Schema{Required: []string{"id"}, Status: []Status{"wip"}}, false}, {"duplicate status", Schema{Required: []string{"id"}, Status: []Status{StatusDraft, StatusDraft}}, false}, {"empty required key", Schema{Required: []string{""}, Status: DefaultStatuses()}, false}, {"duplicate required key", Schema{Required: []string{"id", "id"}, Status: DefaultStatuses()}, false}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { err := tc.in.Validate() if (err == nil) != tc.ok { t.Fatalf("Schema.Validate() = %v, want ok=%v", err, tc.ok) } if err != nil && !errors.Is(err, ErrInvalidPolicy) { t.Fatalf("error %v is not ErrInvalidPolicy", err) } }) } } func TestSchemaValidateFrontmatter(t *testing.T) { tests := []struct { name string schema Schema front string wantErr error // nil means "must succeed" }{ { name: "complete document", schema: DefaultSchema(), front: "id: SPEC-0007\ntitle: Storage\nstatus: draft\n", }, { name: "optional keys absent", schema: DefaultSchema(), front: "id: SPEC-0007\ntitle: Storage\nstatus: review\n", }, { name: "owners with and without tilde", schema: DefaultSchema(), front: "id: SPEC-1\ntitle: T\nstatus: draft\nowners: [~bigbes, bigbes]\n", }, { name: "extra keys ignored", schema: DefaultSchema(), front: "id: SPEC-1\ntitle: T\nstatus: draft\nreviewers: [~x]\n", }, { name: "missing status", schema: DefaultSchema(), front: "id: SPEC-1\ntitle: T\n", wantErr: ErrMissingField, }, { name: "missing id", schema: DefaultSchema(), front: "title: T\nstatus: draft\n", wantErr: ErrMissingField, }, { name: "empty frontmatter", schema: DefaultSchema(), front: "", wantErr: ErrMissingField, }, { name: "blank title", schema: DefaultSchema(), front: "id: SPEC-1\ntitle: \" \"\nstatus: draft\n", wantErr: ErrMissingField, }, { name: "null id", schema: DefaultSchema(), front: "id:\ntitle: T\nstatus: draft\n", wantErr: ErrInvalidDocID, }, { name: "malformed id", schema: DefaultSchema(), front: "id: spec-7\ntitle: T\nstatus: draft\n", wantErr: ErrInvalidDocID, }, { name: "approved status rejected", schema: DefaultSchema(), front: "id: SPEC-1\ntitle: T\nstatus: approved\n", wantErr: ErrInvalidStatus, }, { name: "status outside narrowed enum", schema: Schema{Required: []string{"id", "title", "status"}, Status: []Status{StatusDraft}}, front: "id: SPEC-1\ntitle: T\nstatus: review\n", wantErr: ErrInvalidStatus, }, { name: "supersedes present but empty", schema: DefaultSchema(), front: "id: SPEC-1\ntitle: T\nstatus: draft\nsupersedes:\n", wantErr: ErrInvalidDocID, }, { name: "supersedes malformed", schema: DefaultSchema(), front: "id: SPEC-1\ntitle: T\nstatus: draft\nsupersedes: SPEC 3\n", wantErr: ErrInvalidDocID, }, { name: "bad owner", schema: DefaultSchema(), front: "id: SPEC-1\ntitle: T\nstatus: draft\nowners: [~Big Bes]\n", wantErr: ErrInvalidName, }, { name: "empty owner entry", schema: DefaultSchema(), front: "id: SPEC-1\ntitle: T\nstatus: draft\nowners: ['']\n", wantErr: ErrInvalidName, }, { name: "custom required key missing", schema: Schema{Required: []string{"id", "reviewers"}, Status: DefaultStatuses()}, front: "id: SPEC-1\ntitle: T\nstatus: draft\n", wantErr: ErrMissingField, }, { name: "status not required and absent", schema: Schema{Required: []string{"id"}, Status: DefaultStatuses()}, front: "id: SPEC-1\n", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { fm, err := ParseFrontmatter([]byte(tc.front)) if err != nil { t.Fatalf("ParseFrontmatter(%q): %v", tc.front, err) } err = tc.schema.ValidateFrontmatter(fm) if tc.wantErr == nil { if err != nil { t.Fatalf("ValidateFrontmatter = %v, want nil", err) } return } if !errors.Is(err, tc.wantErr) { t.Fatalf("ValidateFrontmatter = %v, want %v", err, tc.wantErr) } }) } } func TestSchemaAllowsStatus(t *testing.T) { s := DefaultSchema() for _, st := range DefaultStatuses() { if !s.AllowsStatus(st) { t.Errorf("default schema must allow %q", st) } } if s.AllowsStatus("approved") { t.Error(`default schema must not allow "approved"`) } if s.AllowsStatus("") { t.Error("default schema must not allow the empty status") } } func TestDefaultStatusesIsNotShared(t *testing.T) { a := DefaultStatuses() a[0] = "approved" if b := DefaultStatuses(); b[0] != StatusDraft { t.Fatalf("DefaultStatuses() leaked a shared slice: %v", b) } } func TestDefaultSchemaMatchesDesign(t *testing.T) { s := DefaultSchema() if got, want := strings.Join(s.Required, ","), "id,title,status"; got != want { t.Fatalf("default required = %q, want %q", got, want) } if got, want := joinStatuses(s.Status), "draft|review|superseded"; got != want { t.Fatalf("default status enum = %q, want %q", got, want) } }