410a131cec
This session (frontend focus):
- Rebuild /apps/new as a 4-step wizard (Basics → Configure → Trigger → Review):
WizardRail, SourceKindPicker card grid, AppManifest review, per-step validation,
ConfirmDialog-based unsaved-changes guard.
- Extract lib/workload/sourceForms.ts (single source of truth for source_config)
+ {Image,Compose,Static,Dockerfile}SourceForm + StaticDiscoveryWizard; fold the
/apps/[id] edit form onto the same components (removes the duplication). Add
vitest + sourceForms unit tests.
- Branch preview environments UI: /chain is_preview/preview_branch + a Preview
environments panel on /apps/[id] (per-branch URLs, ConfirmDialog teardown, armed
state); RegistryImagePicker on the registry trigger and the image source.
- Fixes: image-inspect 404 -> admin-gated POST /api/discovery/image/inspect;
conflict-panel blur flicker; friendly localized discovery errors; CPU/Memory
label hints; dashboard + /apps "Total workloads" count only source_kind workloads
(drop stale trigger_kind gate); NPM cert/access-list name cache; EntityPicker
empty-list guard.
- Update CLAUDE.md frontend conventions + add a Build & Test section.
Also captures pre-existing in-progress platform work (not from this session):
workload notifications, Prometheus metrics export, store lockfile, health probes,
backup hardening, and related store/webhook/scheduler changes.
171 lines
5.0 KiB
Go
171 lines
5.0 KiB
Go
package store
|
|
|
|
import (
|
|
"errors"
|
|
"testing"
|
|
)
|
|
|
|
// seedWorkloadForNotifications creates a minimal workload row so the FK
|
|
// constraint on workload_notifications is satisfied. Returns the new
|
|
// workload's ID for tests to reference.
|
|
func seedWorkloadForNotifications(t *testing.T, s *Store, name string) string {
|
|
t.Helper()
|
|
w, err := s.CreateWorkload(Workload{
|
|
Kind: string(WorkloadKindProject),
|
|
Name: name,
|
|
SourceKind: "image",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("seed workload: %v", err)
|
|
}
|
|
return w.ID
|
|
}
|
|
|
|
func TestCreateWorkloadNotification_RoundTrip(t *testing.T) {
|
|
s := newTestStore(t)
|
|
wlID := seedWorkloadForNotifications(t, s, "app1")
|
|
|
|
created, err := s.CreateWorkloadNotification(WorkloadNotification{
|
|
WorkloadID: wlID,
|
|
Name: "Slack alerts",
|
|
URL: "https://hooks.slack.test/x",
|
|
Secret: "shh",
|
|
EventTypes: "deploy_failure,build_failure",
|
|
Enabled: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateWorkloadNotification: %v", err)
|
|
}
|
|
if created.ID == "" {
|
|
t.Fatal("expected ID to be assigned")
|
|
}
|
|
|
|
got, err := s.GetWorkloadNotification(created.ID)
|
|
if err != nil {
|
|
t.Fatalf("Get: %v", err)
|
|
}
|
|
if got.URL != "https://hooks.slack.test/x" || got.Name != "Slack alerts" {
|
|
t.Errorf("row mismatch: %+v", got)
|
|
}
|
|
if !got.Enabled {
|
|
t.Error("expected Enabled=true")
|
|
}
|
|
if got.EventTypes != "deploy_failure,build_failure" {
|
|
t.Errorf("event_types = %q", got.EventTypes)
|
|
}
|
|
}
|
|
|
|
func TestCreateWorkloadNotification_RejectsMissingURL(t *testing.T) {
|
|
s := newTestStore(t)
|
|
wlID := seedWorkloadForNotifications(t, s, "app1")
|
|
_, err := s.CreateWorkloadNotification(WorkloadNotification{
|
|
WorkloadID: wlID,
|
|
Name: "broken",
|
|
URL: "",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected URL validation error")
|
|
}
|
|
}
|
|
|
|
func TestListWorkloadNotifications_SortedByOrder(t *testing.T) {
|
|
s := newTestStore(t)
|
|
wlID := seedWorkloadForNotifications(t, s, "app1")
|
|
|
|
// Insert out of order; ListWorkloadNotifications should return
|
|
// them sorted by SortOrder ascending.
|
|
_, _ = s.CreateWorkloadNotification(WorkloadNotification{
|
|
WorkloadID: wlID, Name: "C", URL: "https://c.test", SortOrder: 30,
|
|
})
|
|
_, _ = s.CreateWorkloadNotification(WorkloadNotification{
|
|
WorkloadID: wlID, Name: "A", URL: "https://a.test", SortOrder: 10,
|
|
})
|
|
_, _ = s.CreateWorkloadNotification(WorkloadNotification{
|
|
WorkloadID: wlID, Name: "B", URL: "https://b.test", SortOrder: 20,
|
|
})
|
|
|
|
rows, err := s.ListWorkloadNotifications(wlID)
|
|
if err != nil {
|
|
t.Fatalf("list: %v", err)
|
|
}
|
|
if len(rows) != 3 {
|
|
t.Fatalf("len = %d, want 3", len(rows))
|
|
}
|
|
if rows[0].Name != "A" || rows[1].Name != "B" || rows[2].Name != "C" {
|
|
t.Errorf("sort order wrong: %q %q %q", rows[0].Name, rows[1].Name, rows[2].Name)
|
|
}
|
|
}
|
|
|
|
func TestUpdateWorkloadNotification_PersistsChanges(t *testing.T) {
|
|
s := newTestStore(t)
|
|
wlID := seedWorkloadForNotifications(t, s, "app1")
|
|
n, _ := s.CreateWorkloadNotification(WorkloadNotification{
|
|
WorkloadID: wlID, Name: "old", URL: "https://old.test", Enabled: true,
|
|
})
|
|
n.Name = "new"
|
|
n.URL = "https://new.test"
|
|
n.Enabled = false
|
|
n.EventTypes = "deploy_success"
|
|
if err := s.UpdateWorkloadNotification(n); err != nil {
|
|
t.Fatalf("update: %v", err)
|
|
}
|
|
got, _ := s.GetWorkloadNotification(n.ID)
|
|
if got.Name != "new" || got.URL != "https://new.test" || got.Enabled {
|
|
t.Errorf("update did not persist: %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestDeleteWorkloadNotification_ReturnsNotFoundForMissing(t *testing.T) {
|
|
s := newTestStore(t)
|
|
err := s.DeleteWorkloadNotification("nope")
|
|
if !errors.Is(err, ErrNotFound) {
|
|
t.Errorf("expected ErrNotFound, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDeleteWorkloadNotification_CascadesFromWorkload(t *testing.T) {
|
|
s := newTestStore(t)
|
|
wlID := seedWorkloadForNotifications(t, s, "app1")
|
|
_, _ = s.CreateWorkloadNotification(WorkloadNotification{
|
|
WorkloadID: wlID, Name: "x", URL: "https://x.test",
|
|
})
|
|
if err := s.DeleteWorkload(wlID); err != nil {
|
|
t.Fatalf("delete workload: %v", err)
|
|
}
|
|
rows, err := s.ListWorkloadNotifications(wlID)
|
|
if err != nil {
|
|
t.Fatalf("list after cascade: %v", err)
|
|
}
|
|
if len(rows) != 0 {
|
|
t.Errorf("expected cascade delete to remove rows, got %d", len(rows))
|
|
}
|
|
}
|
|
|
|
func TestMatchesEventType_AllowList(t *testing.T) {
|
|
cases := []struct {
|
|
eventTypes string
|
|
probe string
|
|
want bool
|
|
}{
|
|
{"", "deploy_success", true}, // empty = all
|
|
{"deploy_success,deploy_failure", "deploy_success", true},
|
|
{"deploy_success,deploy_failure", "build_failure", false},
|
|
{"build_failure", "build_failure", true},
|
|
{" deploy_success , build_failure ", "build_failure", true}, // whitespace tolerated
|
|
}
|
|
for _, c := range cases {
|
|
n := WorkloadNotification{Enabled: true, EventTypes: c.eventTypes}
|
|
got := n.MatchesEventType(c.probe)
|
|
if got != c.want {
|
|
t.Errorf("MatchesEventType(%q, %q) = %v, want %v", c.eventTypes, c.probe, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMatchesEventType_DisabledNeverMatches(t *testing.T) {
|
|
n := WorkloadNotification{Enabled: false, EventTypes: ""}
|
|
if n.MatchesEventType("any") {
|
|
t.Error("disabled row should never match")
|
|
}
|
|
}
|