diff --git a/workspace-server/internal/handlers/org_import_pure_test.go b/workspace-server/internal/handlers/org_import_pure_test.go new file mode 100644 index 000000000..1bf40913c --- /dev/null +++ b/workspace-server/internal/handlers/org_import_pure_test.go @@ -0,0 +1,458 @@ +package handlers + +import ( + "sort" + "testing" +) + +// ───────────────────────────────────────────────────────────────────────────── +// envRequirementKey tests +// ───────────────────────────────────────────────────────────────────────────── + +func TestEnvRequirementKey_SingleMember_Pure(t *testing.T) { + key := envRequirementKey([]string{"API_KEY"}) + if key != "API_KEY" { + t.Errorf("single member: got %q, want %q", key, "API_KEY") + } +} + +func TestEnvRequirementKey_TwoMembersSorted_Pure(t *testing.T) { + // envRequirementKey sorts before joining, so [B, A] and [A, B] produce same key. + keyBA := envRequirementKey([]string{"B", "A"}) + keyAB := envRequirementKey([]string{"A", "B"}) + if keyBA != keyAB { + t.Errorf("sort invariance: got %q vs %q", keyBA, keyAB) + } + if keyBA != "A\x00B" { + t.Errorf("sort result: got %q, want %q", keyBA, "A\x00B") + } +} + +func TestEnvRequirementKey_ThreeMembers_Pure(t *testing.T) { + key := envRequirementKey([]string{"C", "A", "B"}) + if key != "A\x00B\x00C" { + t.Errorf("three members sorted: got %q, want %q", key, "A\x00B\x00C") + } +} + +func TestEnvRequirementKey_Empty_Pure(t *testing.T) { + key := envRequirementKey([]string{}) + if key != "" { + t.Errorf("empty: got %q, want %q", key, "") + } +} + +func TestEnvRequirementKey_DedupBySort_Pure(t *testing.T) { + // Two lists that are permutations of each other must have identical keys. + key1 := envRequirementKey([]string{"Z", "M", "A"}) + key2 := envRequirementKey([]string{"A", "Z", "M"}) + if key1 != key2 { + t.Errorf("permutation invariance: got %q vs %q", key1, key2) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// sanitizeEnvMembers tests +// ───────────────────────────────────────────────────────────────────────────── + +func TestSanitizeEnvMembers_AllValid_Pure(t *testing.T) { + members := []string{"API_KEY", "ANTHROPIC_API_KEY", "SECRET_123"} + got, ok := sanitizeEnvMembers(members, "test") + if !ok { + t.Error("expected ok=true for all valid names") + } + if len(got) != 3 { + t.Errorf("length: got %d, want 3", len(got)) + } +} + +func TestSanitizeEnvMembers_FiltersLowercase_Pure(t *testing.T) { + members := []string{"api_key", "ANTHROPIC_API_KEY", "VALID_NAME"} + got, ok := sanitizeEnvMembers(members, "test") + if !ok { + t.Error("expected ok=true for mixed case") + } + if len(got) != 2 { + t.Errorf("length: got %d, want 2", len(got)) + } + want := []string{"ANTHROPIC_API_KEY", "VALID_NAME"} + for i, w := range want { + if got[i] != w { + t.Errorf("got[%d]=%q, want %q", i, got[i], w) + } + } +} + +func TestSanitizeEnvMembers_FiltersInvalidCharacters_Pure(t *testing.T) { + members := []string{"VALID", "in valid", "ALSO-INVALID", "SPACE KEY", "hyphen-key", ""} + got, ok := sanitizeEnvMembers(members, "test") + if !ok { + t.Error("expected ok=true (VALID was present)") + } + if len(got) != 1 || got[0] != "VALID" { + t.Errorf("got %v, want [VALID]", got) + } +} + +func TestSanitizeEnvMembers_AllInvalid_Pure(t *testing.T) { + members := []string{"lowercase", "has-dash", "has space"} + got, ok := sanitizeEnvMembers(members, "test") + if ok { + t.Error("expected ok=false when all members invalid") + } + if len(got) != 0 { + t.Errorf("got %v, want []", got) + } +} + +func TestSanitizeEnvMembers_EmptyInput_Pure(t *testing.T) { + members := []string{} + got, ok := sanitizeEnvMembers(members, "test") + if ok { + t.Error("expected ok=false for empty input") + } + if len(got) != 0 { + t.Errorf("got %v, want []", got) + } +} + +func TestSanitizeEnvMembers_EmptyStringNotLogged_Pure(t *testing.T) { + // Empty string is filtered silently (not logged) — verify no panic. + members := []string{"VALID", ""} + got, ok := sanitizeEnvMembers(members, "test") + if !ok || len(got) != 1 || got[0] != "VALID" { + t.Errorf("unexpected result: ok=%v got=%v", ok, got) + } +} + +func TestSanitizeEnvMembers_StartsWithDigit_Pure(t *testing.T) { + members := []string{"123KEY", "VALID_KEY"} + got, ok := sanitizeEnvMembers(members, "test") + if !ok { + t.Error("expected ok=true (VALID_KEY was present)") + } + if len(got) != 1 || got[0] != "VALID_KEY" { + t.Errorf("got %v, want [VALID_KEY]", got) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// flattenAndSortRequirements tests +// ───────────────────────────────────────────────────────────────────────────── + +func TestFlattenAndSortRequirements_SinglesFirst_Pure(t *testing.T) { + by := map[string]EnvRequirement{ + "B": {Name: "B"}, + "Z": {Name: "Z"}, + "group1": {AnyOf: []string{"A", "C"}}, + } + got := flattenAndSortRequirements(by) + if len(got) != 3 { + t.Fatalf("len: got %d, want 3", len(got)) + } + // Singles first, then groups. + if got[0].Name == "" { + t.Error("first item should be a single") + } + if got[1].Name == "" { + t.Error("second item should be a single") + } + // Within singles, alphabetical. + if got[0].Name != "B" || got[1].Name != "Z" { + t.Errorf("got singles %q, %q, want B, Z", got[0].Name, got[1].Name) + } + // Then groups. + if len(got[2].AnyOf) == 0 { + t.Error("third item should be a group") + } +} + +func TestFlattenAndSortRequirements_GroupsSortedByKey_Pure(t *testing.T) { + by := map[string]EnvRequirement{ + "B\x00A": {AnyOf: []string{"B", "A"}}, // key already sorted + "A\x00C": {AnyOf: []string{"A", "C"}}, + } + got := flattenAndSortRequirements(by) + // After singles, groups sorted by their envRequirementKey. + // "A\x00B" < "A\x00C", so the B/A group (key "A\x00B") comes first. + group0 := got[len(got)-2] + group1 := got[len(got)-1] + if group0.AnyOf[0] != "B" || group1.AnyOf[0] != "A" { + t.Errorf("group order: got %v then %v, want [B,A] then [A,C]", group0.AnyOf, group1.AnyOf) + } +} + +func TestFlattenAndSortRequirements_EmptyMap_Pure(t *testing.T) { + by := map[string]EnvRequirement{} + got := flattenAndSortRequirements(by) + if len(got) != 0 { + t.Errorf("empty map: got %d items, want 0", len(got)) + } +} + +func TestFlattenAndSortRequirements_OnlyGroups_Pure(t *testing.T) { + by := map[string]EnvRequirement{ + "X\x00Y": {AnyOf: []string{"X", "Y"}}, + } + got := flattenAndSortRequirements(by) + if len(got) != 1 { + t.Fatalf("len: got %d, want 1", len(got)) + } + if len(got[0].AnyOf) == 0 { + t.Error("only group expected") + } +} + +func TestFlattenAndSortRequirements_Deterministic_Pure(t *testing.T) { + // Run twice; results must be identical. + by := map[string]EnvRequirement{ + "C": {Name: "C"}, + "A": {Name: "A"}, + "B": {Name: "B"}, + "Z\x00Y": {AnyOf: []string{"Z", "Y"}}, + } + got1 := flattenAndSortRequirements(by) + got2 := flattenAndSortRequirements(by) + if len(got1) != len(got2) { + t.Fatalf("len mismatch: %d vs %d", len(got1), len(got2)) + } + for i := range got1 { + if got1[i].Name != got2[i].Name || len(got1[i].AnyOf) != len(got2[i].AnyOf) { + t.Errorf("item %d differs: %+v vs %+v", i, got1[i], got2[i]) + } + } +} + +func TestFlattenAndSortRequirements_AllSingles_Pure(t *testing.T) { + by := map[string]EnvRequirement{ + "X": {Name: "X"}, + "M": {Name: "M"}, + "A": {Name: "A"}, + } + got := flattenAndSortRequirements(by) + if len(got) != 3 { + t.Fatalf("len: got %d, want 3", len(got)) + } + names := make([]string, len(got)) + for i, r := range got { + names[i] = r.Name + } + // Must be sorted alphabetically. + if !sort.IsSorted(sort.StringSlice(names)) { + t.Errorf("not sorted: %v", names) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// collectOrgEnv tests +// ───────────────────────────────────────────────────────────────────────────── + +func TestCollectOrgEnv_EmptyTemplate_Pure(t *testing.T) { + tmpl := &OrgTemplate{} + req, rec := collectOrgEnv(tmpl) + if len(req) != 0 || len(rec) != 0 { + t.Errorf("empty template: got req=%d rec=%d, want 0,0", len(req), len(rec)) + } +} + +func TestCollectOrgEnv_SingleRequired_Pure(t *testing.T) { + tmpl := &OrgTemplate{ + RequiredEnv: []EnvRequirement{{Name: "API_KEY"}}, + } + req, rec := collectOrgEnv(tmpl) + if len(req) != 1 { + t.Errorf("req count: got %d, want 1", len(req)) + } + if len(rec) != 0 { + t.Errorf("rec count: got %d, want 0", len(rec)) + } + if req[0].Name != "API_KEY" { + t.Errorf("req[0].Name: got %q, want %q", req[0].Name, "API_KEY") + } +} + +func TestCollectOrgEnv_SingleRecommended_Pure(t *testing.T) { + tmpl := &OrgTemplate{ + RecommendedEnv: []EnvRequirement{{Name: "DEBUG_MODE"}}, + } + req, rec := collectOrgEnv(tmpl) + if len(req) != 0 { + t.Errorf("req count: got %d, want 0", len(req)) + } + if len(rec) != 1 { + t.Errorf("rec count: got %d, want 1", len(rec)) + } +} + +func TestCollectOrgEnv_RequiredWinsOverRecommended_Pure(t *testing.T) { + // Same env name in both tiers → appears only in required. + tmpl := &OrgTemplate{ + RequiredEnv: []EnvRequirement{{Name: "SHARED_KEY"}}, + RecommendedEnv: []EnvRequirement{{Name: "SHARED_KEY"}}, + } + req, rec := collectOrgEnv(tmpl) + if len(req) != 1 || len(rec) != 0 { + t.Errorf("got req=%d rec=%d, want 1,0", len(req), len(rec)) + } +} + +func TestCollectOrgEnv_StrictDropsAnyOf_Pure(t *testing.T) { + // Single required + any-of group containing that name → any-of dropped. + tmpl := &OrgTemplate{ + RequiredEnv: []EnvRequirement{{Name: "API_KEY"}}, + RecommendedEnv: []EnvRequirement{{AnyOf: []string{"API_KEY", "FALLBACK_KEY"}}}, + } + req, rec := collectOrgEnv(tmpl) + if len(req) != 1 || req[0].Name != "API_KEY" { + t.Errorf("req: got %+v", req) + } + if len(rec) != 0 { + t.Errorf("rec: got %d, want 0 (any-of dropped by strict required)", len(rec)) + } +} + +func TestCollectOrgEnv_AnyOfGroup_Pure(t *testing.T) { + tmpl := &OrgTemplate{ + RequiredEnv: []EnvRequirement{{AnyOf: []string{"KEY_A", "KEY_B"}}}, + } + req, rec := collectOrgEnv(tmpl) + if len(req) != 1 { + t.Fatalf("req count: got %d, want 1", len(req)) + } + if len(req[0].AnyOf) != 2 { + t.Errorf("req[0].AnyOf: got %v, want [KEY_A, KEY_B]", req[0].AnyOf) + } + _ = rec // may be unused but call is valid +} + +func TestCollectOrgEnv_NestedWorkspace_Pure(t *testing.T) { + tmpl := &OrgTemplate{ + RequiredEnv: []EnvRequirement{{Name: "ORG_KEY"}}, + Workspaces: []OrgWorkspace{ + { + Name: "child-ws", + RequiredEnv: []EnvRequirement{{Name: "CHILD_KEY"}}, + RecommendedEnv: []EnvRequirement{{Name: "CHILD_RECOM"}}, + }, + }, + } + req, rec := collectOrgEnv(tmpl) + // All 3 required names should appear (dedupped). + if len(req) != 2 { + t.Errorf("req count: got %d, want 2", len(req)) + } + if len(rec) != 1 { + t.Errorf("rec count: got %d, want 1", len(rec)) + } + names := make(map[string]bool) + for _, r := range req { + names[r.Name] = true + } + if !names["ORG_KEY"] || !names["CHILD_KEY"] { + t.Errorf("req names: got %v", names) + } +} + +func TestCollectOrgEnv_InvalidEnvNameFiltered_Pure(t *testing.T) { + // Invalid names (lowercase, empty) are silently dropped. + tmpl := &OrgTemplate{ + RequiredEnv: []EnvRequirement{ + {Name: "VALID_KEY"}, + {Name: "ALSO_VALID"}, + {Name: "invalid-name"}, + {Name: ""}, + }, + } + req, rec := collectOrgEnv(tmpl) + if len(req) != 2 { + t.Errorf("got %d req, want 2 (lowercase and empty filtered)", len(req)) + } + _ = rec +} + +func TestCollectOrgEnv_DedupSameMembers_Pure(t *testing.T) { + // Same members declared twice → deduplicated. + tmpl := &OrgTemplate{ + RequiredEnv: []EnvRequirement{ + {AnyOf: []string{"A", "B"}}, + {AnyOf: []string{"B", "A"}}, // same set, reversed + }, + } + req, rec := collectOrgEnv(tmpl) + if len(req) != 1 { + t.Errorf("dedup failed: got %d req, want 1", len(req)) + } + _ = rec +} + +func TestCollectOrgEnv_RecDedupedByStrictRequired_Pure(t *testing.T) { + // Strict required drops any-of groups in recommended tier too. + tmpl := &OrgTemplate{ + RequiredEnv: []EnvRequirement{{Name: "STRICT"}}, + RecommendedEnv: []EnvRequirement{{AnyOf: []string{"STRICT", "OTHER"}}}, + } + req, rec := collectOrgEnv(tmpl) + if len(rec) != 0 { + t.Errorf("rec should be empty (strict required prunes recommended any-of): got %d", len(rec)) + } + _ = req +} + +func TestCollectOrgEnv_RecursiveChildren_Pure(t *testing.T) { + tmpl := &OrgTemplate{ + RequiredEnv: []EnvRequirement{{Name: "ROOT"}}, + Workspaces: []OrgWorkspace{ + { + Name: "parent", + RequiredEnv: []EnvRequirement{{Name: "PARENT"}, {Name: "PARENT2"}}, + Children: []OrgWorkspace{ + { + Name: "grandchild", + RequiredEnv: []EnvRequirement{{Name: "GRANDCHILD"}}, + }, + }, + }, + }, + } + req, rec := collectOrgEnv(tmpl) + if len(req) != 4 { + t.Errorf("got %d required, want 4 (ROOT + PARENT + PARENT2 + GRANDCHILD)", len(req)) + } + _ = rec +} + +// ───────────────────────────────────────────────────────────────────────────── +// countWorkspaces tests +// ───────────────────────────────────────────────────────────────────────────── + +func TestCountWorkspaces_Empty_Pure(t *testing.T) { + if n := countWorkspaces(nil); n != 0 { + t.Errorf("nil: got %d, want 0", n) + } + if n := countWorkspaces([]OrgWorkspace{}); n != 0 { + t.Errorf("empty: got %d, want 0", n) + } +} + +func TestCountWorkspaces_Flat_Pure(t *testing.T) { + ws := []OrgWorkspace{ + {Name: "a"}, {Name: "b"}, + } + if n := countWorkspaces(ws); n != 2 { + t.Errorf("flat: got %d, want 2", n) + } +} + +func TestCountWorkspaces_Nested_Pure(t *testing.T) { + ws := []OrgWorkspace{ + {Name: "a", Children: []OrgWorkspace{ + {Name: "b", Children: []OrgWorkspace{ + {Name: "c"}, + }}, + {Name: "d"}, + }}, + } + if n := countWorkspaces(ws); n != 4 { + t.Errorf("nested: got %d, want 4", n) + } +}