Deleting a bookmark/highlight/note and then re-adding the same content at the same position (same dedup key — e.g. the reader's auto-titled 'Bookmark at X%') was silently swallowed: the save hit the tombstone branch, returned 201 with the deleted row, and the list (which filters deleted) stayed empty. Bookmarks were further blocked by the UNIQUE(media_item_id, user_id, title) slot the tombstoned row holds, and notes had no TTL escape at all. Tombstones now only block saves that predate them (stale replays from a device that still has the annotation). A save whose modification time is newer than max(deleted_at, last_modified_at) — a deliberate re-create from the web or a device — resurrects the row via the LWW update queries, which now clear deleted/deleted_at.
372 lines
11 KiB
Go
372 lines
11 KiB
Go
package sync
|
|
|
|
import (
|
|
"bookhoard/internal/database"
|
|
"encoding/json"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
)
|
|
|
|
func TestComputeDedupKey_Deterministic(t *testing.T) {
|
|
k1 := ComputeDedupKey("Hello World", "epubcfi(/6/4!/4/10/3:100)", "")
|
|
k2 := ComputeDedupKey("Hello World", "epubcfi(/6/4!/4/10/3:100)", "")
|
|
if k1 != k2 {
|
|
t.Errorf("same input should produce same key: %q vs %q", k1, k2)
|
|
}
|
|
}
|
|
|
|
func TestComputeDedupKey_Normalization(t *testing.T) {
|
|
cases := [][]string{
|
|
{"Hello World", " Hello World "},
|
|
{"HELLO WORLD", "hello world"},
|
|
{"Hello World", "Hello World"},
|
|
{"Hello\t\nWorld", "Hello World"},
|
|
}
|
|
cfi := "epubcfi(/6/4!/4/10/3:100)"
|
|
for _, c := range cases {
|
|
k1 := ComputeDedupKey(c[0], cfi, "")
|
|
k2 := ComputeDedupKey(c[1], cfi, "")
|
|
if k1 != k2 {
|
|
t.Errorf("normalized texts should match: %q vs %q → %q vs %q", c[0], c[1], k1, k2)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestComputeDedupKey_PositionSensitivity(t *testing.T) {
|
|
text := "same text"
|
|
k1 := ComputeDedupKey(text, "epubcfi(/6/4!/4/10/3:100)", "")
|
|
k2 := ComputeDedupKey(text, "epubcfi(/6/4!/4/20/3:100)", "")
|
|
if k1 == k2 {
|
|
t.Error("different element paths should produce different keys")
|
|
}
|
|
}
|
|
|
|
func TestComputeDedupKey_OffsetInsensitive(t *testing.T) {
|
|
text := "same text"
|
|
base := "epubcfi(/6/4!/4/10/3:100)"
|
|
offsetShift := "epubcfi(/6/4!/4/10/3:200)"
|
|
k1 := ComputeDedupKey(text, base, "")
|
|
k2 := ComputeDedupKey(text, offsetShift, "")
|
|
if k1 != k2 {
|
|
t.Error("same element path with different char offsets should produce same key (bucket)")
|
|
}
|
|
}
|
|
|
|
func TestComputeDedupKey_FallbackToRawPosition(t *testing.T) {
|
|
text := "same text"
|
|
k1 := ComputeDedupKey(text, "", "page:42")
|
|
k2 := ComputeDedupKey(text, "", "page:42")
|
|
if k1 != k2 {
|
|
t.Error("same raw position should produce same key")
|
|
}
|
|
k3 := ComputeDedupKey(text, "", "page:99")
|
|
if k1 == k3 {
|
|
t.Error("different raw positions should produce different keys")
|
|
}
|
|
}
|
|
|
|
func TestComputeDedupKey_DifferentTextSamePosition(t *testing.T) {
|
|
cfi := "epubcfi(/6/4!/4/10/3:100)"
|
|
k1 := ComputeDedupKey("first highlight", cfi, "")
|
|
k2 := ComputeDedupKey("second highlight", cfi, "")
|
|
if k1 == k2 {
|
|
t.Error("different selection text should produce different keys")
|
|
}
|
|
}
|
|
|
|
func TestNormalizeText(t *testing.T) {
|
|
cases := []struct{ in, want string }{
|
|
{"Hello World", "hello world"},
|
|
{" Hello World ", "hello world"},
|
|
{"Hello\t\nWorld", "hello world"},
|
|
{"", ""},
|
|
{" ", ""},
|
|
}
|
|
for _, c := range cases {
|
|
got := normalizeText(c.in)
|
|
if got != c.want {
|
|
t.Errorf("normalizeText(%q) = %q, want %q", c.in, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBucketPosition(t *testing.T) {
|
|
cases := []struct{ in, want string }{
|
|
{"epubcfi(/6/4!/4/10/3:100)", "epubcfi(/6/4!/4/10/3"},
|
|
{"epubcfi(/6/4!/4/10/3:0)", "epubcfi(/6/4!/4/10/3"},
|
|
{"page:42", "page:42"},
|
|
{"short", "short"},
|
|
{"", ""},
|
|
}
|
|
for _, c := range cases {
|
|
got := bucketPosition(c.in)
|
|
if got != c.want {
|
|
t.Errorf("bucketPosition(%q) = %q, want %q", c.in, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBucketPosition_LongString(t *testing.T) {
|
|
long := "this_is_a_very_long_position_string_that_exceeds_fifty_characters_total"
|
|
got := bucketPosition(long)
|
|
if len(got) > 50 {
|
|
t.Errorf("bucketPosition should truncate to <=50 chars, got %d", len(got))
|
|
}
|
|
if got != long[:50] {
|
|
t.Errorf("bucketPosition truncated wrong: got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestMergeDeviceSyncData_NewEntry(t *testing.T) {
|
|
result := mergeDeviceSyncData(nil, "koreader", json.RawMessage(`{"datetime":"2024-01-01"}`))
|
|
var m map[string]interface{}
|
|
if err := json.Unmarshal(result, &m); err != nil {
|
|
t.Fatalf("unmarshal failed: %v", err)
|
|
}
|
|
entry, ok := m["koreader"]
|
|
if !ok {
|
|
t.Fatal("expected koreader entry")
|
|
}
|
|
entryMap := entry.(map[string]interface{})
|
|
if entryMap["datetime"] != "2024-01-01" {
|
|
t.Errorf("unexpected datetime: %v", entryMap["datetime"])
|
|
}
|
|
}
|
|
|
|
func TestMergeDeviceSyncData_PreservesExisting(t *testing.T) {
|
|
existing := []byte(`{"koreader":{"datetime":"2024-01-01"}}`)
|
|
result := mergeDeviceSyncData(existing, "kobo", json.RawMessage(`{"bookmark_id":"abc"}`))
|
|
var m map[string]interface{}
|
|
if err := json.Unmarshal(result, &m); err != nil {
|
|
t.Fatalf("unmarshal failed: %v", err)
|
|
}
|
|
if _, ok := m["koreader"]; !ok {
|
|
t.Error("koreader entry should be preserved")
|
|
}
|
|
if _, ok := m["kobo"]; !ok {
|
|
t.Error("kobo entry should be added")
|
|
}
|
|
}
|
|
|
|
func TestMergeDeviceSyncData_OverwritesSameSource(t *testing.T) {
|
|
existing := []byte(`{"koreader":{"datetime":"old"}}`)
|
|
result := mergeDeviceSyncData(existing, "koreader", json.RawMessage(`{"datetime":"new"}`))
|
|
var m map[string]interface{}
|
|
json.Unmarshal(result, &m)
|
|
entry := m["koreader"].(map[string]interface{})
|
|
if entry["datetime"] != "new" {
|
|
t.Errorf("expected overwritten datetime 'new', got %v", entry["datetime"])
|
|
}
|
|
}
|
|
|
|
func TestIsCrossSource(t *testing.T) {
|
|
if isCrossSource("koreader", pgtype.Text{String: "kobo", Valid: true}) != true {
|
|
t.Error("different sources should be cross-source")
|
|
}
|
|
if isCrossSource("koreader", pgtype.Text{String: "koreader", Valid: true}) != false {
|
|
t.Error("same sources should not be cross-source")
|
|
}
|
|
if isCrossSource("", pgtype.Text{String: "koreader", Valid: true}) != false {
|
|
t.Error("empty incoming source should not be cross-source")
|
|
}
|
|
if isCrossSource("koreader", pgtype.Text{Valid: false}) != false {
|
|
t.Error("invalid existing source should not be cross-source")
|
|
}
|
|
}
|
|
|
|
func TestCompareIncoming_FieldDiff_Identical(t *testing.T) {
|
|
svc := &AnnotationService{}
|
|
req := SaveHighlightRequest{
|
|
SelectionText: "hello",
|
|
Color: "#ffff00",
|
|
NoteText: "a note",
|
|
PercentageStart: 10.5,
|
|
PercentageEnd: 11.0,
|
|
}
|
|
existing := pgHighlights("hello", "#ffff00", "a note", 10.5, 11.0)
|
|
newer, changed := svc.compareIncoming(req, existing)
|
|
if newer {
|
|
t.Error("identical content should not be newer")
|
|
}
|
|
if changed {
|
|
t.Error("identical content should not be changed")
|
|
}
|
|
}
|
|
|
|
func TestCompareIncoming_FieldDiff_DifferentText(t *testing.T) {
|
|
svc := &AnnotationService{}
|
|
req := SaveHighlightRequest{
|
|
SelectionText: "edited text",
|
|
}
|
|
existing := pgHighlights("original text", "#ffff00", "", 0, 0)
|
|
newer, changed := svc.compareIncoming(req, existing)
|
|
if !newer {
|
|
t.Error("different content should be newer")
|
|
}
|
|
if !changed {
|
|
t.Error("different content should be changed")
|
|
}
|
|
}
|
|
|
|
func TestCompareIncoming_FieldDiff_DifferentColor(t *testing.T) {
|
|
svc := &AnnotationService{}
|
|
req := SaveHighlightRequest{
|
|
SelectionText: "same",
|
|
Color: "#ff0000",
|
|
}
|
|
existing := pgHighlights("same", "#ffff00", "", 0, 0)
|
|
_, changed := svc.compareIncoming(req, existing)
|
|
if !changed {
|
|
t.Error("different color should be detected as changed")
|
|
}
|
|
}
|
|
|
|
func TestCompareIncoming_LWW_NewerWins(t *testing.T) {
|
|
svc := &AnnotationService{}
|
|
now := time.Now()
|
|
req := SaveHighlightRequest{
|
|
SelectionText: "same",
|
|
ModifiedAt: now.Add(1 * time.Hour),
|
|
}
|
|
existing := pgHighlights("same", "", "", 0, 0)
|
|
existing.LastModifiedAt = pgtype.Timestamptz{Time: now, Valid: true}
|
|
newer, changed := svc.compareIncoming(req, existing)
|
|
if !newer {
|
|
t.Error("future timestamp should be newer")
|
|
}
|
|
if !changed {
|
|
t.Error("LWW mode should always report changed=true")
|
|
}
|
|
}
|
|
|
|
func TestCompareIncoming_LWW_OlderSkipped(t *testing.T) {
|
|
svc := &AnnotationService{}
|
|
now := time.Now()
|
|
req := SaveHighlightRequest{
|
|
SelectionText: "same",
|
|
ModifiedAt: now.Add(-1 * time.Hour),
|
|
}
|
|
existing := pgHighlights("same", "", "", 0, 0)
|
|
existing.LastModifiedAt = pgtype.Timestamptz{Time: now, Valid: true}
|
|
newer, _ := svc.compareIncoming(req, existing)
|
|
if newer {
|
|
t.Error("past timestamp should not be newer")
|
|
}
|
|
}
|
|
|
|
func TestCompareIncoming_LWW_FallsBackToUpdatedAt(t *testing.T) {
|
|
svc := &AnnotationService{}
|
|
now := time.Now()
|
|
req := SaveHighlightRequest{
|
|
SelectionText: "same",
|
|
ModifiedAt: now.Add(1 * time.Hour),
|
|
}
|
|
existing := pgHighlights("same", "", "", 0, 0)
|
|
existing.LastModifiedAt = pgtype.Timestamptz{Valid: false}
|
|
existing.UpdatedAt = pgtype.Timestamptz{Time: now, Valid: true}
|
|
newer, _ := svc.compareIncoming(req, existing)
|
|
if !newer {
|
|
t.Error("should fall back to updated_at when last_modified_at is invalid")
|
|
}
|
|
}
|
|
|
|
func TestPgText(t *testing.T) {
|
|
if pgText("").Valid {
|
|
t.Error("empty string should produce invalid pgtype.Text")
|
|
}
|
|
v := pgText("hello")
|
|
if !v.Valid || v.String != "hello" {
|
|
t.Errorf("expected valid 'hello', got %+v", v)
|
|
}
|
|
}
|
|
|
|
func TestPgFloat8(t *testing.T) {
|
|
if pgFloat8(0).Valid {
|
|
t.Error("zero should produce invalid pgtype.Float8")
|
|
}
|
|
v := pgFloat8(1.5)
|
|
if !v.Valid || v.Float64 != 1.5 {
|
|
t.Errorf("expected valid 1.5, got %+v", v)
|
|
}
|
|
}
|
|
|
|
func TestPgInt4(t *testing.T) {
|
|
if pgInt4(0).Valid {
|
|
t.Error("zero should produce invalid pgtype.Int4")
|
|
}
|
|
v := pgInt4(3)
|
|
if !v.Valid || v.Int32 != 3 {
|
|
t.Errorf("expected valid 3, got %+v", v)
|
|
}
|
|
}
|
|
|
|
func TestFloatEq(t *testing.T) {
|
|
if !floatEq(0, pgtype.Float8{Valid: false}) {
|
|
t.Error("0 vs invalid should be equal")
|
|
}
|
|
if !floatEq(10.5, pgtype.Float8{Float64: 10.5, Valid: true}) {
|
|
t.Error("10.5 vs 10.5 should be equal")
|
|
}
|
|
if floatEq(10.6, pgtype.Float8{Float64: 10.5, Valid: true}) {
|
|
t.Error("10.6 vs 10.5 should not be equal")
|
|
}
|
|
}
|
|
|
|
func TestTextEq(t *testing.T) {
|
|
if !textEq("", pgtype.Text{Valid: false}) {
|
|
t.Error("empty vs invalid should be equal")
|
|
}
|
|
if !textEq("hi", pgtype.Text{String: "hi", Valid: true}) {
|
|
t.Error("same strings should be equal")
|
|
}
|
|
if textEq("hi", pgtype.Text{String: "bye", Valid: true}) {
|
|
t.Error("different strings should not be equal")
|
|
}
|
|
}
|
|
|
|
func TestTombstoneTTL(t *testing.T) {
|
|
if TombstoneTTL != 30*24*time.Hour {
|
|
t.Errorf("expected 30 days, got %v", TombstoneTTL)
|
|
}
|
|
}
|
|
|
|
func pgHighlights(text, color, note string, pctStart, pctEnd float64) database.MediaHighlights {
|
|
return database.MediaHighlights{
|
|
SelectionText: text,
|
|
Color: pgtype.Text{String: color, Valid: color != ""},
|
|
NoteText: pgtype.Text{String: note, Valid: note != ""},
|
|
PercentageStart: pgtype.Float8{Float64: pctStart, Valid: pctStart != 0},
|
|
PercentageEnd: pgtype.Float8{Float64: pctEnd, Valid: pctEnd != 0},
|
|
}
|
|
}
|
|
|
|
func TestIncomingNewerThanTombstone(t *testing.T) {
|
|
base := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
|
|
delAt := pgtype.Timestamptz{Time: base, Valid: true}
|
|
lastMod := pgtype.Timestamptz{Time: base.Add(-time.Minute), Valid: true}
|
|
|
|
tests := []struct {
|
|
name string
|
|
incoming time.Time
|
|
deleted pgtype.Timestamptz
|
|
lastMod pgtype.Timestamptz
|
|
want bool
|
|
}{
|
|
{"newer than tombstone resurrects", base.Add(time.Hour), delAt, lastMod, true},
|
|
{"older than tombstone is a stale replay", base.Add(-time.Hour), delAt, lastMod, false},
|
|
{"missing timestamp never resurrects", time.Time{}, delAt, lastMod, false},
|
|
{"exactly equal does not resurrect", base, delAt, lastMod, false},
|
|
{"last_modified newer than deleted_at wins", base.Add(30 * time.Minute), delAt, pgtype.Timestamptz{Time: base.Add(90 * time.Minute), Valid: true}, false},
|
|
{"invalid timestamps compare against deleted_at", base.Add(time.Hour), delAt, pgtype.Timestamptz{}, true},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := incomingNewerThanTombstone(tt.incoming, tt.deleted, tt.lastMod); got != tt.want {
|
|
t.Errorf("incomingNewerThanTombstone() = %v, want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|