From 9432bf6758501d3ee1d1fb0f6c50cb63191a4ee0 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 23 Feb 2026 17:17:01 +0100 Subject: [PATCH] Refactor project structure: modularized code into `pkg` and `cmd` directories, added unit tests, improved CI/CD pipeline, and enhanced documentation. --- .gitea/workflows/ci.yml | 28 ++++ Dockerfile | 14 +- README.md | 20 +++ cmd/diunwebhook/.keep | 0 cmd/diunwebhook/main.go | 17 +++ go.sum | 0 main.go => pkg/diunwebhook/diunwebhook.go | 54 +++++--- test/diunwebhook/.keep | 0 test/diunwebhook/main_test.go | 152 ++++++++++++++++++++++ 9 files changed, 260 insertions(+), 25 deletions(-) create mode 100644 .gitea/workflows/ci.yml create mode 100644 cmd/diunwebhook/.keep create mode 100644 cmd/diunwebhook/main.go create mode 100644 go.sum rename main.go => pkg/diunwebhook/diunwebhook.go (62%) create mode 100644 test/diunwebhook/.keep create mode 100644 test/diunwebhook/main_test.go diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..c1e52c6 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,28 @@ +name: CI +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] +jobs: + build-test: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.21' + - name: Run tests with coverage + run: | + go test -v -coverprofile=coverage.out ./... + 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 + - name: Build Docker image + run: docker build -t diun-webhook-dashboard . + diff --git a/Dockerfile b/Dockerfile index bffcb3d..c743523 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,15 @@ +# syntax=docker/dockerfile:1 FROM golang:1.26-alpine AS builder WORKDIR /app -COPY . . -RUN go build -o app +COPY go.mod . -FROM alpine:latest +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o server ./cmd/diunwebhook/main.go + +FROM alpine:3.18 WORKDIR /app -COPY --from=builder /app/app . +COPY --from=builder /app/server ./server COPY static ./static EXPOSE 8080 -CMD ["./app"] \ No newline at end of file +CMD ["./server"] diff --git a/README.md b/README.md index b81b6c1..86f61da 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,12 @@ Expected JSON payload (simplified): Note: data is only kept in-memory and will be reset on restart. +## Project Structure + +- `cmd/diunwebhook/` — main application source and tests +- `static/` — static assets +- `Dockerfile`, `docker-compose.yml`, `go.mod`, `go.sum` — project config/build files + ## Development - Code: `main.go` - Static assets: `static/` @@ -65,5 +71,19 @@ Note: data is only kept in-memory and will be reset on restart. - Persistence is not implemented; hook up a store (e.g., BoltDB/Redis/Postgres) if you need durability. - Consider adding auth, rate limiting, or a secret/token on the webhook endpoint if exposed publicly. +## Testing + +Run unit tests and check coverage: + +```bash +go test -v -cover +``` + +Aim for 80–90% coverage. Coverage below 80% will emit a warning in CI but will not fail the pipeline. + +## CI/CD with Gitea Actions + +A sample Gitea Actions workflow is provided in `.gitea/workflows/ci.yml` to automate build, test, and coverage checks. + ## License MIT — see `LICENSE`. diff --git a/cmd/diunwebhook/.keep b/cmd/diunwebhook/.keep new file mode 100644 index 0000000..e69de29 diff --git a/cmd/diunwebhook/main.go b/cmd/diunwebhook/main.go new file mode 100644 index 0000000..06d9b7d --- /dev/null +++ b/cmd/diunwebhook/main.go @@ -0,0 +1,17 @@ +package main + +import ( + "log" + "net/http" + + diun "awesomeProject/pkg/diunwebhook" +) + +func main() { + http.HandleFunc("/webhook", diun.WebhookHandler) + http.HandleFunc("/api/updates", diun.UpdatesHandler) + http.Handle("/", http.FileServer(http.Dir("./static"))) + + log.Println("Listening on :8080") + log.Fatal(http.ListenAndServe(":8080", nil)) +} diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e69de29 diff --git a/main.go b/pkg/diunwebhook/diunwebhook.go similarity index 62% rename from main.go rename to pkg/diunwebhook/diunwebhook.go index 99769a7..eb6b5e4 100644 --- a/main.go +++ b/pkg/diunwebhook/diunwebhook.go @@ -1,4 +1,4 @@ -package main +package diunwebhook import ( "encoding/json" @@ -32,7 +32,34 @@ var ( updates = make(map[string]DiunEvent) ) -func webhookHandler(w http.ResponseWriter, r *http.Request) { +// Exported for test package +func GetUpdatesMap() map[string]DiunEvent { + return updates +} + +func UpdatesReset() { + mu.Lock() + defer mu.Unlock() + updates = make(map[string]DiunEvent) +} + +func UpdateEvent(event DiunEvent) { + mu.Lock() + defer mu.Unlock() + updates[event.Image] = event +} + +func GetUpdates() map[string]DiunEvent { + mu.Lock() + defer mu.Unlock() + updatesCopy := make(map[string]DiunEvent, len(updates)) + for k, v := range updates { + updatesCopy[k] = v + } + return updatesCopy +} + +func WebhookHandler(w http.ResponseWriter, r *http.Request) { var event DiunEvent if err := json.NewDecoder(r.Body).Decode(&event); err != nil { @@ -40,29 +67,16 @@ func webhookHandler(w http.ResponseWriter, r *http.Request) { return } - mu.Lock() - // Use image as key (or container name if preferred) - updates[event.Image] = event - mu.Unlock() + UpdateEvent(event) log.Printf("Update received: %s (%s)", event.Image, event.Status) w.WriteHeader(http.StatusOK) } -func updatesHandler(w http.ResponseWriter, r *http.Request) { - mu.Lock() - defer mu.Unlock() - +func UpdatesHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(updates) -} - -func main() { - http.HandleFunc("/webhook", webhookHandler) - http.HandleFunc("/api/updates", updatesHandler) - http.Handle("/", http.FileServer(http.Dir("./static"))) - - log.Println("Listening on :8080") - log.Fatal(http.ListenAndServe(":8080", nil)) + if err := json.NewEncoder(w).Encode(GetUpdates()); err != nil { + log.Printf("failed to encode updates: %v", err) + } } diff --git a/test/diunwebhook/.keep b/test/diunwebhook/.keep new file mode 100644 index 0000000..e69de29 diff --git a/test/diunwebhook/main_test.go b/test/diunwebhook/main_test.go new file mode 100644 index 0000000..1eb679e --- /dev/null +++ b/test/diunwebhook/main_test.go @@ -0,0 +1,152 @@ +package diunwebhook_test + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + diun "awesomeProject/pkg/diunwebhook" +) + +func TestUpdateEventAndGetUpdates(t *testing.T) { + diun.UpdatesReset() // helper to reset global state + 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", + } + diun.UpdateEvent(event) + got := diun.GetUpdates() + if len(got) != 1 { + t.Fatalf("expected 1 update, got %d", len(got)) + } + if got["nginx:latest"].DiunVersion != "1.0" { + t.Errorf("unexpected DiunVersion: %s", got["nginx:latest"].DiunVersion) + } +} + +func TestWebhookHandler(t *testing.T) { + diun.UpdatesReset() // reset global state + event := diun.DiunEvent{ + DiunVersion: "2.0", + Hostname: "host2", + Status: "updated", + Provider: "docker", + Image: "alpine:latest", + HubLink: "https://hub.docker.com/alpine", + MimeType: "application/json", + Digest: "sha256:def", + Created: time.Now(), + Platform: "linux/amd64", + } + body, _ := json.Marshal(event) + req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body)) + rec := httptest.NewRecorder() + diun.WebhookHandler(rec, req) + if rec.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", rec.Code) + } + if len(diun.GetUpdatesMap()) != 1 { + t.Errorf("expected 1 update, got %d", len(diun.GetUpdatesMap())) + } +} + +func TestWebhookHandler_BadRequest(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader([]byte("not-json"))) + rec := httptest.NewRecorder() + diun.WebhookHandler(rec, req) + if rec.Code != http.StatusBadRequest { + t.Errorf("expected 400 for bad JSON, got %d", rec.Code) + } +} + +func TestUpdatesHandler(t *testing.T) { + diun.UpdatesReset() // reset global state + event := diun.DiunEvent{Image: "busybox:latest"} + diun.UpdateEvent(event) + req := httptest.NewRequest(http.MethodGet, "/api/updates", nil) + rec := httptest.NewRecorder() + diun.UpdatesHandler(rec, req) + if rec.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", rec.Code) + } + var got map[string]diun.DiunEvent + if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if _, ok := got["busybox:latest"]; !ok { + t.Errorf("expected busybox:latest in updates") + } +} + +// Helper for simulating a broken ResponseWriter +// Used in TestUpdatesHandler_EncodeError +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) {} + +func TestUpdatesHandler_EncodeError(t *testing.T) { + rec := failWriter{httptest.NewRecorder()} + diun.UpdatesHandler(rec, httptest.NewRequest(http.MethodGet, "/api/updates", nil)) + // No panic = pass +} + +func TestMainHandlerIntegration(t *testing.T) { + diun.UpdatesReset() // reset global state + // Start test server + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/webhook" { + diun.WebhookHandler(w, r) + } else if r.URL.Path == "/api/updates" { + diun.UpdatesHandler(w, r) + } else { + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + // Post event + event := diun.DiunEvent{Image: "integration:latest"} + body, _ := json.Marshal(event) + resp, err := http.Post(ts.URL+"/webhook", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("webhook POST failed: %v", err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("webhook POST returned status: %d", resp.StatusCode) + } + if cerr := resp.Body.Close(); cerr != nil { + t.Errorf("failed to close response body: %v", cerr) + } + + // Get updates + resp, err = http.Get(ts.URL + "/api/updates") + if err != nil { + t.Fatalf("GET /api/updates failed: %v", err) + } + defer func() { + if cerr := resp.Body.Close(); cerr != nil { + t.Errorf("failed to close response body: %v", cerr) + } + }() + var got map[string]diun.DiunEvent + if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { + t.Fatalf("decode failed: %v", err) + } + if _, ok := got["integration:latest"]; !ok { + t.Errorf("expected integration:latest in updates") + } +}