package api
import (
"errors"
"strings"
"testing"
"craftdesign.group/quarry/internal/ops"
)
func (g uvRig) releases(t *testing.T) releaseListOut {
t.Helper()
out, err := g.r.Invoke(as("reader", "o"), "release.list", releaseListIn{Repo: "test"})
if err != nil {
t.Fatalf("FAIL: release.list: %v", err)
}
return out.(releaseListOut)
}
func releaseNames(l releaseListOut) []string {
var names []string
for _, r := range l.Releases {
names = append(names, r.Name)
}
return names
}
// Folders that read as versions are ordered as versions, newest first,
// whatever order their files were written in; a release candidate sits
// below its release. Loose files under releases/ and files outside it
// make no group, and a removed file is not listed.
func TestReleasesGroupByFolderNewestVersionFirst(t *testing.T) {
g := newUVRig(t)
g.bind(1 << 20)
pub := as("pub", "y")
for _, f := range []struct {
name string
mtime int64
}{
{"releases/0.2.0/quarry-0.2.0.tar.gz", 100},
{"releases/0.10.0/quarry-0.10.0.tar.gz", 50},
{"releases/0.9.1/quarry-0.9.1.tar.gz", 200},
{"releases/0.10.0-rc1/quarry.tar.gz", 300},
{"releases/0.9.1/gone.bin", 10},
{"releases/README", 400},
{"download.html", 500},
} {
if _, err := g.put(pub, f.name, f.mtime, f.name); err != nil {
t.Fatalf("FAIL: put %s: %v", f.name, err)
}
}
if _, err := g.r.Invoke(pub, "uv.rm", uvRmIn{Repo: "test", Name: "releases/0.9.1/gone.bin", Mtime: 20}); err != nil {
t.Fatal(err)
}
l := g.releases(t)
if got := strings.Join(releaseNames(l), " "); got != "0.10.0 0.10.0-rc1 0.9.1 0.2.0" {
t.Errorf("FAIL: order = %s", got)
}
for i, r := range l.Releases {
if r.Newest != (i == 0) {
t.Errorf("FAIL: %s newest = %v", r.Name, r.Newest)
}
for _, f := range r.Files {
if f.Deleted || f.Name == "releases/0.9.1/gone.bin" {
t.Errorf("FAIL: %s lists the removed %s", r.Name, f.Name)
}
}
}
if len(l.Releases) == 4 && (len(l.Releases[2].Files) != 1 || l.Releases[2].Mtime != 200) {
t.Errorf("FAIL: 0.9.1 = %+v", l.Releases[2])
}
t.Log("ok: versions order newest first, one newest, removed and loose files left out")
}
// Folder names that are not all versions fall back to the newest file
// in each group.
func TestReleasesFallBackToDate(t *testing.T) {
g := newUVRig(t)
g.bind(1 << 20)
pub := as("pub", "y")
for name, mtime := range map[string]int64{
"releases/nightly/a.bin": 10, "releases/0.1.0/a.bin": 5,
"releases/beta/a.bin": 2, "releases/beta/b.bin": 20,
} {
if _, err := g.put(pub, name, mtime, name); err != nil {
t.Fatal(err)
}
}
if got := strings.Join(releaseNames(g.releases(t)), " "); got != "beta nightly 0.1.0" {
t.Errorf("FAIL: order = %s", got)
}
t.Log("ok: unversioned folder names order by their newest file")
}
const zitManifest = `{
"artifacts": [
{"binary_sha256": "bb", "bytes": 1234, "name": "zit-0.4.0-linux-x86_64.tar.gz",
"qualification": "passed", "sha256": "aa11", "target": "linux-x86_64"},
{"binary_sha256": "cc", "bytes": 99, "name": "zit-0.4.0-macos-aarch64.tar.gz",
"qualification": "passed", "sha256": "bb22", "target": "macos-aarch64"}
],
"omitted_targets": ["windows-x86_64", {"target": "freebsd", "reason": "no runner"}],
"qualification": {"macos-aarch64": ["unit", "e2e"], "linux-x86_64": ["unit", "dst seed 1 x1000"]},
"qualification_source": {"bytes": 1, "files": 2, "name": "src", "sha256": "x", "source_tree_sha256": "y"},
"release": {"date": "2026-09-30", "fossil_check_in": "abcdef0123456789", "fossil_tag": "version-0.4.0", "version": "0.4.0"},
"schema_version": 1,
"source": {"bytes": 555, "fossil_check_in": "abcdef0123456789", "name": "zit-0.4.0-source.tar.gz",
"sha256": "dd44", "target": "source"}
}`
// A group holding release-manifest.json is described by it: zit's
// schema, read through the bucket. Without one, SHA256SUMS is named; a
// manifest that does not parse says so and leaves the files listed.
func TestReleaseManifestIsRead(t *testing.T) {
g := newUVRig(t)
g.bind(1 << 20)
pub := as("pub", "y")
for name, body := range map[string]string{
"releases/0.4.0/release-manifest.json": zitManifest,
"releases/0.4.0/zit-0.4.0-linux-x86_64.tar.gz": "bin",
"releases/0.4.0/SHA256SUMS": "aa11 zit-0.4.0-linux-x86_64.tar.gz\n",
"releases/0.3.0/zit-0.3.0-linux-x86_64.tar.gz": "old",
"releases/0.3.0/SHA256SUMS": "ff zit-0.3.0-linux-x86_64.tar.gz\n",
"releases/0.2.0/release-manifest.json": "{not json",
"releases/0.2.0/zit-0.2.0-linux-x86_64.tar.gz": "older",
} {
if _, err := g.put(pub, name, 1, body); err != nil {
t.Fatal(err)
}
}
l := g.releases(t)
if len(l.Releases) != 3 {
t.Fatalf("FAIL: groups = %v", releaseNames(l))
}
m := l.Releases[0].Manifest
if m == nil {
t.Fatalf("FAIL: 0.4.0 has no manifest: %+v", l.Releases[0])
}
if m.Version != "0.4.0" || m.Date != "2026-09-30" || m.Checkin != "abcdef0123456789" || m.Tag != "version-0.4.0" {
t.Errorf("FAIL: release fields = %+v", m)
}
if len(m.Artifacts) != 2 || m.Artifacts[0].Target != "linux-x86_64" || m.Artifacts[0].Size != 1234 ||
m.Artifacts[0].SHA256 != "aa11" || m.Artifacts[0].Qualification != "passed" ||
m.Artifacts[0].File != "releases/0.4.0/zit-0.4.0-linux-x86_64.tar.gz" || m.Artifacts[1].File != "" {
t.Errorf("FAIL: artifacts = %+v", m.Artifacts)
}
if m.Source == nil || m.Source.SHA256 != "dd44" || m.Source.Size != 555 {
t.Errorf("FAIL: source = %+v", m.Source)
}
if len(m.Omitted) != 2 || m.Omitted[0] != (releaseOmitted{Target: "windows-x86_64"}) ||
m.Omitted[1] != (releaseOmitted{Target: "freebsd", Reason: "no runner"}) {
t.Errorf("FAIL: omitted = %q", m.Omitted)
}
if len(m.Qualification) != 2 || m.Qualification[0].Target != "linux-x86_64" ||
strings.Join(m.Qualification[0].Notes, ",") != "unit,dst seed 1 x1000" {
t.Errorf("FAIL: qualification = %+v", m.Qualification)
}
if l.Releases[0].Sums != "releases/0.4.0/SHA256SUMS" {
t.Errorf("FAIL: 0.4.0 sums = %q", l.Releases[0].Sums)
}
if r := l.Releases[1]; r.Manifest != nil || r.Sums != "releases/0.3.0/SHA256SUMS" || len(r.Files) != 2 {
t.Errorf("FAIL: 0.3.0 = %+v", r)
}
if r := l.Releases[2]; r.Manifest != nil || r.ManifestErr == "" || len(r.Files) != 2 {
t.Errorf("FAIL: 0.2.0 = %+v", r)
}
t.Log("ok: the manifest is read in zit's schema, SHA256SUMS named, a bad manifest said so")
}
// The changelog section is the one whose heading names the release as
// a word of its own, so 0.2 does not claim 0.2.0's notes.
func TestChangelogSectionNamesTheRelease(t *testing.T) {
md := "# Changelog\n\n## Unreleased\n\nnext\n\n## 0.2.0 — 2026-10-01\n\nthe second\n\n### Fixed\n\nthings\n\n## v0.1.0\n\nthe first\n"
heading, body, ok := changelogSection([]byte(md), "0.2.0")
if !ok || heading != "0.2.0 — 2026-10-01" || !strings.Contains(body, "the second") ||
!strings.Contains(body, "### Fixed") || strings.Contains(body, "the first") || strings.Contains(body, "## 0.2.0") {
t.Errorf("FAIL: 0.2.0 = %q %q %v", heading, body, ok)
}
if heading, body, ok := changelogSection([]byte(md), "0.1.0"); !ok || heading != "v0.1.0" || !strings.Contains(body, "the first") {
t.Errorf("FAIL: 0.1.0 = %q %q %v", heading, body, ok)
}
if _, _, ok := changelogSection([]byte(md), "0.2"); ok {
t.Errorf("FAIL: 0.2 matched")
}
t.Log("ok: the section is the one whose heading names the release")
}
// The bar's question and the Files page's block come from the catalog
// alone: no bucket read, ordered as the releases page orders them.
func TestReleaseSummaryFromTheCatalog(t *testing.T) {
g := newUVRig(t)
d := Deps{Repos: NewRepos(map[string]*Repo{"test": {}}), Buckets: g.buckets, UV: g.db}
if d.hasReleases("test") {
t.Errorf("FAIL: releases before any file")
}
g.bind(1 << 20)
pub := as("pub", "y")
for name, mtime := range map[string]int64{"releases/0.9.0/a.bin": 50, "releases/0.10.0/a.bin": 10, "download.html": 90} {
if _, err := g.put(pub, name, mtime, name); err != nil {
t.Fatal(err)
}
}
if !d.hasReleases("test") {
t.Errorf("FAIL: no releases with two folders")
}
lists, gets := g.objects.lists, g.objects.gets
out, err := g.r.Invoke(as("reader", "o"), "release.summary", releaseSummaryIn{Repo: "test"})
if s := out.(releaseSummaryOut); err != nil || s.Count != 2 || s.Latest != "0.10.0" || s.Mtime != 10 {
t.Errorf("FAIL: summary = %+v %v", s, err)
}
if g.objects.lists != lists || g.objects.gets != gets {
t.Errorf("FAIL: the summary touched the bucket")
}
if _, err := g.r.Invoke(pub, "uv.rm", uvRmIn{Repo: "test", Name: "releases/0.10.0/a.bin", Mtime: 20}); err != nil {
t.Fatal(err)
}
if out, _ := g.r.Invoke(as("reader", "o"), "release.summary", releaseSummaryIn{Repo: "test"}); out.(releaseSummaryOut).Latest != "0.9.0" {
t.Errorf("FAIL: a removed release is still the latest")
}
if l := g.list(t); l.Limit != 1<<20 {
t.Errorf("FAIL: uv.list limit = %d", l.Limit)
}
t.Log("ok: the bar and the block read the catalog alone")
}
// With a name, release.list answers that release alone, still marked
// newest when it is; a name that is a path is refused.
func TestReleaseListByName(t *testing.T) {
g := newUVRig(t)
g.bind(1 << 20)
pub := as("pub", "y")
for _, n := range []string{"releases/1.0.0/a.bin", "releases/2.0.0/a.bin"} {
if _, err := g.put(pub, n, 1, n); err != nil {
t.Fatal(err)
}
}
for name, newest := range map[string]bool{"2.0.0": true, "1.0.0": false} {
out, err := g.r.Invoke(as("reader", "o"), "release.list", releaseListIn{Repo: "test", Name: name})
if l := out.(releaseListOut); err != nil || len(l.Releases) != 1 || l.Releases[0].Name != name || l.Releases[0].Newest != newest {
t.Errorf("FAIL: %s = %+v %v", name, out, err)
}
}
if _, err := g.r.Invoke(as("reader", "o"), "release.list", releaseListIn{Repo: "test", Name: "3.0.0"}); !errors.Is(err, ops.ErrNotFound) {
t.Errorf("FAIL: a missing release = %v", err)
}
for _, bad := range []string{".", "..", "1.0.0/a.bin", `a\b`} {
if _, err := g.r.Invoke(as("reader", "o"), "release.list", releaseListIn{Repo: "test", Name: bad}); !errors.Is(err, ops.ErrInvalid) {
t.Errorf("FAIL: name %q = %v", bad, err)
}
}
t.Log("ok: one release by its folder name, paths refused")
}
// A manifest is read from the bucket once per content: the second page
// view is served from the cache, and a replaced manifest is read again.
func TestReleaseManifestIsCachedByContent(t *testing.T) {
g := newUVRig(t)
g.bind(1 << 20)
pub := as("pub", "y")
if _, err := g.put(pub, "releases/9.0.0/release-manifest.json", 1, zitManifest); err != nil {
t.Fatal(err)
}
g.releases(t)
before := g.objects.gets
if m := g.releases(t).Releases[0].Manifest; m == nil || g.objects.gets != before {
t.Errorf("FAIL: second view read the bucket again (%d -> %d) or lost the manifest", before, g.objects.gets)
}
if _, err := g.put(pub, "releases/9.0.0/release-manifest.json", 2, strings.Replace(zitManifest, "2026-09-30", "2026-10-01", 1)); err != nil {
t.Fatal(err)
}
if m := g.releases(t).Releases[0].Manifest; m == nil || m.Date != "2026-10-01" || g.objects.gets != before+1 {
t.Errorf("FAIL: a replaced manifest was not read again: %+v gets %d", m, g.objects.gets)
}
t.Log("ok: one bucket read per manifest content")
}
// An oversize manifest is not fetched and says so; the files stay
// listed. A check-in that is not a hash is dropped, so nothing links it.
func TestReleaseManifestBoundsAndCheckin(t *testing.T) {
g := newUVRig(t)
g.bind(2 << 20)
pub := as("pub", "y")
big := `{"schema_version": 1, "pad": "` + strings.Repeat("x", releaseManifestMax) + `"}`
if _, err := g.put(pub, "releases/2.0/release-manifest.json", 1, big); err != nil {
t.Fatal(err)
}
if _, err := g.put(pub, "releases/2.0/a.bin", 1, "a"); err != nil {
t.Fatal(err)
}
odd := strings.NewReplacer(`"fossil_check_in": "abcdef0123456789"`, `"fossil_check_in": "../../admin"`).Replace(zitManifest)
if _, err := g.put(pub, "releases/1.0/release-manifest.json", 1, odd); err != nil {
t.Fatal(err)
}
gets := g.objects.gets
l := g.releases(t)
if r := l.Releases[0]; r.Manifest != nil || !strings.Contains(r.ManifestErr, "bytes") || len(r.Files) != 2 {
t.Errorf("FAIL: oversize = %+v", r)
}
if g.objects.gets != gets+1 {
t.Errorf("FAIL: %d reads; the oversize manifest was fetched", g.objects.gets-gets)
}
if m := l.Releases[1].Manifest; m == nil || m.Checkin != "" {
t.Errorf("FAIL: a non-hash check-in survived: %+v", m)
}
t.Log("ok: oversize manifests are refused unread, odd check-ins dropped")
}
// A catalog with release files but no bucket of its own has no
// releases anywhere: the bar, the Files box and the list agree.
func TestReleasesNeedTheBindingEverywhere(t *testing.T) {
g := newUVRig(t)
g.bind(1 << 20)
if _, err := g.put(as("pub", "y"), "releases/1.0/a.bin", 1, "a"); err != nil {
t.Fatal(err)
}
delete(g.buckets.rows, "test")
d := Deps{Repos: NewRepos(map[string]*Repo{"test": {}}), Buckets: g.buckets, UV: g.db}
if d.hasReleases("test") {
t.Errorf("FAIL: the bar offers releases with no bucket")
}
out, err := g.r.Invoke(as("reader", "o"), "release.summary", releaseSummaryIn{Repo: "test"})
if s := out.(releaseSummaryOut); err != nil || s.Count != 0 || s.Latest != "" {
t.Errorf("FAIL: summary with no bucket = %+v %v", s, err)
}
if l := g.releases(t); l.Bound || len(l.Releases) != 0 {
t.Errorf("FAIL: list with no bucket = %+v", l)
}
t.Log("ok: no bucket, no releases, in all three")
}
// A manifest whose bytes are not the catalog's is remembered as such,
// not fetched again on every view.
func TestManifestMismatchIsCached(t *testing.T) {
g := newUVRig(t)
g.bind(1 << 20)
if _, err := g.put(as("pub", "y"), "releases/7.7.7/release-manifest.json", 1, zitManifest+" "); err != nil {
t.Fatal(err)
}
g.objects.mu.Lock()
o := g.objects.objects["files/unversioned/test/releases/7.7.7/release-manifest.json"]
o.body = []byte(zitManifest)
g.objects.objects["files/unversioned/test/releases/7.7.7/release-manifest.json"] = o
g.objects.mu.Unlock()
if r := g.releases(t).Releases[0]; r.Manifest != nil || r.ManifestErr == "" {
t.Fatalf("FAIL: a mismatched manifest was used: %+v", r)
}
gets := g.objects.gets
if r := g.releases(t).Releases[0]; r.ManifestErr == "" || g.objects.gets != gets {
t.Errorf("FAIL: the mismatch was fetched again (%d reads)", g.objects.gets-gets)
}
t.Log("ok: a mismatch is cached under the claimed hash")
}