Files
tiny-forge/internal/store/workload_sync_test.go
T
alexei.dolgolyov db235c1412 feat(workload): write-through workload sync + boot-time backfill
CRUD on Project / Stack / StaticSite now keeps a paired Workload
row in sync. Secret setters (webhook secret, signing secret,
require-signature toggle, notification secret) all re-sync after
mutating the source-of-truth row so the workload row always
reflects the canonical state.

Delete cascades: DeleteProject/Stack/StaticSite now drop the
matching workload row plus any container index entries owned by
it, so global views don't show ghost rows.

Boot-time BackfillWorkloads scans every project/stack/site and
ensures each has a workload row. Idempotent — safe to run on
every restart, recovers from a deleted/missing workload row.

Behavior unchanged for existing call sites; the workloads table
just starts being populated. Deployer / reconciler / consumer
switchover land in the next commit.
2026-05-09 13:28:20 +03:00

191 lines
5.6 KiB
Go

package store
import (
"errors"
"testing"
)
func TestCreateProjectAlsoCreatesWorkload(t *testing.T) {
s := newTestStore(t)
p, err := s.CreateProject(Project{
Name: "wf-project", Image: "nginx", Port: 80, Env: "{}", Volumes: "{}",
NotificationURL: "https://example.test/hook",
})
if err != nil {
t.Fatalf("CreateProject: %v", err)
}
w, err := s.GetWorkloadByRef(WorkloadKindProject, p.ID)
if err != nil {
t.Fatalf("workload should exist after CreateProject: %v", err)
}
if w.Name != "wf-project" {
t.Fatalf("workload name not synced: got %q", w.Name)
}
if w.WebhookSecret == "" {
t.Fatal("webhook secret should be carried into workload row")
}
if w.NotificationURL != "https://example.test/hook" {
t.Fatalf("notification url not synced: got %q", w.NotificationURL)
}
}
func TestUpdateProjectSyncsWorkload(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject(Project{
Name: "before", Image: "i", Env: "{}", Volumes: "{}",
})
p.Name = "after"
p.NotificationURL = "https://new.test/hook"
if err := s.UpdateProject(p); err != nil {
t.Fatalf("UpdateProject: %v", err)
}
w, _ := s.GetWorkloadByRef(WorkloadKindProject, p.ID)
if w.Name != "after" {
t.Fatalf("workload name not updated: got %q", w.Name)
}
if w.NotificationURL != "https://new.test/hook" {
t.Fatalf("workload notification url not updated: got %q", w.NotificationURL)
}
}
func TestDeleteProjectCascadesWorkload(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject(Project{Name: "doomed", Image: "i", Env: "{}", Volumes: "{}"})
w, _ := s.GetWorkloadByRef(WorkloadKindProject, p.ID)
// Add a container under this workload to verify cascade.
if _, err := s.CreateContainer(Container{
WorkloadID: w.ID, WorkloadKind: "project", State: "running",
}); err != nil {
t.Fatalf("CreateContainer: %v", err)
}
if err := s.DeleteProject(p.ID); err != nil {
t.Fatalf("DeleteProject: %v", err)
}
if _, err := s.GetWorkloadByID(w.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("workload should be deleted, got %v", err)
}
containers, _ := s.ListContainersByWorkload(w.ID)
if len(containers) != 0 {
t.Fatalf("containers should be deleted, got %d", len(containers))
}
}
func TestSetProjectWebhookSecretSyncsWorkload(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject(Project{Name: "n", Image: "i", Env: "{}", Volumes: "{}"})
newSecret := "new-secret-value-with-enough-entropy-1234"
if err := s.SetProjectWebhookSecret(p.ID, newSecret); err != nil {
t.Fatalf("SetProjectWebhookSecret: %v", err)
}
w, _ := s.GetWorkloadByRef(WorkloadKindProject, p.ID)
if w.WebhookSecret != newSecret {
t.Fatalf("workload webhook secret not synced: got %q", w.WebhookSecret)
}
}
func TestCreateStackAlsoCreatesWorkload(t *testing.T) {
s := newTestStore(t)
st, err := s.CreateStack(Stack{Name: "wf-stack", ComposeProjectName: "wf-stack"})
if err != nil {
t.Fatalf("CreateStack: %v", err)
}
w, err := s.GetWorkloadByRef(WorkloadKindStack, st.ID)
if err != nil {
t.Fatalf("workload should exist after CreateStack: %v", err)
}
if w.Name != "wf-stack" {
t.Fatalf("workload name not synced: got %q", w.Name)
}
}
func TestUpdateStackSyncsWorkload(t *testing.T) {
s := newTestStore(t)
st, _ := s.CreateStack(Stack{Name: "before", ComposeProjectName: "before-cp"})
st.Name = "after"
if err := s.UpdateStack(st); err != nil {
t.Fatalf("UpdateStack: %v", err)
}
w, _ := s.GetWorkloadByRef(WorkloadKindStack, st.ID)
if w.Name != "after" {
t.Fatalf("workload name not updated: got %q", w.Name)
}
}
func TestDeleteStackCascadesWorkload(t *testing.T) {
s := newTestStore(t)
st, _ := s.CreateStack(Stack{Name: "doomed-stack", ComposeProjectName: "doomed-cp"})
w, _ := s.GetWorkloadByRef(WorkloadKindStack, st.ID)
if err := s.DeleteStack(st.ID); err != nil {
t.Fatalf("DeleteStack: %v", err)
}
if _, err := s.GetWorkloadByID(w.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("workload should be deleted, got %v", err)
}
}
func TestBackfillWorkloadsIdempotent(t *testing.T) {
s := newTestStore(t)
// Create rows directly via the store (which already auto-syncs), then run
// the backfill twice — it must be a no-op the second time and not error.
p, _ := s.CreateProject(Project{Name: "p1", Image: "i", Env: "{}", Volumes: "{}"})
st, _ := s.CreateStack(Stack{Name: "s1", ComposeProjectName: "s1-cp"})
if err := s.BackfillWorkloads(); err != nil {
t.Fatalf("first backfill: %v", err)
}
if err := s.BackfillWorkloads(); err != nil {
t.Fatalf("second backfill (should be idempotent): %v", err)
}
all, _ := s.ListWorkloads("")
// Expect exactly 2: one project workload, one stack workload, no duplicates.
if len(all) != 2 {
t.Fatalf("expected 2 workloads after backfill, got %d", len(all))
}
// Confirm both refs are findable.
if _, err := s.GetWorkloadByRef(WorkloadKindProject, p.ID); err != nil {
t.Fatalf("project workload not found: %v", err)
}
if _, err := s.GetWorkloadByRef(WorkloadKindStack, st.ID); err != nil {
t.Fatalf("stack workload not found: %v", err)
}
}
func TestBackfillRecoversFromMissingWorkloads(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProject(Project{Name: "p1", Image: "i", Env: "{}", Volumes: "{}"})
// Simulate the legacy state: a project exists but its workload row is gone
// (e.g. the rollout from before the refactor). Backfill must restore it.
w, _ := s.GetWorkloadByRef(WorkloadKindProject, p.ID)
_ = s.DeleteWorkload(w.ID)
if err := s.BackfillWorkloads(); err != nil {
t.Fatalf("backfill: %v", err)
}
if _, err := s.GetWorkloadByRef(WorkloadKindProject, p.ID); err != nil {
t.Fatalf("workload should be restored: %v", err)
}
}