| new file mode 100644 |
| index 000000000..53b3aaedd |
| --- /dev/null |
| +++ b/internal/ledger/append_test.go |
| @@ -0,0 +1,130 @@ |
| +package ledger |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "os" |
| + "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") |
| + } |
| +} |