package api import ( "encoding/json" "net/http" "testing" "github.com/alexei/tinyforge/internal/store" ) // ============================================================================= // GET /api/workloads/{id}/runtime-state // ============================================================================= func TestGetWorkloadRuntimeState_NotFound_404(t *testing.T) { e := newAPITestEnv(t) resp := e.do(t, http.MethodGet, "/api/workloads/does-not-exist/runtime-state", nil) if resp.StatusCode != http.StatusNotFound { _ = decodeEnvelope(t, resp, nil) t.Fatalf("status = %d, want 404", resp.StatusCode) } } func TestGetWorkloadRuntimeState_NonStaticSource_ReturnsBareKind(t *testing.T) { e := newAPITestEnv(t) wl, err := e.store.CreateWorkload(store.Workload{ Kind: string(store.WorkloadKindProject), Name: "img-app", SourceKind: "image", SourceConfig: `{"image":"nginx:1.25"}`, }) if err != nil { t.Fatalf("seed: %v", err) } resp := e.do(t, http.MethodGet, "/api/workloads/"+wl.ID+"/runtime-state", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200", resp.StatusCode) } var got runtimeStatePayload if errMsg := decodeEnvelope(t, resp, &got); errMsg != "" { t.Fatalf("envelope error: %q", errMsg) } if got.SourceKind != "image" { t.Errorf("SourceKind = %q, want image", got.SourceKind) } if got.HasState { t.Errorf("HasState = true, want false for non-static source") } } func TestGetWorkloadRuntimeState_StaticSourceNeverDeployed_HasStateFalse(t *testing.T) { e := newAPITestEnv(t) wl, err := e.store.CreateWorkload(store.Workload{ Kind: string(store.WorkloadKindSite), Name: "pages", SourceKind: "static", SourceConfig: `{"provider":"gitea"}`, }) if err != nil { t.Fatalf("seed: %v", err) } resp := e.do(t, http.MethodGet, "/api/workloads/"+wl.ID+"/runtime-state", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200", resp.StatusCode) } var got runtimeStatePayload if errMsg := decodeEnvelope(t, resp, &got); errMsg != "" { t.Fatalf("envelope error: %q", errMsg) } if got.HasState { t.Errorf("HasState = true, want false (never deployed)") } } func TestGetWorkloadRuntimeState_StaticSourceDeployed_DecodesExtraJSON(t *testing.T) { e := newAPITestEnv(t) wl, err := e.store.CreateWorkload(store.Workload{ Kind: string(store.WorkloadKindSite), Name: "pages", SourceKind: "static", SourceConfig: `{"provider":"gitea"}`, }) if err != nil { t.Fatalf("seed workload: %v", err) } extra, _ := json.Marshal(map[string]any{ "status": "deployed", "last_commit_sha": "abc1234", "last_sync_at": "2026-05-16T10:00:00Z", "last_error": "", // An unknown key — confirms decoding is lenient. "unknown_future_field": "ignored", }) if err := e.store.UpsertContainer(store.Container{ ID: wl.ID + ":site", WorkloadID: wl.ID, WorkloadKind: string(store.WorkloadKindSite), Host: "local", ContainerID: "abcdef1234", State: "running", ExtraJSON: string(extra), }); err != nil { t.Fatalf("seed container: %v", err) } resp := e.do(t, http.MethodGet, "/api/workloads/"+wl.ID+"/runtime-state", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200", resp.StatusCode) } var got runtimeStatePayload if errMsg := decodeEnvelope(t, resp, &got); errMsg != "" { t.Fatalf("envelope error: %q", errMsg) } if !got.HasState { t.Fatalf("HasState = false, want true") } if got.ContainerID != "abcdef1234" || got.State != "running" { t.Errorf("container fields = (%q,%q), want (abcdef1234, running)", got.ContainerID, got.State) } if got.Status != "deployed" || got.LastCommitSHA != "abc1234" || got.LastSyncAt == "" { t.Errorf("runtime fields = %+v, want deployed/abc1234/non-empty", got) } } func TestGetWorkloadRuntimeState_MalformedExtraJSON_ReturnsContainerFieldsOnly(t *testing.T) { e := newAPITestEnv(t) wl, _ := e.store.CreateWorkload(store.Workload{ Kind: string(store.WorkloadKindSite), Name: "pages", SourceKind: "static", SourceConfig: `{"provider":"gitea"}`, }) // Seed a row with a valid extra_json first, then corrupt it via raw // SQL. Prior to the write-side validateExtraJSON guard this test // could pass a malformed string straight to UpsertContainer; the // guard now rejects that at the boundary, which is the correct // behaviour. The reader resilience this test verifies remains // relevant for pre-existing bad rows from upgrades or external // manipulation, so we still produce one via direct SQL. if err := e.store.UpsertContainer(store.Container{ ID: wl.ID + ":site", WorkloadID: wl.ID, WorkloadKind: string(store.WorkloadKindSite), Host: "local", ContainerID: "abc", State: "running", ExtraJSON: `{}`, }); err != nil { t.Fatalf("seed: %v", err) } if _, err := e.store.DB().Exec( `UPDATE containers SET extra_json = ? WHERE id = ?`, `{this is not json`, wl.ID+":site", ); err != nil { t.Fatalf("corrupt extra_json: %v", err) } resp := e.do(t, http.MethodGet, "/api/workloads/"+wl.ID+"/runtime-state", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200 (decode is non-fatal)", resp.StatusCode) } var got runtimeStatePayload _ = decodeEnvelope(t, resp, &got) if !got.HasState || got.ContainerID != "abc" { t.Errorf("expected HasState + container id present, got %+v", got) } if got.Status != "" || got.LastCommitSHA != "" { t.Errorf("expected typed fields empty on decode failure, got %+v", got) } } func TestGetWorkloadRuntimeState_DockerfileSourceDeployed_DecodesExtraJSON(t *testing.T) { e := newAPITestEnv(t) wl, err := e.store.CreateWorkload(store.Workload{ Kind: string(store.WorkloadKindProject), Name: "build-app", SourceKind: "dockerfile", SourceConfig: `{"provider":"gitea","port":3000}`, }) if err != nil { t.Fatalf("seed workload: %v", err) } extra, _ := json.Marshal(map[string]any{ "status": "deployed", "last_commit_sha": "deadbeef", "last_sync_at": "2026-05-23T10:00:00Z", "last_error": "", }) if err := e.store.UpsertContainer(store.Container{ ID: wl.ID + ":dockerfile", WorkloadID: wl.ID, WorkloadKind: string(store.WorkloadKindBuild), Host: "local", ContainerID: "ffeeddcc", State: "running", ExtraJSON: string(extra), }); err != nil { t.Fatalf("seed container: %v", err) } resp := e.do(t, http.MethodGet, "/api/workloads/"+wl.ID+"/runtime-state", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200", resp.StatusCode) } var got runtimeStatePayload if errMsg := decodeEnvelope(t, resp, &got); errMsg != "" { t.Fatalf("envelope error: %q", errMsg) } if !got.HasState { t.Fatalf("HasState = false, want true") } if got.SourceKind != "dockerfile" { t.Errorf("SourceKind = %q, want dockerfile", got.SourceKind) } if got.ContainerID != "ffeeddcc" || got.State != "running" { t.Errorf("container fields = (%q,%q), want (ffeeddcc, running)", got.ContainerID, got.State) } if got.Status != "deployed" || got.LastCommitSHA != "deadbeef" { t.Errorf("runtime fields = %+v, want deployed/deadbeef", got) } } // ============================================================================= // GET /api/workloads/{id}/storage // ============================================================================= func TestGetWorkloadStorage_NonStaticSource_EmptyPayload(t *testing.T) { e := newAPITestEnv(t) wl, _ := e.store.CreateWorkload(store.Workload{ Kind: string(store.WorkloadKindProject), Name: "img-app", SourceKind: "image", SourceConfig: `{"image":"nginx"}`, }) resp := e.do(t, http.MethodGet, "/api/workloads/"+wl.ID+"/storage", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200", resp.StatusCode) } var got storageUsagePayload _ = decodeEnvelope(t, resp, &got) if got.Enabled || got.UsedBytes != 0 { t.Errorf("expected empty payload for non-static, got %+v", got) } } func TestGetWorkloadStorage_StaticDisabled_ReturnsLimitButNoUsage(t *testing.T) { e := newAPITestEnv(t) wl, _ := e.store.CreateWorkload(store.Workload{ Kind: string(store.WorkloadKindSite), Name: "pages", SourceKind: "static", SourceConfig: `{"provider":"gitea","storage_enabled":false,"storage_limit_mb":0}`, }) resp := e.do(t, http.MethodGet, "/api/workloads/"+wl.ID+"/storage", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200", resp.StatusCode) } var got storageUsagePayload _ = decodeEnvelope(t, resp, &got) if got.Enabled { t.Errorf("Enabled = true, want false") } } func TestGetWorkloadStorage_StaticEnabledNoDockerClient_ReturnsZeroUsage(t *testing.T) { // docker is nil in the test env — the handler must still return // a valid payload (enabled + limit) without panicking. e := newAPITestEnv(t) wl, _ := e.store.CreateWorkload(store.Workload{ Kind: string(store.WorkloadKindSite), Name: "pages", SourceKind: "static", SourceConfig: `{"provider":"gitea","storage_enabled":true,"storage_limit_mb":512}`, }) resp := e.do(t, http.MethodGet, "/api/workloads/"+wl.ID+"/storage", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200", resp.StatusCode) } var got storageUsagePayload _ = decodeEnvelope(t, resp, &got) if !got.Enabled || got.LimitMB != 512 { t.Errorf("got %+v, want enabled=true limit=512", got) } if got.UsedBytes != 0 { t.Errorf("UsedBytes = %d, want 0 (no docker client)", got.UsedBytes) } } // ============================================================================= // POST /api/workloads/{id}/{stop,start} // ============================================================================= func TestStopPluginWorkload_NotFound_404(t *testing.T) { e := newAPITestEnv(t) resp := e.do(t, http.MethodPost, "/api/workloads/missing/stop", nil) if resp.StatusCode != http.StatusNotFound { _ = decodeEnvelope(t, resp, nil) t.Fatalf("status = %d, want 404", resp.StatusCode) } } func TestStopPluginWorkload_NoDockerClient_503(t *testing.T) { // The test env passes a nil dockerClient. The handler must refuse // with 503 rather than panicking on a nil deref. e := newAPITestEnv(t) wl, _ := e.store.CreateWorkload(store.Workload{ Kind: string(store.WorkloadKindSite), Name: "x", SourceKind: "static", }) resp := e.do(t, http.MethodPost, "/api/workloads/"+wl.ID+"/stop", nil) if resp.StatusCode != http.StatusServiceUnavailable { t.Fatalf("status = %d, want 503", resp.StatusCode) } } func TestStartPluginWorkload_NoDockerClient_503(t *testing.T) { e := newAPITestEnv(t) wl, _ := e.store.CreateWorkload(store.Workload{ Kind: string(store.WorkloadKindSite), Name: "x", SourceKind: "static", }) resp := e.do(t, http.MethodPost, "/api/workloads/"+wl.ID+"/start", nil) if resp.StatusCode != http.StatusServiceUnavailable { t.Fatalf("status = %d, want 503", resp.StatusCode) } } // ============================================================================= // stripImageTag-style behaviour assertions for the storage probe cache — // memoization wins on the second call within the TTL window. // ============================================================================= func TestStorageProbeCache_SecondCallSkipsProbe(t *testing.T) { // Clear the cache so a different test order doesn't pre-warm. storageProbeMu.Lock() storageProbeCache = map[string]storageProbeEntry{} storageProbeMu.Unlock() e := newAPITestEnv(t) wl, _ := e.store.CreateWorkload(store.Workload{ Kind: string(store.WorkloadKindSite), Name: "pages", SourceKind: "static", SourceConfig: `{"provider":"gitea","storage_enabled":true,"storage_limit_mb":256}`, }) // First call populates the cache (docker is nil, so it short-circuits // before the probe and never writes a cache entry — this test is // asserting that the no-docker path is well-behaved). resp := e.do(t, http.MethodGet, "/api/workloads/"+wl.ID+"/storage", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("first call status = %d, want 200", resp.StatusCode) } resp.Body.Close() // Second call should also return 200 — the path is idempotent. resp = e.do(t, http.MethodGet, "/api/workloads/"+wl.ID+"/storage", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("second call status = %d, want 200", resp.StatusCode) } resp.Body.Close() }