|
| 1 | +package graphgorm |
| 2 | + |
| 3 | +import ( |
| 4 | + "context" |
| 5 | + "testing" |
| 6 | + |
| 7 | + requestctx "github.com/tae2089/code-context-graph/internal/ctx" |
| 8 | + "github.com/tae2089/code-context-graph/internal/domain/graph" |
| 9 | + "github.com/tae2089/code-context-graph/internal/domain/reference" |
| 10 | +) |
| 11 | + |
| 12 | +func seedCrossRefNodes(t *testing.T, s *Store, namespace string, nodes []graph.Node) map[string]uint { |
| 13 | + t.Helper() |
| 14 | + ctx := requestctx.WithNamespace(context.Background(), namespace) |
| 15 | + if err := s.UpsertNodes(ctx, nodes); err != nil { |
| 16 | + t.Fatalf("seed nodes for %s: %v", namespace, err) |
| 17 | + } |
| 18 | + stored, err := s.GetNodesByFiles(ctx, uniqueFilePaths(nodes)) |
| 19 | + if err != nil { |
| 20 | + t.Fatalf("load seeded nodes: %v", err) |
| 21 | + } |
| 22 | + ids := map[string]uint{} |
| 23 | + for _, byFile := range stored { |
| 24 | + for _, n := range byFile { |
| 25 | + ids[n.QualifiedName] = n.ID |
| 26 | + } |
| 27 | + } |
| 28 | + return ids |
| 29 | +} |
| 30 | + |
| 31 | +func uniqueFilePaths(nodes []graph.Node) []string { |
| 32 | + seen := map[string]bool{} |
| 33 | + paths := []string{} |
| 34 | + for _, n := range nodes { |
| 35 | + if !seen[n.FilePath] { |
| 36 | + seen[n.FilePath] = true |
| 37 | + paths = append(paths, n.FilePath) |
| 38 | + } |
| 39 | + } |
| 40 | + return paths |
| 41 | +} |
| 42 | + |
| 43 | +func TestResolveCCGRef_Scopes(t *testing.T) { |
| 44 | + s := setupTestDB(t) |
| 45 | + ids := seedCrossRefNodes(t, s, "auth-svc", []graph.Node{ |
| 46 | + {QualifiedName: "internal/auth/token.go", Kind: graph.NodeKindFile, Name: "token.go", FilePath: "internal/auth/token.go", StartLine: 1, EndLine: 1}, |
| 47 | + {QualifiedName: "auth.ValidateToken", Kind: graph.NodeKindFunction, Name: "ValidateToken", FilePath: "internal/auth/token.go", StartLine: 10, EndLine: 20}, |
| 48 | + {QualifiedName: "auth.RenewToken", Kind: graph.NodeKindFunction, Name: "RenewToken", FilePath: "internal/auth/token.go", StartLine: 30, EndLine: 40}, |
| 49 | + }) |
| 50 | + ctx := context.Background() |
| 51 | + |
| 52 | + cases := []struct { |
| 53 | + name string |
| 54 | + ref reference.Ref |
| 55 | + wantID uint |
| 56 | + wantOK bool |
| 57 | + }{ |
| 58 | + {"path and symbol", reference.Ref{Namespace: "auth-svc", Path: "internal/auth/token.go", Symbol: "ValidateToken"}, ids["auth.ValidateToken"], true}, |
| 59 | + {"path only prefers file node", reference.Ref{Namespace: "auth-svc", Path: "internal/auth/token.go"}, ids["internal/auth/token.go"], true}, |
| 60 | + {"symbol only", reference.Ref{Namespace: "auth-svc", Symbol: "RenewToken"}, ids["auth.RenewToken"], true}, |
| 61 | + {"namespace scope resolves without node", reference.Ref{Namespace: "auth-svc"}, 0, true}, |
| 62 | + {"missing symbol", reference.Ref{Namespace: "auth-svc", Symbol: "Nope"}, 0, false}, |
| 63 | + {"missing namespace", reference.Ref{Namespace: "ghost"}, 0, false}, |
| 64 | + } |
| 65 | + for _, tc := range cases { |
| 66 | + t.Run(tc.name, func(t *testing.T) { |
| 67 | + id, ok, err := s.ResolveCCGRef(ctx, tc.ref) |
| 68 | + if err != nil { |
| 69 | + t.Fatalf("ResolveCCGRef: %v", err) |
| 70 | + } |
| 71 | + if ok != tc.wantOK || id != tc.wantID { |
| 72 | + t.Fatalf("ResolveCCGRef = (%d, %v), want (%d, %v)", id, ok, tc.wantID, tc.wantOK) |
| 73 | + } |
| 74 | + }) |
| 75 | + } |
| 76 | +} |
| 77 | + |
| 78 | +func TestReplaceCrossRefsFrom_ReplacesAndLists(t *testing.T) { |
| 79 | + s := setupTestDB(t) |
| 80 | + ctx := context.Background() |
| 81 | + first := []graph.CrossRef{ |
| 82 | + {FromNamespace: "web", FromNodeID: 1, Raw: "ccg://auth-svc/#Old", ToNamespace: "auth-svc", ToSymbol: "Old", Status: graph.CrossRefStatusDead, Source: graph.CrossRefSourceAnnotation}, |
| 83 | + } |
| 84 | + if err := s.ReplaceCrossRefsFrom(ctx, "web", first); err != nil { |
| 85 | + t.Fatalf("first replace: %v", err) |
| 86 | + } |
| 87 | + second := []graph.CrossRef{ |
| 88 | + {FromNamespace: "web", FromNodeID: 2, Raw: "ccg://auth-svc/#New", ToNamespace: "auth-svc", ToSymbol: "New", Status: graph.CrossRefStatusResolved, Source: graph.CrossRefSourceAnnotation}, |
| 89 | + {FromNamespace: "web", FromNodeID: 3, Raw: "ccg://billing/#Charge", ToNamespace: "billing", ToSymbol: "Charge", Status: graph.CrossRefStatusDead, Source: graph.CrossRefSourceAnnotation}, |
| 90 | + } |
| 91 | + if err := s.ReplaceCrossRefsFrom(ctx, "web", second); err != nil { |
| 92 | + t.Fatalf("second replace: %v", err) |
| 93 | + } |
| 94 | + |
| 95 | + inbound, err := s.ListInboundCrossRefs(ctx, "auth-svc") |
| 96 | + if err != nil { |
| 97 | + t.Fatalf("ListInboundCrossRefs: %v", err) |
| 98 | + } |
| 99 | + if len(inbound) != 1 || inbound[0].Raw != "ccg://auth-svc/#New" { |
| 100 | + t.Fatalf("inbound after replace = %+v, want only the new auth-svc ref", inbound) |
| 101 | + } |
| 102 | + |
| 103 | + outbound, err := s.ListOutboundCrossRefs(ctx, "web") |
| 104 | + if err != nil { |
| 105 | + t.Fatalf("ListOutboundCrossRefs: %v", err) |
| 106 | + } |
| 107 | + if len(outbound) != 2 { |
| 108 | + t.Fatalf("outbound rows = %d, want 2", len(outbound)) |
| 109 | + } |
| 110 | +} |
| 111 | + |
| 112 | +func TestListInboundCrossRefs_ExcludesSelfNamespace(t *testing.T) { |
| 113 | + s := setupTestDB(t) |
| 114 | + ctx := context.Background() |
| 115 | + rows := []graph.CrossRef{ |
| 116 | + {FromNamespace: "auth-svc", FromNodeID: 1, Raw: "ccg://auth-svc/internal#Self", ToNamespace: "auth-svc", ToSymbol: "Self", Status: graph.CrossRefStatusResolved, Source: graph.CrossRefSourceAnnotation}, |
| 117 | + } |
| 118 | + if err := s.ReplaceCrossRefsFrom(ctx, "auth-svc", rows); err != nil { |
| 119 | + t.Fatalf("replace: %v", err) |
| 120 | + } |
| 121 | + inbound, err := s.ListInboundCrossRefs(ctx, "auth-svc") |
| 122 | + if err != nil { |
| 123 | + t.Fatalf("ListInboundCrossRefs: %v", err) |
| 124 | + } |
| 125 | + if len(inbound) != 0 { |
| 126 | + t.Fatalf("inbound = %d rows, want 0 (self refs are rebuilt outbound)", len(inbound)) |
| 127 | + } |
| 128 | +} |
| 129 | + |
| 130 | +func TestUpdateCrossRefResolution_RemapsAndKills(t *testing.T) { |
| 131 | + s := setupTestDB(t) |
| 132 | + ctx := context.Background() |
| 133 | + resolved := uint(42) |
| 134 | + rows := []graph.CrossRef{ |
| 135 | + {FromNamespace: "web", FromNodeID: 1, Raw: "ccg://auth-svc/#Fn", ToNamespace: "auth-svc", ToSymbol: "Fn", ResolvedNodeID: &resolved, Status: graph.CrossRefStatusResolved, Source: graph.CrossRefSourceAnnotation}, |
| 136 | + } |
| 137 | + if err := s.ReplaceCrossRefsFrom(ctx, "web", rows); err != nil { |
| 138 | + t.Fatalf("replace: %v", err) |
| 139 | + } |
| 140 | + stored, err := s.ListInboundCrossRefs(ctx, "auth-svc") |
| 141 | + if err != nil || len(stored) != 1 { |
| 142 | + t.Fatalf("load stored row: %v (%d rows)", err, len(stored)) |
| 143 | + } |
| 144 | + |
| 145 | + if err := s.UpdateCrossRefResolution(ctx, stored[0].ID, nil, graph.CrossRefStatusDead); err != nil { |
| 146 | + t.Fatalf("kill resolution: %v", err) |
| 147 | + } |
| 148 | + after, err := s.ListInboundCrossRefs(ctx, "auth-svc") |
| 149 | + if err != nil { |
| 150 | + t.Fatalf("reload: %v", err) |
| 151 | + } |
| 152 | + if after[0].Status != graph.CrossRefStatusDead || after[0].ResolvedNodeID != nil { |
| 153 | + t.Fatalf("after kill = %+v, want dead with nil node", after[0]) |
| 154 | + } |
| 155 | + |
| 156 | + remapped := uint(99) |
| 157 | + if err := s.UpdateCrossRefResolution(ctx, stored[0].ID, &remapped, graph.CrossRefStatusResolved); err != nil { |
| 158 | + t.Fatalf("remap resolution: %v", err) |
| 159 | + } |
| 160 | + final, err := s.ListInboundCrossRefs(ctx, "auth-svc") |
| 161 | + if err != nil { |
| 162 | + t.Fatalf("reload final: %v", err) |
| 163 | + } |
| 164 | + if final[0].Status != graph.CrossRefStatusResolved || final[0].ResolvedNodeID == nil || *final[0].ResolvedNodeID != 99 { |
| 165 | + t.Fatalf("after remap = %+v, want resolved node 99", final[0]) |
| 166 | + } |
| 167 | +} |
0 commit comments