chore: add GSD codebase map with 7 analysis documents
Parallel analysis of tech stack, architecture, structure, conventions, testing patterns, integrations, and concerns. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
309
.planning/codebase/TESTING.md
Normal file
309
.planning/codebase/TESTING.md
Normal file
@@ -0,0 +1,309 @@
|
||||
# Testing Patterns
|
||||
|
||||
**Analysis Date:** 2026-03-23
|
||||
|
||||
## Test Framework
|
||||
|
||||
**Runner:**
|
||||
- Go standard `testing` package
|
||||
- No third-party test frameworks (no testify, gomega, etc.)
|
||||
- Config: none beyond standard Go tooling
|
||||
|
||||
**Assertion Style:**
|
||||
- Manual assertions using `t.Errorf` and `t.Fatalf` (no assertion library)
|
||||
- `t.Fatalf` for fatal precondition failures that should stop the test
|
||||
- `t.Errorf` for non-fatal check failures
|
||||
|
||||
**Run Commands:**
|
||||
```bash
|
||||
go test -v -coverprofile=coverage.out -coverpkg=./... ./... # All tests with coverage
|
||||
go test -v -run TestWebhookHandler ./pkg/diunwebhook/ # Single test
|
||||
go tool cover -func=coverage.out # View coverage by function
|
||||
go tool cover -html=coverage.out # View coverage in browser
|
||||
```
|
||||
|
||||
## Test File Organization
|
||||
|
||||
**Location:**
|
||||
- Co-located with source code in `pkg/diunwebhook/`
|
||||
|
||||
**Files:**
|
||||
- `pkg/diunwebhook/diunwebhook_test.go` - All tests (external test package `package diunwebhook_test`)
|
||||
- `pkg/diunwebhook/export_test.go` - Test-only exports (internal package `package diunwebhook`)
|
||||
|
||||
**Naming:**
|
||||
- Test functions: `Test<Function>_<Scenario>` (e.g., `TestWebhookHandler_BadRequest`, `TestDismissHandler_NotFound`)
|
||||
- Helper functions: lowercase descriptive names (e.g., `postTag`, `postTagAndGetID`)
|
||||
|
||||
**Structure:**
|
||||
```
|
||||
pkg/diunwebhook/
|
||||
├── diunwebhook.go # All production code
|
||||
├── diunwebhook_test.go # All tests (external package)
|
||||
└── export_test.go # Test-only exports
|
||||
```
|
||||
|
||||
## Test Structure
|
||||
|
||||
**External Test Package:**
|
||||
Tests use `package diunwebhook_test` (not `package diunwebhook`), which forces testing through the public API only. The production package is imported with an alias:
|
||||
|
||||
```go
|
||||
package diunwebhook_test
|
||||
|
||||
import (
|
||||
diun "awesomeProject/pkg/diunwebhook"
|
||||
)
|
||||
```
|
||||
|
||||
**Test Initialization:**
|
||||
`TestMain` resets the database to an in-memory SQLite instance before all tests:
|
||||
|
||||
```go
|
||||
func TestMain(m *testing.M) {
|
||||
diun.UpdatesReset()
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
```
|
||||
|
||||
**Individual Test Pattern:**
|
||||
Each test resets state at the start, then performs arrange-act-assert:
|
||||
|
||||
```go
|
||||
func TestDismissHandler_Success(t *testing.T) {
|
||||
diun.UpdatesReset() // Reset DB
|
||||
err := diun.UpdateEvent(diun.DiunEvent{Image: "nginx:latest"}) // Arrange
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/updates/nginx:latest", nil) // Act
|
||||
rec := httptest.NewRecorder()
|
||||
diun.DismissHandler(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNoContent { // Assert
|
||||
t.Errorf("expected 204, got %d", rec.Code)
|
||||
}
|
||||
m := diun.GetUpdatesMap()
|
||||
if !m["nginx:latest"].Acknowledged {
|
||||
t.Errorf("expected entry to be acknowledged")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Helper Functions:**
|
||||
Test helpers use `t.Helper()` for proper error line reporting:
|
||||
|
||||
```go
|
||||
func postTag(t *testing.T, name string) (int, int) {
|
||||
t.Helper()
|
||||
body, _ := json.Marshal(map[string]string{"name": name})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/tags", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
diun.TagsHandler(rec, req)
|
||||
return rec.Code, rec.Body.Len()
|
||||
}
|
||||
```
|
||||
|
||||
## Mocking
|
||||
|
||||
**Framework:** No mocking framework used
|
||||
|
||||
**Patterns:**
|
||||
- In-memory SQLite database via `InitDB(":memory:")` replaces the real database
|
||||
- `httptest.NewRequest` and `httptest.NewRecorder` for HTTP handler testing
|
||||
- `httptest.NewServer` for integration-level tests
|
||||
- Custom `failWriter` struct to simulate broken `http.ResponseWriter`:
|
||||
|
||||
```go
|
||||
type failWriter struct{ http.ResponseWriter }
|
||||
|
||||
func (f failWriter) Header() http.Header { return http.Header{} }
|
||||
func (f failWriter) Write([]byte) (int, error) { return 0, errors.New("forced error") }
|
||||
func (f failWriter) WriteHeader(_ int) {}
|
||||
```
|
||||
|
||||
**What to Mock:**
|
||||
- Database: use in-memory SQLite (`:memory:`)
|
||||
- HTTP layer: use `httptest` package
|
||||
- ResponseWriter errors: use custom struct implementing `http.ResponseWriter`
|
||||
|
||||
**What NOT to Mock:**
|
||||
- Handler logic (test through the HTTP interface)
|
||||
- JSON encoding/decoding (test with real payloads)
|
||||
|
||||
## Fixtures and Factories
|
||||
|
||||
**Test Data:**
|
||||
Events are constructed inline with struct literals:
|
||||
|
||||
```go
|
||||
event := diun.DiunEvent{
|
||||
DiunVersion: "1.0",
|
||||
Hostname: "host",
|
||||
Status: "new",
|
||||
Provider: "docker",
|
||||
Image: "nginx:latest",
|
||||
HubLink: "https://hub.docker.com/nginx",
|
||||
MimeType: "application/json",
|
||||
Digest: "sha256:abc",
|
||||
Created: time.Now(),
|
||||
Platform: "linux/amd64",
|
||||
}
|
||||
```
|
||||
|
||||
Minimal events are also used when only the image field matters:
|
||||
|
||||
```go
|
||||
event := diun.DiunEvent{Image: "nginx:latest"}
|
||||
```
|
||||
|
||||
**Location:**
|
||||
- No separate fixtures directory; all test data is inline in `pkg/diunwebhook/diunwebhook_test.go`
|
||||
|
||||
## Test-Only Exports
|
||||
|
||||
**File:** `pkg/diunwebhook/export_test.go`
|
||||
|
||||
These functions are only accessible to test packages (files ending in `_test.go`):
|
||||
|
||||
```go
|
||||
func GetUpdatesMap() map[string]UpdateEntry // Convenience wrapper around GetUpdates()
|
||||
func UpdatesReset() // Re-initializes DB with in-memory SQLite
|
||||
func ResetTags() // Clears tag_assignments and tags tables
|
||||
func ResetWebhookSecret() // Sets webhookSecret to ""
|
||||
```
|
||||
|
||||
## Coverage
|
||||
|
||||
**Requirements:** CI warns (does not fail) when coverage drops below 80%
|
||||
|
||||
**CI Coverage Check:**
|
||||
```bash
|
||||
go test -v -coverprofile=coverage.out -coverpkg=./... ./...
|
||||
go tool cover -func=coverage.out | tee coverage.txt
|
||||
cov=$(go tool cover -func=coverage.out | grep total: | awk '{print substr($3, 1, length($3)-1)}')
|
||||
cov=${cov%.*}
|
||||
if [ "$cov" -lt 80 ]; then
|
||||
echo "::warning::Test coverage is below 80% ($cov%)"
|
||||
fi
|
||||
```
|
||||
|
||||
**View Coverage:**
|
||||
```bash
|
||||
go test -coverprofile=coverage.out -coverpkg=./... ./...
|
||||
go tool cover -func=coverage.out # Text summary
|
||||
go tool cover -html=coverage.out # Browser view
|
||||
```
|
||||
|
||||
## CI Pipeline
|
||||
|
||||
**Platform:** Gitea Actions (Forgejo-compatible)
|
||||
|
||||
**CI Workflow:** `.gitea/workflows/ci.yml`
|
||||
- Triggers: push to `develop`, PRs targeting `develop`
|
||||
- Container: custom Docker image with Go and Node.js
|
||||
- Steps:
|
||||
1. `gofmt -l .` - Formatting check (fails build if unformatted)
|
||||
2. `go vet ./...` - Static analysis
|
||||
3. `go test -v -coverprofile=coverage.out -coverpkg=./... ./...` - Tests with coverage
|
||||
4. Coverage threshold check (80%, warning only)
|
||||
5. `go build ./...` - Build verification
|
||||
|
||||
**Release Workflow:** `.gitea/workflows/release.yml`
|
||||
- Triggers: manual dispatch with version bump type (patch/minor/major)
|
||||
- Runs the same build-test job, then creates a Docker image and Gitea release
|
||||
|
||||
**Missing from CI:**
|
||||
- No frontend build or type-check step
|
||||
- No frontend test step (no frontend tests exist)
|
||||
- No linting beyond `gofmt` and `go vet`
|
||||
|
||||
## Test Types
|
||||
|
||||
**Unit Tests:**
|
||||
- Handler tests using `httptest.NewRequest` / `httptest.NewRecorder`
|
||||
- Direct function tests: `TestUpdateEventAndGetUpdates`
|
||||
- All tests in `pkg/diunwebhook/diunwebhook_test.go`
|
||||
|
||||
**Concurrency Tests:**
|
||||
- `TestConcurrentUpdateEvent` - 100 concurrent goroutines writing to the database via `sync.WaitGroup`
|
||||
|
||||
**Integration Tests:**
|
||||
- `TestMainHandlerIntegration` - Full HTTP server via `httptest.NewServer`, tests webhook POST followed by updates GET
|
||||
|
||||
**Error Path Tests:**
|
||||
- `TestWebhookHandler_BadRequest` - invalid JSON body
|
||||
- `TestWebhookHandler_EmptyImage` - missing required field
|
||||
- `TestWebhookHandler_MethodNotAllowed` - wrong HTTP methods
|
||||
- `TestWebhookHandler_Unauthorized` / `TestWebhookHandler_WrongToken` - auth failures
|
||||
- `TestDismissHandler_NotFound` - dismiss nonexistent entry
|
||||
- `TestDismissHandler_EmptyImage` - empty path parameter
|
||||
- `TestUpdatesHandler_EncodeError` - broken ResponseWriter
|
||||
- `TestCreateTagHandler_DuplicateName` - UNIQUE constraint
|
||||
- `TestCreateTagHandler_EmptyName` - validation
|
||||
|
||||
**Behavioral Tests:**
|
||||
- `TestDismissHandler_ReappearsAfterNewWebhook` - acknowledged state resets on new webhook
|
||||
- `TestDeleteTagHandler_CascadesAssignment` - tag deletion cascades to assignments
|
||||
- `TestTagAssignmentHandler_Reassign` - reassigning image to different tag
|
||||
- `TestDismissHandler_SlashInImageName` - image names with slashes in URL path
|
||||
|
||||
**E2E Tests:**
|
||||
- Not implemented
|
||||
- No frontend tests of any kind (no test runner configured, no test files)
|
||||
|
||||
## Test Coverage Gaps
|
||||
|
||||
**Frontend (no tests at all):**
|
||||
- `frontend/src/App.tsx` - main application component
|
||||
- `frontend/src/hooks/useUpdates.ts` - polling, acknowledge, tag assignment logic
|
||||
- `frontend/src/hooks/useTags.ts` - tag CRUD logic
|
||||
- `frontend/src/components/ServiceCard.tsx` - image name parsing, registry detection
|
||||
- `frontend/src/lib/time.ts` - time formatting utilities
|
||||
- `frontend/src/lib/serviceIcons.ts` - icon lookup logic
|
||||
- Priority: Medium (pure utility functions like `getShortName`, `getRegistry`, `timeAgo` would benefit from unit tests)
|
||||
|
||||
**Backend gaps:**
|
||||
- `cmd/diunwebhook/main.go` - server startup, graceful shutdown, env var reading (not tested)
|
||||
- `TagsHandler` and `TagByIDHandler` method-not-allowed paths for unsupported HTTP methods
|
||||
- `TagAssignmentHandler` bad request paths (missing image, invalid tag_id)
|
||||
- Priority: Low (main.go is thin; handler edge cases are minor)
|
||||
|
||||
## Common Patterns
|
||||
|
||||
**HTTP Handler Testing:**
|
||||
```go
|
||||
func TestSomeHandler(t *testing.T) {
|
||||
diun.UpdatesReset()
|
||||
// arrange: create test data
|
||||
body, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest(http.MethodPost, "/path", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
// act
|
||||
diun.SomeHandler(rec, req)
|
||||
|
||||
// assert status code
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", rec.Code)
|
||||
}
|
||||
// assert response body
|
||||
var got SomeType
|
||||
json.NewDecoder(rec.Body).Decode(&got)
|
||||
}
|
||||
```
|
||||
|
||||
**State Reset Pattern:**
|
||||
Every test calls `diun.UpdatesReset()` at the start, which re-initializes the in-memory SQLite database. This ensures test isolation without needing parallel-safe fixtures.
|
||||
|
||||
**Auth Testing Pattern:**
|
||||
```go
|
||||
diun.SetWebhookSecret("my-secret")
|
||||
defer diun.ResetWebhookSecret()
|
||||
// ... test with/without Authorization header
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Testing analysis: 2026-03-23*
|
||||
Reference in New Issue
Block a user