25 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 02-backend-refactor | 02 | execute | 2 |
|
|
true |
|
|
Purpose: Complete the refactor so handlers use the Store interface (no SQL in handlers), globals are eliminated, and each test is isolated with its own database. This is the "big flip" that makes the codebase ready for PostgreSQL support. Output: Refactored diunwebhook.go, rewritten export_test.go + test file, updated main.go. All existing tests pass.
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/02-backend-refactor/02-RESEARCH.md @.planning/phases/02-backend-refactor/02-01-SUMMARY.mdFrom pkg/diunwebhook/store.go:
type Store interface {
UpsertEvent(event DiunEvent) error
GetUpdates() (map[string]UpdateEntry, error)
AcknowledgeUpdate(image string) (found bool, err error)
ListTags() ([]Tag, error)
CreateTag(name string) (Tag, error)
DeleteTag(id int) (found bool, err error)
AssignTag(image string, tagID int) error
UnassignTag(image string) error
TagExists(id int) (bool, error)
}
From pkg/diunwebhook/sqlite_store.go:
type SQLiteStore struct { db *sql.DB; mu sync.Mutex }
func NewSQLiteStore(db *sql.DB) *SQLiteStore
From pkg/diunwebhook/migrate.go:
func RunMigrations(db *sql.DB) error
-
Remove all package-level globals -- delete these 3 lines entirely:
var ( mu sync.Mutex db *sql.DB webhookSecret string ) -
Remove
SetWebhookSecretfunction -- delete entirely (replaced by NewServer constructor). -
Remove
InitDBfunction -- delete entirely (replaced by RunMigrations + NewSQLiteStore in main.go). -
Remove
UpdateEventfunction -- delete entirely (moved to SQLiteStore.UpsertEvent in sqlite_store.go). -
Remove
GetUpdatesfunction -- delete entirely (moved to SQLiteStore.GetUpdates in sqlite_store.go). -
Add Server struct and constructor:
type Server struct { store Store webhookSecret string } func NewServer(store Store, webhookSecret string) *Server { return &Server{store: store, webhookSecret: webhookSecret} } -
Convert all 6 handler functions to methods on
*Server:func WebhookHandler(w, r)becomesfunc (s *Server) WebhookHandler(w, r)func UpdatesHandler(w, r)becomesfunc (s *Server) UpdatesHandler(w, r)func DismissHandler(w, r)becomesfunc (s *Server) DismissHandler(w, r)func TagsHandler(w, r)becomesfunc (s *Server) TagsHandler(w, r)func TagByIDHandler(w, r)becomesfunc (s *Server) TagByIDHandler(w, r)func TagAssignmentHandler(w, r)becomesfunc (s *Server) TagAssignmentHandler(w, r)
-
Replace all inline SQL in handlers with Store method calls:
In
WebhookHandler: replaceUpdateEvent(event)withs.store.UpsertEvent(event). Keep all auth checks, method checks, MaxBytesReader, and JSON decode logic. Keep exact same error messages and status codes.In
UpdatesHandler: replaceGetUpdates()withs.store.GetUpdates(). Keep JSON encoding logic.In
DismissHandler: replace themu.Lock(); db.Exec(UPDATE...); mu.Unlock()block with:found, err := s.store.AcknowledgeUpdate(image) if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } if !found { http.Error(w, "not found", http.StatusNotFound) return }In
TagsHandlerGET case: replacedb.Query(SELECT...)block with:tags, err := s.store.ListTags() if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(tags)In
TagsHandlerPOST case: replacemu.Lock(); db.Exec(INSERT...)block with:tag, err := s.store.CreateTag(req.Name) if err != nil { if strings.Contains(err.Error(), "UNIQUE") { http.Error(w, "conflict: tag name already exists", http.StatusConflict) return } http.Error(w, "internal error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(tag)In
TagByIDHandler: replacemu.Lock(); db.Exec(DELETE...)block with:found, err := s.store.DeleteTag(id) if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } if !found { http.Error(w, "not found", http.StatusNotFound) return }In
TagAssignmentHandlerPUT case: replace tag-exists check + INSERT with:exists, err := s.store.TagExists(req.TagID) if err != nil || !exists { http.Error(w, "not found: tag does not exist", http.StatusNotFound) return } if err := s.store.AssignTag(req.Image, req.TagID); err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return }In
TagAssignmentHandlerDELETE case: replacemu.Lock(); db.Exec(DELETE...)with:if err := s.store.UnassignTag(req.Image); err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } -
Keep in diunwebhook.go: The 3 type definitions (
DiunEvent,Tag,UpdateEntry), themaxBodyBytesconstant. Remove imports that are no longer needed (database/sql,sync,timeif unused). Addtimeback only if still needed. Thecrypto/subtleimport stays for webhook auth. -
Update
diunwebhook.goimports -- remove:database/sql,sync,time(if no longer used after removing UpdateEvent/GetUpdates). Keep:crypto/subtle,encoding/json,errors,log,net/http,strconv,strings. Remove the blank import_ "modernc.org/sqlite"(it moves to migrate.go or sqlite_store.go).
Update cmd/diunwebhook/main.go:
Replace the current InitDB + SetWebhookSecret + package-level handler registration with:
package main
import (
"context"
"database/sql"
"errors"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
diun "awesomeProject/pkg/diunwebhook"
_ "modernc.org/sqlite"
)
func main() {
dbPath := os.Getenv("DB_PATH")
if dbPath == "" {
dbPath = "./diun.db"
}
db, err := sql.Open("sqlite", dbPath)
if err != nil {
log.Fatalf("sql.Open: %v", err)
}
if err := diun.RunMigrations(db); err != nil {
log.Fatalf("RunMigrations: %v", err)
}
store := diun.NewSQLiteStore(db)
secret := os.Getenv("WEBHOOK_SECRET")
if secret == "" {
log.Println("WARNING: WEBHOOK_SECRET not set — webhook endpoint is unprotected")
} else {
log.Println("Webhook endpoint protected with token authentication")
}
srv := diun.NewServer(store, secret)
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
mux := http.NewServeMux()
mux.HandleFunc("/webhook", srv.WebhookHandler)
mux.HandleFunc("/api/updates/", srv.DismissHandler)
mux.HandleFunc("/api/updates", srv.UpdatesHandler)
mux.HandleFunc("/api/tags", srv.TagsHandler)
mux.HandleFunc("/api/tags/", srv.TagByIDHandler)
mux.HandleFunc("/api/tag-assignments", srv.TagAssignmentHandler)
mux.Handle("/", http.FileServer(http.Dir("./frontend/dist")))
httpSrv := &http.Server{
Addr: ":" + port,
Handler: mux,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
go func() {
log.Printf("Listening on :%s", port)
if err := httpSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("ListenAndServe: %v", err)
}
}()
<-stop
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := httpSrv.Shutdown(ctx); err != nil {
log.Printf("Shutdown error: %v", err)
} else {
log.Println("Server stopped cleanly")
}
}
Key changes in main.go:
sql.Opencalled directly (not via InitDB)diun.RunMigrations(db)called before store creationdiun.NewSQLiteStore(db)creates the store (sets PRAGMA, MaxOpenConns internally)diun.NewServer(store, secret)creates the server- Route registration uses
srv.WebhookHandler(method) instead ofdiun.WebhookHandler(package function) _ "modernc.org/sqlite"blank import is in main.go (driver registration) cd /home/jean-luc-makiola/Development/projects/DiunDashboard && go build ./cmd/diunwebhook/ && go build ./pkg/diunwebhook/ && go vet ./... && echo "BUILD+VET OK" <acceptance_criteria>- pkg/diunwebhook/diunwebhook.go contains
type Server struct { - pkg/diunwebhook/diunwebhook.go contains
func NewServer(store Store, webhookSecret string) *Server - pkg/diunwebhook/diunwebhook.go contains
func (s *Server) WebhookHandler( - pkg/diunwebhook/diunwebhook.go contains
func (s *Server) UpdatesHandler( - pkg/diunwebhook/diunwebhook.go contains
func (s *Server) DismissHandler( - pkg/diunwebhook/diunwebhook.go contains
func (s *Server) TagsHandler( - pkg/diunwebhook/diunwebhook.go contains
func (s *Server) TagByIDHandler( - pkg/diunwebhook/diunwebhook.go contains
func (s *Server) TagAssignmentHandler( - pkg/diunwebhook/diunwebhook.go contains
s.store.UpsertEvent(handler calls store, not direct SQL) - pkg/diunwebhook/diunwebhook.go does NOT contain
var db *sql.DB(global removed) - pkg/diunwebhook/diunwebhook.go does NOT contain
var mu sync.Mutex(global removed) - pkg/diunwebhook/diunwebhook.go does NOT contain
var webhookSecret string(global removed) - pkg/diunwebhook/diunwebhook.go does NOT contain
func InitDB((removed) - pkg/diunwebhook/diunwebhook.go does NOT contain
func SetWebhookSecret((removed) - pkg/diunwebhook/diunwebhook.go does NOT contain
db.Exec(ordb.Query((no SQL in handlers) - cmd/diunwebhook/main.go contains
diun.RunMigrations(db) - cmd/diunwebhook/main.go contains
diun.NewSQLiteStore(db) - cmd/diunwebhook/main.go contains
diun.NewServer(store, secret) - cmd/diunwebhook/main.go contains
srv.WebhookHandler(method reference, not package function) go build ./cmd/diunwebhook/exits 0go vet ./...exits 0 </acceptance_criteria> Handlers are methods on Server calling s.store.X(); no package-level globals remain; main.go wires sql.Open -> RunMigrations -> NewSQLiteStore -> NewServer -> routes; both packages compile and pass go vet
- pkg/diunwebhook/diunwebhook.go contains
Replace the entire file. The old helpers (UpdatesReset, GetUpdatesMap, ResetTags, ResetWebhookSecret) relied on package-level globals that no longer exist.
New content:
package diunwebhook
import "database/sql"
// NewTestServer constructs a Server with a fresh in-memory SQLite database.
// Each call returns an isolated server -- tests do not share state.
func NewTestServer() (*Server, error) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
return nil, err
}
if err := RunMigrations(db); err != nil {
return nil, err
}
store := NewSQLiteStore(db)
return NewServer(store, ""), nil
}
// NewTestServerWithSecret constructs a Server with webhook authentication enabled.
func NewTestServerWithSecret(secret string) (*Server, error) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
return nil, err
}
if err := RunMigrations(db); err != nil {
return nil, err
}
store := NewSQLiteStore(db)
return NewServer(store, secret), nil
}
Rewrite pkg/diunwebhook/diunwebhook_test.go:
The test file is package diunwebhook_test (external test package). Every test that previously called diun.UpdatesReset() to get a clean global DB must now call diun.NewTestServer() to get its own isolated server.
Conversion pattern for every test:
OLD:
func TestFoo(t *testing.T) {
diun.UpdatesReset()
// ... uses diun.WebhookHandler, diun.UpdateEvent, diun.GetUpdatesMap, etc.
}
NEW:
func TestFoo(t *testing.T) {
srv, err := diun.NewTestServer()
if err != nil {
t.Fatalf("NewTestServer: %v", err)
}
// ... uses srv.WebhookHandler, srv.Store().UpsertEvent, etc.
}
But wait: srv.Store() does not exist -- the store field is unexported. Tests need a way to call UpsertEvent and GetUpdates directly. Two options:
Option A: Add a Store() accessor method to Server (exported, for tests).
Option B: Add test-helper functions in export_test.go that access s.store directly (since export_test.go is in the internal package).
Use Option B -- add these helpers in export_test.go:
// TestUpsertEvent calls UpsertEvent on the server's store (for test setup).
func (s *Server) TestUpsertEvent(event DiunEvent) error {
return s.store.UpsertEvent(event)
}
// TestGetUpdates calls GetUpdates on the server's store (for test assertions).
func (s *Server) TestGetUpdates() (map[string]UpdateEntry, error) {
return s.store.GetUpdates()
}
// TestGetUpdatesMap is a convenience wrapper that returns the map without error.
func (s *Server) TestGetUpdatesMap() map[string]UpdateEntry {
m, _ := s.store.GetUpdates()
return m
}
Now convert each test function. Here are the specific conversions for ALL tests:
-
Remove
TestMain-- it only calleddiun.UpdatesReset()which is no longer needed since each test creates its own server. -
TestUpdateEventAndGetUpdates-- replacediun.UpdatesReset()withsrv, err := diun.NewTestServer(). Replacediun.UpdateEvent(event)withsrv.TestUpsertEvent(event). Replacediun.GetUpdates()withsrv.TestGetUpdates(). -
TestWebhookHandler-- replacediun.UpdatesReset()withsrv, err := diun.NewTestServer(). Replacediun.WebhookHandler(rec, req)withsrv.WebhookHandler(rec, req). Replacediun.GetUpdatesMap()withsrv.TestGetUpdatesMap(). -
TestWebhookHandler_Unauthorized-- replace withsrv, err := diun.NewTestServerWithSecret("my-secret"). Removedefer diun.ResetWebhookSecret(). Replacediun.WebhookHandlerwithsrv.WebhookHandler. -
TestWebhookHandler_WrongToken-- same as Unauthorized: useNewTestServerWithSecret("my-secret"). -
TestWebhookHandler_ValidToken-- useNewTestServerWithSecret("my-secret"). -
TestWebhookHandler_NoSecretConfigured-- usediun.NewTestServer()(no secret = open webhook). -
TestWebhookHandler_BadRequest-- usediun.NewTestServer(). (Note: the old test did NOT callUpdatesReset, but it should use a server now.) Replacediun.WebhookHandlerwithsrv.WebhookHandler. -
TestUpdatesHandler-- usediun.NewTestServer(). Replacediun.UpdateEvent(event)withsrv.TestUpsertEvent(event). Replacediun.UpdatesHandlerwithsrv.UpdatesHandler. -
TestUpdatesHandler_EncodeError-- usediun.NewTestServer(). Replacediun.UpdatesHandlerwithsrv.UpdatesHandler. -
TestWebhookHandler_MethodNotAllowed-- usediun.NewTestServer(). Replace alldiun.WebhookHandlerwithsrv.WebhookHandler. -
TestWebhookHandler_EmptyImage-- usediun.NewTestServer(). Replace handler +GetUpdatesMapcalls. -
TestConcurrentUpdateEvent-- usediun.NewTestServer(). Replacediun.UpdateEvent(...)withsrv.TestUpsertEvent(...). Replacediun.GetUpdatesMap()withsrv.TestGetUpdatesMap(). Note: t.Fatalf cannot be called from goroutines. This is a pre-existing issue in the test. Change tot.Errorfinside goroutines (or use a channel/error collection pattern). The current code already has this bug -- preserve the existing behavior for now but changet.Fatalftot.Errorfinside the goroutine. -
TestMainHandlerIntegration-- usediun.NewTestServer(). Replace the inline handler router to usesrv.WebhookHandlerandsrv.UpdatesHandlerin the httptest.NewServer setup. -
TestDismissHandler_Success-- usediun.NewTestServer(). Replacediun.UpdateEvent->srv.TestUpsertEvent. Replacediun.DismissHandler->srv.DismissHandler. Replacediun.GetUpdatesMap->srv.TestGetUpdatesMap. -
TestDismissHandler_NotFound-- usediun.NewTestServer(). Replace handler call. -
TestDismissHandler_EmptyImage-- usediun.NewTestServer(). Replace handler call. -
TestDismissHandler_SlashInImageName-- usediun.NewTestServer(). Replace all calls. -
TestDismissHandler_ReappearsAfterNewWebhook-- usediun.NewTestServer(). Replace all calls. Thediun.UpdateEvent(...)call without error check becomessrv.TestUpsertEvent(...)-- add an error check. -
Helper functions
postTagandpostTagAndGetID-- these need the server as a parameter. Change signatures:func postTag(t *testing.T, srv *diun.Server, name string) (int, int) func postTagAndGetID(t *testing.T, srv *diun.Server, name string) intReplace
diun.TagsHandler(rec, req)withsrv.TagsHandler(rec, req). -
All tag tests (
TestCreateTagHandler_Success,TestCreateTagHandler_DuplicateName,TestCreateTagHandler_EmptyName,TestGetTagsHandler_Empty,TestGetTagsHandler_WithTags,TestDeleteTagHandler_Success,TestDeleteTagHandler_NotFound,TestDeleteTagHandler_CascadesAssignment) -- usediun.NewTestServer(). Replace all handler calls. Passsrvto helper functions. -
All tag assignment tests (
TestTagAssignmentHandler_Assign,TestTagAssignmentHandler_Reassign,TestTagAssignmentHandler_Unassign,TestGetUpdates_IncludesTag) -- usediun.NewTestServer(). Replace all calls. -
Oversized body tests (
TestWebhookHandler_OversizedBody,TestTagsHandler_OversizedBody,TestTagAssignmentHandler_OversizedBody) -- usediun.NewTestServer(). Replace handler calls. -
TestUpdateEvent_PreservesTagOnUpsert-- usediun.NewTestServer(). Replacediun.UpdateEvent->srv.TestUpsertEvent. Replace handler calls. Replacediun.GetUpdatesMap->srv.TestGetUpdatesMap.
Remove these imports from test file (no longer needed):
os(was for TestMain's os.Exit)
Verify all HTTP status codes, error messages, and assertion logic remain IDENTICAL to the original tests. The only change is the source of the handler function (method on srv instead of package function) and the source of test data (srv.TestUpsertEvent instead of diun.UpdateEvent).
cd /home/jean-luc-makiola/Development/projects/DiunDashboard && go test -v -count=1 ./pkg/diunwebhook/ 2>&1 | tail -40
<acceptance_criteria>
- pkg/diunwebhook/export_test.go contains func NewTestServer() (*Server, error)
- pkg/diunwebhook/export_test.go contains func NewTestServerWithSecret(secret string) (*Server, error)
- pkg/diunwebhook/export_test.go contains func (s *Server) TestUpsertEvent(event DiunEvent) error
- pkg/diunwebhook/export_test.go contains func (s *Server) TestGetUpdatesMap() map[string]UpdateEntry
- pkg/diunwebhook/export_test.go does NOT contain func UpdatesReset() (old helper removed)
- pkg/diunwebhook/export_test.go does NOT contain func ResetWebhookSecret() (old helper removed)
- pkg/diunwebhook/diunwebhook_test.go does NOT contain diun.UpdatesReset() (replaced with NewTestServer)
- pkg/diunwebhook/diunwebhook_test.go does NOT contain diun.SetWebhookSecret( (replaced with NewTestServerWithSecret)
- pkg/diunwebhook/diunwebhook_test.go contains diun.NewTestServer() (new pattern)
- pkg/diunwebhook/diunwebhook_test.go contains srv.WebhookHandler( (method call, not package function)
- pkg/diunwebhook/diunwebhook_test.go contains srv.TestUpsertEvent( (test helper)
- pkg/diunwebhook/diunwebhook_test.go contains srv.TestGetUpdatesMap() (test helper)
- pkg/diunwebhook/diunwebhook_test.go does NOT contain func TestMain( (removed, no longer needed)
- go test -v -count=1 ./pkg/diunwebhook/ exits 0 with all tests passing
- go test -v -count=1 ./pkg/diunwebhook/ output contains PASS
</acceptance_criteria>
All existing tests pass against the new Server/Store architecture; each test has its own in-memory database; no shared global state; test output shows PASS with 0 failures
<success_criteria>
- All existing tests pass with zero behavior change (same HTTP status codes, same error messages, same data semantics)
- HTTP handlers contain no SQL -- every persistence call goes through s.store.X()
- Package-level globals db, mu, webhookSecret are deleted from diunwebhook.go
- main.go wires: sql.Open -> RunMigrations -> NewSQLiteStore -> NewServer -> route registration
- Each test creates its own in-memory database via NewTestServer() (parallel-safe)
- go vet passes on all packages </success_criteria>