Artifact 59ed1828537eed4c1e72bdd072749c404a797bbb958b37979ade05871a107a64:
- File
internal/uv/uv_test.go
— part of check-in
[d33a37fffd]
at
2026-10-12 23:24:45
on branch main
— fix(sync): apply received unversioned files as fossil does (#308) (#361)
fossil's xfer_accept_unversioned_file stores a received file under the HASH its card gave, takes only the mtime when the content is the same and newer (status 2), and stores an omitted payload as NULL with size 0; send_unversioned_file sends nothing for a name it does not hold; and the client seeds uv_tosend from rows whose hash is not NULL (sections 7.3, 8.5.1). Both sides stored a received file under a hash computed by local policy and dropped status 2 and omitted-content cards; the server answered an absent name with a tombstone; the client queued its tombstones.
uv.Receive now owns the acceptance rule, which the server and the client share instead of each re-deriving it from uv.Read, uv.Status and uv.Write. (user: [email protected], size: 6564) [annotate] [blame] [check-ins using]
package uv import ( "path/filepath" "testing" "github.com/danmestas/go-libfossil/db" _ "github.com/danmestas/go-libfossil/internal/testdriver" ) // openTestDB creates a temporary repo DB with schema for testing. func openTestDB(t *testing.T) *db.DB { t.Helper() path := filepath.Join(t.TempDir(), "test.fossil") d, err := db.Open(path) if err != nil { t.Fatalf("db.Open: %v", err) } if err := db.CreateRepoSchema(d); err != nil { d.Close() t.Fatalf("CreateRepoSchema: %v", err) } if err := EnsureSchema(d); err != nil { d.Close() t.Fatalf("EnsureSchema: %v", err) } t.Cleanup(func() { d.Close() }) return d } func TestEnsureSchema(t *testing.T) { d := openTestDB(t) // Calling twice should be idempotent. if err := EnsureSchema(d); err != nil { t.Fatalf("second EnsureSchema: %v", err) } } func TestWriteAndRead(t *testing.T) { d := openTestDB(t) if err := Write(d, "test.txt", []byte("hello world"), 1700000000); err != nil { t.Fatalf("Write: %v", err) } content, mtime, hash, err := Read(d, "test.txt") if err != nil { t.Fatalf("Read: %v", err) } if string(content) != "hello world" { t.Errorf("content = %q, want %q", content, "hello world") } if mtime != 1700000000 { t.Errorf("mtime = %d, want %d", mtime, 1700000000) } if hash == "" { t.Error("hash should not be empty") } } func TestWriteOverwrite(t *testing.T) { d := openTestDB(t) Write(d, "f.txt", []byte("v1"), 100) Write(d, "f.txt", []byte("v2"), 200) content, mtime, _, err := Read(d, "f.txt") if err != nil { t.Fatalf("Read: %v", err) } if string(content) != "v2" || mtime != 200 { t.Errorf("got content=%q mtime=%d, want v2/200", content, mtime) } } func TestDeleteAndRead(t *testing.T) { d := openTestDB(t) Write(d, "test.txt", []byte("hello"), 100) if err := Delete(d, "test.txt", 200); err != nil { t.Fatalf("Delete: %v", err) } content, mtime, hash, err := Read(d, "test.txt") if err != nil { t.Fatalf("Read: %v", err) } if content != nil { t.Errorf("content should be nil for tombstone, got %q", content) } if mtime != 200 { t.Errorf("mtime = %d, want 200", mtime) } if hash != "" { t.Errorf("hash should be empty for tombstone, got %q", hash) } } func TestReadNonExistent(t *testing.T) { d := openTestDB(t) content, mtime, hash, err := Read(d, "nope.txt") if err != nil { t.Fatalf("Read: %v", err) } if content != nil || mtime != 0 || hash != "" { t.Errorf("expected zero values for non-existent file") } } func TestList(t *testing.T) { d := openTestDB(t) Write(d, "a.txt", []byte("aaa"), 100) Write(d, "b.txt", []byte("bbb"), 200) Delete(d, "c.txt", 300) entries, err := List(d) if err != nil { t.Fatalf("List: %v", err) } if len(entries) != 3 { t.Fatalf("len(entries) = %d, want 3", len(entries)) } } func TestContentHashEmpty(t *testing.T) { d := openTestDB(t) h, err := ContentHash(d) if err != nil { t.Fatalf("ContentHash: %v", err) } // SHA1 of empty string if h != "da39a3ee5e6b4b0d3255bfef95601890afd80709" { t.Errorf("empty hash = %q, want da39a3ee...", h) } } func TestContentHashDeterministic(t *testing.T) { d := openTestDB(t) Write(d, "b.txt", []byte("bbb"), 200) Write(d, "a.txt", []byte("aaa"), 100) h1, _ := ContentHash(d) h2, _ := ContentHash(d) if h1 != h2 { t.Errorf("ContentHash not deterministic: %q != %q", h1, h2) } } func TestContentHashExcludesTombstones(t *testing.T) { d := openTestDB(t) Write(d, "a.txt", []byte("aaa"), 100) h1, _ := ContentHash(d) Delete(d, "b.txt", 200) // tombstone should not affect hash h2, _ := ContentHash(d) if h1 != h2 { t.Errorf("tombstone changed hash: %q != %q", h1, h2) } } func TestContentHashChangesOnWrite(t *testing.T) { d := openTestDB(t) Write(d, "a.txt", []byte("v1"), 100) h1, _ := ContentHash(d) Write(d, "a.txt", []byte("v2"), 200) h2, _ := ContentHash(d) if h1 == h2 { t.Error("hash should change after write") } } func TestInvalidateHash(t *testing.T) { d := openTestDB(t) Write(d, "a.txt", []byte("aaa"), 100) h1, _ := ContentHash(d) // caches // Manually update without going through Write (simulating external change). d.Exec("UPDATE unversioned SET mtime=999 WHERE name='a.txt'") InvalidateHash(d) h2, _ := ContentHash(d) if h1 == h2 { t.Error("hash should differ after invalidate + mtime change") } } // TestReceive pins ยง7.3's handling of a received uvfile, as fossil's // xfer_accept_unversioned_file applies it: the row keeps the HASH the card // gave, the same content at a later mtime moves only the mtime, and omitted // content is stored as NULL with size 0. func TestReceive(t *testing.T) { d := openTestDB(t) sha1Name := "2aae6c35c94fcfb415dbe95f408b9ce91ee846ed" // SHA1("hello world") if err := Receive(d, "a.txt", 100, sha1Name, []byte("hello world")); err != nil { t.Fatalf("Receive: %v", err) } content, mtime, got, err := Read(d, "a.txt") if err != nil || got != sha1Name || mtime != 100 || string(content) != "hello world" { t.Fatalf("a.txt = %q, %d, %q, %v; want the card's HASH", content, mtime, got, err) } if err := Receive(d, "a.txt", 200, sha1Name, nil); err != nil { t.Fatalf("Receive status 2: %v", err) } content, mtime, _, _ = Read(d, "a.txt") if mtime != 200 || string(content) != "hello world" { t.Fatalf("after status 2: mtime %d, content %q; want 200 and the content kept", mtime, content) } if err := Receive(d, "w.txt", 100, sha1Name, nil); err != nil { t.Fatalf("Receive omitted: %v", err) } var sz int var isNull bool err = d.QueryRow("SELECT sz, content IS NULL FROM unversioned WHERE name='w.txt'"). Scan(&sz, &isNull) if err != nil || sz != 0 || !isNull { t.Fatalf("omitted content: sz %d, NULL %v, %v; want 0 and NULL", sz, isNull, err) } } // TestReceiveDeletionOfAbsentName pins that a received deletion for a name // the repository does not hold changes nothing, as fossil's UPDATE does, // while a local Delete still records a tombstone. func TestReceiveDeletionOfAbsentName(t *testing.T) { d := openTestDB(t) if err := Receive(d, "never.txt", 100, "-", nil); err != nil { t.Fatalf("Receive: %v", err) } var n int if err := d.QueryRow("SELECT count(*) FROM unversioned").Scan(&n); err != nil || n != 0 { t.Fatalf("rows after a received deletion of an absent name: %d, %v; want 0", n, err) } if err := Delete(d, "never.txt", 100); err != nil { t.Fatalf("Delete: %v", err) } if err := d.QueryRow("SELECT count(*) FROM unversioned").Scan(&n); err != nil || n != 1 { t.Fatalf("rows after a local Delete: %d, %v; want 1", n, err) } }