packages.wenpai.net/internal/packages/package_test.go
Scott Walkinshaw 5ae2e34fe4
Set content_changed_at on package deactivation and reactivation (#93)
* Set content_changed_at on package deactivation and reactivation

DeactivatePackage and ReactivatePackage only set is_active and updated_at,
so closures and re-openings detected by check-status never appeared in the
metadata changes feed. Setting content_changed_at ensures Composer clients
are notified of these changes and prepares for the Phase 3 DB-driven sync
which uses this column to determine what needs uploading/deleting on R2.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add test assertions for content_changed_at on status change

Verify that DeactivatePackage and ReactivatePackage set the
content_changed_at column, ensuring closures and re-openings
are reflected in the metadata changes feed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add integration-style test for status change content_changed_at flow

Simulates the check-status lifecycle: active → deactivated (closure) →
reactivated (re-opening), verifying content_changed_at is set on both
transitions and advances monotonically.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Ben Word <ben@benword.com>
2026-04-04 10:41:03 -05:00

610 lines
17 KiB
Go

package packages
import (
"context"
"database/sql"
"testing"
"time"
"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)
}
// Create packages table inline (avoid embed dependency in tests)
_, 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,
wporg_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)
);
CREATE TABLE sync_runs (
id INTEGER PRIMARY KEY,
started_at TEXT NOT NULL,
finished_at TEXT,
status TEXT NOT NULL,
meta_json TEXT NOT NULL DEFAULT '{}'
);
CREATE TABLE package_stats (
id INTEGER PRIMARY KEY CHECK (id = 1),
active_plugins INTEGER NOT NULL DEFAULT 0,
active_themes INTEGER NOT NULL DEFAULT 0,
plugin_installs INTEGER NOT NULL DEFAULT 0,
theme_installs INTEGER NOT NULL DEFAULT 0,
installs_30d INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT ''
);
`)
if err != nil {
t.Fatalf("creating tables: %v", err)
}
t.Cleanup(func() { _ = database.Close() })
return database
}
func TestUpsertShellPackage(t *testing.T) {
database := setupTestDB(t)
ctx := context.Background()
lc := time.Date(2026, 1, 15, 0, 0, 0, 0, time.UTC)
err := UpsertShellPackage(ctx, database, "plugin", "akismet", &lc)
if err != nil {
t.Fatalf("first upsert: %v", err)
}
// Verify row exists
var name string
var isActive int
err = database.QueryRow("SELECT name, is_active FROM packages WHERE type='plugin' AND name='akismet'").Scan(&name, &isActive)
if err != nil {
t.Fatalf("querying: %v", err)
}
if name != "akismet" || isActive != 1 {
t.Errorf("got name=%s active=%d", name, isActive)
}
// Upsert with older date should not update last_committed
olderLC := time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC)
err = UpsertShellPackage(ctx, database, "plugin", "akismet", &olderLC)
if err != nil {
t.Fatalf("second upsert: %v", err)
}
var lastCommitted string
_ = database.QueryRow("SELECT last_committed FROM packages WHERE name='akismet'").Scan(&lastCommitted)
if lastCommitted != "2026-01-15T00:00:00Z" {
t.Errorf("last_committed should not have been overwritten, got %s", lastCommitted)
}
// Upsert with newer date should update
newerLC := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
err = UpsertShellPackage(ctx, database, "plugin", "akismet", &newerLC)
if err != nil {
t.Fatalf("third upsert: %v", err)
}
_ = database.QueryRow("SELECT last_committed FROM packages WHERE name='akismet'").Scan(&lastCommitted)
if lastCommitted != "2026-03-01T00:00:00Z" {
t.Errorf("last_committed should have been updated, got %s", lastCommitted)
}
}
func TestUpsertPackage(t *testing.T) {
database := setupTestDB(t)
ctx := context.Background()
ver := "5.0"
pkg := &Package{
Type: "plugin",
Name: "akismet",
VersionsJSON: `{"5.0":"https://example.com/5.0.zip"}`,
CurrentVersion: &ver,
IsActive: true,
Downloads: 1000,
}
err := UpsertPackage(ctx, database, pkg)
if err != nil {
t.Fatalf("upsert: %v", err)
}
var downloads int64
_ = database.QueryRow("SELECT downloads FROM packages WHERE name='akismet'").Scan(&downloads)
if downloads != 1000 {
t.Errorf("downloads = %d, want 1000", downloads)
}
// Update same package
pkg.Downloads = 2000
err = UpsertPackage(ctx, database, pkg)
if err != nil {
t.Fatalf("second upsert: %v", err)
}
_ = database.QueryRow("SELECT downloads FROM packages WHERE name='akismet'").Scan(&downloads)
if downloads != 2000 {
t.Errorf("downloads = %d, want 2000", downloads)
}
}
func TestDeactivatePackage(t *testing.T) {
database := setupTestDB(t)
ctx := context.Background()
pkg := &Package{
Type: "plugin",
Name: "dead-plugin",
VersionsJSON: "{}",
IsActive: true,
}
_ = UpsertPackage(ctx, database, pkg)
var id int64
_ = database.QueryRow("SELECT id FROM packages WHERE name='dead-plugin'").Scan(&id)
err := DeactivatePackage(ctx, database, id)
if err != nil {
t.Fatalf("deactivate: %v", err)
}
var isActive int
var contentChangedAt *string
_ = database.QueryRow("SELECT is_active, content_changed_at FROM packages WHERE id=?", id).Scan(&isActive, &contentChangedAt)
if isActive != 0 {
t.Error("package should be inactive")
}
if contentChangedAt == nil {
t.Error("content_changed_at should be set after deactivation")
}
}
// TestStatusChangeUpdatesContentChangedAt simulates the check-status flow:
// an active package is deactivated (closure), then reactivated (re-opening).
// Both transitions must set content_changed_at so the changes feed and
// DB-driven sync pick them up.
func TestStatusChangeUpdatesContentChangedAt(t *testing.T) {
database := setupTestDB(t)
ctx := context.Background()
pkg := &Package{
Type: "plugin", Name: "status-test", VersionsJSON: "{}", IsActive: true,
}
_ = UpsertPackage(ctx, database, pkg)
var id int64
_ = database.QueryRow("SELECT id FROM packages WHERE name='status-test'").Scan(&id)
// Verify content_changed_at starts unset
var initial *string
_ = database.QueryRow("SELECT content_changed_at FROM packages WHERE id=?", id).Scan(&initial)
if initial != nil {
t.Fatal("content_changed_at should be nil before any status change")
}
// Deactivate (simulates check-status detecting a closure via wp.org 404)
if err := DeactivatePackage(ctx, database, id); err != nil {
t.Fatalf("deactivate: %v", err)
}
var afterDeactivate string
var isActive int
_ = database.QueryRow("SELECT is_active, content_changed_at FROM packages WHERE id=?", id).Scan(&isActive, &afterDeactivate)
if isActive != 0 {
t.Error("expected inactive after deactivation")
}
if afterDeactivate == "" {
t.Fatal("content_changed_at should be set after deactivation")
}
// Reactivate (simulates check-status detecting a re-opening)
if err := ReactivatePackage(ctx, database, id); err != nil {
t.Fatalf("reactivate: %v", err)
}
var afterReactivate string
_ = database.QueryRow("SELECT is_active, content_changed_at FROM packages WHERE id=?", id).Scan(&isActive, &afterReactivate)
if isActive != 1 {
t.Error("expected active after reactivation")
}
if afterReactivate == "" {
t.Fatal("content_changed_at should be set after reactivation")
}
if afterReactivate < afterDeactivate {
t.Errorf("reactivation timestamp (%s) should be >= deactivation timestamp (%s)",
afterReactivate, afterDeactivate)
}
}
func TestRefreshSiteStats(t *testing.T) {
database := setupTestDB(t)
ctx := context.Background()
cur := "1.0.0"
p1 := &Package{
Type: "plugin",
Name: "plugin-one",
VersionsJSON: "{}",
CurrentVersion: &cur,
IsActive: true,
WpPackagesInstallsTotal: 100,
WpPackagesInstalls30d: 25,
}
p2 := &Package{
Type: "plugin",
Name: "plugin-two",
VersionsJSON: "{}",
CurrentVersion: &cur,
IsActive: false,
WpPackagesInstallsTotal: 999,
WpPackagesInstalls30d: 999,
}
t1 := &Package{
Type: "theme",
Name: "theme-one",
VersionsJSON: "{}",
CurrentVersion: &cur,
IsActive: true,
WpPackagesInstallsTotal: 50,
WpPackagesInstalls30d: 5,
}
if err := UpsertPackage(ctx, database, p1); err != nil {
t.Fatalf("upserting plugin-one: %v", err)
}
if err := UpsertPackage(ctx, database, p2); err != nil {
t.Fatalf("upserting plugin-two: %v", err)
}
if err := UpsertPackage(ctx, database, t1); err != nil {
t.Fatalf("upserting theme-one: %v", err)
}
// Install counters are maintained by telemetry aggregation, not package upserts.
if _, err := database.Exec(`UPDATE packages SET wp_packages_installs_total = 100, wp_packages_installs_30d = 25 WHERE name = 'plugin-one'`); err != nil {
t.Fatalf("updating plugin-one counters: %v", err)
}
if _, err := database.Exec(`UPDATE packages SET wp_packages_installs_total = 999, wp_packages_installs_30d = 999 WHERE name = 'plugin-two'`); err != nil {
t.Fatalf("updating plugin-two counters: %v", err)
}
if _, err := database.Exec(`UPDATE packages SET wp_packages_installs_total = 50, wp_packages_installs_30d = 5 WHERE name = 'theme-one'`); err != nil {
t.Fatalf("updating theme-one counters: %v", err)
}
if err := RefreshSiteStats(ctx, database); err != nil {
t.Fatalf("refreshing site stats: %v", err)
}
var activePlugins, activeThemes, pluginInstalls, themeInstalls, installs30d int
err := database.QueryRow(`SELECT active_plugins, active_themes, plugin_installs, theme_installs, installs_30d FROM package_stats WHERE id = 1`).Scan(
&activePlugins, &activeThemes, &pluginInstalls, &themeInstalls, &installs30d,
)
if err != nil {
t.Fatalf("querying package_stats: %v", err)
}
if activePlugins != 1 {
t.Errorf("active_plugins = %d, want 1", activePlugins)
}
if activeThemes != 1 {
t.Errorf("active_themes = %d, want 1", activeThemes)
}
if pluginInstalls != 100 {
t.Errorf("plugin_installs = %d, want 100", pluginInstalls)
}
if themeInstalls != 50 {
t.Errorf("theme_installs = %d, want 50", themeInstalls)
}
if installs30d != 30 {
t.Errorf("installs_30d = %d, want 30", installs30d)
}
}
func TestGetPackagesNeedingUpdate(t *testing.T) {
database := setupTestDB(t)
ctx := context.Background()
// Package with no last_synced_at — needs update
lc := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
_ = UpsertShellPackage(ctx, database, "plugin", "needs-update", &lc)
// Package already synced recently — does not need update
synced := time.Now().UTC()
pkg := &Package{
Type: "plugin",
Name: "up-to-date",
VersionsJSON: "{}",
IsActive: true,
LastCommitted: &lc,
LastSyncedAt: &synced,
}
_ = UpsertPackage(ctx, database, pkg)
pkgs, err := GetPackagesNeedingUpdate(ctx, database, UpdateQueryOpts{Type: "plugin"})
if err != nil {
t.Fatalf("query: %v", err)
}
if len(pkgs) != 1 {
t.Fatalf("expected 1 package needing update, got %d", len(pkgs))
}
if pkgs[0].Name != "needs-update" {
t.Errorf("got name=%s, want needs-update", pkgs[0].Name)
}
}
func TestGetAllPackages(t *testing.T) {
database := setupTestDB(t)
ctx := context.Background()
_ = UpsertPackage(ctx, database, &Package{
Type: "plugin", Name: "akismet", VersionsJSON: "{}", IsActive: true,
})
_ = UpsertPackage(ctx, database, &Package{
Type: "theme", Name: "astra", VersionsJSON: "{}", IsActive: true,
})
_ = UpsertPackage(ctx, database, &Package{
Type: "plugin", Name: "closed-plugin", VersionsJSON: "{}", IsActive: false,
})
t.Run("all types", func(t *testing.T) {
pkgs, err := GetAllPackages(ctx, database, "all")
if err != nil {
t.Fatalf("query: %v", err)
}
if len(pkgs) != 3 {
t.Errorf("expected 3 packages, got %d", len(pkgs))
}
})
t.Run("filter by type", func(t *testing.T) {
pkgs, err := GetAllPackages(ctx, database, "plugin")
if err != nil {
t.Fatalf("query: %v", err)
}
if len(pkgs) != 2 {
t.Errorf("expected 2 plugins, got %d", len(pkgs))
}
})
t.Run("includes inactive", func(t *testing.T) {
pkgs, err := GetAllPackages(ctx, database, "plugin")
if err != nil {
t.Fatalf("query: %v", err)
}
var found bool
for _, p := range pkgs {
if p.Name == "closed-plugin" && !p.IsActive {
found = true
}
}
if !found {
t.Error("expected inactive plugin to be included")
}
})
}
func TestReactivatePackage(t *testing.T) {
database := setupTestDB(t)
ctx := context.Background()
_ = UpsertPackage(ctx, database, &Package{
Type: "plugin", Name: "closed-plugin", VersionsJSON: "{}", IsActive: false,
})
pkgs, _ := GetAllPackages(ctx, database, "")
if pkgs[0].IsActive {
t.Fatal("expected package to start inactive")
}
if err := ReactivatePackage(ctx, database, pkgs[0].ID); err != nil {
t.Fatalf("reactivate: %v", err)
}
pkgs, _ = GetAllPackages(ctx, database, "")
if !pkgs[0].IsActive {
t.Error("expected package to be active after reactivation")
}
var contentChangedAt *string
_ = database.QueryRow("SELECT content_changed_at FROM packages WHERE id=?", pkgs[0].ID).Scan(&contentChangedAt)
if contentChangedAt == nil {
t.Error("content_changed_at should be set after reactivation")
}
}
func TestBatchUpsertShellPackages(t *testing.T) {
database := setupTestDB(t)
ctx := context.Background()
lc1 := time.Date(2026, 1, 15, 0, 0, 0, 0, time.UTC)
lc2 := time.Date(2026, 2, 20, 0, 0, 0, 0, time.UTC)
entries := []ShellEntry{
{Type: "plugin", Name: "akismet", LastCommitted: &lc1},
{Type: "theme", Name: "astra", LastCommitted: &lc2},
}
err := BatchUpsertShellPackages(ctx, database, entries)
if err != nil {
t.Fatalf("batch upsert: %v", err)
}
var count int
_ = database.QueryRow("SELECT COUNT(*) FROM packages").Scan(&count)
if count != 2 {
t.Errorf("expected 2 packages, got %d", count)
}
// Verify individual values
var name string
var isActive int
_ = database.QueryRow("SELECT name, is_active FROM packages WHERE type='plugin' AND name='akismet'").Scan(&name, &isActive)
if name != "akismet" || isActive != 1 {
t.Errorf("got name=%s active=%d", name, isActive)
}
// Batch upsert with older dates should not overwrite
olderLC := time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC)
entries2 := []ShellEntry{
{Type: "plugin", Name: "akismet", LastCommitted: &olderLC},
}
err = BatchUpsertShellPackages(ctx, database, entries2)
if err != nil {
t.Fatalf("second batch upsert: %v", err)
}
var lastCommitted string
_ = database.QueryRow("SELECT last_committed FROM packages WHERE name='akismet'").Scan(&lastCommitted)
if lastCommitted != "2026-01-15T00:00:00Z" {
t.Errorf("last_committed should not have been overwritten, got %s", lastCommitted)
}
}
func TestBatchUpsertShellPackagesEmpty(t *testing.T) {
database := setupTestDB(t)
ctx := context.Background()
err := BatchUpsertShellPackages(ctx, database, nil)
if err != nil {
t.Fatalf("empty batch upsert should not fail: %v", err)
}
err = BatchUpsertShellPackages(ctx, database, []ShellEntry{})
if err != nil {
t.Fatalf("empty slice batch upsert should not fail: %v", err)
}
var count int
_ = database.QueryRow("SELECT COUNT(*) FROM packages").Scan(&count)
if count != 0 {
t.Errorf("expected 0 packages, got %d", count)
}
}
func TestBatchUpsertPackages(t *testing.T) {
database := setupTestDB(t)
ctx := context.Background()
ver1 := "5.0"
ver2 := "4.0"
pkgs := []*Package{
{
Type: "plugin",
Name: "akismet",
VersionsJSON: `{"5.0":"https://example.com/5.0.zip"}`,
CurrentVersion: &ver1,
IsActive: true,
Downloads: 1000,
},
{
Type: "theme",
Name: "astra",
VersionsJSON: `{"4.0":"https://example.com/4.0.zip"}`,
CurrentVersion: &ver2,
IsActive: true,
Downloads: 500,
},
}
err := BatchUpsertPackages(ctx, database, pkgs)
if err != nil {
t.Fatalf("batch upsert: %v", err)
}
var count int
_ = database.QueryRow("SELECT COUNT(*) FROM packages").Scan(&count)
if count != 2 {
t.Errorf("expected 2 packages, got %d", count)
}
// Update and re-upsert
pkgs[0].Downloads = 2000
err = BatchUpsertPackages(ctx, database, pkgs[:1])
if err != nil {
t.Fatalf("second batch upsert: %v", err)
}
var downloads int64
_ = database.QueryRow("SELECT downloads FROM packages WHERE name='akismet'").Scan(&downloads)
if downloads != 2000 {
t.Errorf("downloads = %d, want 2000", downloads)
}
// Empty batch should be no-op
err = BatchUpsertPackages(ctx, database, nil)
if err != nil {
t.Fatalf("empty batch should not fail: %v", err)
}
}
func TestAllocateSyncRunID(t *testing.T) {
database := setupTestDB(t)
ctx := context.Background()
run1, err := AllocateSyncRunID(ctx, database)
if err != nil {
t.Fatalf("first alloc: %v", err)
}
if run1.RunID != 1 {
t.Errorf("first run ID = %d, want 1", run1.RunID)
}
// Simulate a package with sync_run_id=1
_, _ = database.Exec("INSERT INTO packages (type, name, versions_json, last_sync_run_id, created_at, updated_at) VALUES ('plugin', 'test', '{}', 1, datetime('now'), datetime('now'))")
run2, err := AllocateSyncRunID(ctx, database)
if err != nil {
t.Fatalf("second alloc: %v", err)
}
if run2.RunID != 2 {
t.Errorf("second run ID = %d, want 2", run2.RunID)
}
// Verify sync_runs rows
var count int
_ = database.QueryRow("SELECT COUNT(*) FROM sync_runs").Scan(&count)
if count != 2 {
t.Errorf("expected 2 sync_runs rows, got %d", count)
}
// FinishSyncRun should work
err = FinishSyncRun(ctx, database, run2.RowID, "completed", map[string]any{"updated": 5})
if err != nil {
t.Fatalf("finish: %v", err)
}
var status string
_ = database.QueryRow("SELECT status FROM sync_runs WHERE id=?", run2.RowID).Scan(&status)
if status != "completed" {
t.Errorf("status = %q, want completed", status)
}
}