Found by walking the scenario against production rather than by a test. Two records, each with a phone; the survivor kept its own, and the source's simply stopped existing. Searching for it returned nothing. The first version's rule was "fill the survivor's blanks, never overwrite what it has", which is right about which value WINS and said nothing about the one that loses. One person can have two numbers, two spellings of a name, a work address and a personal one - and a merge that quietly deletes one is exactly the data loss this file already refuses elsewhere: "silently turning Alice back into Visitor 3 is data loss the operator cannot see happen." The profile is now reconciled field by field in Go rather than in one clever upsert, because the interesting case was never the winner. Blanks are still filled and the survivor still keeps its own values, but every losing value is returned in `discarded` AND appended to the survivor's notes - the response is read once and the record is read forever. Notes themselves are additive rather than a winner: two people writing about one customer wrote two different true things. mergeProfiles is pure, so the rule is asserted directly - four cases including the ordinary one, a typed record joining a camera record with no profile at all, which must add no noise. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
72 lines
2.5 KiB
Go
72 lines
2.5 KiB
Go
package store
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// mergeProfiles is pure, so the rule it encodes can be asserted without a
|
|
// database - and it is the rule that matters: what happens to the value that
|
|
// LOSES. The first version dropped it, and lost a phone number on the first
|
|
// live run.
|
|
func TestMergingProfilesKeepsWhatLoses(t *testing.T) {
|
|
src := profileFields{exists: true, fullName: "Asha M", phone: "111", email: "a@b.c"}
|
|
dst := profileFields{exists: true, fullName: "Asha Menon", phone: "222"}
|
|
|
|
out, discarded := mergeProfiles(src, dst)
|
|
|
|
if out.fullName != "Asha Menon" || out.phone != "222" {
|
|
t.Errorf("survivor's own values must win: got %q / %q", out.fullName, out.phone)
|
|
}
|
|
if out.email != "a@b.c" {
|
|
t.Errorf("a blank must be filled from the source, got %q", out.email)
|
|
}
|
|
if len(discarded) != 2 {
|
|
t.Fatalf("discarded %v, want the losing name and phone", discarded)
|
|
}
|
|
// In the record, not only in the response: the response is read once.
|
|
for _, want := range []string{"Asha M", "111"} {
|
|
if !strings.Contains(out.notes, want) {
|
|
t.Errorf("notes must retain %q: %q", want, out.notes)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Nothing to reconcile is the ordinary case - a hand-typed record joining a
|
|
// camera record that has no profile at all - and it must add no noise.
|
|
func TestMergingIntoAnEmptyProfileDiscardsNothing(t *testing.T) {
|
|
src := profileFields{exists: true, fullName: "Asha Menon", phone: "111"}
|
|
out, discarded := mergeProfiles(src, profileFields{})
|
|
|
|
if len(discarded) != 0 {
|
|
t.Errorf("discarded %v, want none", discarded)
|
|
}
|
|
if out.fullName != "Asha Menon" || out.phone != "111" {
|
|
t.Errorf("the typed details must reach the surviving record: %+v", out)
|
|
}
|
|
if out.notes != "" {
|
|
t.Errorf("no conflict should leave no note, got %q", out.notes)
|
|
}
|
|
}
|
|
|
|
// Identical values are not a conflict.
|
|
func TestIdenticalProfileValuesAreNotDiscarded(t *testing.T) {
|
|
p := profileFields{exists: true, fullName: "Asha Menon", phone: "111"}
|
|
out, discarded := mergeProfiles(p, p)
|
|
if len(discarded) != 0 || out.notes != "" {
|
|
t.Errorf("identical profiles produced %v / notes %q", discarded, out.notes)
|
|
}
|
|
}
|
|
|
|
// Two people writing about one customer wrote two different true things.
|
|
func TestNotesAreAdditiveRatherThanAWinner(t *testing.T) {
|
|
out, _ := mergeProfiles(
|
|
profileFields{exists: true, notes: "prefers window seat"},
|
|
profileFields{exists: true, notes: "allergic to nuts"})
|
|
for _, want := range []string{"window seat", "allergic to nuts"} {
|
|
if !strings.Contains(out.notes, want) {
|
|
t.Errorf("notes lost %q: %q", want, out.notes)
|
|
}
|
|
}
|
|
}
|