package ledger import ( "context" "encoding/json" "errors" "os" "strings" "testing" "time" "github.com/carlosframework/platform/internal/release" ) func fixedNow() time.Time { return time.Date(2026, 8, 3, 21, 4, 0, 0, time.UTC) } func TestAppendFirstAndSecond(t *testing.T) { st := release.NewDirStore(t.TempDir()) ctx := context.Background() a1, err := Append(ctx, st, "bab", "platform", "carbon", "paul@keymail.dev", json.RawMessage(`{"n":1}`), fixedNow) if err != nil { t.Fatal(err) } // HEAD points at the entry, count 1, and the stored bytes hash to // the recorded sha. hb, _, err := st.Get(ctx, HeadKey("bab", "platform", "carbon")) if err != nil { t.Fatal(err) } var h Head if err := json.Unmarshal(hb, &h); err != nil { t.Fatal(err) } if h.HeadKey != a1.Key || h.Count != 1 || h.Schema != Schema { t.Fatalf("head = %+v, appended = %+v", h, a1) } eb, _, err := st.Get(ctx, a1.Key) if err != nil { t.Fatal(err) } if SHA256Hex(eb) != a1.SHA256 || h.HeadSHA256 != a1.SHA256 { t.Fatal("stored bytes do not hash to the recorded sha") } var e1 Entry if err := json.Unmarshal(eb, &e1); err != nil { t.Fatal(err) } if e1.PrevKey != "" || e1.PrevSHA256 != "" || e1.Writer != "paul@keymail.dev" { t.Fatalf("first entry = %+v", e1) } // Second append chains to the first. later := func() time.Time { return fixedNow().Add(time.Minute) } a2, err := Append(ctx, st, "bab", "platform", "carbon", "paul@keymail.dev", json.RawMessage(`{"n":2}`), later) if err != nil { t.Fatal(err) } eb2, _, _ := st.Get(ctx, a2.Key) var e2 Entry if err := json.Unmarshal(eb2, &e2); err != nil { t.Fatal(err) } if e2.PrevKey != a1.Key || e2.PrevSHA256 != a1.SHA256 || a2.Count != 2 { t.Fatalf("second entry does not chain: %+v (count %d)", e2, a2.Count) } } func TestAppendRejects(t *testing.T) { st := release.NewDirStore(t.TempDir()) ctx := context.Background() if _, err := Append(ctx, st, "bab", "platform", "Not A Name", "w", json.RawMessage(`{}`), fixedNow); err == nil { t.Fatal("bad ledger name accepted") } big := json.RawMessage(`"` + string(make([]byte, MaxPayload)) + `"`) if _, err := Append(ctx, st, "bab", "platform", "carbon", "w", big, fixedNow); err == nil { t.Fatal("oversized payload accepted") } if _, err := Append(ctx, st, "bab", "platform", "carbon", "w", json.RawMessage(`{not json`), fixedNow); err == nil { t.Fatal("invalid JSON payload accepted") } } // casOnceStore makes the FIRST head Put lose its race, as if another // writer advanced HEAD between our read and our write. type casOnceStore struct { release.Store tripped bool } func (c *casOnceStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { if !c.tripped && key == HeadKey("bab", "platform", "carbon") { c.tripped = true // Simulate the race: a competing append lands first. if _, err := Append(ctx, c.Store, "bab", "platform", "carbon", "rival", json.RawMessage(`{"rival":true}`), fixedNow); err != nil { return "", err } return "", release.ErrCASConflict } return c.Store.Put(ctx, key, body, ifMatch) } func TestAppendRetriesLostRace(t *testing.T) { st := &casOnceStore{Store: release.NewDirStore(t.TempDir())} ctx := context.Background() later := func() time.Time { return fixedNow().Add(time.Minute) } a, err := Append(ctx, st, "bab", "platform", "carbon", "me", json.RawMessage(`{"mine":true}`), later) if err != nil { t.Fatal(err) } if a.Count != 2 { t.Fatalf("count = %d, want 2 (rival's entry then ours)", a.Count) } eb, _, err := st.Get(ctx, a.Key) if errors.Is(err, os.ErrNotExist) || err != nil { t.Fatal(err) } var e Entry if err := json.Unmarshal(eb, &e); err != nil { t.Fatal(err) } if e.PrevKey == "" { t.Fatal("retried entry did not re-chain onto the rival's head") } } // alwaysCASStore loses every HEAD race — the shape of a chain under more // concurrent writers than the retry loop can outlast. type alwaysCASStore struct{ release.Store } func (a alwaysCASStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { if strings.HasSuffix(key, "/HEAD.json") { return "", release.ErrCASConflict } return a.Store.Put(ctx, key, body, ifMatch) } // TestAppendExhaustionIsErrBusy: losing every race is neither the caller's // fault nor a broken store — it is retry-worthy, and a caller must be able // to tell WITHOUT matching on error text, because callers that match on text // break the first time the sentence changes. func TestAppendExhaustionIsErrBusy(t *testing.T) { st := alwaysCASStore{Store: release.NewDirStore(t.TempDir())} _, err := Append(context.Background(), st, "bab", "platform", "carbon", "me", json.RawMessage(`{"n":1}`), fixedNow) if err == nil { t.Fatal("Append succeeded against a store that loses every HEAD race") } if !errors.Is(err, ErrBusy) { t.Fatalf("exhaustion err = %v, want it to wrap ErrBusy", err) } // The underlying cause is still reachable — the sentinel adds to the // chain, it does not replace it. if !errors.Is(err, release.ErrCASConflict) { t.Fatalf("exhaustion err = %v, want it to still wrap ErrCASConflict", err) } // A caller-caused failure must NOT look busy. _, err = Append(context.Background(), st, "bab", "platform", "Not Valid", "me", json.RawMessage(`{"n":1}`), fixedNow) if err == nil || errors.Is(err, ErrBusy) { t.Fatalf("invalid-name err = %v, want a non-ErrBusy failure", err) } }