- **refactor(main):** migrate static HTML to React components
- **feat(ui):** implement `AcknowledgeButton` component for acknowledging images - **feat(stats):** add dashboard stats for total images, pending updates, and acknowledged status - **chore(deps):** introduce `bun` dependency management and add required libraries - **style(ui):** enhance UI with Tailwind-based components and modularity improvements - **chore:** add drag-and-drop tag assignment using `@dnd-kit/core`
This commit is contained in:
@@ -1,11 +1,16 @@
|
||||
package diunwebhook
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
type DiunEvent struct {
|
||||
@@ -27,25 +32,121 @@ type DiunEvent struct {
|
||||
} `json:"metadata"`
|
||||
}
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
updates = make(map[string]DiunEvent)
|
||||
)
|
||||
|
||||
func UpdateEvent(event DiunEvent) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
updates[event.Image] = event
|
||||
type Tag struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func GetUpdates() map[string]DiunEvent {
|
||||
type UpdateEntry struct {
|
||||
Event DiunEvent `json:"event"`
|
||||
ReceivedAt time.Time `json:"received_at"`
|
||||
Acknowledged bool `json:"acknowledged"`
|
||||
Tag *Tag `json:"tag"`
|
||||
}
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
db *sql.DB
|
||||
)
|
||||
|
||||
func InitDB(path string) error {
|
||||
var err error
|
||||
db, err = sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS updates (
|
||||
image TEXT PRIMARY KEY,
|
||||
diun_version TEXT NOT NULL DEFAULT '',
|
||||
hostname TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT '',
|
||||
provider TEXT NOT NULL DEFAULT '',
|
||||
hub_link TEXT NOT NULL DEFAULT '',
|
||||
mime_type TEXT NOT NULL DEFAULT '',
|
||||
digest TEXT NOT NULL DEFAULT '',
|
||||
created TEXT NOT NULL DEFAULT '',
|
||||
platform TEXT NOT NULL DEFAULT '',
|
||||
ctn_name TEXT NOT NULL DEFAULT '',
|
||||
ctn_id TEXT NOT NULL DEFAULT '',
|
||||
ctn_state TEXT NOT NULL DEFAULT '',
|
||||
ctn_status TEXT NOT NULL DEFAULT '',
|
||||
received_at TEXT NOT NULL,
|
||||
acknowledged_at TEXT
|
||||
)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Migration: add acknowledged_at to existing databases (silently ignored if already present)
|
||||
_, _ = db.Exec(`ALTER TABLE updates ADD COLUMN acknowledged_at TEXT`)
|
||||
|
||||
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS tags (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE
|
||||
)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS tag_assignments (
|
||||
image TEXT PRIMARY KEY,
|
||||
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE
|
||||
)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func UpdateEvent(event DiunEvent) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
updatesCopy := make(map[string]DiunEvent, len(updates))
|
||||
for k, v := range updates {
|
||||
updatesCopy[k] = v
|
||||
_, err := db.Exec(`INSERT OR REPLACE INTO updates VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,NULL)`,
|
||||
event.Image, event.DiunVersion, event.Hostname, event.Status, event.Provider,
|
||||
event.HubLink, event.MimeType, event.Digest,
|
||||
event.Created.Format(time.RFC3339), event.Platform,
|
||||
event.Metadata.ContainerName, event.Metadata.ContainerID,
|
||||
event.Metadata.State, event.Metadata.Status,
|
||||
time.Now().Format(time.RFC3339),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func GetUpdates() (map[string]UpdateEntry, error) {
|
||||
rows, err := db.Query(`SELECT u.image, u.diun_version, u.hostname, u.status, u.provider,
|
||||
u.hub_link, u.mime_type, u.digest, u.created, u.platform,
|
||||
u.ctn_name, u.ctn_id, u.ctn_state, u.ctn_status, u.received_at, COALESCE(u.acknowledged_at, ''),
|
||||
t.id, t.name
|
||||
FROM updates u
|
||||
LEFT JOIN tag_assignments ta ON u.image = ta.image
|
||||
LEFT JOIN tags t ON ta.tag_id = t.id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return updatesCopy
|
||||
defer rows.Close()
|
||||
result := make(map[string]UpdateEntry)
|
||||
for rows.Next() {
|
||||
var e UpdateEntry
|
||||
var createdStr, receivedStr, acknowledgedAt string
|
||||
var tagID sql.NullInt64
|
||||
var tagName sql.NullString
|
||||
err := rows.Scan(&e.Event.Image, &e.Event.DiunVersion, &e.Event.Hostname,
|
||||
&e.Event.Status, &e.Event.Provider, &e.Event.HubLink, &e.Event.MimeType,
|
||||
&e.Event.Digest, &createdStr, &e.Event.Platform,
|
||||
&e.Event.Metadata.ContainerName, &e.Event.Metadata.ContainerID,
|
||||
&e.Event.Metadata.State, &e.Event.Metadata.Status,
|
||||
&receivedStr, &acknowledgedAt, &tagID, &tagName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.Event.Created, _ = time.Parse(time.RFC3339, createdStr)
|
||||
e.ReceivedAt, _ = time.Parse(time.RFC3339, receivedStr)
|
||||
e.Acknowledged = acknowledgedAt != ""
|
||||
if tagID.Valid && tagName.Valid {
|
||||
e.Tag = &Tag{ID: int(tagID.Int64), Name: tagName.String}
|
||||
}
|
||||
result[e.Event.Image] = e
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func WebhookHandler(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -67,7 +168,11 @@ func WebhookHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
UpdateEvent(event)
|
||||
if err := UpdateEvent(event); err != nil {
|
||||
log.Printf("WebhookHandler: failed to store event: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Update received: %s (%s)", event.Image, event.Status)
|
||||
|
||||
@@ -75,8 +180,171 @@ func WebhookHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func UpdatesHandler(w http.ResponseWriter, r *http.Request) {
|
||||
updates, err := GetUpdates()
|
||||
if err != nil {
|
||||
log.Printf("UpdatesHandler: failed to get updates: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(GetUpdates()); err != nil {
|
||||
if err := json.NewEncoder(w).Encode(updates); err != nil {
|
||||
log.Printf("failed to encode updates: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func DismissHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPatch {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
image := strings.TrimPrefix(r.URL.Path, "/api/updates/")
|
||||
if image == "" {
|
||||
http.Error(w, "bad request: image name required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
res, err := db.Exec(`UPDATE updates SET acknowledged_at = datetime('now') WHERE image = ?`, image)
|
||||
mu.Unlock()
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// TagsHandler handles GET /api/tags and POST /api/tags
|
||||
func TagsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
rows, err := db.Query(`SELECT id, name FROM tags ORDER BY name`)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
tags := []Tag{}
|
||||
for rows.Next() {
|
||||
var t Tag
|
||||
if err := rows.Scan(&t.ID, &t.Name); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
tags = append(tags, t)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(tags)
|
||||
|
||||
case http.MethodPost:
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" {
|
||||
http.Error(w, "bad request: name required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
res, err := db.Exec(`INSERT INTO tags (name) VALUES (?)`, req.Name)
|
||||
mu.Unlock()
|
||||
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
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(Tag{ID: int(id), Name: req.Name})
|
||||
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
// TagByIDHandler handles DELETE /api/tags/{id}
|
||||
func TagByIDHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
idStr := strings.TrimPrefix(r.URL.Path, "/api/tags/")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil || id <= 0 {
|
||||
http.Error(w, "bad request: invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
res, err := db.Exec(`DELETE FROM tags WHERE id = ?`, id)
|
||||
mu.Unlock()
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// TagAssignmentHandler handles PUT /api/tag-assignments and DELETE /api/tag-assignments
|
||||
func TagAssignmentHandler(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodPut:
|
||||
var req struct {
|
||||
Image string `json:"image"`
|
||||
TagID int `json:"tag_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Image == "" {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Check tag exists
|
||||
var exists int
|
||||
err := db.QueryRow(`SELECT COUNT(*) FROM tags WHERE id = ?`, req.TagID).Scan(&exists)
|
||||
if err != nil || exists == 0 {
|
||||
http.Error(w, "not found: tag does not exist", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
_, err = db.Exec(`INSERT OR REPLACE INTO tag_assignments (image, tag_id) VALUES (?, ?)`, req.Image, req.TagID)
|
||||
mu.Unlock()
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
||||
case http.MethodDelete:
|
||||
var req struct {
|
||||
Image string `json:"image"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Image == "" {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
_, err := db.Exec(`DELETE FROM tag_assignments WHERE image = ?`, req.Image)
|
||||
mu.Unlock()
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -14,8 +15,13 @@ import (
|
||||
diun "awesomeProject/pkg/diunwebhook"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
diun.UpdatesReset()
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func TestUpdateEventAndGetUpdates(t *testing.T) {
|
||||
diun.UpdatesReset() // helper to reset global state
|
||||
diun.UpdatesReset()
|
||||
event := diun.DiunEvent{
|
||||
DiunVersion: "1.0",
|
||||
Hostname: "host",
|
||||
@@ -29,17 +35,20 @@ func TestUpdateEventAndGetUpdates(t *testing.T) {
|
||||
Platform: "linux/amd64",
|
||||
}
|
||||
diun.UpdateEvent(event)
|
||||
got := diun.GetUpdates()
|
||||
got, err := diun.GetUpdates()
|
||||
if err != nil {
|
||||
t.Fatalf("GetUpdates error: %v", err)
|
||||
}
|
||||
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)
|
||||
if got["nginx:latest"].Event.DiunVersion != "1.0" {
|
||||
t.Errorf("unexpected DiunVersion: %s", got["nginx:latest"].Event.DiunVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookHandler(t *testing.T) {
|
||||
diun.UpdatesReset() // reset global state
|
||||
diun.UpdatesReset()
|
||||
event := diun.DiunEvent{
|
||||
DiunVersion: "2.0",
|
||||
Hostname: "host2",
|
||||
@@ -74,7 +83,7 @@ func TestWebhookHandler_BadRequest(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdatesHandler(t *testing.T) {
|
||||
diun.UpdatesReset() // reset global state
|
||||
diun.UpdatesReset()
|
||||
event := diun.DiunEvent{Image: "busybox:latest"}
|
||||
diun.UpdateEvent(event)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/updates", nil)
|
||||
@@ -83,7 +92,7 @@ func TestUpdatesHandler(t *testing.T) {
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d", rec.Code)
|
||||
}
|
||||
var got map[string]diun.DiunEvent
|
||||
var got map[string]diun.UpdateEntry
|
||||
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
@@ -159,8 +168,7 @@ func TestConcurrentUpdateEvent(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMainHandlerIntegration(t *testing.T) {
|
||||
diun.UpdatesReset() // reset global state
|
||||
// Start test server
|
||||
diun.UpdatesReset()
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/webhook" {
|
||||
diun.WebhookHandler(w, r)
|
||||
@@ -172,7 +180,6 @@ func TestMainHandlerIntegration(t *testing.T) {
|
||||
}))
|
||||
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))
|
||||
@@ -186,7 +193,6 @@ func TestMainHandlerIntegration(t *testing.T) {
|
||||
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)
|
||||
@@ -196,7 +202,7 @@ func TestMainHandlerIntegration(t *testing.T) {
|
||||
t.Errorf("failed to close response body: %v", cerr)
|
||||
}
|
||||
}()
|
||||
var got map[string]diun.DiunEvent
|
||||
var got map[string]diun.UpdateEntry
|
||||
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("decode failed: %v", err)
|
||||
}
|
||||
@@ -204,3 +210,329 @@ func TestMainHandlerIntegration(t *testing.T) {
|
||||
t.Errorf("expected integration:latest in updates")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDismissHandler_Success(t *testing.T) {
|
||||
diun.UpdatesReset()
|
||||
diun.UpdateEvent(diun.DiunEvent{Image: "nginx:latest"})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/updates/nginx:latest", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
diun.DismissHandler(rec, req)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Errorf("expected 204, got %d", rec.Code)
|
||||
}
|
||||
m := diun.GetUpdatesMap()
|
||||
if len(m) != 1 {
|
||||
t.Errorf("expected entry to remain after acknowledge, got %d entries", len(m))
|
||||
}
|
||||
if !m["nginx:latest"].Acknowledged {
|
||||
t.Errorf("expected entry to be acknowledged")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDismissHandler_NotFound(t *testing.T) {
|
||||
diun.UpdatesReset()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/updates/does-not-exist:latest", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
diun.DismissHandler(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDismissHandler_EmptyImage(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/updates/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
diun.DismissHandler(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDismissHandler_SlashInImageName(t *testing.T) {
|
||||
diun.UpdatesReset()
|
||||
diun.UpdateEvent(diun.DiunEvent{Image: "ghcr.io/user/image:tag"})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/updates/ghcr.io/user/image:tag", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
diun.DismissHandler(rec, req)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Errorf("expected 204, got %d", rec.Code)
|
||||
}
|
||||
m := diun.GetUpdatesMap()
|
||||
if len(m) != 1 {
|
||||
t.Errorf("expected entry to remain after acknowledge, got %d entries", len(m))
|
||||
}
|
||||
if !m["ghcr.io/user/image:tag"].Acknowledged {
|
||||
t.Errorf("expected entry to be acknowledged")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDismissHandler_ReappearsAfterNewWebhook(t *testing.T) {
|
||||
diun.UpdatesReset()
|
||||
diun.UpdateEvent(diun.DiunEvent{Image: "nginx:latest"})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/updates/nginx:latest", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
diun.DismissHandler(rec, req)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected 204 on acknowledge, got %d", rec.Code)
|
||||
}
|
||||
if !diun.GetUpdatesMap()["nginx:latest"].Acknowledged {
|
||||
t.Errorf("expected entry to be acknowledged after PATCH")
|
||||
}
|
||||
|
||||
diun.UpdateEvent(diun.DiunEvent{Image: "nginx:latest", Status: "update"})
|
||||
m := diun.GetUpdatesMap()
|
||||
if len(m) != 1 {
|
||||
t.Errorf("expected entry to remain, got %d entries", len(m))
|
||||
}
|
||||
if m["nginx:latest"].Event.Status != "update" {
|
||||
t.Errorf("unexpected status: %s", m["nginx:latest"].Event.Status)
|
||||
}
|
||||
if m["nginx:latest"].Acknowledged {
|
||||
t.Errorf("expected acknowledged to be reset after new webhook")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tag handler tests ---
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
func postTagAndGetID(t *testing.T, name string) 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)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201 creating tag %q, got %d", name, rec.Code)
|
||||
}
|
||||
var tag diun.Tag
|
||||
json.NewDecoder(rec.Body).Decode(&tag)
|
||||
return tag.ID
|
||||
}
|
||||
|
||||
func TestCreateTagHandler_Success(t *testing.T) {
|
||||
diun.UpdatesReset()
|
||||
body, _ := json.Marshal(map[string]string{"name": "nextcloud"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/tags", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
diun.TagsHandler(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d", rec.Code)
|
||||
}
|
||||
var tag diun.Tag
|
||||
if err := json.NewDecoder(rec.Body).Decode(&tag); err != nil {
|
||||
t.Fatalf("decode failed: %v", err)
|
||||
}
|
||||
if tag.Name != "nextcloud" {
|
||||
t.Errorf("expected name 'nextcloud', got %q", tag.Name)
|
||||
}
|
||||
if tag.ID == 0 {
|
||||
t.Errorf("expected non-zero ID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTagHandler_DuplicateName(t *testing.T) {
|
||||
diun.UpdatesReset()
|
||||
postTag(t, "monitoring")
|
||||
code, _ := postTag(t, "monitoring")
|
||||
if code != http.StatusConflict {
|
||||
t.Errorf("expected 409, got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTagHandler_EmptyName(t *testing.T) {
|
||||
diun.UpdatesReset()
|
||||
body, _ := json.Marshal(map[string]string{"name": ""})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/tags", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
diun.TagsHandler(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTagsHandler_Empty(t *testing.T) {
|
||||
diun.UpdatesReset()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/tags", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
diun.TagsHandler(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rec.Code)
|
||||
}
|
||||
var tags []diun.Tag
|
||||
json.NewDecoder(rec.Body).Decode(&tags)
|
||||
if len(tags) != 0 {
|
||||
t.Errorf("expected empty list, got %d tags", len(tags))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTagsHandler_WithTags(t *testing.T) {
|
||||
diun.UpdatesReset()
|
||||
postTag(t, "alpha")
|
||||
postTag(t, "beta")
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/tags", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
diun.TagsHandler(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rec.Code)
|
||||
}
|
||||
var tags []diun.Tag
|
||||
json.NewDecoder(rec.Body).Decode(&tags)
|
||||
if len(tags) != 2 {
|
||||
t.Errorf("expected 2 tags, got %d", len(tags))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteTagHandler_Success(t *testing.T) {
|
||||
diun.UpdatesReset()
|
||||
id := postTagAndGetID(t, "to-delete")
|
||||
req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/api/tags/%d", id), nil)
|
||||
rec := httptest.NewRecorder()
|
||||
diun.TagByIDHandler(rec, req)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Errorf("expected 204, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteTagHandler_NotFound(t *testing.T) {
|
||||
diun.UpdatesReset()
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/tags/9999", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
diun.TagByIDHandler(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteTagHandler_CascadesAssignment(t *testing.T) {
|
||||
diun.UpdatesReset()
|
||||
diun.UpdateEvent(diun.DiunEvent{Image: "nginx:latest"})
|
||||
id := postTagAndGetID(t, "cascade-test")
|
||||
|
||||
// Assign the tag
|
||||
body, _ := json.Marshal(map[string]interface{}{"image": "nginx:latest", "tag_id": id})
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/tag-assignments", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
diun.TagAssignmentHandler(rec, req)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected 204 on assign, got %d", rec.Code)
|
||||
}
|
||||
|
||||
// Delete the tag
|
||||
req = httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/api/tags/%d", id), nil)
|
||||
rec = httptest.NewRecorder()
|
||||
diun.TagByIDHandler(rec, req)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected 204 on delete, got %d", rec.Code)
|
||||
}
|
||||
|
||||
// Confirm assignment cascaded
|
||||
m := diun.GetUpdatesMap()
|
||||
if m["nginx:latest"].Tag != nil {
|
||||
t.Errorf("expected tag to be nil after cascade delete, got %+v", m["nginx:latest"].Tag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTagAssignmentHandler_Assign(t *testing.T) {
|
||||
diun.UpdatesReset()
|
||||
diun.UpdateEvent(diun.DiunEvent{Image: "alpine:latest"})
|
||||
id := postTagAndGetID(t, "assign-test")
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{"image": "alpine:latest", "tag_id": id})
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/tag-assignments", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
diun.TagAssignmentHandler(rec, req)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Errorf("expected 204, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTagAssignmentHandler_Reassign(t *testing.T) {
|
||||
diun.UpdatesReset()
|
||||
diun.UpdateEvent(diun.DiunEvent{Image: "redis:latest"})
|
||||
id1 := postTagAndGetID(t, "group-a")
|
||||
id2 := postTagAndGetID(t, "group-b")
|
||||
|
||||
assign := func(tagID int) {
|
||||
body, _ := json.Marshal(map[string]interface{}{"image": "redis:latest", "tag_id": tagID})
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/tag-assignments", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
diun.TagAssignmentHandler(rec, req)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected 204, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
assign(id1)
|
||||
assign(id2)
|
||||
|
||||
m := diun.GetUpdatesMap()
|
||||
if m["redis:latest"].Tag == nil || m["redis:latest"].Tag.ID != id2 {
|
||||
t.Errorf("expected tag id %d after reassign, got %+v", id2, m["redis:latest"].Tag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTagAssignmentHandler_Unassign(t *testing.T) {
|
||||
diun.UpdatesReset()
|
||||
diun.UpdateEvent(diun.DiunEvent{Image: "busybox:latest"})
|
||||
id := postTagAndGetID(t, "unassign-test")
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{"image": "busybox:latest", "tag_id": id})
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/tag-assignments", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
diun.TagAssignmentHandler(rec, req)
|
||||
|
||||
// Now unassign
|
||||
body, _ = json.Marshal(map[string]string{"image": "busybox:latest"})
|
||||
req = httptest.NewRequest(http.MethodDelete, "/api/tag-assignments", bytes.NewReader(body))
|
||||
rec = httptest.NewRecorder()
|
||||
diun.TagAssignmentHandler(rec, req)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Errorf("expected 204, got %d", rec.Code)
|
||||
}
|
||||
|
||||
m := diun.GetUpdatesMap()
|
||||
if m["busybox:latest"].Tag != nil {
|
||||
t.Errorf("expected tag nil after unassign, got %+v", m["busybox:latest"].Tag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUpdates_IncludesTag(t *testing.T) {
|
||||
diun.UpdatesReset()
|
||||
diun.UpdateEvent(diun.DiunEvent{Image: "postgres:latest"})
|
||||
id := postTagAndGetID(t, "databases")
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{"image": "postgres:latest", "tag_id": id})
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/tag-assignments", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
diun.TagAssignmentHandler(rec, req)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected 204, got %d", rec.Code)
|
||||
}
|
||||
|
||||
m := diun.GetUpdatesMap()
|
||||
entry, ok := m["postgres:latest"]
|
||||
if !ok {
|
||||
t.Fatal("expected postgres:latest in updates")
|
||||
}
|
||||
if entry.Tag == nil {
|
||||
t.Fatal("expected tag to be set")
|
||||
}
|
||||
if entry.Tag.Name != "databases" {
|
||||
t.Errorf("expected tag name 'databases', got %q", entry.Tag.Name)
|
||||
}
|
||||
if entry.Tag.ID != id {
|
||||
t.Errorf("expected tag id %d, got %d", id, entry.Tag.ID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package diunwebhook
|
||||
|
||||
func GetUpdatesMap() map[string]DiunEvent {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return updates
|
||||
func GetUpdatesMap() map[string]UpdateEntry {
|
||||
m, _ := GetUpdates()
|
||||
return m
|
||||
}
|
||||
|
||||
func UpdatesReset() {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
updates = make(map[string]DiunEvent)
|
||||
InitDB(":memory:")
|
||||
}
|
||||
|
||||
func ResetTags() {
|
||||
db.Exec(`DELETE FROM tag_assignments`)
|
||||
db.Exec(`DELETE FROM tags`)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user