@memberjunction/server 5.24.0 → 5.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/agents/skip-sdk.d.ts +12 -0
  2. package/dist/agents/skip-sdk.d.ts.map +1 -1
  3. package/dist/agents/skip-sdk.js +70 -1
  4. package/dist/agents/skip-sdk.js.map +1 -1
  5. package/dist/generated/generated.d.ts +492 -0
  6. package/dist/generated/generated.d.ts.map +1 -1
  7. package/dist/generated/generated.js +2731 -0
  8. package/dist/generated/generated.js.map +1 -1
  9. package/dist/index.d.ts +2 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +2 -0
  12. package/dist/index.js.map +1 -1
  13. package/dist/resolvers/ArtifactFileResolver.d.ts +15 -0
  14. package/dist/resolvers/ArtifactFileResolver.d.ts.map +1 -0
  15. package/dist/resolvers/ArtifactFileResolver.js +74 -0
  16. package/dist/resolvers/ArtifactFileResolver.js.map +1 -0
  17. package/dist/resolvers/AutotagPipelineResolver.d.ts +13 -0
  18. package/dist/resolvers/AutotagPipelineResolver.d.ts.map +1 -1
  19. package/dist/resolvers/AutotagPipelineResolver.js +103 -3
  20. package/dist/resolvers/AutotagPipelineResolver.js.map +1 -1
  21. package/dist/resolvers/FileResolver.d.ts.map +1 -1
  22. package/dist/resolvers/FileResolver.js +12 -32
  23. package/dist/resolvers/FileResolver.js.map +1 -1
  24. package/dist/resolvers/GeoResolver.d.ts +58 -0
  25. package/dist/resolvers/GeoResolver.d.ts.map +1 -0
  26. package/dist/resolvers/GeoResolver.js +302 -0
  27. package/dist/resolvers/GeoResolver.js.map +1 -0
  28. package/dist/resolvers/RunAIAgentResolver.d.ts +13 -1
  29. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  30. package/dist/resolvers/RunAIAgentResolver.js +115 -20
  31. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  32. package/dist/resolvers/SearchKnowledgeResolver.d.ts +21 -80
  33. package/dist/resolvers/SearchKnowledgeResolver.d.ts.map +1 -1
  34. package/dist/resolvers/SearchKnowledgeResolver.js +129 -604
  35. package/dist/resolvers/SearchKnowledgeResolver.js.map +1 -1
  36. package/dist/resolvers/SearchKnowledgeSystemUserResolver.d.ts +19 -0
  37. package/dist/resolvers/SearchKnowledgeSystemUserResolver.d.ts.map +1 -0
  38. package/dist/resolvers/SearchKnowledgeSystemUserResolver.js +149 -0
  39. package/dist/resolvers/SearchKnowledgeSystemUserResolver.js.map +1 -0
  40. package/package.json +63 -63
  41. package/src/__tests__/search-knowledge-tags.test.ts +177 -337
  42. package/src/__tests__/skip-sdk-organic-keys.test.ts +274 -0
  43. package/src/agents/skip-sdk.ts +83 -2
  44. package/src/generated/generated.ts +1884 -1
  45. package/src/index.ts +2 -0
  46. package/src/resolvers/ArtifactFileResolver.ts +71 -0
  47. package/src/resolvers/AutotagPipelineResolver.ts +118 -4
  48. package/src/resolvers/FileResolver.ts +12 -41
  49. package/src/resolvers/GeoResolver.ts +258 -0
  50. package/src/resolvers/RunAIAgentResolver.ts +137 -23
  51. package/src/resolvers/SearchKnowledgeResolver.ts +114 -715
  52. package/src/resolvers/SearchKnowledgeSystemUserResolver.ts +138 -0
@@ -1,415 +1,255 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
 
3
3
  /**
4
- * Tests for SearchKnowledgeResolver — focusing on tag enrichment,
5
- * title extraction, and metadata-to-result conversion logic.
4
+ * Tests for SearchKnowledgeResolver — the thin wrapper that delegates
5
+ * to SearchEngine.Instance.Search() and maps results to GraphQL types.
6
6
  *
7
- * The resolver has heavy infrastructure dependencies (type-graphql, vector DBs,
8
- * embeddings), so we test the pure-logic methods by constructing an instance
9
- * and accessing them via prototype / casting tricks.
7
+ * The actual search logic (providers, fusion, enrichment, permissions)
8
+ * is tested in the @memberjunction/search-engine package.
10
9
  */
11
10
 
12
11
  // ── Mocks ──────────────────────────────────────────────────────────────
13
12
 
14
- const mockRunViewResults = new Map<string, { Success: boolean; Results: unknown[] }>();
13
+ const { mockSearch, mockPreviewSearch } = vi.hoisted(() => ({
14
+ mockSearch: vi.fn(),
15
+ mockPreviewSearch: vi.fn(),
16
+ }));
15
17
 
16
- vi.mock('@memberjunction/core', () => {
17
- class MockRunView {
18
- async RunView(params: { EntityName: string; ExtraFilter?: string }): Promise<{ Success: boolean; Results: unknown[] }> {
19
- // Allow tests to set up per-entity results
20
- for (const [key, val] of mockRunViewResults.entries()) {
21
- if (params.EntityName === key || (params.ExtraFilter && params.ExtraFilter.includes(key))) {
22
- return val;
23
- }
24
- }
25
- return { Success: true, Results: [] };
18
+ vi.mock('@memberjunction/search-engine', () => ({
19
+ SearchEngine: {
20
+ Instance: {
21
+ Config: vi.fn().mockResolvedValue(undefined),
22
+ Search: (...args: unknown[]) => mockSearch(...args),
23
+ PreviewSearch: (...args: unknown[]) => mockPreviewSearch(...args),
26
24
  }
27
25
  }
26
+ }));
28
27
 
29
- const mockEntities = [
30
- {
31
- Name: 'Contacts',
32
- ID: 'entity-contacts-id',
33
- Icon: 'fa-solid fa-user',
34
- NameField: { Name: 'FullName', IsNameField: true, Sequence: 1 },
35
- Fields: [
36
- { Name: 'ID', IsNameField: false, IsPrimaryKey: true, Sequence: 0, TSType: 'string' },
37
- { Name: 'FirstName', IsNameField: true, Sequence: 1, TSType: 'string', IsPrimaryKey: false },
38
- { Name: 'LastName', IsNameField: true, Sequence: 2, TSType: 'string', IsPrimaryKey: false },
39
- { Name: 'Email', IsNameField: false, Sequence: 3, TSType: 'string', IsPrimaryKey: false },
40
- ]
41
- },
42
- {
43
- Name: 'Companies',
44
- ID: 'entity-companies-id',
45
- Icon: 'fa-solid fa-building',
46
- NameField: { Name: 'Name', IsNameField: true, Sequence: 1 },
47
- Fields: [
48
- { Name: 'ID', IsNameField: false, IsPrimaryKey: true, Sequence: 0, TSType: 'string' },
49
- { Name: 'Name', IsNameField: true, Sequence: 1, TSType: 'string', IsPrimaryKey: false },
50
- ]
51
- },
52
- {
53
- Name: 'Events',
54
- ID: 'entity-events-id',
55
- Icon: null,
56
- NameField: null,
57
- Fields: [
58
- { Name: 'ID', IsNameField: false, IsPrimaryKey: true, Sequence: 0, TSType: 'string' },
59
- { Name: 'EventCode', IsNameField: false, Sequence: 1, TSType: 'number', IsPrimaryKey: false },
60
- ]
61
- }
62
- ];
63
-
64
- class MockMetadata {
65
- Entities = mockEntities;
66
- }
28
+ vi.mock('@memberjunction/global', async (importOriginal) => {
29
+ const actual = await importOriginal<Record<string, unknown>>();
30
+ return { ...actual };
31
+ });
67
32
 
33
+ vi.mock('@memberjunction/core', async (importOriginal) => {
34
+ const actual = await importOriginal<Record<string, unknown>>();
68
35
  return {
69
- RunView: MockRunView,
70
- Metadata: MockMetadata,
36
+ ...actual,
71
37
  LogError: vi.fn(),
72
38
  LogStatus: vi.fn(),
73
- UserInfo: class {},
74
- ComputeRRF: vi.fn(),
75
- ScoredCandidate: class {},
76
- EntityRecordNameInput: class {},
77
- CompositeKey: class { LoadFromURLSegment = vi.fn(); },
78
39
  };
79
40
  });
80
41
 
81
- vi.mock('@memberjunction/core-entities', () => ({
82
- MJVectorIndexEntity: class {},
83
- MJVectorDatabaseEntity: class {},
84
- KnowledgeHubMetadataEngine: {
85
- Instance: {
86
- Config: vi.fn(),
87
- ContentSourceTypes: [],
88
- ContentSources: [],
89
- },
90
- },
91
- }));
92
-
93
- vi.mock('@memberjunction/ai', () => ({
94
- GetAIAPIKey: vi.fn(() => 'mock-key'),
95
- BaseEmbeddings: class {},
96
- }));
97
-
98
- vi.mock('@memberjunction/ai-vectordb', () => ({
99
- VectorDBBase: class {},
100
- BaseResponse: class {},
101
- }));
42
+ vi.mock('@memberjunction/core-entities', async (importOriginal) => {
43
+ const actual = await importOriginal<Record<string, unknown>>();
44
+ return { ...actual };
45
+ });
102
46
 
103
- vi.mock('@memberjunction/aiengine', () => ({
104
- AIEngine: { Instance: { Config: vi.fn(), Models: [] } },
105
- }));
47
+ // Mock type-graphql to avoid reflect-metadata dependency
48
+ vi.mock('type-graphql', () => {
49
+ const noop = () => () => undefined;
50
+ const passthrough = () => (target: unknown) => target;
51
+ return {
52
+ Resolver: passthrough,
53
+ Mutation: noop,
54
+ Query: noop,
55
+ Subscription: noop,
56
+ Arg: noop,
57
+ Args: noop,
58
+ Ctx: noop,
59
+ ObjectType: passthrough,
60
+ InputType: passthrough,
61
+ ArgsType: passthrough,
62
+ Field: noop,
63
+ Float: Number,
64
+ Int: Number,
65
+ ID: String,
66
+ Authorized: noop,
67
+ PubSub: noop,
68
+ Root: noop,
69
+ };
70
+ });
106
71
 
107
- vi.mock('@memberjunction/global', () => ({
108
- MJGlobal: { Instance: { ClassFactory: { CreateInstance: vi.fn() } } },
109
- UUIDsEqual: (a: string, b: string) => a?.toLowerCase() === b?.toLowerCase(),
110
- }));
72
+ // ── Import after mocks ─────────────────────────────────────────────────
111
73
 
112
- vi.mock('type-graphql', () => ({
113
- Resolver: () => (target: unknown) => target,
114
- Query: () => (target: unknown, key: string, descriptor: PropertyDescriptor) => descriptor,
115
- Mutation: () => (target: unknown, key: string, descriptor: PropertyDescriptor) => descriptor,
116
- Arg: () => (target: unknown, key: string, index: number) => {},
117
- Ctx: () => (target: unknown, key: string, index: number) => {},
118
- ObjectType: () => (target: unknown) => target,
119
- Field: () => (target: unknown, key: string) => {},
120
- Float: Number,
121
- Int: Number,
122
- InputType: () => (target: unknown) => target,
123
- }));
74
+ import { SearchKnowledgeResolver } from '../resolvers/SearchKnowledgeResolver.js';
124
75
 
125
- vi.mock('../generic/ResolverBase.js', () => ({
126
- ResolverBase: class {
127
- GetUserFromPayload() { return {}; }
128
- },
129
- }));
76
+ // ── Helpers ─────────────────────────────────────────────────────────────
130
77
 
131
- vi.mock('../types.js', () => ({
132
- AppContext: class {},
133
- }));
78
+ function createResolver(): SearchKnowledgeResolver {
79
+ const resolver = new SearchKnowledgeResolver();
80
+ // Mock GetUserFromPayload to return a fake user
81
+ (resolver as Record<string, unknown>)['GetUserFromPayload'] = vi.fn().mockReturnValue({
82
+ ID: 'test-user-id',
83
+ Email: 'test@test.com',
84
+ Name: 'Test User'
85
+ });
86
+ return resolver;
87
+ }
134
88
 
135
- // ── Import under test (after mocks) ──────────────────────────────────
89
+ function createMockSearchResult(overrides: Record<string, unknown> = {}) {
90
+ return {
91
+ Success: true,
92
+ Results: [
93
+ {
94
+ ID: 'result-1',
95
+ EntityName: 'Contacts',
96
+ RecordID: 'abc-123',
97
+ SourceType: 'entity',
98
+ ResultType: 'entity-record',
99
+ Title: 'John Smith',
100
+ Snippet: 'A contact record',
101
+ Score: 0.85,
102
+ ScoreBreakdown: { Entity: 0.85 },
103
+ Tags: ['VIP'],
104
+ EntityIcon: 'fa-solid fa-user',
105
+ RecordName: 'John Smith',
106
+ MatchedAt: new Date(),
107
+ }
108
+ ],
109
+ TotalCount: 1,
110
+ ElapsedMs: 42,
111
+ SourceCounts: { Vector: 0, FullText: 0, Entity: 1, Storage: 0 },
112
+ ...overrides
113
+ };
114
+ }
136
115
 
137
- import { SearchKnowledgeResolver } from '../resolvers/SearchKnowledgeResolver.js';
116
+ const fakeContext = { userPayload: { email: 'test@test.com' } };
138
117
 
139
- // Helper to access private methods
140
- function getPrivateMethod<T>(instance: T, method: string): (...args: unknown[]) => unknown {
141
- return (instance as Record<string, unknown>)[method] as (...args: unknown[]) => unknown;
142
- }
118
+ // ── Tests ───────────────────────────────────────────────────────────────
143
119
 
144
120
  describe('SearchKnowledgeResolver', () => {
145
- let resolver: SearchKnowledgeResolver;
146
-
147
121
  beforeEach(() => {
148
- resolver = new SearchKnowledgeResolver();
149
- mockRunViewResults.clear();
122
+ vi.clearAllMocks();
150
123
  });
151
124
 
152
- // ─── extractDisplayTitle ──────────────────────────────────────────
153
-
154
- describe('extractDisplayTitle', () => {
155
- const extractDisplayTitle = (meta: Record<string, unknown>, fallback: string) =>
156
- getPrivateMethod(resolver, 'extractDisplayTitle')(meta, fallback) as string;
157
-
158
- it('should combine multiple IsNameField values for a multi-name entity', () => {
159
- const meta = { Entity: 'Contacts', FirstName: 'Sarah', LastName: 'Chen' };
160
- const title = extractDisplayTitle(meta, 'Contacts');
161
- expect(title).toBe('Sarah Chen');
162
- });
125
+ describe('SearchKnowledge mutation', () => {
126
+ it('should delegate to SearchEngine.Instance.Search()', async () => {
127
+ const resolver = createResolver();
128
+ mockSearch.mockResolvedValueOnce(createMockSearchResult());
163
129
 
164
- it('should use single NameField when IsNameField fields are not populated', () => {
165
- const meta = { Entity: 'Companies', Name: 'Acme Corp' };
166
- const title = extractDisplayTitle(meta, 'Companies');
167
- // Companies has Name as IsNameField, so it should use it
168
- expect(title).toBe('Acme Corp');
169
- });
130
+ const result = await resolver.SearchKnowledge('test query', 20, undefined, undefined, fakeContext as never);
170
131
 
171
- it('should fall back to heuristic fields when no entity metadata matches', () => {
172
- const meta = { Entity: 'UnknownEntity', Title: 'My Document' };
173
- const title = extractDisplayTitle(meta, 'UnknownEntity');
174
- expect(title).toBe('My Document');
132
+ expect(mockSearch).toHaveBeenCalledOnce();
133
+ expect(result.Success).toBe(true);
134
+ expect(result.TotalCount).toBe(1);
135
+ expect(result.Results).toHaveLength(1);
175
136
  });
176
137
 
177
- it('should try Name heuristic field', () => {
178
- const meta = { Entity: 'UnknownEntity', Name: 'Widget A' };
179
- const title = extractDisplayTitle(meta, 'UnknownEntity');
180
- expect(title).toBe('Widget A');
181
- });
138
+ it('should pass query and maxResults to SearchEngine', async () => {
139
+ const resolver = createResolver();
140
+ mockSearch.mockResolvedValueOnce(createMockSearchResult());
182
141
 
183
- it('should try Subject heuristic field', () => {
184
- const meta = { Entity: 'NonExistent', Subject: 'RE: Meeting' };
185
- const title = extractDisplayTitle(meta, 'NonExistent');
186
- expect(title).toBe('RE: Meeting');
187
- });
142
+ await resolver.SearchKnowledge('cheese', 50, undefined, undefined, fakeContext as never);
188
143
 
189
- it('should return fallback when no fields match', () => {
190
- const meta = { Entity: 'Events', NumericCode: 42 };
191
- const title = extractDisplayTitle(meta, 'Events');
192
- expect(title).toBe('Events Record');
144
+ const searchParams = mockSearch.mock.calls[0][0];
145
+ expect(searchParams.Query).toBe('cheese');
146
+ expect(searchParams.MaxResults).toBe(50);
193
147
  });
194
148
 
195
- it('should skip empty/whitespace name field values', () => {
196
- const meta = { Entity: 'Contacts', FirstName: '', LastName: 'Rodriguez' };
197
- const title = extractDisplayTitle(meta, 'Contacts');
198
- expect(title).toBe('Rodriguez');
199
- });
149
+ it('should pass filters when provided', async () => {
150
+ const resolver = createResolver();
151
+ mockSearch.mockResolvedValueOnce(createMockSearchResult());
200
152
 
201
- it('should handle metadata with no Entity key by using heuristics', () => {
202
- const meta = { Name: 'Fallback Name' };
203
- const title = extractDisplayTitle(meta, 'SomeEntity');
204
- expect(title).toBe('Fallback Name');
205
- });
153
+ const filters = { EntityNames: ['Contacts'], SourceTypes: undefined, Tags: ['VIP'] };
154
+ await resolver.SearchKnowledge('test', 20, filters as never, undefined, fakeContext as never);
206
155
 
207
- it('should return fallback entity record string when all heuristic fields are non-string', () => {
208
- const meta = { Entity: 'NonExistent', Count: 42, Active: true };
209
- const title = extractDisplayTitle(meta, 'MyEntity');
210
- expect(title).toBe('MyEntity Record');
156
+ const searchParams = mockSearch.mock.calls[0][0];
157
+ expect(searchParams.Filters).toEqual({
158
+ EntityNames: ['Contacts'],
159
+ SourceTypes: undefined,
160
+ Tags: ['VIP']
161
+ });
211
162
  });
212
- });
213
163
 
214
- // ─── extractDisplaySnippet ────────────────────────────────────────
164
+ it('should pass minScore when provided', async () => {
165
+ const resolver = createResolver();
166
+ mockSearch.mockResolvedValueOnce(createMockSearchResult());
215
167
 
216
- describe('extractDisplaySnippet', () => {
217
- const extractDisplaySnippet = (meta: Record<string, unknown>, indexName: string, score?: number) =>
218
- getPrivateMethod(resolver, 'extractDisplaySnippet')(meta, indexName, score) as string;
168
+ await resolver.SearchKnowledge('test', 20, undefined, 0.5, fakeContext as never);
219
169
 
220
- it('should return Description field when present', () => {
221
- const meta = { Description: 'A detailed description of the record' };
222
- expect(extractDisplaySnippet(meta, 'idx-1', 0.95)).toBe('A detailed description of the record');
170
+ const searchParams = mockSearch.mock.calls[0][0];
171
+ expect(searchParams.MinScore).toBe(0.5);
223
172
  });
224
173
 
225
- it('should truncate long descriptions to 200 chars', () => {
226
- const longDesc = 'A'.repeat(250);
227
- const meta = { Description: longDesc };
228
- const snippet = extractDisplaySnippet(meta, 'idx-1');
229
- expect(snippet.length).toBe(203); // 200 + '...'
230
- expect(snippet.endsWith('...')).toBe(true);
231
- });
174
+ it('should map result fields correctly', async () => {
175
+ const resolver = createResolver();
176
+ mockSearch.mockResolvedValueOnce(createMockSearchResult());
232
177
 
233
- it('should build snippet from metadata fields when no description fields exist', () => {
234
- const meta = { Status: 'Active', Type: 'Premium' };
235
- const snippet = extractDisplaySnippet(meta, 'idx-1');
236
- expect(snippet).toContain('Status: Active');
237
- expect(snippet).toContain('Type: Premium');
238
- });
178
+ const result = await resolver.SearchKnowledge('test', 20, undefined, undefined, fakeContext as never);
239
179
 
240
- it('should return index fallback when no usable metadata', () => {
241
- const meta = { RecordID: 'abc-123', Entity: 'Contacts' };
242
- const snippet = extractDisplaySnippet(meta, 'my-index', 0.85);
243
- expect(snippet).toContain('my-index');
244
- expect(snippet).toContain('0.8500');
180
+ const item = result.Results[0];
181
+ expect(item.ID).toBe('result-1');
182
+ expect(item.EntityName).toBe('Contacts');
183
+ expect(item.RecordID).toBe('abc-123');
184
+ expect(item.Title).toBe('John Smith');
185
+ expect(item.Score).toBe(0.85);
186
+ expect(item.Tags).toEqual(['VIP']);
187
+ expect(item.EntityIcon).toBe('fa-solid fa-user');
245
188
  });
246
- });
247
189
 
248
- // ─── Tag extraction from vector metadata (inline logic) ────────
190
+ it('should map source counts correctly', async () => {
191
+ const resolver = createResolver();
192
+ mockSearch.mockResolvedValueOnce(createMockSearchResult({
193
+ SourceCounts: { Vector: 5, FullText: 3, Entity: 10, Storage: 0 }
194
+ }));
249
195
 
250
- describe('tag extraction from vector metadata', () => {
251
- it('should extract Tags array when present and is an array', () => {
252
- const meta: Record<string, unknown> = { Tags: ['AI', 'Machine Learning'] };
253
- const tags = Array.isArray(meta['Tags']) ? (meta['Tags'] as string[]) : [];
254
- expect(tags).toEqual(['AI', 'Machine Learning']);
255
- });
196
+ const result = await resolver.SearchKnowledge('test', 20, undefined, undefined, fakeContext as never);
256
197
 
257
- it('should return empty array when Tags is not in metadata', () => {
258
- const meta: Record<string, unknown> = { Entity: 'Companies', RecordID: 'r2' };
259
- const tags = Array.isArray(meta['Tags']) ? (meta['Tags'] as string[]) : [];
260
- expect(tags).toEqual([]);
198
+ expect(result.SourceCounts.Vector).toBe(5);
199
+ expect(result.SourceCounts.FullText).toBe(3);
200
+ expect(result.SourceCounts.Entity).toBe(10);
261
201
  });
262
202
 
263
- it('should return empty array when Tags is not an array', () => {
264
- const meta: Record<string, unknown> = { Tags: 'not-an-array' };
265
- const tags = Array.isArray(meta['Tags']) ? (meta['Tags'] as string[]) : [];
266
- expect(tags).toEqual([]);
267
- });
268
-
269
- it('should return empty array when metadata is empty', () => {
270
- const meta: Record<string, unknown> = {};
271
- const tags = Array.isArray(meta['Tags']) ? (meta['Tags'] as string[]) : [];
272
- expect(tags).toEqual([]);
273
- });
274
- });
203
+ it('should pass empty query through to SearchEngine (validation happens there)', async () => {
204
+ const resolver = createResolver();
205
+ mockSearch.mockResolvedValueOnce(createMockSearchResult({ Success: false, ErrorMessage: 'Query cannot be empty' }));
275
206
 
276
- // ─── enrichResultsWithTags ────────────────────────────────────────
207
+ const result = await resolver.SearchKnowledge(' ', 20, undefined, undefined, fakeContext as never);
277
208
 
278
- describe('enrichResultsWithTags', () => {
279
- const enrichResultsWithTags = (
280
- results: Array<{ EntityName: string; RecordID: string; Tags: string[] }>,
281
- md: { Entities: Array<{ Name: string; ID: string }> },
282
- contextUser: unknown
283
- ) => getPrivateMethod(resolver, 'enrichResultsWithTags')(results, md, contextUser) as Promise<void>;
284
-
285
- it('should not modify results when results array is empty', async () => {
286
- const results: Array<{ EntityName: string; RecordID: string; Tags: string[] }> = [];
287
- const md = { Entities: [{ Name: 'Contacts', ID: 'eid-1' }] };
288
- await enrichResultsWithTags(results, md, {});
289
- expect(results).toEqual([]);
209
+ expect(mockSearch).toHaveBeenCalled();
210
+ expect(result.Success).toBe(false);
290
211
  });
291
212
 
292
- it('should not modify results when entity is not found in metadata', async () => {
293
- const results = [{ EntityName: 'NonExistent', RecordID: 'r1', Tags: [] as string[] }];
294
- const md = { Entities: [{ Name: 'Contacts', ID: 'eid-1' }] };
295
- await enrichResultsWithTags(results, md, {});
296
- expect(results[0].Tags).toEqual([]);
297
- });
213
+ it('should return error when user cannot be determined', async () => {
214
+ const resolver = new SearchKnowledgeResolver();
215
+ (resolver as Record<string, unknown>)['GetUserFromPayload'] = vi.fn().mockReturnValue(null);
298
216
 
299
- it('should enrich results with tags from RunView response', async () => {
300
- const results = [
301
- { EntityName: 'Contacts', RecordID: 'rec-1', Tags: [] as string[] },
302
- { EntityName: 'Contacts', RecordID: 'rec-2', Tags: [] as string[] },
303
- ];
304
-
305
- // Set up mock RunView to return tagged items
306
- // loadTaggedItemTags queries 'MJ: Tagged Items' with EntityID+RecordID filter
307
- mockRunViewResults.set('MJ: Tagged Items', {
308
- Success: true,
309
- Results: [
310
- { EntityID: 'entity-contacts-id', RecordID: 'rec-1', Tag: 'VIP' },
311
- { EntityID: 'entity-contacts-id', RecordID: 'rec-1', Tag: 'Partner' },
312
- { EntityID: 'entity-contacts-id', RecordID: 'rec-2', Tag: 'Prospect' },
313
- ]
314
- });
217
+ const result = await resolver.SearchKnowledge('test', 20, undefined, undefined, fakeContext as never);
315
218
 
316
- // Test loadTaggedItemTags directly since enrichResultsWithTags wraps it in try/catch
317
- const loadTaggedItemTags = getPrivateMethod(resolver, 'loadTaggedItemTags');
318
- const { Metadata: MetadataCtor } = await import('@memberjunction/core');
319
- const md = new MetadataCtor();
320
- await loadTaggedItemTags(results, md, {});
321
- expect(results[0].Tags).toEqual(['VIP', 'Partner']);
322
- expect(results[1].Tags).toEqual(['Prospect']);
219
+ expect(result.Success).toBe(false);
220
+ expect(result.ErrorMessage).toContain('current user');
323
221
  });
324
222
 
325
- it('should leave existing tags unchanged when RunView fails', async () => {
326
- const results = [
327
- { EntityName: 'Contacts', RecordID: 'rec-1', Tags: ['Existing'] },
328
- ];
329
- const md = { Entities: [{ Name: 'Contacts', ID: 'eid-contacts' }] };
223
+ it('should handle SearchEngine errors gracefully', async () => {
224
+ const resolver = createResolver();
225
+ mockSearch.mockRejectedValueOnce(new Error('Connection failed'));
330
226
 
331
- mockRunViewResults.set('MJ: Tagged Items', {
332
- Success: false,
333
- Results: []
334
- });
227
+ const result = await resolver.SearchKnowledge('test', 20, undefined, undefined, fakeContext as never);
335
228
 
336
- // enrichResultsWithTags replaces Tags; on RunView failure it returns early
337
- await enrichResultsWithTags(results, md, {});
338
- // Tags should remain unchanged since RunView failed before tag assignment
339
- expect(results[0].Tags).toEqual(['Existing']);
229
+ expect(result.Success).toBe(false);
230
+ expect(result.ErrorMessage).toContain('Connection failed');
340
231
  });
341
232
  });
342
233
 
343
- // ─── deduplicateResults ───────────────────────────────────────────
344
-
345
- describe('deduplicateResults', () => {
346
- const deduplicateResults = (results: Array<{ EntityName: string; RecordID: string; Score: number }>) =>
347
- getPrivateMethod(resolver, 'deduplicateResults')(results) as Array<{ EntityName: string; RecordID: string; Score: number }>;
348
-
349
- it('should keep the highest-scored entry for duplicate entity+recordID', () => {
350
- const results = [
351
- { EntityName: 'Contacts', RecordID: 'r1', Score: 0.8 },
352
- { EntityName: 'Contacts', RecordID: 'r1', Score: 0.95 },
353
- { EntityName: 'Contacts', RecordID: 'r1', Score: 0.7 },
354
- ];
355
- const deduped = deduplicateResults(results);
356
- expect(deduped).toHaveLength(1);
357
- expect(deduped[0].Score).toBe(0.95);
358
- });
234
+ describe('PreviewSearch mutation', () => {
235
+ it('should delegate to SearchEngine.Instance.PreviewSearch()', async () => {
236
+ const resolver = createResolver();
237
+ mockPreviewSearch.mockResolvedValueOnce(createMockSearchResult());
359
238
 
360
- it('should keep distinct records from different entities', () => {
361
- const results = [
362
- { EntityName: 'Contacts', RecordID: 'r1', Score: 0.8 },
363
- { EntityName: 'Companies', RecordID: 'r1', Score: 0.7 },
364
- ];
365
- const deduped = deduplicateResults(results);
366
- expect(deduped).toHaveLength(2);
367
- });
368
-
369
- it('should sort results by score descending', () => {
370
- const results = [
371
- { EntityName: 'A', RecordID: 'r1', Score: 0.3 },
372
- { EntityName: 'B', RecordID: 'r2', Score: 0.9 },
373
- { EntityName: 'C', RecordID: 'r3', Score: 0.6 },
374
- ];
375
- const deduped = deduplicateResults(results);
376
- expect(deduped[0].Score).toBe(0.9);
377
- expect(deduped[1].Score).toBe(0.6);
378
- expect(deduped[2].Score).toBe(0.3);
379
- });
239
+ const result = await resolver.PreviewSearch('test', 8, fakeContext as never);
380
240
 
381
- it('should return empty array for empty input', () => {
382
- expect(deduplicateResults([])).toEqual([]);
241
+ expect(mockPreviewSearch).toHaveBeenCalledOnce();
242
+ expect(mockPreviewSearch).toHaveBeenCalledWith('test', 8, expect.anything());
243
+ expect(result.Success).toBe(true);
383
244
  });
384
- });
385
245
 
386
- // ─── buildPineconeFilter ──────────────────────────────────────────
246
+ it('should default maxResults to 8', async () => {
247
+ const resolver = createResolver();
248
+ mockPreviewSearch.mockResolvedValueOnce(createMockSearchResult());
387
249
 
388
- describe('buildPineconeFilter', () => {
389
- const buildPineconeFilter = (filters?: { EntityNames?: string[]; SourceTypes?: string[]; Tags?: string[] }) =>
390
- getPrivateMethod(resolver, 'buildPineconeFilter')(filters) as object | undefined;
250
+ await resolver.PreviewSearch('test', 8, fakeContext as never);
391
251
 
392
- it('should return undefined for no filters', () => {
393
- expect(buildPineconeFilter(undefined)).toBeUndefined();
394
- });
395
-
396
- it('should return undefined for empty filters', () => {
397
- expect(buildPineconeFilter({})).toBeUndefined();
398
- });
399
-
400
- it('should return single condition without $and wrapper', () => {
401
- const result = buildPineconeFilter({ EntityNames: ['Contacts'] });
402
- expect(result).toEqual({ Entity: { $in: ['Contacts'] } });
403
- });
404
-
405
- it('should combine multiple filters with $and', () => {
406
- const result = buildPineconeFilter({
407
- EntityNames: ['Contacts'],
408
- Tags: ['VIP']
409
- }) as Record<string, unknown>;
410
- expect(result).toHaveProperty('$and');
411
- const conditions = result['$and'] as object[];
412
- expect(conditions).toHaveLength(2);
252
+ expect(mockPreviewSearch).toHaveBeenCalledWith('test', 8, expect.anything());
413
253
  });
414
254
  });
415
255
  });