packages.wenpai.net/internal/repository/builder_test.go
Scott Walkinshaw 59ed9bc044
Add content_hash tracking and extract composer package (Phase 1) (#74)
Foundation for incremental sync: compute and store content hashes at
update time so the future sync step can diff against deployed_hash
to find what needs uploading to R2.

- Migration 024: add content_hash, deployed_hash, content_changed_at
  columns to packages table
- Extract internal/composer/ from internal/repository/ (format.go,
  serialize.go, hash.go) — pure functions for Composer serialization
  and hashing with no I/O
- Add SerializePackage() for tagged/dev file splitting and
  HashVersions() for lightweight content hashing
- Compute content_hash in update step after NormalizeAndStoreVersions;
  only advance content_changed_at when hash actually changes
- Hash includes trunk_revision so dev-trunk SVN reference changes
  trigger re-sync
- repository/ re-exports from composer/ for builder.go compatibility

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 09:47:15 -05:00

601 lines
20 KiB
Go

package repository
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"testing"
"github.com/roots/wp-packages/internal/db"
)
func setupTestDB(t *testing.T) *sql.DB {
t.Helper()
database, err := db.Open(":memory:")
if err != nil {
t.Fatalf("opening test db: %v", err)
}
_, err = database.Exec(`
CREATE TABLE packages (
id INTEGER PRIMARY KEY,
type TEXT NOT NULL CHECK(type IN ('plugin','theme')),
name TEXT NOT NULL,
display_name TEXT,
description TEXT,
author TEXT,
homepage TEXT,
slug_url TEXT,
versions_json TEXT NOT NULL DEFAULT '{}',
downloads INTEGER NOT NULL DEFAULT 0,
active_installs INTEGER NOT NULL DEFAULT 0,
current_version TEXT,
rating REAL,
num_ratings INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
last_committed TEXT,
last_synced_at TEXT,
last_sync_run_id INTEGER,
trunk_revision INTEGER,
content_hash TEXT,
deployed_hash TEXT,
content_changed_at TEXT,
wp_packages_installs_total INTEGER NOT NULL DEFAULT 0,
wp_packages_installs_30d INTEGER NOT NULL DEFAULT 0,
last_installed_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(type, name)
)`)
if err != nil {
t.Fatalf("creating table: %v", err)
}
t.Cleanup(func() { _ = database.Close() })
return database
}
func TestBuild(t *testing.T) {
database := setupTestDB(t)
// Insert test packages
_, _ = database.Exec(`INSERT INTO packages (type, name, display_name, versions_json, is_active, last_sync_run_id, created_at, updated_at)
VALUES ('plugin', 'akismet', 'Akismet',
'{"5.0":"https://downloads.wordpress.org/plugin/akismet.5.0.zip","4.0":"https://downloads.wordpress.org/plugin/akismet.4.0.zip"}',
1, 1, datetime('now'), datetime('now'))`)
_, _ = database.Exec(`INSERT INTO packages (type, name, display_name, versions_json, is_active, last_sync_run_id, created_at, updated_at)
VALUES ('theme', 'astra', 'Astra',
'{"4.0":"https://downloads.wordpress.org/theme/astra.4.0.zip"}',
1, 1, datetime('now'), datetime('now'))`)
tmpDir := t.TempDir()
result, err := Build(context.Background(), database, BuildOpts{
OutputDir: tmpDir,
AppURL: "https://app.example.com",
Logger: slog.Default(),
})
if err != nil {
t.Fatalf("build failed: %v", err)
}
if result.PackagesTotal != 2 {
t.Errorf("packages_total = %d, want 2", result.PackagesTotal)
}
// Verify packages.json exists and is valid
packagesPath := filepath.Join(result.BuildDir, "packages.json")
data, err := os.ReadFile(packagesPath)
if err != nil {
t.Fatalf("packages.json missing: %v", err)
}
var root map[string]any
if err := json.Unmarshal(data, &root); err != nil {
t.Fatalf("packages.json invalid: %v", err)
}
// Check notify-batch is absolute
notifyBatch, ok := root["notify-batch"].(string)
if !ok || notifyBatch != "https://app.example.com/downloads" {
t.Errorf("notify-batch = %q, want https://app.example.com/downloads", notifyBatch)
}
// packages.json should NOT contain provider-includes or providers-url
if _, ok := root["provider-includes"]; ok {
t.Error("packages.json should not contain provider-includes")
}
if _, ok := root["providers-url"]; ok {
t.Error("packages.json should not contain providers-url")
}
// Check metadata-url
if root["metadata-url"] != "/p2/%package%.json" {
t.Errorf("metadata-url = %q, want /p2/%%package%%.json", root["metadata-url"])
}
// Check p2 files exist
for _, path := range []string{"p2/wp-plugin/akismet.json", "p2/wp-theme/astra.json"} {
if _, err := os.Stat(filepath.Join(result.BuildDir, path)); err != nil {
t.Errorf("p2 file missing: %s", path)
}
}
// p/ directory should NOT exist
if _, err := os.Stat(filepath.Join(result.BuildDir, "p")); !os.IsNotExist(err) {
t.Error("p/ directory should not exist")
}
// Check manifest.json
manifestData, err := os.ReadFile(filepath.Join(result.BuildDir, "manifest.json"))
if err != nil {
t.Fatal("manifest.json missing")
}
var manifest map[string]any
_ = json.Unmarshal(manifestData, &manifest)
if manifest["packages_total"].(float64) != 2 {
t.Errorf("manifest packages_total = %v", manifest["packages_total"])
}
// Integrity validation should pass
errors := ValidateIntegrity(result.BuildDir)
if len(errors) > 0 {
t.Errorf("integrity validation failed: %v", errors)
}
}
func TestBuildParallelWrites(t *testing.T) {
database := setupTestDB(t)
// Insert many packages to exercise parallel writes
for i := 0; i < 20; i++ {
slug := fmt.Sprintf("plugin-%d", i)
_, _ = database.Exec(`INSERT INTO packages (type, name, display_name, versions_json, is_active, last_sync_run_id, created_at, updated_at)
VALUES ('plugin', ?, ?,
'{"1.0":"https://downloads.wordpress.org/plugin/test.1.0.zip"}',
1, 1, datetime('now'), datetime('now'))`, slug, slug)
}
tmpDir := t.TempDir()
result, err := Build(context.Background(), database, BuildOpts{
OutputDir: tmpDir,
Logger: slog.Default(),
})
if err != nil {
t.Fatalf("build failed: %v", err)
}
if result.PackagesTotal != 20 {
t.Errorf("packages_total = %d, want 20", result.PackagesTotal)
}
// Verify p2 files exist on disk
for i := 0; i < 20; i++ {
slug := fmt.Sprintf("plugin-%d", i)
p2Path := filepath.Join(result.BuildDir, "p2", "wp-plugin", slug+".json")
if _, err := os.Stat(p2Path); err != nil {
t.Errorf("p2 file missing: %s", p2Path)
}
}
}
func TestBuildDevTrunkSplit(t *testing.T) {
database := setupTestDB(t)
// Package with tagged versions
_, _ = database.Exec(`INSERT INTO packages (type, name, display_name, versions_json, is_active, last_sync_run_id, created_at, updated_at)
VALUES ('plugin', 'akismet', 'Akismet',
'{"5.0":"https://downloads.wordpress.org/plugin/akismet.5.0.zip"}',
1, 1, datetime('now'), datetime('now'))`)
// Package with no tagged versions (trunk-only in SVN)
_, _ = database.Exec(`INSERT INTO packages (type, name, display_name, versions_json, is_active, last_sync_run_id, created_at, updated_at)
VALUES ('plugin', 'trunk-only', 'Trunk Only',
'{}',
1, 1, datetime('now'), datetime('now'))`)
tmpDir := t.TempDir()
result, err := Build(context.Background(), database, BuildOpts{
OutputDir: tmpDir,
Logger: slog.Default(),
})
if err != nil {
t.Fatalf("build failed: %v", err)
}
if result.PackagesTotal != 2 {
t.Errorf("packages_total = %d, want 2", result.PackagesTotal)
}
// akismet should have both .json and ~dev.json
for _, path := range []string{"p2/wp-plugin/akismet.json", "p2/wp-plugin/akismet~dev.json"} {
if _, err := os.Stat(filepath.Join(result.BuildDir, path)); err != nil {
t.Errorf("file missing: %s", path)
}
}
// akismet.json should NOT contain dev-trunk
data, _ := os.ReadFile(filepath.Join(result.BuildDir, "p2/wp-plugin/akismet.json"))
var tagged map[string]any
_ = json.Unmarshal(data, &tagged)
pkgs := tagged["packages"].(map[string]any)
versions := pkgs["wp-plugin/akismet"].(map[string]any)
if _, ok := versions["dev-trunk"]; ok {
t.Error("akismet.json should not contain dev-trunk")
}
if _, ok := versions["5.0"]; !ok {
t.Error("akismet.json should contain version 5.0")
}
// akismet~dev.json should contain dev-trunk with source but no dist
devData, _ := os.ReadFile(filepath.Join(result.BuildDir, "p2/wp-plugin/akismet~dev.json"))
var dev map[string]any
_ = json.Unmarshal(devData, &dev)
devPkgs := dev["packages"].(map[string]any)
devVersions := devPkgs["wp-plugin/akismet"].(map[string]any)
devTrunk := devVersions["dev-trunk"].(map[string]any)
if _, ok := devTrunk["source"]; !ok {
t.Error("dev-trunk should have source")
}
if _, ok := devTrunk["dist"]; ok {
t.Error("dev-trunk should not have dist (unversioned zip is not reproducible)")
}
source := devTrunk["source"].(map[string]any)
if source["reference"] != "trunk" {
t.Errorf("dev-trunk reference = %q, want trunk (no revision set)", source["reference"])
}
// trunk-only should have both .json (with dev-trunk) and ~dev.json
for _, path := range []string{"p2/wp-plugin/trunk-only.json", "p2/wp-plugin/trunk-only~dev.json"} {
if _, err := os.Stat(filepath.Join(result.BuildDir, path)); err != nil {
t.Errorf("file missing: %s", path)
}
}
// Integrity validation should pass
errors := ValidateIntegrity(result.BuildDir)
if len(errors) > 0 {
t.Errorf("integrity validation failed: %v", errors)
}
}
func TestBuildDevTrunkRevision(t *testing.T) {
database := setupTestDB(t)
// Plugin with trunk_revision set
_, _ = database.Exec(`INSERT INTO packages (type, name, display_name, versions_json, is_active, trunk_revision, last_sync_run_id, created_at, updated_at)
VALUES ('plugin', 'akismet', 'Akismet',
'{"5.0":"https://downloads.wordpress.org/plugin/akismet.5.0.zip"}',
1, 3470087, 1, datetime('now'), datetime('now'))`)
// Plugin without trunk_revision (cold start)
_, _ = database.Exec(`INSERT INTO packages (type, name, display_name, versions_json, is_active, last_sync_run_id, created_at, updated_at)
VALUES ('plugin', 'no-rev', 'No Rev',
'{"1.0":"https://downloads.wordpress.org/plugin/no-rev.1.0.zip"}',
1, 1, datetime('now'), datetime('now'))`)
tmpDir := t.TempDir()
result, err := Build(context.Background(), database, BuildOpts{
OutputDir: tmpDir,
Logger: slog.Default(),
})
if err != nil {
t.Fatalf("build failed: %v", err)
}
if result.PackagesTotal != 2 {
t.Errorf("packages_total = %d, want 2", result.PackagesTotal)
}
// akismet~dev.json should have trunk@3470087 reference
devData, _ := os.ReadFile(filepath.Join(result.BuildDir, "p2/wp-plugin/akismet~dev.json"))
var dev map[string]any
_ = json.Unmarshal(devData, &dev)
devPkgs := dev["packages"].(map[string]any)
devVersions := devPkgs["wp-plugin/akismet"].(map[string]any)
devTrunk := devVersions["dev-trunk"].(map[string]any)
source := devTrunk["source"].(map[string]any)
if source["reference"] != "trunk@3470087" {
t.Errorf("reference = %q, want trunk@3470087", source["reference"])
}
if _, ok := devTrunk["dist"]; ok {
t.Error("dev-trunk should not have dist")
}
// no-rev~dev.json should have plain "trunk" reference (cold start)
noRevData, _ := os.ReadFile(filepath.Join(result.BuildDir, "p2/wp-plugin/no-rev~dev.json"))
var noRev map[string]any
_ = json.Unmarshal(noRevData, &noRev)
noRevPkgs := noRev["packages"].(map[string]any)
noRevVersions := noRevPkgs["wp-plugin/no-rev"].(map[string]any)
noRevTrunk := noRevVersions["dev-trunk"].(map[string]any)
noRevSource := noRevTrunk["source"].(map[string]any)
if noRevSource["reference"] != "trunk" {
t.Errorf("reference = %q, want trunk (no revision)", noRevSource["reference"])
}
}
func TestBuildDeleteDetectionWithDevFiles(t *testing.T) {
database := setupTestDB(t)
tmpDir := t.TempDir()
// First build: one package with tagged + dev, one trunk-only
_, _ = database.Exec(`INSERT INTO packages (type, name, display_name, versions_json, is_active, last_sync_run_id, created_at, updated_at)
VALUES ('plugin', 'akismet', 'Akismet',
'{"5.0":"https://downloads.wordpress.org/plugin/akismet.5.0.zip","dev-trunk":"https://downloads.wordpress.org/plugin/akismet.zip"}',
1, 1, datetime('now'), datetime('now'))`)
_, _ = database.Exec(`INSERT INTO packages (type, name, display_name, versions_json, is_active, last_sync_run_id, created_at, updated_at)
VALUES ('plugin', 'removed-plugin', 'Removed',
'{"1.0":"https://downloads.wordpress.org/plugin/removed-plugin.1.0.zip","dev-trunk":"https://downloads.wordpress.org/plugin/removed-plugin.zip"}',
1, 1, datetime('now'), datetime('now'))`)
first, err := Build(context.Background(), database, BuildOpts{
OutputDir: tmpDir,
BuildID: "build-1",
Logger: slog.Default(),
})
if err != nil {
t.Fatalf("first build failed: %v", err)
}
// Deactivate removed-plugin before second build
_, _ = database.Exec(`UPDATE packages SET is_active = 0 WHERE name = 'removed-plugin'`)
second, err := Build(context.Background(), database, BuildOpts{
OutputDir: tmpDir,
BuildID: "build-2",
Logger: slog.Default(),
PreviousBuildDir: first.BuildDir,
})
if err != nil {
t.Fatalf("second build failed: %v", err)
}
// Should have exactly one delete for wp-plugin/removed-plugin (not a duplicate, not ~dev suffix)
var deletes []PackageChange
for _, c := range second.ChangedPackages {
if c.Action == "delete" {
deletes = append(deletes, c)
}
}
if len(deletes) != 1 {
t.Errorf("expected 1 delete, got %d: %v", len(deletes), deletes)
}
if len(deletes) == 1 && deletes[0].Name != "wp-plugin/removed-plugin" {
t.Errorf("delete name = %q, want wp-plugin/removed-plugin", deletes[0].Name)
}
}
func TestBuildDeleteTrunkOnlyPackage(t *testing.T) {
database := setupTestDB(t)
tmpDir := t.TempDir()
// Trunk-only package (only ~dev.json, no .json)
_, _ = database.Exec(`INSERT INTO packages (type, name, display_name, versions_json, is_active, last_sync_run_id, created_at, updated_at)
VALUES ('plugin', 'trunk-pkg', 'Trunk',
'{"dev-trunk":"https://downloads.wordpress.org/plugin/trunk-pkg.zip"}',
1, 1, datetime('now'), datetime('now'))`)
first, err := Build(context.Background(), database, BuildOpts{
OutputDir: tmpDir,
BuildID: "build-1",
Logger: slog.Default(),
})
if err != nil {
t.Fatalf("first build failed: %v", err)
}
// Remove trunk-only package
_, _ = database.Exec(`UPDATE packages SET is_active = 0 WHERE name = 'trunk-pkg'`)
second, err := Build(context.Background(), database, BuildOpts{
OutputDir: tmpDir,
BuildID: "build-2",
Logger: slog.Default(),
PreviousBuildDir: first.BuildDir,
})
if err != nil {
t.Fatalf("second build failed: %v", err)
}
var deletes []PackageChange
for _, c := range second.ChangedPackages {
if c.Action == "delete" {
deletes = append(deletes, c)
}
}
if len(deletes) != 1 {
t.Fatalf("expected 1 delete, got %d: %v", len(deletes), deletes)
}
if deletes[0].Name != "wp-plugin/trunk-pkg" {
t.Errorf("delete name = %q, want wp-plugin/trunk-pkg", deletes[0].Name)
}
}
func TestBuildTaggedToDevOnlyNoFalseDelete(t *testing.T) {
database := setupTestDB(t)
tmpDir := t.TempDir()
// Package starts with tagged + dev versions
_, _ = database.Exec(`INSERT INTO packages (type, name, display_name, versions_json, is_active, last_sync_run_id, created_at, updated_at)
VALUES ('plugin', 'migrating', 'Migrating',
'{"1.0":"https://downloads.wordpress.org/plugin/migrating.1.0.zip","dev-trunk":"https://downloads.wordpress.org/plugin/migrating.zip"}',
1, 1, datetime('now'), datetime('now'))`)
first, err := Build(context.Background(), database, BuildOpts{
OutputDir: tmpDir,
BuildID: "build-1",
Logger: slog.Default(),
})
if err != nil {
t.Fatalf("first build failed: %v", err)
}
// Transition to dev-only (tagged versions removed, e.g. author untagged)
_, _ = database.Exec(`UPDATE packages SET versions_json = '{"dev-trunk":"https://downloads.wordpress.org/plugin/migrating.zip"}' WHERE name = 'migrating'`)
second, err := Build(context.Background(), database, BuildOpts{
OutputDir: tmpDir,
BuildID: "build-2",
Logger: slog.Default(),
PreviousBuildDir: first.BuildDir,
})
if err != nil {
t.Fatalf("second build failed: %v", err)
}
// Should NOT emit a delete — the package still exists via ~dev.json
for _, c := range second.ChangedPackages {
if c.Action == "delete" {
t.Errorf("unexpected delete for %s — package still exists as dev-only", c.Name)
}
}
}
func TestBuildDevOnlyChangeDetection(t *testing.T) {
database := setupTestDB(t)
tmpDir := t.TempDir()
// First build
_, _ = database.Exec(`INSERT INTO packages (type, name, display_name, versions_json, is_active, last_sync_run_id, created_at, updated_at)
VALUES ('plugin', 'mixed', 'Mixed',
'{"1.0":"https://downloads.wordpress.org/plugin/mixed.1.0.zip","dev-trunk":"https://downloads.wordpress.org/plugin/mixed.zip"}',
1, 1, datetime('now'), datetime('now'))`)
first, err := Build(context.Background(), database, BuildOpts{
OutputDir: tmpDir,
BuildID: "build-1",
Logger: slog.Default(),
})
if err != nil {
t.Fatalf("first build failed: %v", err)
}
// Second build with same data — nothing changed
second, err := Build(context.Background(), database, BuildOpts{
OutputDir: tmpDir,
BuildID: "build-2",
Logger: slog.Default(),
PreviousBuildDir: first.BuildDir,
})
if err != nil {
t.Fatalf("second build failed: %v", err)
}
if second.PackagesChanged != 0 {
t.Errorf("expected 0 changes, got %d", second.PackagesChanged)
}
// Third build: change only the dev version (simulate trunk update via last_committed)
_, _ = database.Exec(`UPDATE packages SET last_committed = '2099-01-01T00:00:00Z' WHERE name = 'mixed'`)
third, err := Build(context.Background(), database, BuildOpts{
OutputDir: tmpDir,
BuildID: "build-3",
Logger: slog.Default(),
PreviousBuildDir: second.BuildDir,
})
if err != nil {
t.Fatalf("third build failed: %v", err)
}
// The dev file changed (different "time" field), so package should be marked changed
if third.PackagesChanged != 1 {
t.Errorf("expected 1 change, got %d", third.PackagesChanged)
}
// Should not be duplicated
updateCount := 0
for _, c := range third.ChangedPackages {
if c.Name == "wp-plugin/mixed" && c.Action == "update" {
updateCount++
}
}
if updateCount != 1 {
t.Errorf("expected 1 update entry for wp-plugin/mixed, got %d", updateCount)
}
}
func TestBuildThemeNoTaggedVersions(t *testing.T) {
database := setupTestDB(t)
// Theme with no tagged versions — should be skipped entirely
_, _ = database.Exec(`INSERT INTO packages (type, name, display_name, versions_json, is_active, last_sync_run_id, created_at, updated_at)
VALUES ('theme', 'empty-theme', 'Empty Theme',
'{}',
1, 1, datetime('now'), datetime('now'))`)
// Plugin with no tagged versions — should still get .json and ~dev.json
_, _ = database.Exec(`INSERT INTO packages (type, name, display_name, versions_json, is_active, last_sync_run_id, created_at, updated_at)
VALUES ('plugin', 'empty-plugin', 'Empty Plugin',
'{}',
1, 1, datetime('now'), datetime('now'))`)
// Theme with tagged versions — should work normally (no ~dev.json)
_, _ = database.Exec(`INSERT INTO packages (type, name, display_name, versions_json, is_active, last_sync_run_id, created_at, updated_at)
VALUES ('theme', 'astra', 'Astra',
'{"4.0":"https://downloads.wordpress.org/theme/astra.4.0.zip"}',
1, 1, datetime('now'), datetime('now'))`)
tmpDir := t.TempDir()
result, err := Build(context.Background(), database, BuildOpts{
OutputDir: tmpDir,
Logger: slog.Default(),
})
if err != nil {
t.Fatalf("build failed: %v", err)
}
// Only plugin and astra should be counted (empty theme skipped)
if result.PackagesTotal != 2 {
t.Errorf("packages_total = %d, want 2", result.PackagesTotal)
}
// Empty theme should have no files
if _, err := os.Stat(filepath.Join(result.BuildDir, "p2/wp-theme/empty-theme.json")); !os.IsNotExist(err) {
t.Error("empty-theme.json should not exist")
}
if _, err := os.Stat(filepath.Join(result.BuildDir, "p2/wp-theme/empty-theme~dev.json")); !os.IsNotExist(err) {
t.Error("empty-theme~dev.json should not exist")
}
// Astra should have .json but no ~dev.json
if _, err := os.Stat(filepath.Join(result.BuildDir, "p2/wp-theme/astra.json")); err != nil {
t.Errorf("astra.json missing: %v", err)
}
if _, err := os.Stat(filepath.Join(result.BuildDir, "p2/wp-theme/astra~dev.json")); !os.IsNotExist(err) {
t.Error("astra~dev.json should not exist")
}
// Empty plugin should have both .json (with dev-trunk) and ~dev.json
if _, err := os.Stat(filepath.Join(result.BuildDir, "p2/wp-plugin/empty-plugin.json")); err != nil {
t.Errorf("empty-plugin.json missing: %v", err)
}
if _, err := os.Stat(filepath.Join(result.BuildDir, "p2/wp-plugin/empty-plugin~dev.json")); err != nil {
t.Errorf("empty-plugin~dev.json missing: %v", err)
}
// Integrity should pass
errors := ValidateIntegrity(result.BuildDir)
if len(errors) > 0 {
t.Errorf("integrity validation failed: %v", errors)
}
}
func TestBuildEmpty(t *testing.T) {
database := setupTestDB(t)
tmpDir := t.TempDir()
result, err := Build(context.Background(), database, BuildOpts{
OutputDir: tmpDir,
Logger: slog.Default(),
})
if err != nil {
t.Fatalf("build failed: %v", err)
}
if result.PackagesTotal != 0 {
t.Errorf("expected 0 packages, got %d", result.PackagesTotal)
}
}