Refactor project structure: enhance tests, improve server shutdown, expand CI checks, and update UI for better event presentation.
Some checks failed
CI / build-test (push) Failing after 4s
CI / docker (push) Has been skipped

This commit is contained in:
2026-02-23 21:12:39 +01:00
parent d38a0e7044
commit e4f32132e3
7 changed files with 153 additions and 28 deletions

View File

@@ -14,6 +14,18 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v6 uses: actions/checkout@v6
- name: Check formatting
run: |
unformatted=$(gofmt -l .)
if [ -n "$unformatted" ]; then
echo "Unformatted files:"
echo "$unformatted"
exit 1
fi
- name: Run go vet
run: go vet ./...
- name: Run tests with coverage - name: Run tests with coverage
run: | run: |
go test -v -coverprofile=coverage.out -coverpkg=./... ./... go test -v -coverprofile=coverage.out -coverpkg=./... ./...

View File

@@ -1,17 +1,54 @@
package main package main
import ( import (
"context"
"log" "log"
"net/http" "net/http"
"os"
"os/signal"
"syscall"
"time"
diun "awesomeProject/pkg/diunwebhook" diun "awesomeProject/pkg/diunwebhook"
) )
func main() { func main() {
http.HandleFunc("/webhook", diun.WebhookHandler) port := os.Getenv("PORT")
http.HandleFunc("/api/updates", diun.UpdatesHandler) if port == "" {
http.Handle("/", http.FileServer(http.Dir("./static"))) port = "8080"
}
log.Println("Listening on :8080")
log.Fatal(http.ListenAndServe(":8080", nil)) mux := http.NewServeMux()
mux.HandleFunc("/webhook", diun.WebhookHandler)
mux.HandleFunc("/api/updates", diun.UpdatesHandler)
mux.Handle("/", http.FileServer(http.Dir("./static")))
srv := &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 := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("ListenAndServe: %v", err)
}
}()
<-stop
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Printf("Shutdown error: %v", err)
} else {
log.Println("Server stopped cleanly")
}
} }

View File

@@ -32,17 +32,6 @@ var (
updates = make(map[string]DiunEvent) updates = make(map[string]DiunEvent)
) )
// 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) { func UpdateEvent(event DiunEvent) {
mu.Lock() mu.Lock()
defer mu.Unlock() defer mu.Unlock()
@@ -60,10 +49,21 @@ func GetUpdates() map[string]DiunEvent {
} }
func WebhookHandler(w http.ResponseWriter, r *http.Request) { func WebhookHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var event DiunEvent var event DiunEvent
if err := json.NewDecoder(r.Body).Decode(&event); err != nil { if err := json.NewDecoder(r.Body).Decode(&event); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) log.Printf("WebhookHandler: failed to decode request: %v", err)
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if event.Image == "" {
http.Error(w, "bad request: image field is required", http.StatusBadRequest)
return return
} }

View File

@@ -4,8 +4,10 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"sync"
"testing" "testing"
"time" "time"
@@ -104,6 +106,58 @@ func TestUpdatesHandler_EncodeError(t *testing.T) {
// No panic = pass // No panic = pass
} }
func TestWebhookHandler_MethodNotAllowed(t *testing.T) {
methods := []string{http.MethodGet, http.MethodPut, http.MethodDelete}
for _, method := range methods {
req := httptest.NewRequest(method, "/webhook", nil)
rec := httptest.NewRecorder()
diun.WebhookHandler(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("method %s: expected 405, got %d", method, rec.Code)
}
}
// POST should not return 405
event := diun.DiunEvent{Image: "nginx:latest"}
body, _ := json.Marshal(event)
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body))
rec := httptest.NewRecorder()
diun.WebhookHandler(rec, req)
if rec.Code == http.StatusMethodNotAllowed {
t.Errorf("POST should not return 405")
}
}
func TestWebhookHandler_EmptyImage(t *testing.T) {
diun.UpdatesReset()
body, _ := json.Marshal(diun.DiunEvent{Image: ""})
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body))
rec := httptest.NewRecorder()
diun.WebhookHandler(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("expected 400 for empty image, got %d", rec.Code)
}
if len(diun.GetUpdatesMap()) != 0 {
t.Errorf("expected map to stay empty, got %d entries", len(diun.GetUpdatesMap()))
}
}
func TestConcurrentUpdateEvent(t *testing.T) {
diun.UpdatesReset()
const n = 100
var wg sync.WaitGroup
wg.Add(n)
for i := range n {
go func(i int) {
defer wg.Done()
diun.UpdateEvent(diun.DiunEvent{Image: fmt.Sprintf("image:%d", i)})
}(i)
}
wg.Wait()
if got := len(diun.GetUpdatesMap()); got != n {
t.Errorf("expected %d entries, got %d", n, got)
}
}
func TestMainHandlerIntegration(t *testing.T) { func TestMainHandlerIntegration(t *testing.T) {
diun.UpdatesReset() // reset global state diun.UpdatesReset() // reset global state
// Start test server // Start test server

View File

@@ -0,0 +1,12 @@
package diunwebhook
func GetUpdatesMap() map[string]DiunEvent {
mu.Lock()
defer mu.Unlock()
return updates
}
func UpdatesReset() {
mu.Lock()
defer mu.Unlock()
updates = make(map[string]DiunEvent)
}

View File

@@ -15,9 +15,11 @@
<thead> <thead>
<tr> <tr>
<th>Image</th> <th>Image</th>
<th>Tag</th>
<th>Status</th> <th>Status</th>
<th>Last Seen</th> <th>Hostname</th>
<th>Provider</th>
<th>Digest</th>
<th>Created</th>
</tr> </tr>
</thead> </thead>
<tbody></tbody> <tbody></tbody>
@@ -28,16 +30,24 @@
const res = await fetch('/api/updates'); const res = await fetch('/api/updates');
const data = await res.json(); const data = await res.json();
const tbody = document.querySelector('tbody'); const tbody = document.querySelector('tbody');
tbody.innerHTML = ''; tbody.textContent = '';
Object.values(data).forEach(update => { Object.values(data).forEach(update => {
const row = `<tr> const row = document.createElement('tr');
<td>${update.image}</td> const fields = [
<td>${update.tag}</td> update.image,
<td>${update.status}</td> update.status,
<td>${update.time}</td> update.hostname,
</tr>`; update.provider,
tbody.innerHTML += row; update.digest,
update.created ? new Date(update.created).toLocaleString() : '',
];
fields.forEach(value => {
const td = document.createElement('td');
td.textContent = value || '';
row.appendChild(td);
});
tbody.appendChild(row);
}); });
} }

View File