* Add GET /api/stats endpoint for total downloads badge
Public JSON endpoint that reads the existing package_stats singleton
row (same data source as the admin dashboard). Includes per-IP rate
limiting (10 req/min) and Cache-Control: public, max-age=300 so
CDN/shields.io caching reduces actual hits further.
Response shape:
{ total_installs, installs_30d, active_plugins, active_themes, total_packages }
https://claude.ai/code/session_019NzdEnnohsqdqyuw5VXUgy
* Use golang.org/x/time/rate for API rate limiting and add installs badge
Replace custom token-counting rate limiter with per-IP rate.Limiter
instances from golang.org/x/time/rate. Add total composer installs
badge to README.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Strip port from RemoteAddr before rate limiting
Use clientIP to normalize host:port to just the host, so varying
source ports from the same IP share a single rate limiter. Update
middleware test to use realistic host:port addresses.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Show exact install counts with per-type breakdown on admin dashboard
- Display exact numbers with commas instead of rounded "24K"
- Show plugin and theme install counts separately
- Show timestamp in CST instead of relative time
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
123 lines
3.2 KiB
Go
123 lines
3.2 KiB
Go
package http
|
|
|
|
import (
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/roots/wp-packages/internal/app"
|
|
"github.com/roots/wp-packages/internal/config"
|
|
"github.com/roots/wp-packages/internal/db"
|
|
)
|
|
|
|
func setupStatsTestApp(t *testing.T) *app.App {
|
|
t.Helper()
|
|
database, err := db.Open(":memory:")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = database.Close() })
|
|
|
|
_, err = database.Exec(`
|
|
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
|
|
);
|
|
INSERT INTO package_stats (id, active_plugins, active_themes, plugin_installs, theme_installs, installs_30d, updated_at)
|
|
VALUES (1, 500, 200, 100000, 23456, 7890, datetime('now'));
|
|
`)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
return &app.App{
|
|
Config: &config.Config{},
|
|
DB: database,
|
|
Logger: slog.Default(),
|
|
}
|
|
}
|
|
|
|
func TestAPIStats_ReturnsJSON(t *testing.T) {
|
|
a := setupStatsTestApp(t)
|
|
handler := handleAPIStats(a)
|
|
|
|
req := httptest.NewRequest("GET", "/api/stats", nil)
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("GET /api/stats: got %d, want 200", w.Code)
|
|
}
|
|
|
|
if ct := w.Header().Get("Content-Type"); ct != "application/json" {
|
|
t.Errorf("Content-Type: got %q, want application/json", ct)
|
|
}
|
|
|
|
if cc := w.Header().Get("Cache-Control"); cc != "public, max-age=300" {
|
|
t.Errorf("Cache-Control: got %q, want public, max-age=300", cc)
|
|
}
|
|
|
|
var resp statsResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
|
|
if resp.TotalInstalls != 123456 {
|
|
t.Errorf("TotalInstalls: got %d, want 123456", resp.TotalInstalls)
|
|
}
|
|
if resp.Installs30d != 7890 {
|
|
t.Errorf("Installs30d: got %d, want 7890", resp.Installs30d)
|
|
}
|
|
if resp.ActivePlugins != 500 {
|
|
t.Errorf("ActivePlugins: got %d, want 500", resp.ActivePlugins)
|
|
}
|
|
if resp.ActiveThemes != 200 {
|
|
t.Errorf("ActiveThemes: got %d, want 200", resp.ActiveThemes)
|
|
}
|
|
if resp.TotalPackages != 700 {
|
|
t.Errorf("TotalPackages: got %d, want 700", resp.TotalPackages)
|
|
}
|
|
}
|
|
|
|
func TestAPIStats_NoStats(t *testing.T) {
|
|
database, err := db.Open(":memory:")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = database.Close() })
|
|
|
|
// Create table but no rows
|
|
_, _ = database.Exec(`
|
|
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
|
|
);
|
|
`)
|
|
|
|
a := &app.App{
|
|
Config: &config.Config{},
|
|
DB: database,
|
|
Logger: slog.Default(),
|
|
}
|
|
|
|
handler := handleAPIStats(a)
|
|
req := httptest.NewRequest("GET", "/api/stats", nil)
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusInternalServerError {
|
|
t.Errorf("GET /api/stats (no data): got %d, want 500", w.Code)
|
|
}
|
|
}
|