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)
}
}