packages.wenpai.net/internal/integration/smoke_test.go
Ben Word 8287dc5528
Add metadata changes feed (#51)
* Add metadata changes feed

Implements GET /metadata/changes.json so mirrors, indexers, and other
tools can track incremental metadata updates without crawling and
reprocessing full repository metadata.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:45:31 -05:00

281 lines
8.2 KiB
Go

//go:build integration
package integration
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/roots/wp-packages/internal/app"
"github.com/roots/wp-packages/internal/config"
apphttp "github.com/roots/wp-packages/internal/http"
"github.com/roots/wp-packages/internal/packagist"
"github.com/roots/wp-packages/internal/repository"
"github.com/roots/wp-packages/internal/testutil"
"github.com/roots/wp-packages/internal/wporg"
)
func TestSmoke(t *testing.T) {
// --- Setup ---
fixtureDir := filepath.Join("..", "wporg", "testdata")
mock := wporg.NewMockServer(fixtureDir)
defer mock.Close()
db := testutil.OpenTestDB(t)
testutil.SeedFromFixtures(t, db, mock.URL)
// --- Start app server (unstarted to get URL before build) ---
cfg := &config.Config{
Env: "test",
}
a := &app.App{
Config: cfg,
DB: db,
Logger: testLogger(t),
Packagist: packagist.NewStubCache(),
}
router := apphttp.NewRouter(a)
srv := httptest.NewUnstartedServer(router)
srv.Start()
defer srv.Close()
// Set AppURL to the real app server so notify-batch points there
cfg.AppURL = srv.URL
// --- Build ---
buildDir := t.TempDir()
buildsDir := filepath.Join(buildDir, "builds")
repoDir := buildDir
result, err := repository.Build(t.Context(), db, repository.BuildOpts{
OutputDir: buildsDir,
AppURL: srv.URL,
Force: true,
Logger: testLogger(t),
})
if err != nil {
t.Fatalf("build failed: %v", err)
}
if result.PackagesTotal == 0 {
t.Fatal("build produced zero packages")
}
// Symlink current -> build
currentLink := filepath.Join(repoDir, "current")
if err := os.Symlink(filepath.Join("builds", result.BuildID), currentLink); err != nil {
t.Fatalf("creating current symlink: %v", err)
}
// Serve repository files from the build directory
actualBuildDir := filepath.Join(buildsDir, result.BuildID)
repoServer := httptest.NewServer(http.FileServer(http.Dir(actualBuildDir)))
defer repoServer.Close()
// --- Composer metadata endpoints ---
t.Run("packages.json", func(t *testing.T) {
body := httpGet(t, repoServer.URL+"/packages.json")
var pkgJSON map[string]any
if err := json.Unmarshal([]byte(body), &pkgJSON); err != nil {
t.Fatalf("invalid packages.json: %v", err)
}
// notify-batch URL should point to the app server
if nb, ok := pkgJSON["notify-batch"].(string); !ok || nb != srv.URL+"/downloads" {
t.Errorf("notify-batch = %q, want %q", nb, srv.URL+"/downloads")
}
// metadata-url
if mu, ok := pkgJSON["metadata-url"].(string); !ok || mu != "/p2/%package%.json" {
t.Errorf("unexpected metadata-url: %v", pkgJSON["metadata-url"])
}
// metadata-changes-url
if mcu, ok := pkgJSON["metadata-changes-url"].(string); !ok || mcu != srv.URL+"/metadata/changes.json" {
t.Errorf("metadata-changes-url = %q, want %q", mcu, srv.URL+"/metadata/changes.json")
}
// providers-url and provider-includes should NOT exist (v1 removed)
if _, ok := pkgJSON["providers-url"]; ok {
t.Error("packages.json should not contain providers-url")
}
if _, ok := pkgJSON["provider-includes"]; ok {
t.Error("packages.json should not contain provider-includes")
}
})
t.Run("p2 endpoint", func(t *testing.T) {
body := httpGet(t, repoServer.URL+"/p2/wp-plugin/akismet.json")
var data map[string]any
if err := json.Unmarshal([]byte(body), &data); err != nil {
t.Fatalf("invalid p2 response: %v", err)
}
pkgs, ok := data["packages"].(map[string]any)
if !ok {
t.Fatal("missing 'packages' key in p2 response")
}
akismet, ok := pkgs["wp-plugin/akismet"].(map[string]any)
if !ok {
t.Fatal("missing wp-plugin/akismet in packages")
}
// Check at least one version entry
if len(akismet) == 0 {
t.Fatal("no version entries for akismet")
}
// Verify version entry structure
for ver, entry := range akismet {
e, ok := entry.(map[string]any)
if !ok {
t.Fatalf("version %s is not an object", ver)
}
// Required fields
for _, field := range []string{"name", "version", "type", "dist", "source", "require"} {
if _, ok := e[field]; !ok {
t.Errorf("version %s missing field: %s", ver, field)
}
}
// dist URL should point to downloads.wordpress.org
if dist, ok := e["dist"].(map[string]any); ok {
if url, ok := dist["url"].(string); ok {
if !strings.Contains(url, "downloads.wordpress.org") {
t.Errorf("version %s dist URL unexpected: %s", ver, url)
}
}
}
// type should be wordpress-plugin
if typ, ok := e["type"].(string); ok {
if typ != "wordpress-plugin" {
t.Errorf("version %s type: got %s, want wordpress-plugin", ver, typ)
}
}
break // checking one entry is sufficient
}
})
t.Run("p directory should not exist", func(t *testing.T) {
resp, err := http.Get(repoServer.URL + "/p/wp-plugin/akismet.json")
if err != nil {
t.Fatalf("GET p/ endpoint: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == 200 {
t.Error("p/ directory should not exist after dropping Composer v1 support")
}
})
t.Run("package detail page", func(t *testing.T) {
resp, err := http.Get(srv.URL + "/packages/wp-plugin/akismet")
if err != nil {
t.Fatalf("GET package detail: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != 200 {
t.Errorf("package detail status: got %d, want 200", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), "composer require") {
t.Error("package detail page missing 'composer require'")
}
})
t.Run("package detail 404", func(t *testing.T) {
resp, err := http.Get(srv.URL + "/packages/wp-plugin/nonexistent")
if err != nil {
t.Fatalf("GET nonexistent package: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != 404 {
t.Errorf("nonexistent package status: got %d, want 404", resp.StatusCode)
}
})
// --- Composer install ---
t.Run("composer install", func(t *testing.T) {
composerPath, err := exec.LookPath("composer")
if err != nil {
t.Skip("composer not in PATH")
}
dir := t.TempDir()
writeComposerJSON(t, dir, repoServer.URL, map[string]string{
"wp-plugin/akismet": "*",
"wp-plugin/classic-editor": "*",
"wp-theme/astra": "*",
})
out := runComposer(t, composerPath, dir, "install", "--no-interaction", "--no-progress")
for _, pkg := range []string{"wp-plugin/akismet", "wp-plugin/classic-editor", "wp-theme/astra"} {
if !strings.Contains(out, pkg) {
t.Errorf("composer install output missing %s", pkg)
}
}
})
t.Run("install events recorded", func(t *testing.T) {
if _, err := exec.LookPath("composer"); err != nil {
t.Skip("composer not in PATH — install events require composer install to run first")
}
var count int
if err := db.QueryRow("SELECT COUNT(*) FROM install_events").Scan(&count); err != nil {
t.Fatalf("querying install_events: %v", err)
}
if count == 0 {
t.Error("no install events recorded — notify-batch may not be reaching the app server")
}
})
t.Run("composer version pinning", func(t *testing.T) {
composerPath, err := exec.LookPath("composer")
if err != nil {
t.Skip("composer not in PATH")
}
dir := t.TempDir()
writeComposerJSON(t, dir, repoServer.URL, map[string]string{
"wp-plugin/akismet": "5.3.3",
})
out := runComposer(t, composerPath, dir, "install", "--no-interaction", "--no-progress")
if !strings.Contains(out, "5.3.3") {
t.Errorf("composer install did not lock pinned version 5.3.3, output: %s", out)
}
})
// --- Build integrity ---
t.Run("build integrity", func(t *testing.T) {
manifestPath := filepath.Join(actualBuildDir, "manifest.json")
data, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatalf("reading manifest: %v", err)
}
var manifest map[string]any
if err := json.Unmarshal(data, &manifest); err != nil {
t.Fatalf("invalid manifest: %v", err)
}
if _, ok := manifest["root_hash"]; !ok {
t.Error("manifest missing root_hash")
}
if count, ok := manifest["artifact_count"].(float64); !ok || count == 0 {
t.Error("manifest missing or zero artifact_count")
}
// Run full integrity validation
errs := repository.ValidateIntegrity(actualBuildDir)
if len(errs) > 0 {
for _, e := range errs {
t.Errorf("integrity error: %s", e)
}
}
})
}