diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml
new file mode 100644
index 0000000..52dd7ca
--- /dev/null
+++ b/.github/workflows/canary.yml
@@ -0,0 +1,24 @@
+name: canary
+
+on:
+ schedule:
+ - cron: "0 6 * * *" # daily at 6am UTC
+ workflow_dispatch:
+
+jobs:
+ live:
+ name: Live wp.org Canary
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ - uses: actions/setup-go@v6
+ with:
+ go-version-file: go.mod
+ - uses: shivammathur/setup-php@v2
+ with:
+ php-version: "8.2"
+ tools: composer:v2
+ - name: Create stub CSS for embed
+ run: touch internal/http/static/assets/styles/app.css
+ - name: Live Canary Test
+ run: go test -tags=wporg_live -count=1 -timeout=5m -v ./internal/integration/...
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9a853c8..2edaec1 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -97,3 +97,20 @@ jobs:
run: make tailwind
- name: Test
run: go test -v ./...
+
+ integration:
+ name: Integration Test
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ - uses: actions/setup-go@v6
+ with:
+ go-version-file: go.mod
+ - uses: shivammathur/setup-php@v2
+ with:
+ php-version: "8.2"
+ tools: composer:v2
+ - name: Create stub CSS for embed
+ run: touch internal/http/static/assets/styles/app.css
+ - name: Integration Test
+ run: go test -tags=integration -count=1 -timeout=5m -v ./internal/integration/...
diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml
deleted file mode 100644
index f61bd8c..0000000
--- a/.github/workflows/smoke-test.yml
+++ /dev/null
@@ -1,45 +0,0 @@
-name: smoke-test
-
-on:
- schedule:
- - cron: "0 */6 * * *"
- workflow_dispatch:
-
-jobs:
- install:
- name: Composer Install
- runs-on: ubuntu-latest
- steps:
- - name: Set up PHP
- uses: shivammathur/setup-php@v2
- with:
- php-version: "8.2"
- tools: composer:v2
-
- - name: Create composer.json
- run: |
- cat > composer.json <<'EOF'
- {
- "repositories": [
- {
- "name": "wp-composer",
- "type": "composer",
- "url": "https://repo.wp-composer.com",
- "only": ["wp-plugin/*", "wp-theme/*"]
- }
- ],
- "require": {
- "composer/installers": "^2.2",
- "wp-plugin/woocommerce": "*",
- "wp-theme/twentytwentyfive": "*"
- },
- "config": {
- "allow-plugins": {
- "composer/installers": true
- }
- }
- }
- EOF
-
- - name: Composer install
- run: composer install --no-interaction --no-progress
diff --git a/Makefile b/Makefile
index 3cb106a..ff9c13c 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-.PHONY: build install dev test smoke lint clean tailwind
+.PHONY: build install dev test integration lint clean tailwind
TAILWIND ?= ./bin/tailwindcss
@@ -38,9 +38,9 @@ dev: tailwind-install
test:
go test ./...
-# End-to-end smoke test (requires composer, sqlite3)
-smoke: build
- ./test/smoke_test.sh
+# Integration tests (requires composer)
+integration:
+ go test -tags=integration -count=1 -timeout=5m -v ./internal/integration/...
# Lint (matches CI: golangci-lint + gofmt + go vet + go mod tidy)
lint:
diff --git a/go.mod b/go.mod
index a1a07ae..60a3419 100644
--- a/go.mod
+++ b/go.mod
@@ -9,6 +9,7 @@ require (
github.com/fogleman/gg v1.3.0
github.com/getsentry/sentry-go v0.43.0
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0
+ github.com/johannesboyne/gofakes3 v0.0.0-20260208201424-4c385a1f6a73
github.com/pressly/goose/v3 v3.27.0
github.com/spf13/cobra v1.10.2
golang.org/x/crypto v0.49.0
@@ -36,12 +37,15 @@ require (
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
+ go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
+ golang.org/x/tools v0.42.0 // indirect
modernc.org/libc v1.68.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
diff --git a/go.sum b/go.sum
index bbfc120..0012ea6 100644
--- a/go.sum
+++ b/go.sum
@@ -4,6 +4,8 @@ github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 h1:3kGOqnh1pPeddVa/
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
github.com/aws/aws-sdk-go-v2/credentials v1.19.12 h1:oqtA6v+y5fZg//tcTWahyN9PEn5eDU/Wpvc2+kJ4aY8=
github.com/aws/aws-sdk-go-v2/credentials v1.19.12/go.mod h1:U3R1RtSHx6NB0DvEQFGyf/0sbrpJrluENHdPy1j/3TE=
+github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.75 h1:S61/E3N01oral6B3y9hZ2E1iFDqCZPPOBoBQretCnBI=
+github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.75/go.mod h1:bDMQbkI1vJbNjnvJYpPTSNYBkI/VIv18ngWb/K84tkk=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 h1:CNXO7mvgThFGqOFgbNAP2nol2qAWBOGfqR/7tQlvLmc=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20/go.mod h1:oydPDJKcfMhgfcgBUZaG+toBbwy8yPWubJXBVERtI4o=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 h1:tN6W/hg+pkM+tf9XDkWUbDEjGLb+raoBMFsTodcoYKw=
@@ -22,6 +24,8 @@ github.com/aws/aws-sdk-go-v2/service/s3 v1.97.1 h1:csi9NLpFZXb9fxY7rS1xVzgPRGMt7
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.1/go.mod h1:qXVal5H0ChqXP63t6jze5LmFalc7+ZE7wOdLtZ0LCP0=
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
+github.com/cevatbarisyilmaz/ara v0.0.4 h1:SGH10hXpBJhhTlObuZzTuFn1rrdmjQImITXnZVPSodc=
+github.com/cevatbarisyilmaz/ara v0.0.4/go.mod h1:BfFOxnUd6Mj6xmcvRxHN3Sr21Z1T3U2MYkYOmoQe4Ts=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -46,6 +50,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/johannesboyne/gofakes3 v0.0.0-20260208201424-4c385a1f6a73 h1:0xkWp+RMC2ImuKacheMHEAtrbOTMOa0kYkxyzM1Z/II=
+github.com/johannesboyne/gofakes3 v0.0.0-20260208201424-4c385a1f6a73/go.mod h1:S4S9jGBVlLri0OeqrSSbCGG5vsI6he06UJyuz1WT1EE=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -69,14 +75,22 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qq
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 h1:GHRpF1pTW19a8tTFrMLUcfWwyC0pnifVo2ClaLq+hP8=
+github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
+github.com/spf13/afero v1.2.1 h1:qgMbHoJbPbw579P+1zVY+6n4nIFuIchaIjzZ/I/Yq8M=
+github.com/spf13/afero v1.2.1/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+go.etcd.io/bbolt v1.3.5 h1:XAzx9gjCb0Rxj7EoqcClPD1d5ZBxZJk0jbuoPHenBt0=
+go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
+go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d h1:Ns9kd1Rwzw7t0BR8XMphenji4SmIoNZPn8zhYmaVKP8=
+go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d/go.mod h1:92Uoe3l++MlthCm+koNi0tcUCX3anayogF0Pa/sp24k=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
@@ -102,6 +116,8 @@ golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce h1:xcEWjVhvbDy+nHP67nPDDpbYrY+ILlfndk4bRioVHaU=
+gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
diff --git a/internal/deploy/r2.go b/internal/deploy/r2.go
index a80317b..83e8a9c 100644
--- a/internal/deploy/r2.go
+++ b/internal/deploy/r2.go
@@ -548,6 +548,7 @@ func newS3Client(cfg config.R2Config) *s3.Client {
"",
),
BaseEndpoint: aws.String(cfg.Endpoint),
+ UsePathStyle: true,
})
}
diff --git a/internal/integration/helpers_test.go b/internal/integration/helpers_test.go
new file mode 100644
index 0000000..11e7433
--- /dev/null
+++ b/internal/integration/helpers_test.go
@@ -0,0 +1,74 @@
+//go:build integration || wporg_live
+
+package integration
+
+import (
+ "encoding/json"
+ "io"
+ "log/slog"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func testLogger(t *testing.T) *slog.Logger {
+ t.Helper()
+ return slog.Default()
+}
+
+func httpGet(t *testing.T, url string) string {
+ t.Helper()
+ resp, err := http.Get(url)
+ if err != nil {
+ t.Fatalf("GET %s: %v", url, err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != 200 {
+ t.Fatalf("GET %s: status %d", url, resp.StatusCode)
+ }
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ t.Fatalf("reading response from %s: %v", url, err)
+ }
+ return string(body)
+}
+
+func writeComposerJSON(t *testing.T, dir, repoURL string, require map[string]string) {
+ t.Helper()
+ data := map[string]any{
+ "repositories": []map[string]any{
+ {
+ "type": "composer",
+ "url": repoURL,
+ },
+ },
+ "require": require,
+ "config": map[string]any{
+ "allow-plugins": map[string]any{
+ "composer/installers": true,
+ },
+ "secure-http": false,
+ },
+ }
+ jsonData, err := json.MarshalIndent(data, "", " ")
+ if err != nil {
+ t.Fatalf("marshaling composer.json: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "composer.json"), jsonData, 0644); err != nil {
+ t.Fatalf("writing composer.json: %v", err)
+ }
+}
+
+func runComposer(t *testing.T, composerPath, dir string, args ...string) string {
+ t.Helper()
+ cmd := exec.Command(composerPath, args...)
+ cmd.Dir = dir
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("composer %s failed: %v\noutput: %s", strings.Join(args, " "), err, out)
+ }
+ return string(out)
+}
diff --git a/internal/integration/smoke_test.go b/internal/integration/smoke_test.go
new file mode 100644
index 0000000..cd5cacf
--- /dev/null
+++ b/internal/integration/smoke_test.go
@@ -0,0 +1,295 @@
+//go:build integration
+
+package integration
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/roots/wp-composer/internal/app"
+ "github.com/roots/wp-composer/internal/config"
+ apphttp "github.com/roots/wp-composer/internal/http"
+ "github.com/roots/wp-composer/internal/packagist"
+ "github.com/roots/wp-composer/internal/repository"
+ "github.com/roots/wp-composer/internal/testutil"
+ "github.com/roots/wp-composer/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)
+
+ // --- Build ---
+ buildDir := t.TempDir()
+ buildsDir := filepath.Join(buildDir, "builds")
+ repoDir := buildDir
+
+ result, err := repository.Build(t.Context(), db, repository.BuildOpts{
+ OutputDir: buildsDir,
+ AppURL: "http://test.local",
+ 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)
+ }
+
+ // --- Start HTTP server ---
+ cfg := &config.Config{
+ AppURL: "http://test.local",
+ Env: "test",
+ }
+ a := &app.App{
+ Config: cfg,
+ DB: db,
+ Logger: testLogger(t),
+ Packagist: packagist.NewStubCache(),
+ }
+
+ // Serve repository files from the build directory
+ actualBuildDir := filepath.Join(buildsDir, result.BuildID)
+ repoServer := httptest.NewServer(http.FileServer(http.Dir(actualBuildDir)))
+ defer repoServer.Close()
+
+ // Start app server
+ router := apphttp.NewRouter(a)
+ srv := httptest.NewServer(router)
+ defer srv.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
+ if nb, ok := pkgJSON["notify-batch"].(string); !ok || nb == "" {
+ t.Error("missing notify-batch URL")
+ }
+
+ // metadata-url
+ if mu, ok := pkgJSON["metadata-url"].(string); !ok || mu != "/p2/%package%.json" {
+ t.Errorf("unexpected metadata-url: %v", pkgJSON["metadata-url"])
+ }
+
+ // providers-url
+ if pu, ok := pkgJSON["providers-url"].(string); !ok || !strings.Contains(pu, "/p/") {
+ t.Errorf("unexpected providers-url: %v", pkgJSON["providers-url"])
+ }
+
+ // provider-includes
+ pi, ok := pkgJSON["provider-includes"].(map[string]any)
+ if !ok || len(pi) == 0 {
+ t.Error("missing or empty 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 content-addressed endpoint", func(t *testing.T) {
+ // Extract hash from packages.json provider-includes
+ 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)
+ }
+
+ pi := pkgJSON["provider-includes"].(map[string]any)
+ for providerPath := range pi {
+ // Fetch the provider file
+ providerBody := httpGet(t, repoServer.URL+"/"+providerPath)
+ var provider map[string]any
+ if err := json.Unmarshal([]byte(providerBody), &provider); err != nil {
+ t.Fatalf("invalid provider file %s: %v", providerPath, err)
+ }
+
+ providers, ok := provider["providers"].(map[string]any)
+ if !ok {
+ continue
+ }
+
+ // Find akismet and verify the content-addressed file exists
+ if akInfo, ok := providers["wp-plugin/akismet"]; ok {
+ info := akInfo.(map[string]any)
+ hash := info["sha256"].(string)
+ pPath := fmt.Sprintf("/p/wp-plugin/akismet$%s.json", hash)
+ pBody := httpGet(t, repoServer.URL+pPath)
+
+ // Should match p2 content
+ p2Body := httpGet(t, repoServer.URL+"/p2/wp-plugin/akismet.json")
+ if pBody != p2Body {
+ t.Error("p/ content-addressed file does not match p2/ file")
+ }
+ return
+ }
+ }
+ t.Error("could not find akismet in any provider-includes")
+ })
+
+ 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 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 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("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)
+ }
+ }
+ })
+}
diff --git a/internal/integration/sync_test.go b/internal/integration/sync_test.go
new file mode 100644
index 0000000..b2e250b
--- /dev/null
+++ b/internal/integration/sync_test.go
@@ -0,0 +1,177 @@
+//go:build integration
+
+package integration
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http/httptest"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/aws/aws-sdk-go-v2/aws"
+ "github.com/aws/aws-sdk-go-v2/credentials"
+ "github.com/aws/aws-sdk-go-v2/service/s3"
+ "github.com/johannesboyne/gofakes3"
+ "github.com/johannesboyne/gofakes3/backend/s3mem"
+ "github.com/roots/wp-composer/internal/config"
+ "github.com/roots/wp-composer/internal/deploy"
+ "github.com/roots/wp-composer/internal/repository"
+ "github.com/roots/wp-composer/internal/testutil"
+ "github.com/roots/wp-composer/internal/wporg"
+)
+
+func TestR2Sync(t *testing.T) {
+ ctx := context.Background()
+
+ // 1. Seed DB from fixtures
+ fixtureDir := filepath.Join("..", "wporg", "testdata")
+ mock := wporg.NewMockServer(fixtureDir)
+ defer mock.Close()
+
+ db := testutil.OpenTestDB(t)
+ testutil.SeedFromFixtures(t, db, mock.URL)
+
+ // 2. Build artifacts
+ buildOutputDir := filepath.Join(t.TempDir(), "builds")
+ result, err := repository.Build(ctx, db, repository.BuildOpts{
+ OutputDir: buildOutputDir,
+ AppURL: "http://test.local",
+ Force: true,
+ Logger: testLogger(t),
+ })
+ if err != nil {
+ t.Fatalf("build failed: %v", err)
+ }
+ buildDir := filepath.Join(buildOutputDir, result.BuildID)
+
+ // 3. Start gofakes3 in-process
+ backend := s3mem.New()
+ faker := gofakes3.New(backend)
+ ts := httptest.NewServer(faker.Server())
+ defer ts.Close()
+
+ // Create the bucket
+ s3Client := newTestS3Client(ts.URL)
+ _, err = s3Client.CreateBucket(ctx, &s3.CreateBucketInput{
+ Bucket: aws.String("test-bucket"),
+ })
+ if err != nil {
+ t.Fatalf("creating bucket: %v", err)
+ }
+
+ r2Cfg := config.R2Config{
+ AccessKeyID: "test",
+ SecretAccessKey: "test",
+ Bucket: "test-bucket",
+ Endpoint: ts.URL,
+ }
+
+ // 4. First sync — all packages uploaded
+ err = deploy.SyncToR2(ctx, r2Cfg, buildDir, result.BuildID, "", testLogger(t))
+ if err != nil {
+ t.Fatalf("first sync failed: %v", err)
+ }
+
+ // Verify packages.json exists in bucket
+ rootObj, err := s3Client.GetObject(ctx, &s3.GetObjectInput{
+ Bucket: aws.String("test-bucket"),
+ Key: aws.String("packages.json"),
+ })
+ if err != nil {
+ t.Fatalf("packages.json not found after sync: %v", err)
+ }
+ rootData, _ := io.ReadAll(rootObj.Body)
+ _ = rootObj.Body.Close()
+
+ var rootJSON map[string]any
+ if err := json.Unmarshal(rootData, &rootJSON); err != nil {
+ t.Fatalf("invalid packages.json: %v", err)
+ }
+ if rootJSON["build-id"] != result.BuildID {
+ t.Errorf("root packages.json build-id = %v, want %s", rootJSON["build-id"], result.BuildID)
+ }
+
+ // Verify p2/ files exist
+ p2Key := "p2/wp-plugin/akismet.json"
+ p2Obj, err := s3Client.GetObject(ctx, &s3.GetObjectInput{
+ Bucket: aws.String("test-bucket"),
+ Key: aws.String(p2Key),
+ })
+ if err != nil {
+ t.Fatalf("p2 file %s not found: %v", p2Key, err)
+ }
+ _ = p2Obj.Body.Close()
+
+ // Verify p/ content-addressed files exist
+ listResp, err := s3Client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{
+ Bucket: aws.String("test-bucket"),
+ Prefix: aws.String("p/"),
+ })
+ if err != nil {
+ t.Fatalf("listing p/ objects: %v", err)
+ }
+ pFileCount := 0
+ for _, obj := range listResp.Contents {
+ key := aws.ToString(obj.Key)
+ if strings.Contains(key, "$") {
+ pFileCount++
+ }
+ }
+ if pFileCount == 0 {
+ t.Error("no content-addressed p/ files found after sync")
+ }
+
+ // Verify release-prefixed index files exist
+ releaseKey := "releases/" + result.BuildID + "/packages.json"
+ relObj, err := s3Client.GetObject(ctx, &s3.GetObjectInput{
+ Bucket: aws.String("test-bucket"),
+ Key: aws.String(releaseKey),
+ })
+ if err != nil {
+ t.Fatalf("release packages.json not found: %v", err)
+ }
+ _ = relObj.Body.Close()
+
+ // Count total uploaded objects for the idempotency check
+ allResp, err := s3Client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{
+ Bucket: aws.String("test-bucket"),
+ })
+ if err != nil {
+ t.Fatalf("listing all objects: %v", err)
+ }
+ firstSyncCount := len(allResp.Contents)
+ t.Logf("first sync: %d objects in bucket", firstSyncCount)
+
+ // 5. Second sync — same build, nothing should change
+ // (pass buildDir as previousBuildDir so unchanged files are skipped)
+ err = deploy.SyncToR2(ctx, r2Cfg, buildDir, result.BuildID, buildDir, testLogger(t))
+ if err != nil {
+ t.Fatalf("second sync failed: %v", err)
+ }
+
+ // Object count should be the same (idempotent)
+ allResp2, err := s3Client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{
+ Bucket: aws.String("test-bucket"),
+ })
+ if err != nil {
+ t.Fatalf("listing all objects after second sync: %v", err)
+ }
+ secondSyncCount := len(allResp2.Contents)
+ if secondSyncCount != firstSyncCount {
+ t.Errorf("second sync changed object count: %d -> %d", firstSyncCount, secondSyncCount)
+ }
+}
+
+func newTestS3Client(endpoint string) *s3.Client {
+ return s3.New(s3.Options{
+ Region: "auto",
+ Credentials: credentials.NewStaticCredentialsProvider(
+ "test", "test", "",
+ ),
+ BaseEndpoint: aws.String(endpoint),
+ UsePathStyle: true,
+ })
+}
diff --git a/internal/integration/wporg_live_test.go b/internal/integration/wporg_live_test.go
new file mode 100644
index 0000000..1eb6601
--- /dev/null
+++ b/internal/integration/wporg_live_test.go
@@ -0,0 +1,196 @@
+//go:build wporg_live
+
+package integration
+
+import (
+ "encoding/json"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/roots/wp-composer/internal/app"
+ "github.com/roots/wp-composer/internal/config"
+ apphttp "github.com/roots/wp-composer/internal/http"
+ "github.com/roots/wp-composer/internal/packages"
+ "github.com/roots/wp-composer/internal/packagist"
+ "github.com/roots/wp-composer/internal/repository"
+ "github.com/roots/wp-composer/internal/testutil"
+ "github.com/roots/wp-composer/internal/wporg"
+)
+
+// TestWpOrgLive tests the full pipeline against the real WordPress.org API.
+// Run with: go test -tags=wporg_live -count=1 -timeout=5m ./internal/integration/...
+func TestWpOrgLive(t *testing.T) {
+ ctx := t.Context()
+ db := testutil.OpenTestDB(t)
+
+ cfg := config.DiscoveryConfig{
+ APITimeoutS: 30,
+ MaxRetries: 3,
+ RetryDelayMs: 1000,
+ Concurrency: 5,
+ }
+ client := wporg.NewClient(cfg, slog.Default())
+
+ // Discover + update a small set of packages against real wp.org
+ seeds := []struct {
+ slug string
+ pkgType string
+ }{
+ {"akismet", "plugin"},
+ {"classic-editor", "plugin"},
+ {"astra", "theme"},
+ }
+
+ for _, s := range seeds {
+ lastUpdated, err := client.FetchLastUpdated(ctx, s.pkgType, s.slug)
+ if err != nil {
+ t.Fatalf("discover %s: %v", s.slug, err)
+ }
+ if err := packages.UpsertShellPackage(ctx, db, s.pkgType, s.slug, lastUpdated); err != nil {
+ t.Fatalf("upsert shell %s: %v", s.slug, err)
+ }
+ }
+
+ syncRun, err := packages.AllocateSyncRunID(ctx, db)
+ if err != nil {
+ t.Fatalf("allocate sync run: %v", err)
+ }
+
+ pkgs, err := packages.GetPackagesNeedingUpdate(ctx, db, packages.UpdateQueryOpts{
+ Force: true,
+ Type: "all",
+ })
+ if err != nil {
+ t.Fatalf("get packages needing update: %v", err)
+ }
+
+ for _, p := range pkgs {
+ var data map[string]any
+ var fetchErr error
+ if p.Type == "plugin" {
+ data, fetchErr = client.FetchPlugin(ctx, p.Name)
+ } else {
+ data, fetchErr = client.FetchTheme(ctx, p.Name)
+ }
+ if fetchErr != nil {
+ t.Fatalf("fetch %s/%s: %v", p.Type, p.Name, fetchErr)
+ }
+
+ pkg := packages.PackageFromAPIData(data, p.Type)
+ pkg.ID = p.ID
+ if _, err := pkg.NormalizeAndStoreVersions(); err != nil {
+ t.Fatalf("normalize %s: %v", p.Name, err)
+ }
+ pkg.LastSyncRunID = &syncRun.RunID
+
+ if err := packages.UpsertPackage(ctx, db, pkg); err != nil {
+ t.Fatalf("upsert %s: %v", p.Name, err)
+ }
+ }
+
+ if err := packages.FinishSyncRun(ctx, db, syncRun.RowID, "completed", nil); err != nil {
+ t.Fatalf("finish sync run: %v", err)
+ }
+
+ // Build
+ buildDir := t.TempDir()
+ buildsDir := filepath.Join(buildDir, "builds")
+ result, err := repository.Build(ctx, db, repository.BuildOpts{
+ OutputDir: buildsDir,
+ AppURL: "http://test.local",
+ Force: true,
+ Logger: testLogger(t),
+ })
+ if err != nil {
+ t.Fatalf("build: %v", err)
+ }
+ if result.PackagesTotal == 0 {
+ t.Fatal("build produced zero packages")
+ }
+
+ // Symlink current
+ if err := os.Symlink(filepath.Join("builds", result.BuildID), filepath.Join(buildDir, "current")); err != nil {
+ t.Fatalf("symlink: %v", err)
+ }
+
+ // Serve repository
+ actualBuildDir := filepath.Join(buildsDir, result.BuildID)
+ repoServer := httptest.NewServer(http.FileServer(http.Dir(actualBuildDir)))
+ defer repoServer.Close()
+
+ // Start app server
+ appCfg := &config.Config{
+ AppURL: "http://test.local",
+ Env: "test",
+ }
+ a := &app.App{
+ Config: appCfg,
+ DB: db,
+ Logger: testLogger(t),
+ Packagist: packagist.NewStubCache(),
+ }
+ router := apphttp.NewRouter(a)
+ appSrv := httptest.NewServer(router)
+ defer appSrv.Close()
+
+ // Verify packages.json
+ 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)
+ }
+ if _, ok := pkgJSON["provider-includes"]; !ok {
+ t.Error("missing provider-includes")
+ }
+ })
+
+ // Verify p2 endpoint
+ 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)
+ }
+ if _, ok := data["packages"]; !ok {
+ t.Error("missing packages key")
+ }
+ })
+
+ // Verify integrity
+ t.Run("build integrity", func(t *testing.T) {
+ errs := repository.ValidateIntegrity(actualBuildDir)
+ if len(errs) > 0 {
+ for _, e := range errs {
+ t.Errorf("integrity error: %s", e)
+ }
+ }
+ })
+
+ // 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{"akismet", "classic-editor", "astra"} {
+ if !strings.Contains(out, pkg) {
+ t.Errorf("composer output missing %s", pkg)
+ }
+ }
+ })
+}
diff --git a/internal/packagist/downloads.go b/internal/packagist/downloads.go
index cb7d808..e0ad07e 100644
--- a/internal/packagist/downloads.go
+++ b/internal/packagist/downloads.go
@@ -23,6 +23,13 @@ func NewDownloadsCache(logger *slog.Logger) *DownloadsCache {
return c
}
+// NewStubCache returns a DownloadsCache that never fetches, for use in tests.
+func NewStubCache() *DownloadsCache {
+ c := &DownloadsCache{logger: slog.Default()}
+ c.value.Store(0)
+ return c
+}
+
func (c *DownloadsCache) Total() int64 {
return c.value.Load()
}
diff --git a/internal/testutil/testdb.go b/internal/testutil/testdb.go
new file mode 100644
index 0000000..815c9f2
--- /dev/null
+++ b/internal/testutil/testdb.go
@@ -0,0 +1,113 @@
+package testutil
+
+import (
+ "context"
+ "database/sql"
+ "log/slog"
+ "testing"
+ "time"
+
+ wpcomposergo "github.com/roots/wp-composer"
+ "github.com/roots/wp-composer/internal/config"
+ "github.com/roots/wp-composer/internal/db"
+ "github.com/roots/wp-composer/internal/packages"
+ "github.com/roots/wp-composer/internal/wporg"
+)
+
+// OpenTestDB opens an in-memory SQLite database and runs all migrations.
+func OpenTestDB(t *testing.T) *sql.DB {
+ t.Helper()
+ database, err := db.Open(":memory:")
+ if err != nil {
+ t.Fatalf("opening test db: %v", err)
+ }
+ t.Cleanup(func() { _ = database.Close() })
+
+ if err := db.Migrate(database, wpcomposergo.Migrations); err != nil {
+ t.Fatalf("running migrations: %v", err)
+ }
+ return database
+}
+
+// SeedFromFixtures runs the discover + update pipeline against a mock wp.org
+// server, populating the database with package data derived from fixtures.
+func SeedFromFixtures(t *testing.T, database *sql.DB, mockURL string) {
+ t.Helper()
+ ctx := context.Background()
+
+ cfg := config.DiscoveryConfig{
+ APITimeoutS: 5,
+ MaxRetries: 1,
+ RetryDelayMs: 10,
+ Concurrency: 2,
+ }
+ client := wporg.NewClient(cfg, slog.Default())
+ client.SetBaseURL(mockURL)
+
+ // Discover: fetch last_updated for each fixture slug and create shell records
+ type seed struct {
+ slug string
+ pkgType string
+ }
+ seeds := []seed{
+ {"akismet", "plugin"},
+ {"classic-editor", "plugin"},
+ {"contact-form-7", "plugin"},
+ {"astra", "theme"},
+ {"twentytwentyfive", "theme"},
+ }
+
+ for _, s := range seeds {
+ lastUpdated, err := client.FetchLastUpdated(ctx, s.pkgType, s.slug)
+ if err != nil {
+ t.Fatalf("fetching last_updated for %s: %v", s.slug, err)
+ }
+ if err := packages.UpsertShellPackage(ctx, database, s.pkgType, s.slug, lastUpdated); err != nil {
+ t.Fatalf("upserting shell package %s: %v", s.slug, err)
+ }
+ }
+
+ // Update: fetch full metadata and upsert
+ syncRun, err := packages.AllocateSyncRunID(ctx, database)
+ if err != nil {
+ t.Fatalf("allocating sync run: %v", err)
+ }
+
+ pkgs, err := packages.GetPackagesNeedingUpdate(ctx, database, packages.UpdateQueryOpts{
+ Force: true,
+ Type: "all",
+ })
+ if err != nil {
+ t.Fatalf("getting packages needing update: %v", err)
+ }
+
+ for _, p := range pkgs {
+ var data map[string]any
+ var fetchErr error
+ if p.Type == "plugin" {
+ data, fetchErr = client.FetchPlugin(ctx, p.Name)
+ } else {
+ data, fetchErr = client.FetchTheme(ctx, p.Name)
+ }
+ if fetchErr != nil {
+ t.Fatalf("fetching %s/%s: %v", p.Type, p.Name, fetchErr)
+ }
+
+ pkg := packages.PackageFromAPIData(data, p.Type)
+ pkg.ID = p.ID
+ if _, err := pkg.NormalizeAndStoreVersions(); err != nil {
+ t.Fatalf("normalizing versions for %s: %v", p.Name, err)
+ }
+ now := time.Now().UTC()
+ pkg.LastSyncedAt = &now
+ pkg.LastSyncRunID = &syncRun.RunID
+
+ if err := packages.UpsertPackage(ctx, database, pkg); err != nil {
+ t.Fatalf("upserting package %s: %v", p.Name, err)
+ }
+ }
+
+ if err := packages.FinishSyncRun(ctx, database, syncRun.RowID, "completed", map[string]any{"updated": len(pkgs)}); err != nil {
+ t.Fatalf("finishing sync run: %v", err)
+ }
+}
diff --git a/internal/wporg/client.go b/internal/wporg/client.go
index 6c02508..772f62b 100644
--- a/internal/wporg/client.go
+++ b/internal/wporg/client.go
@@ -25,6 +25,7 @@ type Client struct {
logger *slog.Logger
maxRetries int
retryDelay time.Duration
+ baseURL string // override for testing; defaults to "https://api.wordpress.org"
}
func NewClient(cfg config.DiscoveryConfig, logger *slog.Logger) *Client {
@@ -49,11 +50,17 @@ func NewClient(cfg config.DiscoveryConfig, logger *slog.Logger) *Client {
logger: logger,
maxRetries: cfg.MaxRetries,
retryDelay: time.Duration(cfg.RetryDelayMs) * time.Millisecond,
+ baseURL: "https://api.wordpress.org",
}
}
+// SetBaseURL overrides the WordPress.org API base URL (for testing).
+func (c *Client) SetBaseURL(u string) {
+ c.baseURL = u
+}
+
func (c *Client) FetchPlugin(ctx context.Context, slug string) (map[string]any, error) {
- u := "https://api.wordpress.org/plugins/info/1.2/?action=plugin_information" +
+ u := c.baseURL + "/plugins/info/1.2/?action=plugin_information" +
"&request%5Bslug%5D=" + url.QueryEscape(slug) +
"&request%5Bfields%5D%5Bversions%5D=true" +
"&request%5Bfields%5D%5Bdescription%5D=false" +
@@ -81,7 +88,7 @@ func (c *Client) FetchPlugin(ctx context.Context, slug string) (map[string]any,
}
func (c *Client) FetchTheme(ctx context.Context, slug string) (map[string]any, error) {
- u := "https://api.wordpress.org/themes/info/1.2/?action=theme_information" +
+ u := c.baseURL + "/themes/info/1.2/?action=theme_information" +
"&request%5Bslug%5D=" + url.QueryEscape(slug) +
"&request%5Bfields%5D%5Bversions%5D=true" +
"&request%5Bfields%5D%5Bactive_installs%5D=true" +
@@ -97,14 +104,14 @@ func (c *Client) FetchTheme(ctx context.Context, slug string) (map[string]any, e
func (c *Client) FetchLastUpdated(ctx context.Context, pkgType, slug string) (*time.Time, error) {
var u string
if pkgType == "plugin" {
- u = "https://api.wordpress.org/plugins/info/1.2/?action=plugin_information" +
+ u = c.baseURL + "/plugins/info/1.2/?action=plugin_information" +
"&request%5Bslug%5D=" + url.QueryEscape(slug) +
"&request%5Bfields%5D%5Blast_updated%5D=true" +
"&request%5Bfields%5D%5Bdescription%5D=false" +
"&request%5Bfields%5D%5Bsections%5D=false" +
"&request%5Bfields%5D%5Bversions%5D=false"
} else {
- u = "https://api.wordpress.org/themes/info/1.2/?action=theme_information" +
+ u = c.baseURL + "/themes/info/1.2/?action=theme_information" +
"&request%5Bslug%5D=" + url.QueryEscape(slug) +
"&request%5Bfields%5D%5Blast_updated%5D=true" +
"&request%5Bfields%5D%5Bversions%5D=false"
diff --git a/internal/wporg/mock_server.go b/internal/wporg/mock_server.go
new file mode 100644
index 0000000..eff6883
--- /dev/null
+++ b/internal/wporg/mock_server.go
@@ -0,0 +1,92 @@
+package wporg
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+// NewMockServer returns an httptest.Server that serves fixtures from fixtureDir.
+// Routes:
+// - GET /plugins/info/1.2/?...&request[slug]=X → testdata/plugins/X.json
+// - GET /themes/info/1.2/?...&request[slug]=X → testdata/themes/X.json
+//
+// Returns 404 for unknown slugs. Handles both full info and last_updated-only requests.
+func NewMockServer(fixtureDir string) *httptest.Server {
+ mux := http.NewServeMux()
+
+ mux.HandleFunc("/plugins/info/1.2/", func(w http.ResponseWriter, r *http.Request) {
+ serveFixture(w, r, filepath.Join(fixtureDir, "plugins"))
+ })
+
+ mux.HandleFunc("/themes/info/1.2/", func(w http.ResponseWriter, r *http.Request) {
+ serveFixture(w, r, filepath.Join(fixtureDir, "themes"))
+ })
+
+ return httptest.NewServer(mux)
+}
+
+func serveFixture(w http.ResponseWriter, r *http.Request, dir string) {
+ slug := extractSlug(r)
+ if slug == "" {
+ http.Error(w, `{"error":"missing slug"}`, http.StatusBadRequest)
+ return
+ }
+
+ data, err := os.ReadFile(filepath.Join(dir, slug+".json"))
+ if err != nil {
+ w.WriteHeader(http.StatusNotFound)
+ _, _ = w.Write([]byte(`{"error":"Plugin not found.","slug":"` + slug + `"}`))
+ return
+ }
+
+ // If the request only asks for last_updated, return a minimal response
+ if isLastUpdatedOnly(r) {
+ var full map[string]any
+ if err := json.Unmarshal(data, &full); err == nil {
+ minimal := map[string]any{
+ "last_updated": full["last_updated"],
+ "slug": full["slug"],
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(minimal)
+ return
+ }
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write(data)
+}
+
+func extractSlug(r *http.Request) string {
+ // Check query string (GET-style URLs used by our client)
+ q := r.URL.RawQuery
+ if slug := extractParam(q, "request%5Bslug%5D"); slug != "" {
+ return slug
+ }
+ if slug := extractParam(q, "request[slug]"); slug != "" {
+ return slug
+ }
+ return ""
+}
+
+func extractParam(query, key string) string {
+ idx := strings.Index(query, key+"=")
+ if idx < 0 {
+ return ""
+ }
+ val := query[idx+len(key)+1:]
+ if end := strings.IndexByte(val, '&'); end >= 0 {
+ val = val[:end]
+ }
+ return val
+}
+
+func isLastUpdatedOnly(r *http.Request) bool {
+ q := r.URL.RawQuery
+ // Check if versions=false is in the query (indicates a minimal last_updated-only request)
+ return strings.Contains(q, "versions%5D=false") || strings.Contains(q, "versions]=false")
+}
diff --git a/internal/wporg/testdata/plugins/akismet.json b/internal/wporg/testdata/plugins/akismet.json
new file mode 100644
index 0000000..98c5018
--- /dev/null
+++ b/internal/wporg/testdata/plugins/akismet.json
@@ -0,0 +1,25 @@
+{
+ "name": "Akismet Anti-spam: Spam Protection",
+ "slug": "akismet",
+ "version": "5.3.3",
+ "author": "Automattic - Anti-spam Team",
+ "requires": "5.8",
+ "tested": "6.7.2",
+ "requires_php": "5.6.20",
+ "download_link": "https://downloads.wordpress.org/plugin/akismet.5.3.3.zip",
+ "added": "2005-10-25",
+ "last_updated": "2024-10-03 14:31:00",
+ "homepage": "https://akismet.com/",
+ "short_description": "The best anti-spam protection to block spam comments and spam in a contact form.",
+ "downloaded": 312000000,
+ "active_installs": 5000000,
+ "rating": 94,
+ "num_ratings": 1100,
+ "versions": {
+ "5.3": "https://downloads.wordpress.org/plugin/akismet.5.3.zip",
+ "5.3.1": "https://downloads.wordpress.org/plugin/akismet.5.3.1.zip",
+ "5.3.2": "https://downloads.wordpress.org/plugin/akismet.5.3.2.zip",
+ "5.3.3": "https://downloads.wordpress.org/plugin/akismet.5.3.3.zip",
+ "dev-trunk": "https://downloads.wordpress.org/plugin/akismet.zip"
+ }
+}
diff --git a/internal/wporg/testdata/plugins/classic-editor.json b/internal/wporg/testdata/plugins/classic-editor.json
new file mode 100644
index 0000000..0bbc48c
--- /dev/null
+++ b/internal/wporg/testdata/plugins/classic-editor.json
@@ -0,0 +1,25 @@
+{
+ "name": "Classic Editor",
+ "slug": "classic-editor",
+ "version": "1.6.6",
+ "author": "WordPress Contributors",
+ "requires": "4.9",
+ "tested": "6.7.2",
+ "requires_php": "5.2.4",
+ "download_link": "https://downloads.wordpress.org/plugin/classic-editor.1.6.6.zip",
+ "added": "2018-03-08",
+ "last_updated": "2024-11-11 20:45:00",
+ "homepage": "https://wordpress.org/plugins/classic-editor/",
+ "short_description": "Enables the previous \"classic\" editor and the old-style Edit Post screen.",
+ "downloaded": 68000000,
+ "active_installs": 5000000,
+ "rating": 98,
+ "num_ratings": 1200,
+ "versions": {
+ "1.6.3": "https://downloads.wordpress.org/plugin/classic-editor.1.6.3.zip",
+ "1.6.4": "https://downloads.wordpress.org/plugin/classic-editor.1.6.4.zip",
+ "1.6.5": "https://downloads.wordpress.org/plugin/classic-editor.1.6.5.zip",
+ "1.6.6": "https://downloads.wordpress.org/plugin/classic-editor.1.6.6.zip",
+ "dev-trunk": "https://downloads.wordpress.org/plugin/classic-editor.zip"
+ }
+}
diff --git a/internal/wporg/testdata/plugins/contact-form-7.json b/internal/wporg/testdata/plugins/contact-form-7.json
new file mode 100644
index 0000000..62a223a
--- /dev/null
+++ b/internal/wporg/testdata/plugins/contact-form-7.json
@@ -0,0 +1,24 @@
+{
+ "name": "Contact Form 7",
+ "slug": "contact-form-7",
+ "version": "6.0.2",
+ "author": "Takayuki Miyoshi",
+ "requires": "6.2",
+ "tested": "6.7.2",
+ "requires_php": "7.4",
+ "download_link": "https://downloads.wordpress.org/plugin/contact-form-7.6.0.2.zip",
+ "added": "2007-11-11",
+ "last_updated": "2024-12-14 22:20:00",
+ "homepage": "https://contactform7.com/",
+ "short_description": "Just another contact form plugin. Simple but flexible.",
+ "downloaded": 400000000,
+ "active_installs": 5000000,
+ "rating": 88,
+ "num_ratings": 2200,
+ "versions": {
+ "6.0": "https://downloads.wordpress.org/plugin/contact-form-7.6.0.zip",
+ "6.0.1": "https://downloads.wordpress.org/plugin/contact-form-7.6.0.1.zip",
+ "6.0.2": "https://downloads.wordpress.org/plugin/contact-form-7.6.0.2.zip",
+ "dev-trunk": "https://downloads.wordpress.org/plugin/contact-form-7.zip"
+ }
+}
diff --git a/internal/wporg/testdata/themes/astra.json b/internal/wporg/testdata/themes/astra.json
new file mode 100644
index 0000000..a9d7062
--- /dev/null
+++ b/internal/wporg/testdata/themes/astra.json
@@ -0,0 +1,20 @@
+{
+ "name": "Astra",
+ "slug": "astra",
+ "version": "4.8.4",
+ "author": "Starter Templates",
+ "homepage": "https://wpastra.com/",
+ "last_updated": "2024-12-16 10:00:00",
+ "active_installs": 2000000,
+ "downloaded": 100000000,
+ "rating": 98,
+ "num_ratings": 5800,
+ "sections": {
+ "description": "Starter Templates lets you build beautiful WordPress websites with starter templates."
+ },
+ "versions": {
+ "4.8.2": "https://downloads.wordpress.org/theme/astra.4.8.2.zip",
+ "4.8.3": "https://downloads.wordpress.org/theme/astra.4.8.3.zip",
+ "4.8.4": "https://downloads.wordpress.org/theme/astra.4.8.4.zip"
+ }
+}
diff --git a/internal/wporg/testdata/themes/twentytwentyfive.json b/internal/wporg/testdata/themes/twentytwentyfive.json
new file mode 100644
index 0000000..2981f83
--- /dev/null
+++ b/internal/wporg/testdata/themes/twentytwentyfive.json
@@ -0,0 +1,19 @@
+{
+ "name": "Twenty Twenty-Five",
+ "slug": "twentytwentyfive",
+ "version": "1.1",
+ "author": "WordPress team",
+ "homepage": "https://wordpress.org/themes/twentytwentyfive/",
+ "last_updated": "2025-01-15 12:00:00",
+ "active_installs": 3000000,
+ "downloaded": 50000000,
+ "rating": 72,
+ "num_ratings": 40,
+ "sections": {
+ "description": "Twenty Twenty-Five is a versatile theme designed for blogs and websites."
+ },
+ "versions": {
+ "1.0": "https://downloads.wordpress.org/theme/twentytwentyfive.1.0.zip",
+ "1.1": "https://downloads.wordpress.org/theme/twentytwentyfive.1.1.zip"
+ }
+}
diff --git a/test/capture-fixtures.sh b/test/capture-fixtures.sh
new file mode 100755
index 0000000..0392908
--- /dev/null
+++ b/test/capture-fixtures.sh
@@ -0,0 +1,31 @@
+#!/usr/bin/env bash
+#
+# Capture WordPress.org API fixtures for integration tests.
+# Run once to snapshot, commit the results, re-run to refresh.
+#
+# Usage: ./test/capture-fixtures.sh
+#
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
+FIXTURE_DIR="${ROOT_DIR}/internal/wporg/testdata"
+
+PLUGINS=(akismet classic-editor contact-form-7)
+THEMES=(astra twentytwentyfive)
+
+mkdir -p "${FIXTURE_DIR}/plugins" "${FIXTURE_DIR}/themes"
+
+echo "Capturing plugin fixtures..."
+for slug in "${PLUGINS[@]}"; do
+ echo " → ${slug}"
+ curl -sS "https://api.wordpress.org/plugins/info/1.2/?action=plugin_information&request%5Bslug%5D=${slug}&request%5Bfields%5D%5Bversions%5D=true&request%5Bfields%5D%5Bdescription%5D=false&request%5Bfields%5D%5Bsections%5D=false&request%5Bfields%5D%5Bcompatibility%5D=false&request%5Bfields%5D%5Breviews%5D=false&request%5Bfields%5D%5Bbanners%5D=false&request%5Bfields%5D%5Bicons%5D=false&request%5Bfields%5D%5Bdonate_link%5D=false&request%5Bfields%5D%5Bratings%5D=false&request%5Bfields%5D%5Bcontributors%5D=false&request%5Bfields%5D%5Btags%5D=false&request%5Bfields%5D%5Bactive_installs%5D=true&request%5Bfields%5D%5Brequires%5D=true&request%5Bfields%5D%5Btested%5D=true&request%5Bfields%5D%5Brequires_php%5D=true&request%5Bfields%5D%5Bauthor%5D=true&request%5Bfields%5D%5Bshort_description%5D=true&request%5Bfields%5D%5Bhomepage%5D=true&request%5Bfields%5D%5Blast_updated%5D=true&request%5Bfields%5D%5Badded%5D=true&request%5Bfields%5D%5Bdownload_link%5D=true" | python3 -m json.tool > "${FIXTURE_DIR}/plugins/${slug}.json"
+done
+
+echo "Capturing theme fixtures..."
+for slug in "${THEMES[@]}"; do
+ echo " → ${slug}"
+ curl -sS "https://api.wordpress.org/themes/info/1.2/?action=theme_information&request%5Bslug%5D=${slug}&request%5Bfields%5D%5Bversions%5D=true&request%5Bfields%5D%5Bactive_installs%5D=true&request%5Bfields%5D%5Bsections%5D=true&request%5Bfields%5D%5Bauthor%5D=true&request%5Bfields%5D%5Bhomepage%5D=true&request%5Bfields%5D%5Blast_updated%5D=true" | python3 -m json.tool > "${FIXTURE_DIR}/themes/${slug}.json"
+done
+
+echo "Done. Fixtures written to ${FIXTURE_DIR}"
diff --git a/test/smoke_test.sh b/test/smoke_test.sh
deleted file mode 100755
index 859adb0..0000000
--- a/test/smoke_test.sh
+++ /dev/null
@@ -1,478 +0,0 @@
-#!/usr/bin/env bash
-#
-# End-to-end smoke test for WP Composer
-#
-# Boots the app, builds repository artifacts, then uses Composer to
-# install real WordPress plugins/themes from the local repository.
-# Verifies telemetry events are recorded via notify-batch.
-#
-# Prerequisites: go, composer, curl, sqlite3, jq
-#
-set -euo pipefail
-
-SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
-ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
-TEST_DIR=$(mktemp -d)
-APP_PORT=19876
-APP_URL="http://localhost:${APP_PORT}"
-DB_PATH="${TEST_DIR}/wpcomposer.db"
-BINARY="${ROOT_DIR}/wpcomposer"
-PASSED=0
-FAILED=0
-
-cleanup() {
- if [ -n "${APP_PID:-}" ] && kill -0 "$APP_PID" 2>/dev/null; then
- kill "$APP_PID" 2>/dev/null || true
- wait "$APP_PID" 2>/dev/null || true
- fi
- rm -rf "$TEST_DIR"
- rm -rf "${ROOT_DIR}/storage"
-
- echo ""
- echo "================================"
- echo "Results: ${PASSED} passed, ${FAILED} failed"
- echo "================================"
-
- if [ "$FAILED" -gt 0 ]; then
- exit 1
- fi
-}
-trap cleanup EXIT
-
-pass() {
- echo " ✓ $1"
- PASSED=$((PASSED + 1))
-}
-
-fail() {
- echo " ✗ $1"
- FAILED=$((FAILED + 1))
-}
-
-assert_eq() {
- if [ "$1" = "$2" ]; then
- pass "$3"
- else
- fail "$3 (expected '$2', got '$1')"
- fi
-}
-
-assert_contains() {
- if echo "$1" | grep -q "$2"; then
- pass "$3"
- else
- fail "$3 (expected to contain '$2')"
- fi
-}
-
-assert_gt() {
- if [ "$1" -gt "$2" ]; then
- pass "$3"
- else
- fail "$3 (expected > $2, got $1)"
- fi
-}
-
-echo "=== Building binary ==="
-cd "$ROOT_DIR"
-go build -o "$BINARY" ./cmd/wpcomposer
-
-echo ""
-echo "=== Setting up database and repository ==="
-"$BINARY" migrate --db "$DB_PATH" > /dev/null 2>&1
-"$BINARY" discover --source=config --type=plugin --limit=5 --db "$DB_PATH" > /dev/null 2>&1
-"$BINARY" discover --source=config --type=theme --limit=2 --db "$DB_PATH" > /dev/null 2>&1
-"$BINARY" update --force --db "$DB_PATH" > /dev/null 2>&1
-APP_URL="$APP_URL" "$BINARY" build --db "$DB_PATH" > /dev/null 2>&1
-"$BINARY" deploy --db "$DB_PATH" > /dev/null 2>&1
-
-echo ""
-echo "=== Starting server ==="
-"$BINARY" serve --db "$DB_PATH" --addr ":${APP_PORT}" > /dev/null 2>&1 &
-APP_PID=$!
-sleep 2
-
-if ! kill -0 "$APP_PID" 2>/dev/null; then
- echo "Server failed to start"
- exit 1
-fi
-
-# ─── Health check ───────────────────────────────────────────────────
-
-echo ""
-echo "--- Health & endpoints ---"
-
-HEALTH=$(curl -sf "${APP_URL}/health")
-assert_contains "$HEALTH" '"status":"ok"' "GET /health returns ok"
-
-INDEX_STATUS=$(curl -sf -o /dev/null -w "%{http_code}" "${APP_URL}/")
-assert_eq "$INDEX_STATUS" "200" "GET / returns 200"
-
-# ─── packages.json validation ──────────────────────────────────────
-
-echo ""
-echo "--- packages.json ---"
-
-PACKAGES_JSON=$(curl -sf "${APP_URL}/packages.json")
-NOTIFY_BATCH=$(echo "$PACKAGES_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('notify-batch',''))")
-assert_eq "$NOTIFY_BATCH" "${APP_URL}/downloads" "notify-batch is absolute URL"
-
-PROVIDERS_URL=$(echo "$PACKAGES_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('providers-url',''))")
-assert_eq "$PROVIDERS_URL" "/p/%package%\$%hash%.json" "providers-url is set"
-
-METADATA_URL=$(echo "$PACKAGES_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('metadata-url',''))")
-assert_eq "$METADATA_URL" "/p2/%package%.json" "metadata-url is set"
-
-PROVIDER_COUNT=$(echo "$PACKAGES_JSON" | python3 -c "import sys,json; print(len(json.load(sys.stdin).get('provider-includes',{})))")
-assert_gt "$PROVIDER_COUNT" "0" "provider-includes has entries"
-
-# ─── Composer install ──────────────────────────────────────────────
-
-echo ""
-echo "--- Composer install ---"
-
-COMPOSER_DIR="${TEST_DIR}/composer-project"
-mkdir -p "$COMPOSER_DIR"
-
-cat > "${COMPOSER_DIR}/composer.json" << COMPOSERJSON
-{
- "repositories": [
- {
- "type": "composer",
- "url": "${APP_URL}",
- "only": ["wp-plugin/*", "wp-theme/*"]
- }
- ],
- "require": {
- "composer/installers": "^2.2",
- "wp-plugin/akismet": "*",
- "wp-plugin/classic-editor": "*",
- "wp-theme/astra": "*"
- },
- "config": {
- "secure-http": false,
- "allow-plugins": {
- "composer/installers": true
- }
- },
- "minimum-stability": "dev",
- "prefer-stable": true
-}
-COMPOSERJSON
-
-cd "$COMPOSER_DIR"
-COMPOSER_OUTPUT=$(composer install --no-interaction --no-progress 2>&1) || true
-
-# Check packages were installed (composer/installers puts them in vendor/ by default
-# since we don't have the web/app directory structure)
-if echo "$COMPOSER_OUTPUT" | grep -q "Installing wp-plugin/akismet"; then
- pass "wp-plugin/akismet installed"
-else
- fail "wp-plugin/akismet not installed"
- echo " Composer output:"
- echo "$COMPOSER_OUTPUT" | head -20 | sed 's/^/ /'
-fi
-
-if echo "$COMPOSER_OUTPUT" | grep -q "Installing wp-plugin/classic-editor"; then
- pass "wp-plugin/classic-editor installed"
-else
- fail "wp-plugin/classic-editor not installed"
-fi
-
-if echo "$COMPOSER_OUTPUT" | grep -q "Installing wp-theme/astra"; then
- pass "wp-theme/astra installed"
-else
- fail "wp-theme/astra not installed"
-fi
-
-# ─── Telemetry verification ────────────────────────────────────────
-
-echo ""
-echo "--- Telemetry ---"
-
-# Give the notify-batch POST a moment to complete
-sleep 2
-
-EVENT_COUNT=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM install_events;")
-assert_gt "$EVENT_COUNT" "0" "install_events has records after composer install"
-
-# Check specific packages got events
-AKISMET_EVENTS=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM install_events ie JOIN packages p ON p.id = ie.package_id WHERE p.name = 'akismet';")
-assert_gt "$AKISMET_EVENTS" "0" "akismet has install events"
-
-# Aggregate and verify counters
-cd "$ROOT_DIR"
-"$BINARY" aggregate-installs --db "$DB_PATH" > /dev/null 2>&1
-
-AKISMET_TOTAL=$(sqlite3 "$DB_PATH" "SELECT wp_composer_installs_total FROM packages WHERE name = 'akismet';")
-assert_gt "$AKISMET_TOTAL" "0" "akismet wp_composer_installs_total > 0 after aggregation"
-
-AKISMET_30D=$(sqlite3 "$DB_PATH" "SELECT wp_composer_installs_30d FROM packages WHERE name = 'akismet';")
-assert_gt "$AKISMET_30D" "0" "akismet wp_composer_installs_30d > 0 after aggregation"
-
-AKISMET_LAST=$(sqlite3 "$DB_PATH" "SELECT last_installed_at FROM packages WHERE name = 'akismet';")
-if [ -n "$AKISMET_LAST" ] && [ "$AKISMET_LAST" != "" ]; then
- pass "akismet last_installed_at is set"
-else
- fail "akismet last_installed_at is not set"
-fi
-
-# ─── Admin access ──────────────────────────────────────────────────
-
-echo ""
-echo "--- Admin access ---"
-
-# Login page should be accessible
-LOGIN_STATUS=$(curl -sf -o /dev/null -w "%{http_code}" "${APP_URL}/admin/login")
-assert_eq "$LOGIN_STATUS" "200" "GET /admin/login returns 200"
-
-# Admin dashboard should redirect to login (no session)
-ADMIN_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "${APP_URL}/admin/")
-assert_eq "$ADMIN_STATUS" "303" "GET /admin/ without auth redirects (303)"
-
-# ─── Dedupe verification ──────────────────────────────────────────
-
-echo ""
-echo "--- Dedupe ---"
-
-# POST the same install twice — second should be deduplicated
-curl -sf -X POST "${APP_URL}/downloads" \
- -H 'Content-Type: application/json' \
- -d '{"downloads":[{"name":"wp-plugin/akismet","version":"5.6"}]}' > /dev/null
-sleep 1
-AFTER_FIRST=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM install_events;")
-
-curl -sf -X POST "${APP_URL}/downloads" \
- -H 'Content-Type: application/json' \
- -d '{"downloads":[{"name":"wp-plugin/akismet","version":"5.6"}]}' > /dev/null
-sleep 1
-AFTER_SECOND=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM install_events;")
-
-assert_eq "$AFTER_SECOND" "$AFTER_FIRST" "duplicate notify-batch is deduplicated"
-
-# ─── Version pinning ─────────────────────────────────────────────
-
-echo ""
-echo "--- Version pinning ---"
-
-PINNED_DIR="${TEST_DIR}/composer-pinned"
-mkdir -p "$PINNED_DIR"
-
-cat > "${PINNED_DIR}/composer.json" << COMPOSERJSON
-{
- "repositories": [
- {
- "type": "composer",
- "url": "${APP_URL}",
- "only": ["wp-plugin/*", "wp-theme/*"]
- }
- ],
- "require": {
- "composer/installers": "^2.2",
- "wp-plugin/akismet": "5.3.3",
- "wp-plugin/classic-editor": "1.6.6"
- },
- "config": {
- "secure-http": false,
- "allow-plugins": {
- "composer/installers": true
- }
- },
- "minimum-stability": "dev",
- "prefer-stable": true
-}
-COMPOSERJSON
-
-cd "$PINNED_DIR"
-PINNED_OUTPUT=$(composer install --no-interaction --no-progress 2>&1) || true
-
-if echo "$PINNED_OUTPUT" | grep -q "Locking wp-plugin/akismet (5.3.3)"; then
- pass "wp-plugin/akismet pinned to 5.3.3"
-else
- fail "wp-plugin/akismet version pin failed"
- echo " Output:" && echo "$PINNED_OUTPUT" | grep -i "akismet\|error\|lock" | head -5 | sed 's/^/ /'
-fi
-
-if echo "$PINNED_OUTPUT" | grep -q "Locking wp-plugin/classic-editor (1.6.6)"; then
- pass "wp-plugin/classic-editor pinned to 1.6.6"
-else
- fail "wp-plugin/classic-editor version pin failed"
- echo " Output:" && echo "$PINNED_OUTPUT" | grep -i "classic\|error\|lock" | head -5 | sed 's/^/ /'
-fi
-
-# ─── Package detail pages ────────────────────────────────────────
-
-echo ""
-echo "--- Package pages ---"
-
-DETAIL_STATUS=$(curl -sf -o /dev/null -w "%{http_code}" "${APP_URL}/packages/wp-plugin/akismet")
-assert_eq "$DETAIL_STATUS" "200" "GET /packages/wp-plugin/akismet returns 200"
-
-DETAIL_BODY=$(curl -sf "${APP_URL}/packages/wp-plugin/akismet")
-assert_contains "$DETAIL_BODY" "composer require wp-plugin/akismet" "detail page has install command"
-assert_contains "$DETAIL_BODY" "Package Info" "detail page has Package Info sidebar"
-
-MISSING_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "${APP_URL}/packages/wp-plugin/nonexistent-plugin-xyz")
-assert_eq "$MISSING_STATUS" "404" "GET /packages/wp-plugin/nonexistent returns 404"
-
-# ─── Admin auth flow ─────────────────────────────────────────────
-
-echo ""
-echo "--- Admin auth flow ---"
-
-cd "$ROOT_DIR"
-
-# Create test admin user
-echo 'smoke-test-pass' | "$BINARY" admin create \
- --email smoke@test.com --name "Smoke Test" --password-stdin \
- --db "$DB_PATH" > /dev/null 2>&1
-
-# Login with correct credentials
-LOGIN_RESPONSE=$(curl -s -D - -o /dev/null -X POST "${APP_URL}/admin/login" \
- -d "email=smoke@test.com&password=smoke-test-pass" \
- -H "Content-Type: application/x-www-form-urlencoded" 2>&1)
-
-if echo "$LOGIN_RESPONSE" | grep -q "Set-Cookie: session="; then
- pass "login sets session cookie"
- SESSION_COOKIE=$(echo "$LOGIN_RESPONSE" | grep "Set-Cookie: session=" | sed 's/.*session=//;s/;.*//')
-else
- fail "login did not set session cookie"
- SESSION_COOKIE=""
-fi
-
-if echo "$LOGIN_RESPONSE" | grep -q "Location: /admin"; then
- pass "login redirects to /admin"
-else
- fail "login did not redirect to /admin"
-fi
-
-# Access admin with session cookie
-if [ -n "$SESSION_COOKIE" ]; then
- AUTHED_STATUS=$(curl -sf -o /dev/null -w "%{http_code}" \
- -b "session=${SESSION_COOKIE}" "${APP_URL}/admin/")
- assert_eq "$AUTHED_STATUS" "200" "GET /admin/ with session returns 200"
-
- AUTHED_PACKAGES=$(curl -sf -o /dev/null -w "%{http_code}" \
- -b "session=${SESSION_COOKIE}" "${APP_URL}/admin/packages")
- assert_eq "$AUTHED_PACKAGES" "200" "GET /admin/packages with session returns 200"
-
- AUTHED_BUILDS=$(curl -sf -o /dev/null -w "%{http_code}" \
- -b "session=${SESSION_COOKIE}" "${APP_URL}/admin/builds")
- assert_eq "$AUTHED_BUILDS" "200" "GET /admin/builds with session returns 200"
-fi
-
-# Login with wrong password
-BAD_LOGIN=$(curl -s -o /dev/null -w "%{http_code}" -X POST "${APP_URL}/admin/login" \
- -d "email=smoke@test.com&password=wrong" \
- -H "Content-Type: application/x-www-form-urlencoded")
-assert_eq "$BAD_LOGIN" "303" "login with wrong password redirects (303)"
-
-# Logout
-if [ -n "$SESSION_COOKIE" ]; then
- LOGOUT_RESPONSE=$(curl -s -D - -o /dev/null -X POST "${APP_URL}/admin/logout" \
- -b "session=${SESSION_COOKIE}" 2>&1)
- if echo "$LOGOUT_RESPONSE" | grep -q "session=;"; then
- pass "logout clears session cookie"
- elif echo "$LOGOUT_RESPONSE" | grep -q "Max-Age=-1\|Max-Age=0"; then
- pass "logout clears session cookie"
- else
- # Check if session is invalidated by trying to access admin
- POST_LOGOUT=$(curl -s -o /dev/null -w "%{http_code}" \
- -b "session=${SESSION_COOKIE}" "${APP_URL}/admin/")
- assert_eq "$POST_LOGOUT" "303" "admin inaccessible after logout"
- fi
-fi
-
-# ─── Golden fixture: p2 metadata structure ───────────────────────
-
-echo ""
-echo "--- Golden fixtures ---"
-
-P2_AKISMET=$(curl -sf "${APP_URL}/p2/wp-plugin/akismet.json")
-
-# Verify structure has packages key with wp-plugin/akismet
-HAS_PACKAGES=$(echo "$P2_AKISMET" | python3 -c "
-import sys, json
-d = json.load(sys.stdin)
-pkgs = d.get('packages', {})
-akismet = pkgs.get('wp-plugin/akismet', {})
-print(len(akismet))
-" 2>/dev/null || echo "0")
-assert_gt "$HAS_PACKAGES" "0" "p2/wp-plugin/akismet.json has version entries"
-
-# Verify a version entry has required Composer fields
-HAS_FIELDS=$(echo "$P2_AKISMET" | python3 -c "
-import sys, json
-d = json.load(sys.stdin)
-versions = d.get('packages', {}).get('wp-plugin/akismet', {})
-v = next(iter(versions.values()), {})
-fields = ['name', 'version', 'type', 'dist', 'source', 'require']
-print(sum(1 for f in fields if f in v))
-" 2>/dev/null || echo "0")
-assert_eq "$HAS_FIELDS" "6" "version entry has all required Composer fields (name, version, type, dist, source, require)"
-
-# Verify dist URL points to wordpress.org
-DIST_URL=$(echo "$P2_AKISMET" | python3 -c "
-import sys, json
-d = json.load(sys.stdin)
-versions = d.get('packages', {}).get('wp-plugin/akismet', {})
-v = next(iter(versions.values()), {})
-print(v.get('dist', {}).get('url', ''))
-" 2>/dev/null || echo "")
-assert_contains "$DIST_URL" "downloads.wordpress.org" "dist URL points to wordpress.org"
-
-# Verify type is wordpress-plugin
-PKG_TYPE=$(echo "$P2_AKISMET" | python3 -c "
-import sys, json
-d = json.load(sys.stdin)
-versions = d.get('packages', {}).get('wp-plugin/akismet', {})
-v = next(iter(versions.values()), {})
-print(v.get('type', ''))
-" 2>/dev/null || echo "")
-assert_eq "$PKG_TYPE" "wordpress-plugin" "package type is wordpress-plugin"
-
-# ─── Build integrity ─────────────────────────────────────────────
-
-echo ""
-echo "--- Build integrity ---"
-
-MANIFEST=$(cat storage/repository/current/manifest.json)
-ROOT_HASH=$(echo "$MANIFEST" | python3 -c "import sys,json; print(json.load(sys.stdin).get('root_hash',''))")
-if [ -n "$ROOT_HASH" ] && [ "$ROOT_HASH" != "" ]; then
- pass "manifest.json has root_hash"
-else
- fail "manifest.json missing root_hash"
-fi
-
-ARTIFACT_COUNT=$(echo "$MANIFEST" | python3 -c "import sys,json; print(int(json.load(sys.stdin).get('artifact_count',0)))")
-assert_gt "$ARTIFACT_COUNT" "0" "manifest reports artifact_count > 0"
-
-# ─── Rollback ────────────────────────────────────────────────────
-
-echo ""
-echo "--- Rollback ---"
-
-# Build again to get a second build
-sleep 1
-APP_URL="$APP_URL" "$BINARY" build --db "$DB_PATH" > /dev/null 2>&1
-"$BINARY" deploy --db "$DB_PATH" > /dev/null 2>&1
-
-BUILD_COUNT=$(ls storage/repository/builds/ | wc -l | tr -d ' ')
-assert_gt "$BUILD_COUNT" "1" "multiple builds exist"
-
-CURRENT_BEFORE=$(readlink storage/repository/current | xargs basename)
-"$BINARY" deploy --rollback --db "$DB_PATH" > /dev/null 2>&1
-CURRENT_AFTER=$(readlink storage/repository/current | xargs basename)
-
-if [ "$CURRENT_BEFORE" != "$CURRENT_AFTER" ]; then
- pass "rollback changed current build"
-else
- fail "rollback did not change current build"
-fi
-
-# Verify rolled-back build still serves valid packages.json
-ROLLBACK_PJ=$(curl -sf "${APP_URL}/packages.json" | python3 -c "import sys,json; print(json.load(sys.stdin).get('notify-batch',''))" 2>/dev/null || echo "")
-assert_eq "$ROLLBACK_PJ" "${APP_URL}/downloads" "packages.json valid after rollback"
-
-echo ""
-echo "=== Smoke test complete ==="