Go 364 lines
package memory
import (
"strings"
"testing"
"time"
)
func TestParseFact_HappyPath(t *testing.T) {
src := strings.Join([]string{
"---",
"name: build-gate",
"description: gate is `go vet ./...`",
"type: reference",
"created: 2026-05-19",
"last_used: 2026-05-19",
"pin: false",
"---",
"",
"body line one",
"body line two",
"",
}, "\n")
f, err := ParseFact([]byte(src))
if err != nil {
t.Fatalf("ParseFact: %v", err)
}
if f.Name != "build-gate" {
t.Errorf("name = %q", f.Name)
}
if f.Type != TypeReference {
t.Errorf("type = %q", f.Type)
}
if f.Pin {
t.Error("pin should be false")
}
if f.Body != "body line one\nbody line two\n" {
t.Errorf("body = %q", f.Body)
}
}
func TestParseFact_OptionalFields(t *testing.T) {
src := strings.Join([]string{
"---",
"name: bug-x",
"description: fix bug X",
"type: finding",
"created: 2026-05-19",
"last_used: 2026-05-19",
"ref: internal/config/config.go",
"expires: 2026-12-31",
"status: open",
"pin: true",
"---",
"detail",
"",
}, "\n")
f, err := ParseFact([]byte(src))
if err != nil {
t.Fatalf("ParseFact: %v", err)
}
if f.Ref != "internal/config/config.go" {
t.Errorf("ref = %q", f.Ref)
}
if f.Expires == nil || !f.Expires.Equal(time.Date(2026, 12, 31, 0, 0, 0, 0, time.UTC)) {
t.Errorf("expires = %v", f.Expires)
}
if f.Status != "open" {
t.Errorf("status = %q", f.Status)
}
if !f.Pin {
t.Error("pin should be true")
}
}
func TestParseFact_QuotedAndComments(t *testing.T) {
src := strings.Join([]string{
"---",
`name: "quoted-name"`,
`description: 'single quoted'`,
"# comment line",
"",
"type: user",
"created: 2026-05-19",
"last_used: 2026-05-19",
"pin: false",
"unknown_key: tolerated",
"---",
}, "\n")
f, err := ParseFact([]byte(src))
if err != nil {
t.Fatalf("ParseFact: %v", err)
}
if f.Name != "quoted-name" {
t.Errorf("name = %q", f.Name)
}
if f.Description != "single quoted" {
t.Errorf("description = %q", f.Description)
}
}
func TestParseFact_ErrorCases(t *testing.T) {
cases := map[string]string{
"missing opening delim": "name: x\n",
"missing closing delim": "---\nname: x\n",
"missing colon": "---\nthis has no colon\n---\n",
"bad date": "---\nname: a\ndescription: b\ntype: user\ncreated: not-a-date\nlast_used: 2026-05-19\npin: false\n---\n",
"bad type": "---\nname: a\ndescription: b\ntype: not-a-type\ncreated: 2026-05-19\nlast_used: 2026-05-19\npin: false\n---\n",
"bad pin": "---\nname: a\ndescription: b\ntype: user\ncreated: 2026-05-19\nlast_used: 2026-05-19\npin: maybe\n---\n",
"bad ref-traversal": "---\nname: a\ndescription: b\ntype: reference\ncreated: 2026-05-19\nlast_used: 2026-05-19\nref: ../escape\npin: false\n---\n",
"bad ref-absolute": "---\nname: a\ndescription: b\ntype: reference\ncreated: 2026-05-19\nlast_used: 2026-05-19\nref: /etc/passwd\npin: false\n---\n",
"missing name": "---\ndescription: b\ntype: user\ncreated: 2026-05-19\nlast_used: 2026-05-19\npin: false\n---\n",
"bad name": "---\nname: NotKebab\ndescription: b\ntype: user\ncreated: 2026-05-19\nlast_used: 2026-05-19\npin: false\n---\n",
"missing description": "---\nname: a\ntype: user\ncreated: 2026-05-19\nlast_used: 2026-05-19\npin: false\n---\n",
}
for label, src := range cases {
t.Run(label, func(t *testing.T) {
if _, err := ParseFact([]byte(src)); err == nil {
t.Fatalf("ParseFact(%q) succeeded; expected error", label)
}
})
}
}
func TestSerialize_RoundTrip(t *testing.T) {
exp := time.Date(2026, 12, 31, 0, 0, 0, 0, time.UTC)
in := &Fact{
Name: "round-trip",
Description: "round trip",
Type: TypeFinding,
Created: time.Date(2026, 5, 19, 0, 0, 0, 0, time.UTC),
LastUsed: time.Date(2026, 5, 20, 0, 0, 0, 0, time.UTC),
Ref: "internal/config/config.go",
Expires: &exp,
Status: "open",
Pin: true,
Body: "body text\nwith two lines",
}
raw, err := Serialize(in)
if err != nil {
t.Fatal(err)
}
got, err := ParseFact(raw)
if err != nil {
t.Fatalf("re-parse:\n%s\nerr: %v", raw, err)
}
if got.Name != in.Name || got.Description != in.Description || got.Type != in.Type ||
!got.Created.Equal(in.Created) || !got.LastUsed.Equal(in.LastUsed) ||
got.Ref != in.Ref || got.Status != in.Status || got.Pin != in.Pin {
t.Errorf("round-trip diverged.\nwant: %+v\ngot: %+v", *in, *got)
}
if got.Expires == nil || !got.Expires.Equal(*in.Expires) {
t.Errorf("expires diverged: %v", got.Expires)
}
if strings.TrimRight(got.Body, "\n") != strings.TrimRight(in.Body, "\n") {
t.Errorf("body diverged:\nwant: %q\ngot: %q", in.Body, got.Body)
}
}
func TestSerialize_OmitsEmptyOptionals(t *testing.T) {
in := &Fact{
Name: "minimal",
Description: "minimal fact",
Type: TypeUser,
Created: time.Date(2026, 5, 19, 0, 0, 0, 0, time.UTC),
LastUsed: time.Date(2026, 5, 19, 0, 0, 0, 0, time.UTC),
}
raw, err := Serialize(in)
if err != nil {
t.Fatal(err)
}
s := string(raw)
for _, banned := range []string{"ref:", "expires:", "status:", "source:", "agent:", "disabled:"} {
if strings.Contains(s, banned) {
t.Errorf("output should omit %q for empty values:\n%s", banned, s)
}
}
if !strings.Contains(s, "pin: false") {
t.Errorf("pin: false should always be emitted:\n%s", s)
}
}
func TestParseFact_AdaptationFields(t *testing.T) {
src := strings.Join([]string{
"---",
"name: terse-feedback",
"description: user prefers terse responses",
"type: feedback",
"created: 2026-05-22",
"last_used: 2026-05-22",
"pin: false",
"source: stop summarizing what you just did",
"agent: claude-opus-4-7",
"disabled: true",
"---",
"reasoning",
"",
}, "\n")
f, err := ParseFact([]byte(src))
if err != nil {
t.Fatalf("ParseFact: %v", err)
}
if f.Source != "stop summarizing what you just did" {
t.Errorf("source = %q", f.Source)
}
if f.Agent != "claude-opus-4-7" {
t.Errorf("agent = %q", f.Agent)
}
if !f.Disabled {
t.Error("disabled should be true")
}
}
func TestParseFact_DisabledBadValue(t *testing.T) {
src := strings.Join([]string{
"---",
"name: bad",
"description: bad",
"type: user",
"created: 2026-05-22",
"last_used: 2026-05-22",
"pin: false",
"disabled: maybe",
"---",
}, "\n")
if _, err := ParseFact([]byte(src)); err == nil {
t.Fatal("expected error on disabled: maybe")
}
}
func TestParseFact_OldFactStillLoads(t *testing.T) {
// A fact written before the source/agent/disabled fields existed
// must still parse cleanly: the new fields are optional on the wire.
src := strings.Join([]string{
"---",
"name: legacy",
"description: legacy fact",
"type: feedback",
"created: 2026-01-01",
"last_used: 2026-01-01",
"pin: false",
"---",
}, "\n")
f, err := ParseFact([]byte(src))
if err != nil {
t.Fatalf("ParseFact: %v", err)
}
if f.Source != "" || f.Agent != "" || f.Disabled {
t.Errorf("new fields should be zero for legacy fact: %+v", f)
}
}
func TestSerialize_RoundTripAdaptationFields(t *testing.T) {
in := &Fact{
Name: "adapt",
Description: "adapt to operator",
Type: TypeUser,
Created: time.Date(2026, 5, 22, 0, 0, 0, 0, time.UTC),
LastUsed: time.Date(2026, 5, 22, 0, 0, 0, 0, time.UTC),
Source: "stop using emojis",
Agent: "claude-opus-4-7",
Disabled: true,
Body: "body",
}
raw, err := Serialize(in)
if err != nil {
t.Fatal(err)
}
got, err := ParseFact(raw)
if err != nil {
t.Fatalf("re-parse: %v", err)
}
if got.Source != in.Source || got.Agent != in.Agent || got.Disabled != in.Disabled {
t.Errorf("adaptation fields diverged: %+v", got)
}
}
func TestValidate_RejectsOversizeSource(t *testing.T) {
long := strings.Repeat("x", MaxSourceLen+1)
in := &Fact{
Name: "oversize",
Description: "oversize source",
Type: TypeFeedback,
Created: time.Date(2026, 5, 22, 0, 0, 0, 0, time.UTC),
LastUsed: time.Date(2026, 5, 22, 0, 0, 0, 0, time.UTC),
Source: long,
}
if err := in.Validate(); err == nil {
t.Fatal("Validate: expected error on oversize source")
}
}
func TestSerialize_RejectsInvalid(t *testing.T) {
in := &Fact{Name: "x"} // missing required fields
if _, err := Serialize(in); err == nil {
t.Fatal("Serialize: expected validation error")
}
}
// --- setField residual branches ---
func TestSetField_ExpiresEmpty_ClearsField(t *testing.T) {
src := strings.Join([]string{
"---",
"name: exp-empty",
"description: empty expires clears the field",
"type: user",
"created: 2026-05-19",
"last_used: 2026-05-19",
"expires:",
"pin: false",
"---",
}, "\n")
f, err := ParseFact([]byte(src))
if err != nil {
t.Fatalf("ParseFact: %v", err)
}
if f.Expires != nil {
t.Errorf("expires = %v, want nil", f.Expires)
}
}
func TestSetField_ExpiresBadDate(t *testing.T) {
src := strings.Join([]string{
"---",
"name: exp-bad",
"description: bad expires date",
"type: user",
"created: 2026-05-19",
"last_used: 2026-05-19",
"expires: not-a-date",
"pin: false",
"---",
}, "\n")
_, err := ParseFact([]byte(src))
if err == nil {
t.Fatal("expected bad expires date to error")
}
if !strings.Contains(err.Error(), "expires:") {
t.Errorf("err = %v, want substring expires:", err)
}
}
func TestSetField_DisabledFalseExplicit(t *testing.T) {
src := strings.Join([]string{
"---",
"name: dis-false",
"description: explicit disabled false",
"type: user",
"created: 2026-05-19",
"last_used: 2026-05-19",
"pin: false",
"disabled: false",
"---",
}, "\n")
f, err := ParseFact([]byte(src))
if err != nil {
t.Fatalf("ParseFact: %v", err)
}
if f.Disabled {
t.Error("disabled should be false")
}
}