#150: address covers by bytes; ReplaceSeriesCover

This commit is contained in:
2026-08-22 09:05:53 +07:00
parent 889f0f3f38
commit b9fc83217d
8 changed files with 338 additions and 77 deletions
+128 -12
View File
@@ -850,7 +850,7 @@ func TestSetSeriesCoverDoesNotOverwrite(t *testing.T) {
if err != nil || !ok {
t.Fatalf("Get = %v, %v", ok, err)
}
if want := "https://bookmarks.test/covers/" + CoverAddress(first); got.Cover != want {
if want := "https://bookmarks.test/covers/" + CoverAddressForBytes([]byte("first")); got.Cover != want {
t.Fatalf("Cover = %q, want the first one %q", got.Cover, want)
}
}
@@ -869,7 +869,7 @@ func TestCoverByAddress(t *testing.T) {
t.Fatalf("SetSeriesCover: %v", err)
}
body, contentType, ok, err := store.CoverByAddress(CoverAddress(source))
body, contentType, ok, err := store.CoverByAddress(CoverAddressForBytes([]byte("bytes")))
if err != nil || !ok {
t.Fatalf("CoverByAddress = %v, %v", ok, err)
}
@@ -877,8 +877,8 @@ func TestCoverByAddress(t *testing.T) {
t.Fatalf("CoverByAddress = %q, %q, want the stored bytes", body, contentType)
}
for _, address := range []string{"", "../../etc/passwd", "ZZ" + CoverAddress(source)[2:],
CoverAddress("never stored")} {
for _, address := range []string{"", "../../etc/passwd", "ZZ" + CoverAddressForBytes([]byte("bytes"))[2:],
CoverAddressForBytes([]byte("never stored"))} {
_, _, ok, err := store.CoverByAddress(address)
if err != nil || ok {
t.Fatalf("CoverByAddress(%q) = %v, %v, want a clean miss", address, ok, err)
@@ -913,7 +913,7 @@ func TestUpsertExistingSeriesIgnoresClientTitleCoverURL(t *testing.T) {
if err != nil {
t.Fatalf("Upsert: %v", err)
}
wantCover := "https://bookmarks.test/covers/" + CoverAddress(acquired)
wantCover := "https://bookmarks.test/covers/" + CoverAddressForBytes([]byte("bytes"))
if got.Title != "Solo Leveling" || got.SeriesURL != "https://asurascans.com/comics/solo" ||
got.Cover != wantCover {
t.Fatalf("stored = %+v, want original title/url/cover kept", got)
@@ -981,7 +981,7 @@ func TestDeleteKeepsSeriesRow(t *testing.T) {
if err != nil {
t.Fatalf("re-upsert: %v", err)
}
wantCover := "https://bookmarks.test/covers/" + CoverAddress(acquired)
wantCover := "https://bookmarks.test/covers/" + CoverAddressForBytes([]byte("bytes"))
if stored.Title != "Solo Leveling" || stored.Cover != wantCover {
t.Fatalf("re-bookmark = %+v, want title/cover from the surviving series row", stored)
}
@@ -1498,9 +1498,9 @@ func TestCoverPersistsAcrossReopen(t *testing.T) {
t.Fatalf("reopen: %v", err)
}
defer second.Close()
got, contentType, ok, err := second.GetCover(sourceURL)
got, contentType, ok, err := second.CoverByAddress(CoverAddressForBytes(body))
if err != nil {
t.Fatalf("GetCover: %v", err)
t.Fatalf("CoverByAddress: %v", err)
}
if !ok || !bytes.Equal(got, body) || contentType != "image/webp" {
t.Fatalf("stored cover = (%q, %q, %v), want (%q, image/webp, true)", got, contentType, ok, body)
@@ -1539,7 +1539,7 @@ func TestCoverIsContentAddressedOnFilesystem(t *testing.T) {
}
defer first.Close()
addressBytes := sha256.Sum256([]byte(sourceURL))
addressBytes := sha256.Sum256(body)
address := hex.EncodeToString(addressBytes[:])
wantPath := filepath.Join(address[:2], address[2:4], address)
@@ -1570,9 +1570,9 @@ func TestCoverStoreAcceptsAnySourceURL(t *testing.T) {
if err := s.PutCover(sourceURL, want, "image/jpeg"); err != nil {
t.Fatalf("PutCover: %v", err)
}
got, contentType, ok, err := s.GetCover(sourceURL)
got, contentType, ok, err := s.CoverByAddress(CoverAddressForBytes(want))
if err != nil {
t.Fatalf("GetCover: %v", err)
t.Fatalf("CoverByAddress: %v", err)
}
if !ok || !bytes.Equal(got, want) || contentType != "image/jpeg" {
t.Fatalf("GetCover = (%q, %q, %v), want (%q, image/jpeg, true)", got, contentType, ok, want)
@@ -1580,7 +1580,7 @@ func TestCoverStoreAcceptsAnySourceURL(t *testing.T) {
if err := s.PutCover("https://cdn.example/not-image", []byte("html"), "text/html"); err == nil {
t.Fatal("PutCover accepted a non-image")
}
if _, _, ok, err := s.GetCover("https://cdn.example/not-image"); err != nil || ok {
if _, _, ok, err := s.CoverByAddress(CoverAddressForBytes([]byte("html"))); err != nil || ok {
t.Fatalf("rejected cover = found %v, err %v; want missing", ok, err)
}
}
@@ -1899,3 +1899,119 @@ func TestDueForLatestCheckForcedDoesNotOverrideURLOrJoin(t *testing.T) {
t.Fatalf("due = %v, want neither the URL-less nor the orphan series", due)
}
}
// --- Cover addressing (ADR-0014): the address is the bytes' SHA-256 ---
// The address is what makes a re-art visible at all, so the same bytes must
// always name the same address and different bytes different ones — and the
// address must keep the 64-hex-digit shape CoverByAddress's guard still checks
// before any request-supplied value becomes a filesystem path.
func TestCoverAddressForBytesIsDeterministicAndDistinct(t *testing.T) {
first := CoverAddressForBytes([]byte("art"))
again := CoverAddressForBytes([]byte("art"))
other := CoverAddressForBytes([]byte("artwork"))
if first != again {
t.Fatalf("same bytes gave %q then %q, want one address", first, again)
}
if first == other {
t.Fatalf("different bytes gave the same address %q", first)
}
if !coverAddressRe.MatchString(first) {
t.Fatalf("address %q is not the 64-hex-digit shape the serving guard checks", first)
}
}
// ReplaceSeriesCover is the forced-replacement installer: it moves a Cover
// whether or not one exists, writes the source URL alongside it, and reports
// the three outcomes the Forced Poll has to tell apart.
func TestReplaceSeriesCover(t *testing.T) {
store := newTestStore(t)
if _, err := store.Upsert(store.OwnerID(), Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo", UpdatedAt: 1000,
}); err != nil {
t.Fatalf("seed: %v", err)
}
// A blank Cover: previous is "", and the row points at the new bytes.
previous, current, err := store.ReplaceSeriesCover("asura", "solo",
"https://cdn.asurascans.com/covers/solo.webp", []byte("first-art"), "image/webp")
if err != nil {
t.Fatalf("ReplaceSeriesCover on a blank: %v", err)
}
if previous != "" {
t.Fatalf("previous on a blank = %q, want empty", previous)
}
if want := CoverAddressForBytes([]byte("first-art")); current != want {
t.Fatalf("current = %q, want %q", current, want)
}
if sr := readSeries(t, store, "asura", "solo"); sr.CoverAddress != current ||
sr.Cover != "https://cdn.asurascans.com/covers/solo.webp" {
t.Fatalf("series after blank fill = %+v, want the new address and source URL", sr)
}
// A re-art: previous is the stranded address, current the new one.
previous, current, err = store.ReplaceSeriesCover("asura", "solo",
"https://cdn.asurascans.com/covers/solo-rebrand.webp", []byte("second-art"), "image/jpeg")
if err != nil {
t.Fatalf("ReplaceSeriesCover over a filled Cover: %v", err)
}
if want := CoverAddressForBytes([]byte("first-art")); previous != want {
t.Fatalf("previous = %q, want the replaced address %q", previous, want)
}
if want := CoverAddressForBytes([]byte("second-art")); current != want {
t.Fatalf("current = %q, want %q", current, want)
}
if sr := readSeries(t, store, "asura", "solo"); sr.CoverAddress != current ||
sr.Cover != "https://cdn.asurascans.com/covers/solo-rebrand.webp" {
t.Fatalf("series after replacement = %+v, want the new address and source URL", sr)
}
// The replaced bytes stay served under their old address; nothing reclaims
// them in this ticket (the forced-poll wave does).
if _, _, ok, err := store.CoverByAddress(CoverAddressForBytes([]byte("first-art"))); err != nil || !ok {
t.Fatalf("superseded bytes = found %v, err %v, want still served", ok, err)
}
// The Site is serving the same artwork again: previous == current is the
// honest no-op the caller reports as "unchanged".
previous, current, err = store.ReplaceSeriesCover("asura", "solo",
"https://cdn.asurascans.com/covers/solo-rebrand.webp", []byte("second-art"), "image/jpeg")
if err != nil {
t.Fatalf("ReplaceSeriesCover over identical bytes: %v", err)
}
if previous != current {
t.Fatalf("identical bytes: previous = %q, current = %q, want one address", previous, current)
}
if want := CoverAddressForBytes([]byte("second-art")); current != want {
t.Fatalf("current = %q, want %q", current, want)
}
}
// Rows written before byte addressing hold the hash of their source URL and
// are never rehashed: GetCover — the poller's heal path — keeps resolving
// them through coverSourceAddress.
func TestGetCoverResolvesLegacyURLDerivedAddress(t *testing.T) {
store := newTestStore(t)
source := "https://cdn.example/legacy.jpg"
legacy := coverSourceAddress(source)
relativePath := coverRelativePath(legacy)
coverPath := filepath.Join(store.coverDir, filepath.FromSlash(relativePath))
if err := os.MkdirAll(filepath.Dir(coverPath), 0o755); err != nil {
t.Fatalf("create shard dir: %v", err)
}
if err := os.WriteFile(coverPath, []byte("legacy-bytes"), 0o644); err != nil {
t.Fatalf("write legacy file: %v", err)
}
if _, err := store.db.Exec(
`INSERT INTO covers (address, path, content_type) VALUES ($1, $2, $3)`,
legacy, relativePath, "image/jpeg"); err != nil {
t.Fatalf("plant legacy row: %v", err)
}
body, contentType, ok, err := store.GetCover(source)
if err != nil || !ok {
t.Fatalf("GetCover on a legacy row = %v, %v, want found", ok, err)
}
if string(body) != "legacy-bytes" || contentType != "image/jpeg" {
t.Fatalf("legacy cover = (%q, %q), want the planted bytes", body, contentType)
}
}