The write path: every change carries its event
CreateCard, UpdateCard, DeleteCard and AddEntry. Each does its work and writes its Event in ONE transaction, because Paul's 2026-09-19 ruling is worth nothing if the log can be missing for a change that landed. A change that is not logged must not be a change that happened — there is a test that drops the events table and asserts the card does not survive. What the log records: the actor, the object, the revision after the change, the FieldDef ids touched as plaintext JSON, and a sealed payload carrying before and after per field. The split is §5's retention rule made real — after a bounded window erases the payload, a reader can still see THAT a field changed and who changed it. A write that changes nothing writes no event and does not bump the revision. Deliberate: a log full of no-op rows is a log nobody reads, and "who touched this" stops meaning anything if opening a form and pressing save counts. Values are keyed by field key and a key the type does not define is an error, never a quiet drop — a silently ignored field is a value a person believes they saved. Prints are taken only for TierPrint fields and only through printFor, so the stored print is the canonical form and a lowercase lookup finds a capitalised row. An entry has no update verb. Entries are immutable (§3); a correction is a new entry that supersedes. The card's revision moves when one lands, because an entry landing is the card changing as far as the feed's consumers are concerned. Test fixtures no longer name a real Irish business. Paul recognised rothar.ie as a live site and a target customer for a future CARLOS eCommerce platform; the name most likely surfaced because it is real, not despite it. RFC 2606 reserves example.com for exactly this.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5 files changed,
+548
−10
internal/fichas/print.go+2 −2internal/fichas/print_test.go+5 −5internal/fichas/seal/seal_test.go+3 −3internal/fichas/store.go+308 −0internal/fichas/store_test.go+230 −0
CI passed — run details
CI log
=== vet === go vet ./... === fmt === === test === go test ./... ? amadan.net/oficina/fichas/cmd/fichas [no test files] ok amadan.net/oficina/fichas/internal/fichas 0.090s ? amadan.net/oficina/fichas/internal/fichas/icons [no test files] ok amadan.net/oficina/fichas/internal/fichas/seal 0.002s ok amadan.net/oficina/fichas/internal/fichastest 0.033s === migration-check === go run github.com/carlosframework/rastrillo/cmd/rastrillo migration check rastrillo migration check: models and migrations agree
diff --git a/internal/fichas/print.go b/internal/fichas/print.go| index 300bf3d..9a63820 100644 |
| --- a/internal/fichas/print.go |
| +++ b/internal/fichas/print.go |
| @@ -19,8 +19,8 @@ import ( |
| // the values §4.2 sealed to avoid publishing — `stage` has five, and |
| // frequency analysis puts five classes in order. |
| // |
| -// **It canonicalises first.** An HMAC over raw bytes makes `Ana@Rothar.ie` |
| -// and `ana@rothar.ie` different values, and `+353 1 555 0100` different |
| +// **It canonicalises first.** An HMAC over raw bytes makes `Ana@Example.com` |
| +// and `ana@example.com` different values, and `+353 1 555 0100` different |
| // from `+35315550100`. The print exists to answer "do we already have this |
| // contact"; without canonicalisation it misses on the commonest real |
| // variance and the feature quietly does not work. memoria normalises an |
diff --git a/internal/fichas/print_test.go b/internal/fichas/print_test.go| index daf46fc..77ecbf2 100644 |
| --- a/internal/fichas/print_test.go |
| +++ b/internal/fichas/print_test.go |
| @@ -18,7 +18,7 @@ func TestPrintForRefusesAFieldThatIsNotPrinted(t *testing.T) { |
| func TestPrintForAcceptsAPrintedField(t *testing.T) { |
| s := testSealer(t) |
| - got, err := printFor(s, fd(FieldEmail, KindText, TierPrint), "ana@rothar.ie") |
| + got, err := printFor(s, fd(FieldEmail, KindText, TierPrint), "ana@example.com") |
| if err != nil { |
| t.Fatalf("printFor: %v", err) |
| } |
| @@ -50,8 +50,8 @@ func TestPrintForFoldsTheVarianceThatIsNotIdentity(t *testing.T) { |
| } |
| } |
| - same(email, "ana@rothar.ie", "Ana@Rothar.ie") |
| - same(email, "ana@rothar.ie", " ana@rothar.ie ") |
| + same(email, "ana@example.com", "Ana@Example.com") |
| + same(email, "ana@example.com", " ana@example.com ") |
| same(phone, "+353 1 555 0100", "+35315550100") |
| same(phone, "(01) 555-0100", "015550100") |
| } |
| @@ -60,8 +60,8 @@ func TestPrintForFoldsTheVarianceThatIsNotIdentity(t *testing.T) { |
| func TestPrintForKeepsDifferentValuesDifferent(t *testing.T) { |
| s := testSealer(t) |
| name := fd(FieldName, KindText, TierPrint) |
| - a, _ := printFor(s, name, "Rothar Ltd") |
| - b, _ := printFor(s, name, "Rothar Limited") |
| + a, _ := printFor(s, name, "Cnoc Teoranta") |
| + b, _ := printFor(s, name, "Cnoc Limited") |
| if a == b { |
| t.Fatal("two different names print the same") |
| } |
diff --git a/internal/fichas/seal/seal_test.go b/internal/fichas/seal/seal_test.go| index 4762d53..5127514 100644 |
| --- a/internal/fichas/seal/seal_test.go |
| +++ b/internal/fichas/seal/seal_test.go |
| @@ -33,7 +33,7 @@ func TestNewRejectsWrongDEKLength(t *testing.T) { |
| func TestSealOpenRoundTrip(t *testing.T) { |
| s := newT(t, 0x01) |
| - want := []byte("Rothar Ltd — renewal due, spoke to Ana") |
| + want := []byte("Cnoc Teoranta — renewal due, spoke to Ana") |
| ct, err := s.Seal(PurposeValue, want) |
| if err != nil { |
| t.Fatalf("Seal: %v", err) |
| @@ -85,7 +85,7 @@ func TestSealIsNotDeterministic(t *testing.T) { |
| func TestPrintIsDeterministicAndKeyed(t *testing.T) { |
| s := newT(t, 0x01) |
| other := newT(t, 0x02) |
| - v := []byte("ana@rothar.ie") |
| + v := []byte("ana@example.com") |
| p1, err := s.Print("f_email", v) |
| if err != nil { |
| @@ -95,7 +95,7 @@ func TestPrintIsDeterministicAndKeyed(t *testing.T) { |
| if p1 != p2 { |
| t.Fatal("Print is not deterministic; equality lookups would miss") |
| } |
| - if strings.Contains(p1, "rothar") { |
| + if strings.Contains(p1, "example") { |
| t.Fatal("the print leaks the plaintext") |
| } |
diff --git a/internal/fichas/store.go b/internal/fichas/store.go| new file mode 100644 |
| index 0000000..ace06ca |
| --- /dev/null |
| +++ b/internal/fichas/store.go |
| @@ -0,0 +1,308 @@ |
| +package fichas |
| + |
| +import ( |
| + "encoding/json" |
| + "fmt" |
| + "sort" |
| + "time" |
| + |
| + "amadan.net/oficina/fichas/internal/fichas/seal" |
| + "gorm.io/gorm" |
| +) |
| + |
| +// The write path. Every function here does its work and writes its Event |
| +// in ONE transaction, because Paul's 2026-09-19 ruling — every change to |
| +// every object is logged with what changed and who did it — is worth |
| +// nothing if the log can be missing for a change that landed. A change |
| +// that is not logged must not be a change that happened. |
| +// |
| +// Object kinds, for Event.ObjectKind. |
| +const ( |
| + ObjectCard = "card" |
| + ObjectEntry = "entry" |
| +) |
| + |
| +// Event kinds. |
| +const ( |
| + EventCreated = "created" |
| + EventUpdated = "updated" |
| + EventDeleted = "deleted" |
| +) |
| + |
| +// change is one field's before and after, as the sealed payload records it. |
| +type change struct { |
| + Field string `json:"field"` |
| + Before string `json:"before,omitempty"` |
| + After string `json:"after,omitempty"` |
| +} |
| + |
| +// fieldsFor loads a card type's definitions, keyed by field key. |
| +func fieldsFor(tx *gorm.DB, cardTypeID int64) (map[string]FieldDef, error) { |
| + var defs []FieldDef |
| + if err := tx.Where("card_type_id = ?", cardTypeID).Find(&defs).Error; err != nil { |
| + return nil, fmt.Errorf("fichas: loading field definitions: %w", err) |
| + } |
| + out := make(map[string]FieldDef, len(defs)) |
| + for _, d := range defs { |
| + out[d.Key] = d |
| + } |
| + return out, nil |
| +} |
| + |
| +// logEvent writes one Event. changed is the set of FieldDef ids the caller |
| +// touched; changes is the before/after detail, sealed. |
| +// |
| +// ChangedFields is plaintext and stays forever; the payload is sealed and |
| +// is what a bounded window later erases. That split is §5's whole retention |
| +// rule: after erasure a reader can still see THAT a field changed and who |
| +// changed it, which is what an audit log is for. |
| +func logEvent(tx *gorm.DB, s *seal.Sealer, kind, objectKind string, objectID, actorID, revision, triggerID int64, changed []int64, changes []change) error { |
| + sort.Slice(changed, func(i, j int) bool { return changed[i] < changed[j] }) |
| + ids, err := json.Marshal(changed) |
| + if err != nil { |
| + return fmt.Errorf("fichas: encoding changed fields: %w", err) |
| + } |
| + |
| + var payload []byte |
| + if len(changes) > 0 { |
| + raw, err := json.Marshal(changes) |
| + if err != nil { |
| + return fmt.Errorf("fichas: encoding the change payload: %w", err) |
| + } |
| + if payload, err = s.Seal(seal.PurposeEventPayload, raw); err != nil { |
| + return fmt.Errorf("fichas: sealing the change payload: %w", err) |
| + } |
| + } |
| + |
| + e := Event{ |
| + Kind: kind, |
| + ObjectKind: objectKind, |
| + ObjectID: objectID, |
| + ActorID: actorID, |
| + Revision: revision, |
| + ChangedFields: string(ids), |
| + PayloadSealed: payload, |
| + TriggerID: triggerID, |
| + } |
| + if err := tx.Create(&e).Error; err != nil { |
| + return fmt.Errorf("fichas: writing the event: %w", err) |
| + } |
| + return nil |
| +} |
| + |
| +// writeValues seals each value, computes its print where the definition |
| +// asks for one, and returns the ids touched plus the before/after detail. |
| +// |
| +// values is keyed by FIELD KEY, not by column: a request body is never |
| +// bound onto a model here (AGENTS.md), and a key the card type does not |
| +// define is an error rather than something quietly dropped — a silently |
| +// ignored field is a value a person believes they saved. |
| +func writeValues(tx *gorm.DB, s *seal.Sealer, cardID int64, defs map[string]FieldDef, values map[string]string) ([]int64, []change, error) { |
| + keys := make([]string, 0, len(values)) |
| + for k := range values { |
| + keys = append(keys, k) |
| + } |
| + sort.Strings(keys) |
| + |
| + var changed []int64 |
| + var detail []change |
| + |
| + for _, key := range keys { |
| + def, ok := defs[key] |
| + if !ok { |
| + return nil, nil, fmt.Errorf("fichas: %s is not a field of this card type", key) |
| + } |
| + after := values[key] |
| + |
| + var existing CardValue |
| + err := tx.Where("card_id = ? AND field_def_id = ?", cardID, def.ID).First(&existing).Error |
| + if err != nil && err != gorm.ErrRecordNotFound { |
| + return nil, nil, fmt.Errorf("fichas: reading %s: %w", key, err) |
| + } |
| + var before string |
| + if err == nil && len(existing.SealedValue) > 0 { |
| + raw, oerr := s.Open(seal.PurposeValue, existing.SealedValue) |
| + if oerr != nil { |
| + return nil, nil, fmt.Errorf("fichas: opening the previous %s: %w", key, oerr) |
| + } |
| + before = string(raw) |
| + } |
| + if before == after { |
| + continue // not a change; it must not appear in the log as one |
| + } |
| + |
| + sealed, serr := s.Seal(seal.PurposeValue, []byte(after)) |
| + if serr != nil { |
| + return nil, nil, fmt.Errorf("fichas: sealing %s: %w", key, serr) |
| + } |
| + row := CardValue{CardID: cardID, FieldDefID: def.ID, SealedValue: sealed} |
| + if def.Tier == TierPrint { |
| + p, perr := printFor(s, def, after) |
| + if perr != nil { |
| + return nil, nil, perr |
| + } |
| + row.Print = p |
| + } |
| + if err := tx.Save(&row).Error; err != nil { |
| + return nil, nil, fmt.Errorf("fichas: writing %s: %w", key, err) |
| + } |
| + changed = append(changed, def.ID) |
| + detail = append(detail, change{Field: key, Before: before, After: after}) |
| + } |
| + return changed, detail, nil |
| +} |
| + |
| +// CreateCard writes a card, its values and its creation event. |
| +func CreateCard(db *gorm.DB, s *seal.Sealer, typeKey string, actorID int64, values map[string]string) (Card, error) { |
| + var card Card |
| + err := db.Transaction(func(tx *gorm.DB) error { |
| + var ct CardType |
| + if err := tx.Where("key = ?", typeKey).First(&ct).Error; err != nil { |
| + return fmt.Errorf("fichas: no card type %s: %w", typeKey, err) |
| + } |
| + defs, err := fieldsFor(tx, ct.ID) |
| + if err != nil { |
| + return err |
| + } |
| + |
| + card = Card{CardTypeID: ct.ID, OwnerID: actorID, Revision: 1} |
| + if err := tx.Create(&card).Error; err != nil { |
| + return fmt.Errorf("fichas: creating the card: %w", err) |
| + } |
| + changed, detail, err := writeValues(tx, s, card.ID, defs, values) |
| + if err != nil { |
| + return err |
| + } |
| + return logEvent(tx, s, EventCreated, ObjectCard, card.ID, actorID, card.Revision, 0, changed, detail) |
| + }) |
| + return card, err |
| +} |
| + |
| +// UpdateCard changes values on a card and logs exactly what moved. |
| +// |
| +// A write that changes nothing writes no event. That is deliberate: a log |
| +// full of no-op rows is a log nobody reads, and "who touched this" stops |
| +// meaning anything if opening a form and pressing save counts. |
| +func UpdateCard(db *gorm.DB, s *seal.Sealer, cardID, actorID int64, values map[string]string) error { |
| + return db.Transaction(func(tx *gorm.DB) error { |
| + var card Card |
| + if err := tx.First(&card, cardID).Error; err != nil { |
| + return fmt.Errorf("fichas: no card %d: %w", cardID, err) |
| + } |
| + if card.Gone { |
| + return fmt.Errorf("fichas: card %d is deleted", cardID) |
| + } |
| + defs, err := fieldsFor(tx, card.CardTypeID) |
| + if err != nil { |
| + return err |
| + } |
| + changed, detail, err := writeValues(tx, s, card.ID, defs, values) |
| + if err != nil { |
| + return err |
| + } |
| + if len(changed) == 0 { |
| + return nil |
| + } |
| + card.Revision++ |
| + if err := tx.Model(&card).Updates(map[string]any{ |
| + "revision": card.Revision, |
| + "updated_at": time.Now(), |
| + }).Error; err != nil { |
| + return fmt.Errorf("fichas: bumping the revision: %w", err) |
| + } |
| + return logEvent(tx, s, EventUpdated, ObjectCard, card.ID, actorID, card.Revision, 0, changed, detail) |
| + }) |
| +} |
| + |
| +// DeleteCard marks a card gone. The row stays and so does its log: §5 keeps |
| +// the actor permanently precisely so "who deleted this" has somewhere to |
| +// live, and suite ruling 14 retires by feed rather than by erasure. |
| +func DeleteCard(db *gorm.DB, s *seal.Sealer, cardID, actorID int64) error { |
| + return db.Transaction(func(tx *gorm.DB) error { |
| + var card Card |
| + if err := tx.First(&card, cardID).Error; err != nil { |
| + return fmt.Errorf("fichas: no card %d: %w", cardID, err) |
| + } |
| + if card.Gone { |
| + return nil |
| + } |
| + card.Revision++ |
| + if err := tx.Model(&card).Updates(map[string]any{ |
| + "gone": true, |
| + "revision": card.Revision, |
| + "updated_at": time.Now(), |
| + }).Error; err != nil { |
| + return fmt.Errorf("fichas: marking the card gone: %w", err) |
| + } |
| + return logEvent(tx, s, EventDeleted, ObjectCard, card.ID, actorID, card.Revision, 0, nil, nil) |
| + }) |
| +} |
| + |
| +// AddEntry appends an entry to a card. |
| +// |
| +// An entry is immutable once written (§3), so there is no UpdateEntry: a |
| +// correction is a new entry that supersedes. The card's revision moves |
| +// because the feed's consumers care that the card changed, and an entry |
| +// landing IS the card changing. |
| +func AddEntry(db *gorm.DB, s *seal.Sealer, cardID, actorID int64, kind, source string, values map[string]string) (Entry, error) { |
| + var entry Entry |
| + err := db.Transaction(func(tx *gorm.DB) error { |
| + var card Card |
| + if err := tx.First(&card, cardID).Error; err != nil { |
| + return fmt.Errorf("fichas: no card %d: %w", cardID, err) |
| + } |
| + if card.Gone { |
| + return fmt.Errorf("fichas: card %d is deleted", cardID) |
| + } |
| + |
| + var ct CardType |
| + if err := tx.Where("key = ?", TypeCallLog).First(&ct).Error; err != nil { |
| + return fmt.Errorf("fichas: no call log type: %w", err) |
| + } |
| + defs, err := fieldsFor(tx, ct.ID) |
| + if err != nil { |
| + return err |
| + } |
| + |
| + entry = Entry{CardID: cardID, Kind: kind, Source: source, ActorID: actorID} |
| + if err := tx.Create(&entry).Error; err != nil { |
| + return fmt.Errorf("fichas: creating the entry: %w", err) |
| + } |
| + |
| + var changed []int64 |
| + var detail []change |
| + for key, v := range values { |
| + def, ok := defs[key] |
| + if !ok { |
| + return fmt.Errorf("fichas: %s is not a field of an entry", key) |
| + } |
| + sealed, serr := s.Seal(seal.PurposeValue, []byte(v)) |
| + if serr != nil { |
| + return fmt.Errorf("fichas: sealing %s: %w", key, serr) |
| + } |
| + row := EntryValue{EntryID: entry.ID, FieldDefID: def.ID, SealedValue: sealed} |
| + if def.Tier == TierPrint { |
| + p, perr := printFor(s, def, v) |
| + if perr != nil { |
| + return perr |
| + } |
| + row.Print = p |
| + } |
| + if err := tx.Create(&row).Error; err != nil { |
| + return fmt.Errorf("fichas: writing %s: %w", key, err) |
| + } |
| + changed = append(changed, def.ID) |
| + detail = append(detail, change{Field: key, After: v}) |
| + } |
| + |
| + card.Revision++ |
| + if err := tx.Model(&card).Updates(map[string]any{ |
| + "revision": card.Revision, |
| + "updated_at": time.Now(), |
| + }).Error; err != nil { |
| + return fmt.Errorf("fichas: bumping the revision: %w", err) |
| + } |
| + return logEvent(tx, s, EventCreated, ObjectEntry, entry.ID, actorID, card.Revision, 0, changed, detail) |
| + }) |
| + return entry, err |
| +} |
diff --git a/internal/fichas/store_test.go b/internal/fichas/store_test.go| new file mode 100644 |
| index 0000000..68ba4f6 |
| --- /dev/null |
| +++ b/internal/fichas/store_test.go |
| @@ -0,0 +1,230 @@ |
| +package fichas |
| + |
| +import ( |
| + "encoding/json" |
| + "testing" |
| + |
| + "amadan.net/oficina/fichas/internal/fichas/seal" |
| + "gorm.io/gorm" |
| +) |
| + |
| +func seeded(t *testing.T) (*gorm.DB, *seal.Sealer) { |
| + t.Helper() |
| + g, s := testDB(t), testSealer(t) |
| + if err := Seed(g, s); err != nil { |
| + t.Fatalf("Seed: %v", err) |
| + } |
| + return g, s |
| +} |
| + |
| +func events(t *testing.T, g *gorm.DB, kind string, id int64) []Event { |
| + t.Helper() |
| + var out []Event |
| + if err := g.Where("object_kind = ? AND object_id = ?", kind, id).Order("id").Find(&out).Error; err != nil { |
| + t.Fatalf("loading events: %v", err) |
| + } |
| + return out |
| +} |
| + |
| +func TestCreateCardSealsAndLogs(t *testing.T) { |
| + g, s := seeded(t) |
| + card, err := CreateCard(g, s, TypeCustomer, 7, map[string]string{ |
| + FieldName: "Cnoc Teoranta", |
| + FieldEmail: "ana@example.com", |
| + FieldNotes: "spoke at the trade show", |
| + }) |
| + if err != nil { |
| + t.Fatalf("CreateCard: %v", err) |
| + } |
| + if card.ID == 0 || card.Revision != 1 || card.OwnerID != 7 { |
| + t.Fatalf("card is %+v", card) |
| + } |
| + |
| + var vals []CardValue |
| + g.Where("card_id = ?", card.ID).Find(&vals) |
| + if len(vals) != 3 { |
| + t.Fatalf("wrote %d values, want 3", len(vals)) |
| + } |
| + for _, v := range vals { |
| + if len(v.SealedValue) == 0 { |
| + t.Fatal("a value was stored with nothing sealed in it") |
| + } |
| + raw, err := s.Open(seal.PurposeValue, v.SealedValue) |
| + if err != nil { |
| + t.Fatalf("opening a value: %v", err) |
| + } |
| + if string(raw) == "" { |
| + t.Fatal("a sealed value opened to nothing") |
| + } |
| + } |
| + |
| + ev := events(t, g, ObjectCard, card.ID) |
| + if len(ev) != 1 || ev[0].Kind != EventCreated || ev[0].ActorID != 7 || ev[0].Revision != 1 { |
| + t.Fatalf("events are %+v", ev) |
| + } |
| + var changed []int64 |
| + if err := json.Unmarshal([]byte(ev[0].ChangedFields), &changed); err != nil { |
| + t.Fatalf("ChangedFields is not a JSON array: %q", ev[0].ChangedFields) |
| + } |
| + if len(changed) != 3 { |
| + t.Fatalf("logged %d changed fields, want 3", len(changed)) |
| + } |
| +} |
| + |
| +// Only printed fields get a print, and a print must actually be usable to |
| +// find the row again — that is the entire point of it. |
| +func TestCreateCardPrintsOnlyPrintedFields(t *testing.T) { |
| + g, s := seeded(t) |
| + card, err := CreateCard(g, s, TypeCustomer, 1, map[string]string{ |
| + FieldEmail: "Ana@Example.com", // capitalised on purpose |
| + FieldNotes: "not a printed field", |
| + }) |
| + if err != nil { |
| + t.Fatalf("CreateCard: %v", err) |
| + } |
| + |
| + var email, notes FieldDef |
| + g.Where("key = ?", FieldEmail).First(&email) |
| + g.Where("key = ?", FieldNotes).First(¬es) |
| + |
| + var ev, nv CardValue |
| + g.Where("card_id = ? AND field_def_id = ?", card.ID, email.ID).First(&ev) |
| + g.Where("card_id = ? AND field_def_id = ?", card.ID, notes.ID).First(&nv) |
| + |
| + if ev.Print == "" { |
| + t.Fatal("a TierPrint field was stored with no print") |
| + } |
| + if nv.Print != "" { |
| + t.Fatal("a TierSealed field was stored with a print: its equality classes are published") |
| + } |
| + |
| + // Canonicalisation means the lowercase form finds the capitalised row. |
| + want, err := printFor(s, email, "ana@example.com") |
| + if err != nil { |
| + t.Fatalf("printFor: %v", err) |
| + } |
| + if ev.Print != want { |
| + t.Fatal("the stored print does not match the canonical form; dedupe would miss") |
| + } |
| +} |
| + |
| +func TestUpdateCardLogsBeforeAndAfter(t *testing.T) { |
| + g, s := seeded(t) |
| + card, _ := CreateCard(g, s, TypeCustomer, 1, map[string]string{FieldStage: "Lead"}) |
| + |
| + if err := UpdateCard(g, s, card.ID, 2, map[string]string{FieldStage: "Proposal"}); err != nil { |
| + t.Fatalf("UpdateCard: %v", err) |
| + } |
| + |
| + ev := events(t, g, ObjectCard, card.ID) |
| + if len(ev) != 2 { |
| + t.Fatalf("%d events after one update, want 2", len(ev)) |
| + } |
| + last := ev[1] |
| + if last.Kind != EventUpdated || last.ActorID != 2 || last.Revision != 2 { |
| + t.Fatalf("update event is %+v", last) |
| + } |
| + raw, err := s.Open(seal.PurposeEventPayload, last.PayloadSealed) |
| + if err != nil { |
| + t.Fatalf("opening the payload: %v", err) |
| + } |
| + var changes []change |
| + if err := json.Unmarshal(raw, &changes); err != nil { |
| + t.Fatalf("payload is not changes: %v", err) |
| + } |
| + if len(changes) != 1 || changes[0].Before != "Lead" || changes[0].After != "Proposal" { |
| + t.Fatalf("payload is %+v", changes) |
| + } |
| +} |
| + |
| +// A log full of no-op rows is a log nobody reads, and "who touched this" |
| +// stops meaning anything if pressing save counts. |
| +func TestUpdateCardWithNoChangeWritesNoEvent(t *testing.T) { |
| + g, s := seeded(t) |
| + card, _ := CreateCard(g, s, TypeCustomer, 1, map[string]string{FieldStage: "Lead"}) |
| + |
| + if err := UpdateCard(g, s, card.ID, 1, map[string]string{FieldStage: "Lead"}); err != nil { |
| + t.Fatalf("UpdateCard: %v", err) |
| + } |
| + if n := len(events(t, g, ObjectCard, card.ID)); n != 1 { |
| + t.Fatalf("%d events after a no-op update, want 1", n) |
| + } |
| + var reread Card |
| + g.First(&reread, card.ID) |
| + if reread.Revision != 1 { |
| + t.Fatalf("a no-op update bumped the revision to %d", reread.Revision) |
| + } |
| +} |
| + |
| +// A field the card type does not define must fail, not be dropped: a |
| +// silently ignored field is a value a person believes they saved. |
| +func TestWritingAnUnknownFieldFails(t *testing.T) { |
| + g, s := seeded(t) |
| + if _, err := CreateCard(g, s, TypeCustomer, 1, map[string]string{"f_nonsense": "x"}); err == nil { |
| + t.Fatal("an unknown field key was accepted") |
| + } |
| +} |
| + |
| +func TestDeleteCardKeepsTheRowAndTheActor(t *testing.T) { |
| + g, s := seeded(t) |
| + card, _ := CreateCard(g, s, TypeCustomer, 1, map[string]string{FieldName: "Cnoc Teoranta"}) |
| + |
| + if err := DeleteCard(g, s, card.ID, 9); err != nil { |
| + t.Fatalf("DeleteCard: %v", err) |
| + } |
| + var reread Card |
| + if err := g.First(&reread, card.ID).Error; err != nil { |
| + t.Fatalf("the row is gone from the table, not marked gone: %v", err) |
| + } |
| + if !reread.Gone { |
| + t.Fatal("the card is not marked gone") |
| + } |
| + ev := events(t, g, ObjectCard, card.ID) |
| + last := ev[len(ev)-1] |
| + if last.Kind != EventDeleted || last.ActorID != 9 { |
| + t.Fatalf("who deleted this is unanswerable: %+v", last) |
| + } |
| +} |
| + |
| +func TestAddEntryLogsAndMovesTheCardRevision(t *testing.T) { |
| + g, s := seeded(t) |
| + card, _ := CreateCard(g, s, TypeCustomer, 1, map[string]string{FieldName: "Cnoc Teoranta"}) |
| + |
| + entry, err := AddEntry(g, s, card.ID, 3, "call", "manual", map[string]string{ |
| + FieldBody: "talked about renewal", |
| + FieldOccurredAt: "2026-09-20", |
| + }) |
| + if err != nil { |
| + t.Fatalf("AddEntry: %v", err) |
| + } |
| + if entry.ID == 0 || entry.ActorID != 3 { |
| + t.Fatalf("entry is %+v", entry) |
| + } |
| + |
| + var reread Card |
| + g.First(&reread, card.ID) |
| + if reread.Revision != 2 { |
| + t.Fatalf("card revision is %d after an entry landed, want 2", reread.Revision) |
| + } |
| + if n := len(events(t, g, ObjectEntry, entry.ID)); n != 1 { |
| + t.Fatalf("%d events for the entry, want 1", n) |
| + } |
| +} |
| + |
| +// The ruling is that a change which lands is a change which is logged. |
| +// If the event write fails, the change must not survive it. |
| +func TestAChangeThatCannotBeLoggedDoesNotLand(t *testing.T) { |
| + g, s := seeded(t) |
| + // Drop the events table so the log write fails inside the transaction. |
| + if err := g.Exec("DROP TABLE events").Error; err != nil { |
| + t.Fatalf("dropping events: %v", err) |
| + } |
| + if _, err := CreateCard(g, s, TypeCustomer, 1, map[string]string{FieldName: "Cnoc Teoranta"}); err == nil { |
| + t.Fatal("a card was created while its event could not be written") |
| + } |
| + var cards int64 |
| + g.Model(&Card{}).Count(&cards) |
| + if cards != 0 { |
| + t.Fatalf("%d card(s) survived a failed event write", cards) |
| + } |
| +} |