docs(02-backend-refactor): create phase plan

This commit is contained in:
2026-03-23 21:46:57 +01:00
parent e72e1d1bea
commit 12cf34ce57
3 changed files with 941 additions and 2 deletions

View File

@@ -0,0 +1,573 @@
---
phase: 02-backend-refactor
plan: 02
type: execute
wave: 2
depends_on: [02-01]
files_modified:
- pkg/diunwebhook/diunwebhook.go
- pkg/diunwebhook/export_test.go
- pkg/diunwebhook/diunwebhook_test.go
- cmd/diunwebhook/main.go
autonomous: true
requirements: [REFAC-01, REFAC-02, REFAC-03]
must_haves:
truths:
- "All 33 existing tests pass with zero behavior change after the refactor"
- "HTTP handlers contain no SQL -- all persistence goes through Store method calls"
- "Package-level globals db, mu, and webhookSecret no longer exist"
- "main.go constructs SQLiteStore, runs migrations, builds Server, and registers routes"
- "Each test gets its own in-memory database via NewTestServer (no shared global state)"
artifacts:
- path: "pkg/diunwebhook/diunwebhook.go"
provides: "Server struct with handler methods, types, maxBodyBytes constant"
exports: ["Server", "NewServer", "DiunEvent", "UpdateEntry", "Tag"]
- path: "pkg/diunwebhook/export_test.go"
provides: "NewTestServer helper for tests"
exports: ["NewTestServer"]
- path: "cmd/diunwebhook/main.go"
provides: "Wiring: sql.Open -> RunMigrations -> NewSQLiteStore -> NewServer -> route registration"
key_links:
- from: "pkg/diunwebhook/diunwebhook.go"
to: "pkg/diunwebhook/store.go"
via: "Server.store field of type Store"
pattern: "s\\.store\\."
- from: "cmd/diunwebhook/main.go"
to: "pkg/diunwebhook/sqlite_store.go"
via: "diun.NewSQLiteStore(db)"
pattern: "NewSQLiteStore"
- from: "cmd/diunwebhook/main.go"
to: "pkg/diunwebhook/migrate.go"
via: "diun.RunMigrations(db)"
pattern: "RunMigrations"
- from: "pkg/diunwebhook/diunwebhook_test.go"
to: "pkg/diunwebhook/export_test.go"
via: "diun.NewTestServer()"
pattern: "NewTestServer"
---
<objective>
Convert all handlers from package-level functions to Server struct methods, remove global state, rewrite tests to use per-test in-memory databases, and update main.go to wire everything together.
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<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.md
<interfaces>
<!-- From Plan 01 outputs -- these files will exist when this plan runs -->
From pkg/diunwebhook/store.go:
```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:
```go
type SQLiteStore struct { db *sql.DB; mu sync.Mutex }
func NewSQLiteStore(db *sql.DB) *SQLiteStore
```
From pkg/diunwebhook/migrate.go:
```go
func RunMigrations(db *sql.DB) error
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Convert diunwebhook.go to Server struct and update main.go</name>
<files>pkg/diunwebhook/diunwebhook.go, cmd/diunwebhook/main.go</files>
<read_first>
- pkg/diunwebhook/diunwebhook.go (full current file -- handlers to convert)
- pkg/diunwebhook/store.go (Store interface from Plan 01)
- pkg/diunwebhook/sqlite_store.go (SQLiteStore from Plan 01)
- pkg/diunwebhook/migrate.go (RunMigrations from Plan 01)
- cmd/diunwebhook/main.go (current wiring to replace)
- .planning/phases/02-backend-refactor/02-RESEARCH.md (Server struct pattern, handler method pattern)
</read_first>
<action>
**Refactor `pkg/diunwebhook/diunwebhook.go`:**
1. **Remove all package-level globals** -- delete these 3 lines entirely:
```go
var (
mu sync.Mutex
db *sql.DB
webhookSecret string
)
```
2. **Remove `SetWebhookSecret` function** -- delete entirely (replaced by NewServer constructor).
3. **Remove `InitDB` function** -- delete entirely (replaced by RunMigrations + NewSQLiteStore in main.go).
4. **Remove `UpdateEvent` function** -- delete entirely (moved to SQLiteStore.UpsertEvent in sqlite_store.go).
5. **Remove `GetUpdates` function** -- delete entirely (moved to SQLiteStore.GetUpdates in sqlite_store.go).
6. **Add Server struct and constructor:**
```go
type Server struct {
store Store
webhookSecret string
}
func NewServer(store Store, webhookSecret string) *Server {
return &Server{store: store, webhookSecret: webhookSecret}
}
```
7. **Convert all 6 handler functions to methods on `*Server`:**
- `func WebhookHandler(w, r)` becomes `func (s *Server) WebhookHandler(w, r)`
- `func UpdatesHandler(w, r)` becomes `func (s *Server) UpdatesHandler(w, r)`
- `func DismissHandler(w, r)` becomes `func (s *Server) DismissHandler(w, r)`
- `func TagsHandler(w, r)` becomes `func (s *Server) TagsHandler(w, r)`
- `func TagByIDHandler(w, r)` becomes `func (s *Server) TagByIDHandler(w, r)`
- `func TagAssignmentHandler(w, r)` becomes `func (s *Server) TagAssignmentHandler(w, r)`
8. **Replace all inline SQL in handlers with Store method calls:**
In `WebhookHandler`: replace `UpdateEvent(event)` with `s.store.UpsertEvent(event)`. Keep all auth checks, method checks, MaxBytesReader, and JSON decode logic. Keep exact same error messages and status codes.
In `UpdatesHandler`: replace `GetUpdates()` with `s.store.GetUpdates()`. Keep JSON encoding logic.
In `DismissHandler`: replace the `mu.Lock(); db.Exec(UPDATE...); mu.Unlock()` block with:
```go
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 `TagsHandler` GET case: replace `db.Query(SELECT...)` block with:
```go
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 `TagsHandler` POST case: replace `mu.Lock(); db.Exec(INSERT...)` block with:
```go
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`: replace `mu.Lock(); db.Exec(DELETE...)` block with:
```go
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 `TagAssignmentHandler` PUT case: replace tag-exists check + INSERT with:
```go
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 `TagAssignmentHandler` DELETE case: replace `mu.Lock(); db.Exec(DELETE...)` with:
```go
if err := s.store.UnassignTag(req.Image); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
```
9. **Keep in diunwebhook.go:** The 3 type definitions (`DiunEvent`, `Tag`, `UpdateEntry`), the `maxBodyBytes` constant. Remove imports that are no longer needed (`database/sql`, `sync`, `time` if unused). Add `time` back only if still needed. The `crypto/subtle` import stays for webhook auth.
10. **Update `diunwebhook.go` imports** -- 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:
```go
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.Open` called directly (not via InitDB)
- `diun.RunMigrations(db)` called before store creation
- `diun.NewSQLiteStore(db)` creates the store (sets PRAGMA, MaxOpenConns internally)
- `diun.NewServer(store, secret)` creates the server
- Route registration uses `srv.WebhookHandler` (method) instead of `diun.WebhookHandler` (package function)
- `_ "modernc.org/sqlite"` blank import is in main.go (driver registration)
</action>
<verify>
<automated>cd /home/jean-luc-makiola/Development/projects/DiunDashboard && go build ./cmd/diunwebhook/ && go build ./pkg/diunwebhook/ && go vet ./... && echo "BUILD+VET OK"</automated>
</verify>
<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(` or `db.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 0
- `go vet ./...` exits 0
</acceptance_criteria>
<done>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</done>
</task>
<task type="auto">
<name>Task 2: Rewrite export_test.go and update all tests for Server/Store</name>
<files>pkg/diunwebhook/export_test.go, pkg/diunwebhook/diunwebhook_test.go</files>
<read_first>
- pkg/diunwebhook/diunwebhook_test.go (all 33 existing tests to convert)
- pkg/diunwebhook/export_test.go (current helpers to replace)
- pkg/diunwebhook/diunwebhook.go (refactored Server/handler signatures from Task 1)
- pkg/diunwebhook/store.go (Store interface)
- pkg/diunwebhook/sqlite_store.go (NewSQLiteStore)
- pkg/diunwebhook/migrate.go (RunMigrations)
- .planning/phases/02-backend-refactor/02-RESEARCH.md (export_test.go redesign pattern)
</read_first>
<action>
**Rewrite `pkg/diunwebhook/export_test.go`:**
Replace the entire file. The old helpers (`UpdatesReset`, `GetUpdatesMap`, `ResetTags`, `ResetWebhookSecret`) relied on package-level globals that no longer exist.
New content:
```go
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:
```go
func TestFoo(t *testing.T) {
diun.UpdatesReset()
// ... uses diun.WebhookHandler, diun.UpdateEvent, diun.GetUpdatesMap, etc.
}
```
NEW:
```go
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:
```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:**
1. **Remove `TestMain`** -- it only called `diun.UpdatesReset()` which is no longer needed since each test creates its own server.
2. **`TestUpdateEventAndGetUpdates`** -- replace `diun.UpdatesReset()` with `srv, err := diun.NewTestServer()`. Replace `diun.UpdateEvent(event)` with `srv.TestUpsertEvent(event)`. Replace `diun.GetUpdates()` with `srv.TestGetUpdates()`.
3. **`TestWebhookHandler`** -- replace `diun.UpdatesReset()` with `srv, err := diun.NewTestServer()`. Replace `diun.WebhookHandler(rec, req)` with `srv.WebhookHandler(rec, req)`. Replace `diun.GetUpdatesMap()` with `srv.TestGetUpdatesMap()`.
4. **`TestWebhookHandler_Unauthorized`** -- replace with `srv, err := diun.NewTestServerWithSecret("my-secret")`. Remove `defer diun.ResetWebhookSecret()`. Replace `diun.WebhookHandler` with `srv.WebhookHandler`.
5. **`TestWebhookHandler_WrongToken`** -- same as Unauthorized: use `NewTestServerWithSecret("my-secret")`.
6. **`TestWebhookHandler_ValidToken`** -- use `NewTestServerWithSecret("my-secret")`.
7. **`TestWebhookHandler_NoSecretConfigured`** -- use `diun.NewTestServer()` (no secret = open webhook).
8. **`TestWebhookHandler_BadRequest`** -- use `diun.NewTestServer()`. (Note: the old test did NOT call `UpdatesReset`, but it should use a server now.) Replace `diun.WebhookHandler` with `srv.WebhookHandler`.
9. **`TestUpdatesHandler`** -- use `diun.NewTestServer()`. Replace `diun.UpdateEvent(event)` with `srv.TestUpsertEvent(event)`. Replace `diun.UpdatesHandler` with `srv.UpdatesHandler`.
10. **`TestUpdatesHandler_EncodeError`** -- use `diun.NewTestServer()`. Replace `diun.UpdatesHandler` with `srv.UpdatesHandler`.
11. **`TestWebhookHandler_MethodNotAllowed`** -- use `diun.NewTestServer()`. Replace all `diun.WebhookHandler` with `srv.WebhookHandler`.
12. **`TestWebhookHandler_EmptyImage`** -- use `diun.NewTestServer()`. Replace handler + `GetUpdatesMap` calls.
13. **`TestConcurrentUpdateEvent`** -- use `diun.NewTestServer()`. Replace `diun.UpdateEvent(...)` with `srv.TestUpsertEvent(...)`. Replace `diun.GetUpdatesMap()` with `srv.TestGetUpdatesMap()`. **Note:** t.Fatalf cannot be called from goroutines. This is a pre-existing issue in the test. Change to `t.Errorf` inside goroutines (or use a channel/error collection pattern). The current code already has this bug -- preserve the existing behavior for now but change `t.Fatalf` to `t.Errorf` inside the goroutine.
14. **`TestMainHandlerIntegration`** -- use `diun.NewTestServer()`. Replace the inline handler router to use `srv.WebhookHandler` and `srv.UpdatesHandler` in the httptest.NewServer setup.
15. **`TestDismissHandler_Success`** -- use `diun.NewTestServer()`. Replace `diun.UpdateEvent` -> `srv.TestUpsertEvent`. Replace `diun.DismissHandler` -> `srv.DismissHandler`. Replace `diun.GetUpdatesMap` -> `srv.TestGetUpdatesMap`.
16. **`TestDismissHandler_NotFound`** -- use `diun.NewTestServer()`. Replace handler call.
17. **`TestDismissHandler_EmptyImage`** -- use `diun.NewTestServer()`. Replace handler call.
18. **`TestDismissHandler_SlashInImageName`** -- use `diun.NewTestServer()`. Replace all calls.
19. **`TestDismissHandler_ReappearsAfterNewWebhook`** -- use `diun.NewTestServer()`. Replace all calls. The `diun.UpdateEvent(...)` call without error check becomes `srv.TestUpsertEvent(...)` -- add an error check.
20. **Helper functions `postTag` and `postTagAndGetID`** -- these need the server as a parameter. Change signatures:
```go
func postTag(t *testing.T, srv *diun.Server, name string) (int, int)
func postTagAndGetID(t *testing.T, srv *diun.Server, name string) int
```
Replace `diun.TagsHandler(rec, req)` with `srv.TagsHandler(rec, req)`.
21. **All tag tests** (`TestCreateTagHandler_Success`, `TestCreateTagHandler_DuplicateName`, `TestCreateTagHandler_EmptyName`, `TestGetTagsHandler_Empty`, `TestGetTagsHandler_WithTags`, `TestDeleteTagHandler_Success`, `TestDeleteTagHandler_NotFound`, `TestDeleteTagHandler_CascadesAssignment`) -- use `diun.NewTestServer()`. Replace all handler calls. Pass `srv` to helper functions.
22. **All tag assignment tests** (`TestTagAssignmentHandler_Assign`, `TestTagAssignmentHandler_Reassign`, `TestTagAssignmentHandler_Unassign`, `TestGetUpdates_IncludesTag`) -- use `diun.NewTestServer()`. Replace all calls.
23. **Oversized body tests** (`TestWebhookHandler_OversizedBody`, `TestTagsHandler_OversizedBody`, `TestTagAssignmentHandler_OversizedBody`) -- use `diun.NewTestServer()`. Replace handler calls.
24. **`TestUpdateEvent_PreservesTagOnUpsert`** -- use `diun.NewTestServer()`. Replace `diun.UpdateEvent` -> `srv.TestUpsertEvent`. Replace handler calls. Replace `diun.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).
</action>
<verify>
<automated>cd /home/jean-luc-makiola/Development/projects/DiunDashboard && go test -v -count=1 ./pkg/diunwebhook/ 2>&1 | tail -40</automated>
</verify>
<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>
<done>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</done>
</task>
</tasks>
<verification>
- `go test -v -count=1 ./pkg/diunwebhook/` -- ALL tests pass (same test count as before the refactor)
- `go build ./cmd/diunwebhook/` -- binary compiles
- `go vet ./...` -- no issues
- `grep -r 'var db \|var mu \|var webhookSecret' pkg/diunwebhook/diunwebhook.go` -- returns empty (globals removed)
- `grep -r 'db\.Exec\|db\.Query\|db\.QueryRow' pkg/diunwebhook/diunwebhook.go` -- returns empty (no SQL in handlers)
- `grep 's\.store\.' pkg/diunwebhook/diunwebhook.go` -- returns multiple matches (handlers use Store interface)
- `grep 'diun\.UpdatesReset' pkg/diunwebhook/diunwebhook_test.go` -- returns empty (old pattern gone)
</verification>
<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>
<output>
After completion, create `.planning/phases/02-backend-refactor/02-02-SUMMARY.md`
</output>