Stage 7: Dockerfile, integration tests, updated README
Dockerfile: - Multi-stage build (golang:1.25-alpine → scratch) - CGO_ENABLED=0, static binary, stripped with -ldflags="-w -s" (~11 MB) Tests (13 cases, internal/api/api_test.go): - Auth middleware: missing token, invalid token, valid token - Bootstrap idempotency (second call → 403) - Alert upsert: same fingerprint updates row; different fingerprints add rows - Acknowledge: set and clear, verified via GET - Comment ownership: only author can delete own comment (404 for others) - Schedule conflict: duplicate date → 409; multi-date rollback on partial conflict - Stats: totals, by-hour returns 24 slots, by-day returns 7 slots README: quick start, Docker, env vars, Alertmanager config, full API reference
This commit is contained in:
+11
@@ -0,0 +1,11 @@
|
|||||||
|
FROM golang:1.25-alpine AS builder
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /terdut ./cmd/terdut
|
||||||
|
|
||||||
|
FROM scratch
|
||||||
|
COPY --from=builder /terdut /terdut
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["/terdut"]
|
||||||
@@ -1,14 +1,149 @@
|
|||||||
# Terminal Duty
|
# Terminal Duty (terdut-server)
|
||||||
Terminal Duty(termdut) is a server that helps teams manage alerts, oncall schedules etc.
|
|
||||||
- The app will integrate with alertmanager(prometheus alertmanager), as a receiver of alertmanager webhooks without the need for adapters.
|
On-call alert management server for teams using Prometheus Alertmanager.
|
||||||
- Incoming alerts will be logged to a database(postgres or sqlite).
|
|
||||||
- Alerts can be marked as "acknowledged", when someone is already working on it.
|
- Receives Alertmanager webhooks directly — no adapter needed
|
||||||
- Comments can be added to alerts, such as solutions, tips or actions.
|
- Stores and queries alerts (acknowledge, comment)
|
||||||
- Statistics can be produced for the alerts in the database, such as most common alert, most busy times of the day/week etc.
|
- On-call schedule management (user-to-day assignments)
|
||||||
- The server exposes a rest-api, where users, schedules, alerts etc can be managed.
|
- Alert statistics (by status, by hour, by day)
|
||||||
- Alerts will be received from alertmanager with no authentication initially.
|
- REST API with per-user API key authentication
|
||||||
- The exposed api however(besides receiving alert from alertmanager) needs authentication.
|
- Single binary, SQLite storage — trivial to self-host
|
||||||
- Authentication is managed on a user basis, each user has his or her own credentials, these are stored hashed in the database.
|
|
||||||
- Each user can be assigned to time slots in the oncall-schedule.
|
---
|
||||||
- The smallest unit of time managed in the schedule is a day. This may change later.
|
|
||||||
- The server is written in golang
|
## Quick start
|
||||||
|
|
||||||
|
**Prerequisites:** Go 1.21+
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/yeniklas/terdut-server
|
||||||
|
cd terdut-server
|
||||||
|
go run ./cmd/terdut
|
||||||
|
```
|
||||||
|
|
||||||
|
The server starts on `:8080` with a `terdut.db` file in the working directory.
|
||||||
|
|
||||||
|
### Create the first user
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8080/api/bootstrap \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"username": "admin", "email": "admin@example.com"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Save the `api_key.key` value from the response — it is shown **once only**.
|
||||||
|
|
||||||
|
Use it as a bearer token for all subsequent requests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export KEY=<your-key>
|
||||||
|
curl -H "Authorization: Bearer $KEY" http://localhost:8080/api/users
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t terdut-server .
|
||||||
|
docker run -p 8080:8080 -v $(pwd)/data:/data \
|
||||||
|
-e TERDUT_DB_PATH=/data/terdut.db \
|
||||||
|
terdut-server
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `TERDUT_ADDR` | `:8080` | TCP address to listen on |
|
||||||
|
| `TERDUT_DB_PATH` | `terdut.db` | Path to the SQLite database file |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Alertmanager configuration
|
||||||
|
|
||||||
|
Add terdut-server as a webhook receiver in your `alertmanager.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
receivers:
|
||||||
|
- name: terdut
|
||||||
|
webhook_configs:
|
||||||
|
- url: http://terdut-server:8080/api/alertmanager/webhook
|
||||||
|
send_resolved: true
|
||||||
|
|
||||||
|
route:
|
||||||
|
receiver: terdut
|
||||||
|
```
|
||||||
|
|
||||||
|
The webhook endpoint requires no authentication.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API reference
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
All endpoints except `/api/bootstrap` and `/api/alertmanager/webhook` require:
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer <api-key>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Users
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `POST` | `/api/bootstrap` | Create first user + API key (only works on empty DB) |
|
||||||
|
| `GET` | `/api/users` | List users |
|
||||||
|
| `POST` | `/api/users` | Create user `{"username","email"}` |
|
||||||
|
| `DELETE` | `/api/users/{id}` | Delete user (cascades to keys) |
|
||||||
|
| `POST` | `/api/users/{id}/api-keys` | Issue API key `{"name"}` — key shown once |
|
||||||
|
| `DELETE` | `/api/users/{id}/api-keys/{keyID}` | Revoke API key |
|
||||||
|
|
||||||
|
### Alert ingestion
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `POST` | `/api/alertmanager/webhook` | Alertmanager v4 webhook receiver (no auth) |
|
||||||
|
|
||||||
|
### Alerts
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET` | `/api/alerts` | List alerts. Filters: `?status=firing\|resolved`, `?name=`, `?from=YYYY-MM-DD`, `?to=YYYY-MM-DD`, `?limit=` (default 50, max 500) |
|
||||||
|
| `GET` | `/api/alerts/{id}` | Get single alert |
|
||||||
|
| `POST` | `/api/alerts/{id}/acknowledge` | Acknowledge alert (stamps authed user + time) |
|
||||||
|
| `DELETE` | `/api/alerts/{id}/acknowledge` | Clear acknowledgement |
|
||||||
|
| `GET` | `/api/alerts/{id}/comments` | List comments (chronological) |
|
||||||
|
| `POST` | `/api/alerts/{id}/comments` | Add comment `{"content"}` |
|
||||||
|
| `DELETE` | `/api/alerts/{id}/comments/{commentID}` | Delete own comment |
|
||||||
|
|
||||||
|
### On-call schedule
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `POST` | `/api/schedule` | Assign user to dates `{"user_id", "dates":["YYYY-MM-DD",...]}` — all-or-nothing |
|
||||||
|
| `GET` | `/api/schedule` | List entries. Filters: `?from=YYYY-MM-DD`, `?to=YYYY-MM-DD` |
|
||||||
|
| `GET` | `/api/schedule/current` | Today's on-call user (UTC), 404 if none |
|
||||||
|
| `DELETE` | `/api/schedule/{id}` | Remove schedule entry |
|
||||||
|
|
||||||
|
### Statistics
|
||||||
|
|
||||||
|
All stat endpoints accept optional `?from=YYYY-MM-DD` and `?to=YYYY-MM-DD` to filter by `received_at`.
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET` | `/api/stats/alerts` | `{total, firing, resolved}` counts |
|
||||||
|
| `GET` | `/api/stats/alerts/top` | Most frequent alert names. `?limit=` (default 10, max 100) |
|
||||||
|
| `GET` | `/api/stats/alerts/by-hour` | Count per hour-of-day (UTC), all 24 slots returned |
|
||||||
|
| `GET` | `/api/stats/alerts/by-day` | Count per day-of-week, all 7 slots with names returned |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./... # run all tests
|
||||||
|
go build ./... # compile all packages
|
||||||
|
go run ./cmd/terdut # run locally
|
||||||
|
```
|
||||||
|
|||||||
@@ -0,0 +1,368 @@
|
|||||||
|
package api_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/yeniklas/terdut-server/internal/api"
|
||||||
|
"github.com/yeniklas/terdut-server/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ts wraps httptest.Server with a pre-bootstrapped API key.
|
||||||
|
type ts struct {
|
||||||
|
*httptest.Server
|
||||||
|
key string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTS(t *testing.T) *ts {
|
||||||
|
t.Helper()
|
||||||
|
database, err := db.Open(":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Migrate(database); err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
srv := httptest.NewServer(api.NewRouter(database))
|
||||||
|
t.Cleanup(func() { srv.Close(); database.Close() })
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]string{"username": "admin", "email": "admin@test.com"})
|
||||||
|
resp, err := http.Post(srv.URL+"/api/bootstrap", "application/json", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("bootstrap: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("bootstrap returned %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var result map[string]any
|
||||||
|
json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
key := result["api_key"].(map[string]any)["key"].(string)
|
||||||
|
|
||||||
|
return &ts{Server: srv, key: key}
|
||||||
|
}
|
||||||
|
|
||||||
|
// req sends an authenticated request, optionally with a JSON body.
|
||||||
|
func (s *ts) req(t *testing.T, method, path string, body any) *http.Response {
|
||||||
|
t.Helper()
|
||||||
|
var r io.Reader
|
||||||
|
if body != nil {
|
||||||
|
data, _ := json.Marshal(body)
|
||||||
|
r = bytes.NewReader(data)
|
||||||
|
}
|
||||||
|
req, _ := http.NewRequest(method, s.URL+path, r)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+s.key)
|
||||||
|
if body != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s %s: %v", method, path, err)
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func decode(t *testing.T, resp *http.Response, v any) {
|
||||||
|
t.Helper()
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(v); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Auth middleware
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestAuthMiddleware_MissingToken(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, s.URL+"/api/users", nil)
|
||||||
|
resp, _ := http.DefaultClient.Do(req)
|
||||||
|
if resp.StatusCode != http.StatusUnauthorized {
|
||||||
|
t.Errorf("expected 401, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthMiddleware_InvalidToken(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, s.URL+"/api/users", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer notavalidkey")
|
||||||
|
resp, _ := http.DefaultClient.Do(req)
|
||||||
|
if resp.StatusCode != http.StatusUnauthorized {
|
||||||
|
t.Errorf("expected 401, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthMiddleware_ValidToken(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
resp := s.req(t, http.MethodGet, "/api/users", nil)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("expected 200, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Bootstrap idempotency
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestBootstrap_SecondCallForbidden(t *testing.T) {
|
||||||
|
s := newTS(t) // already bootstrapped
|
||||||
|
body, _ := json.Marshal(map[string]string{"username": "x", "email": "x@x.com"})
|
||||||
|
resp, _ := http.Post(s.URL+"/api/bootstrap", "application/json", bytes.NewReader(body))
|
||||||
|
if resp.StatusCode != http.StatusForbidden {
|
||||||
|
t.Errorf("expected 403, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Alert upsert by fingerprint
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func postWebhook(t *testing.T, s *ts, alerts []map[string]any) {
|
||||||
|
t.Helper()
|
||||||
|
payload := map[string]any{"version": "4", "status": "firing", "alerts": alerts}
|
||||||
|
data, _ := json.Marshal(payload)
|
||||||
|
resp, err := http.Post(s.URL+"/api/alertmanager/webhook", "application/json", bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("post webhook: %v", err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("webhook returned %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertUpsert_SameFingerprintUpdates(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
|
||||||
|
alert := map[string]any{
|
||||||
|
"status": "firing",
|
||||||
|
"labels": map[string]string{"alertname": "HighCPU"},
|
||||||
|
"annotations": map[string]string{},
|
||||||
|
"startsAt": "2026-05-20T10:00:00Z",
|
||||||
|
"endsAt": "0001-01-01T00:00:00Z",
|
||||||
|
"generatorURL": "",
|
||||||
|
"fingerprint": "fp-upsert",
|
||||||
|
}
|
||||||
|
postWebhook(t, s, []map[string]any{alert})
|
||||||
|
|
||||||
|
// Same fingerprint, now resolved.
|
||||||
|
alert["status"] = "resolved"
|
||||||
|
alert["endsAt"] = "2026-05-20T11:00:00Z"
|
||||||
|
postWebhook(t, s, []map[string]any{alert})
|
||||||
|
|
||||||
|
resp := s.req(t, http.MethodGet, "/api/alerts", nil)
|
||||||
|
var list []map[string]any
|
||||||
|
decode(t, resp, &list)
|
||||||
|
|
||||||
|
if len(list) != 1 {
|
||||||
|
t.Fatalf("expected 1 alert (upsert), got %d", len(list))
|
||||||
|
}
|
||||||
|
if list[0]["status"] != "resolved" {
|
||||||
|
t.Errorf("expected status resolved, got %s", list[0]["status"])
|
||||||
|
}
|
||||||
|
if list[0]["ends_at"] == nil {
|
||||||
|
t.Error("expected ends_at to be set after resolve")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertUpsert_DifferentFingerprintsStored(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
|
||||||
|
for i := range 3 {
|
||||||
|
alert := map[string]any{
|
||||||
|
"status": "firing",
|
||||||
|
"labels": map[string]string{"alertname": fmt.Sprintf("Alert%d", i)},
|
||||||
|
"annotations": map[string]string{},
|
||||||
|
"startsAt": "2026-05-20T10:00:00Z",
|
||||||
|
"endsAt": "0001-01-01T00:00:00Z",
|
||||||
|
"generatorURL": "",
|
||||||
|
"fingerprint": fmt.Sprintf("fp-%d", i),
|
||||||
|
}
|
||||||
|
postWebhook(t, s, []map[string]any{alert})
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := s.req(t, http.MethodGet, "/api/alerts", nil)
|
||||||
|
var list []map[string]any
|
||||||
|
decode(t, resp, &list)
|
||||||
|
if len(list) != 3 {
|
||||||
|
t.Errorf("expected 3 alerts, got %d", len(list))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Alert acknowledge
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestAcknowledge(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
postWebhook(t, s, []map[string]any{{
|
||||||
|
"status": "firing", "labels": map[string]string{"alertname": "X"},
|
||||||
|
"annotations": map[string]string{}, "startsAt": "2026-05-20T10:00:00Z",
|
||||||
|
"endsAt": "0001-01-01T00:00:00Z", "generatorURL": "", "fingerprint": "fp-ack",
|
||||||
|
}})
|
||||||
|
|
||||||
|
resp := s.req(t, http.MethodPost, "/api/alerts/1/acknowledge", nil)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("acknowledge returned %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var alert map[string]any
|
||||||
|
decode(t, resp, &alert)
|
||||||
|
if alert["acknowledged_by"] == nil {
|
||||||
|
t.Error("expected acknowledged_by to be set")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear it.
|
||||||
|
resp = s.req(t, http.MethodDelete, "/api/alerts/1/acknowledge", nil)
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Errorf("unacknowledge returned %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = s.req(t, http.MethodGet, "/api/alerts/1", nil)
|
||||||
|
var alert2 map[string]any
|
||||||
|
decode(t, resp, &alert2)
|
||||||
|
if alert2["acknowledged_by"] != nil {
|
||||||
|
t.Error("expected acknowledged_by to be cleared")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Comments — own-only deletion
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestComment_DeleteOwnOnly(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
postWebhook(t, s, []map[string]any{{
|
||||||
|
"status": "firing", "labels": map[string]string{"alertname": "Y"},
|
||||||
|
"annotations": map[string]string{}, "startsAt": "2026-05-20T10:00:00Z",
|
||||||
|
"endsAt": "0001-01-01T00:00:00Z", "generatorURL": "", "fingerprint": "fp-comment",
|
||||||
|
}})
|
||||||
|
|
||||||
|
// Create a second user and their own key.
|
||||||
|
s.req(t, http.MethodPost, "/api/users",
|
||||||
|
map[string]string{"username": "alice", "email": "alice@test.com"})
|
||||||
|
keyResp := s.req(t, http.MethodPost, "/api/users/2/api-keys",
|
||||||
|
map[string]string{"name": "alice-key"})
|
||||||
|
var keyData map[string]any
|
||||||
|
decode(t, keyResp, &keyData)
|
||||||
|
aliceKey := keyData["key"].(string)
|
||||||
|
|
||||||
|
// Admin posts a comment.
|
||||||
|
s.req(t, http.MethodPost, "/api/alerts/1/comments",
|
||||||
|
map[string]string{"content": "admin note"})
|
||||||
|
|
||||||
|
// Alice tries to delete admin's comment (should 404).
|
||||||
|
req, _ := http.NewRequest(http.MethodDelete, s.URL+"/api/alerts/1/comments/1", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+aliceKey)
|
||||||
|
resp, _ := http.DefaultClient.Do(req)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("expected 404 when deleting another user's comment, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin deletes own comment (should 204).
|
||||||
|
resp = s.req(t, http.MethodDelete, "/api/alerts/1/comments/1", nil)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Errorf("expected 204 when deleting own comment, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Schedule conflict
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestSchedule_ConflictOnSameDate(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
|
||||||
|
first := s.req(t, http.MethodPost, "/api/schedule",
|
||||||
|
map[string]any{"user_id": 1, "dates": []string{"2026-06-01"}})
|
||||||
|
if first.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("first assignment returned %d", first.StatusCode)
|
||||||
|
}
|
||||||
|
first.Body.Close()
|
||||||
|
|
||||||
|
second := s.req(t, http.MethodPost, "/api/schedule",
|
||||||
|
map[string]any{"user_id": 1, "dates": []string{"2026-06-01"}})
|
||||||
|
if second.StatusCode != http.StatusConflict {
|
||||||
|
t.Errorf("expected 409 on duplicate date, got %d", second.StatusCode)
|
||||||
|
}
|
||||||
|
second.Body.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSchedule_MultiDateRollbackOnConflict(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
|
||||||
|
// Claim 2026-06-10 first.
|
||||||
|
s.req(t, http.MethodPost, "/api/schedule",
|
||||||
|
map[string]any{"user_id": 1, "dates": []string{"2026-06-10"}}).Body.Close()
|
||||||
|
|
||||||
|
// Try to assign two dates in one request where the second conflicts.
|
||||||
|
resp := s.req(t, http.MethodPost, "/api/schedule",
|
||||||
|
map[string]any{"user_id": 1, "dates": []string{"2026-06-09", "2026-06-10"}})
|
||||||
|
if resp.StatusCode != http.StatusConflict {
|
||||||
|
t.Fatalf("expected 409, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
// 2026-06-09 must NOT have been committed (transaction rolled back).
|
||||||
|
listResp := s.req(t, http.MethodGet, "/api/schedule?from=2026-06-09&to=2026-06-09", nil)
|
||||||
|
var entries []any
|
||||||
|
decode(t, listResp, &entries)
|
||||||
|
if len(entries) != 0 {
|
||||||
|
t.Errorf("expected rollback to leave 2026-06-09 unassigned, got %d entries", len(entries))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Stats
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestStats_Totals(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
|
||||||
|
alerts := []map[string]any{
|
||||||
|
{"status": "firing", "labels": map[string]string{"alertname": "A"}, "annotations": map[string]string{}, "startsAt": "2026-05-20T10:00:00Z", "endsAt": "0001-01-01T00:00:00Z", "generatorURL": "", "fingerprint": "s1"},
|
||||||
|
{"status": "resolved", "labels": map[string]string{"alertname": "B"}, "annotations": map[string]string{}, "startsAt": "2026-05-20T10:00:00Z", "endsAt": "2026-05-20T11:00:00Z", "generatorURL": "", "fingerprint": "s2"},
|
||||||
|
}
|
||||||
|
postWebhook(t, s, alerts)
|
||||||
|
|
||||||
|
resp := s.req(t, http.MethodGet, "/api/stats/alerts", nil)
|
||||||
|
var stats map[string]any
|
||||||
|
decode(t, resp, &stats)
|
||||||
|
|
||||||
|
if int(stats["total"].(float64)) != 2 {
|
||||||
|
t.Errorf("expected total=2, got %v", stats["total"])
|
||||||
|
}
|
||||||
|
if int(stats["firing"].(float64)) != 1 {
|
||||||
|
t.Errorf("expected firing=1, got %v", stats["firing"])
|
||||||
|
}
|
||||||
|
if int(stats["resolved"].(float64)) != 1 {
|
||||||
|
t.Errorf("expected resolved=1, got %v", stats["resolved"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStats_ByHourReturnsTwentyFourSlots(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
resp := s.req(t, http.MethodGet, "/api/stats/alerts/by-hour", nil)
|
||||||
|
var slots []any
|
||||||
|
decode(t, resp, &slots)
|
||||||
|
if len(slots) != 24 {
|
||||||
|
t.Errorf("expected 24 hour slots, got %d", len(slots))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStats_ByDayReturnsSevenSlots(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
resp := s.req(t, http.MethodGet, "/api/stats/alerts/by-day", nil)
|
||||||
|
var slots []any
|
||||||
|
decode(t, resp, &slots)
|
||||||
|
if len(slots) != 7 {
|
||||||
|
t.Errorf("expected 7 day slots, got %d", len(slots))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user