package preview import ( "encoding/json" "errors" "strings" "testing" "github.com/alexei/tinyforge/internal/store" ) // fakeStore is a minimal in-memory store satisfying the preview.Store // interface. Tests verify business logic without the SQLite layer. type fakeStore struct { workloads map[string]store.Workload createErr error } func newFakeStore() *fakeStore { return &fakeStore{workloads: map[string]store.Workload{}} } func (f *fakeStore) GetWorkloadByID(id string) (store.Workload, error) { w, ok := f.workloads[id] if !ok { return store.Workload{}, errors.New("not found") } return w, nil } func (f *fakeStore) ListChildrenByParent(parentID string) ([]store.Workload, error) { out := []store.Workload{} for _, w := range f.workloads { if w.ParentWorkloadID == parentID { out = append(out, w) } } return out, nil } func (f *fakeStore) CreateWorkload(w store.Workload) (store.Workload, error) { if f.createErr != nil { return store.Workload{}, f.createErr } if w.ID == "" { w.ID = "preview-" + w.Name } f.workloads[w.ID] = w return w, nil } func (f *fakeStore) DeleteWorkload(id string) error { delete(f.workloads, id) return nil } func TestSlugifyBranch_StripsUnsafeChars(t *testing.T) { cases := []struct { in string want string }{ {"main", "main"}, {"Feature/User-Auth", "feature-user-auth"}, {"PR#42", "pr-42"}, {"release/v1.2.3", "release-v1-2-3"}, {"___", "branch"}, {strings.Repeat("a", 50), strings.Repeat("a", 32)}, } for _, c := range cases { got := slugifyBranch(c.in) if got != c.want { t.Errorf("slugifyBranch(%q) = %q, want %q", c.in, got, c.want) } } } func TestPatchSourceConfigBranch_PreservesUnknownKeys(t *testing.T) { src := `{"port":3000,"dockerfile_path":"Dockerfile","branch":"main","provider":"github"}` out, err := patchSourceConfigBranch(src, "feat/x") if err != nil { t.Fatalf("patch: %v", err) } var got map[string]any if err := json.Unmarshal([]byte(out), &got); err != nil { t.Fatalf("decode: %v", err) } if got["branch"] != "feat/x" { t.Errorf("branch = %v, want feat/x", got["branch"]) } if got["port"] == nil || got["dockerfile_path"] == nil || got["provider"] == nil { t.Errorf("unknown keys dropped: %+v", got) } } func TestPatchPublicFacesSubdomain_PrefixesSubdomains(t *testing.T) { faces := `[{"subdomain":"app","domain":"example.com"},{"subdomain":"","domain":"raw.example.com"}]` out, err := patchPublicFacesSubdomain(faces, "feat-x") if err != nil { t.Fatalf("patch: %v", err) } var got []map[string]any if err := json.Unmarshal([]byte(out), &got); err != nil { t.Fatalf("decode: %v", err) } if got[0]["subdomain"] != "feat-x-app" { t.Errorf("first subdomain = %v, want feat-x-app", got[0]["subdomain"]) } if got[1]["subdomain"] != "" { t.Errorf("empty subdomain must stay empty, got %v", got[1]["subdomain"]) } } func TestMaterializeForBranch_CreatesNewWhenMissing(t *testing.T) { fs := newFakeStore() template := store.Workload{ ID: "tmpl-1", Kind: "project", Name: "myapp", AppID: "app-1", SourceKind: "dockerfile", SourceConfig: `{"branch":"main","port":3000}`, TriggerKind: "git", PublicFaces: `[{"subdomain":"www","domain":"x.test"}]`, } fs.workloads[template.ID] = template child, err := MaterializeForBranch(fs, template, "feat/login") if err != nil { t.Fatalf("materialize: %v", err) } if child.ParentWorkloadID != template.ID { t.Errorf("parent = %q, want %q", child.ParentWorkloadID, template.ID) } if !strings.Contains(child.Name, "feat-login") { t.Errorf("name = %q, want it to include slug", child.Name) } var cfg map[string]any if err := json.Unmarshal([]byte(child.SourceConfig), &cfg); err != nil { t.Fatalf("decode child source_config: %v", err) } if cfg["branch"] != "feat/login" { t.Errorf("child branch = %v, want feat/login", cfg["branch"]) } if cfg["port"] == nil { t.Errorf("child should inherit template port; got %+v", cfg) } var faces []map[string]any if err := json.Unmarshal([]byte(child.PublicFaces), &faces); err != nil { t.Fatalf("decode child faces: %v", err) } if !strings.HasPrefix(faces[0]["subdomain"].(string), "feat-login-") { t.Errorf("face subdomain = %v, want feat-login- prefix", faces[0]["subdomain"]) } } func TestMaterializeForBranch_ReusesExisting(t *testing.T) { fs := newFakeStore() template := store.Workload{ ID: "tmpl-1", Kind: "project", Name: "myapp", SourceKind: "dockerfile", SourceConfig: `{"branch":"main"}`, } fs.workloads[template.ID] = template first, err := MaterializeForBranch(fs, template, "feat/x") if err != nil { t.Fatalf("first materialize: %v", err) } second, err := MaterializeForBranch(fs, template, "feat/x") if err != nil { t.Fatalf("second materialize: %v", err) } if first.ID != second.ID { t.Errorf("expected idempotence: got %q then %q", first.ID, second.ID) } if len(fs.workloads) != 2 { t.Errorf("expected exactly one preview created, store has %d", len(fs.workloads)) } } func TestMaterializeForBranch_RejectsEmptyBranch(t *testing.T) { fs := newFakeStore() _, err := MaterializeForBranch(fs, store.Workload{ID: "tmpl"}, "") if err == nil { t.Fatal("expected error for empty branch") } } func TestFindPreviewForBranch_MissingReturnsFalse(t *testing.T) { fs := newFakeStore() _, ok, err := FindPreviewForBranch(fs, "tmpl", "feat/x") if err != nil { t.Fatalf("find: %v", err) } if ok { t.Error("expected ok=false for missing preview") } }