@memberjunction/server 6.1.0-edge.2 → 6.1.0-edge.3
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.
- package/LICENSE +180 -4
- package/dist/auth/AuthProviderCatalogRouter.d.ts +31 -0
- package/dist/auth/AuthProviderCatalogRouter.d.ts.map +1 -0
- package/dist/auth/AuthProviderCatalogRouter.js +69 -0
- package/dist/auth/AuthProviderCatalogRouter.js.map +1 -0
- package/dist/auth/AuthProviderEngine.d.ts +96 -0
- package/dist/auth/AuthProviderEngine.d.ts.map +1 -0
- package/dist/auth/AuthProviderEngine.js +245 -0
- package/dist/auth/AuthProviderEngine.js.map +1 -0
- package/dist/auth/index.d.ts +11 -0
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +56 -13
- package/dist/auth/index.js.map +1 -1
- package/dist/auth/initializeProviders.d.ts +64 -1
- package/dist/auth/initializeProviders.d.ts.map +1 -1
- package/dist/auth/initializeProviders.js +117 -5
- package/dist/auth/initializeProviders.js.map +1 -1
- package/dist/config.d.ts +89 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +52 -35
- package/dist/config.js.map +1 -1
- package/dist/generated/generated.d.ts +501 -1597
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +2711 -17021
- package/dist/generated/generated.js.map +1 -1
- package/dist/generic/ResolverBase.d.ts +1 -1
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +7 -4
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/generic/RunViewResolver.d.ts +4 -0
- package/dist/generic/RunViewResolver.d.ts.map +1 -1
- package/dist/generic/RunViewResolver.js +31 -0
- package/dist/generic/RunViewResolver.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +99 -14
- package/dist/index.js.map +1 -1
- package/dist/integration/RSUProgressBridge.d.ts +83 -0
- package/dist/integration/RSUProgressBridge.d.ts.map +1 -0
- package/dist/integration/RSUProgressBridge.js +230 -0
- package/dist/integration/RSUProgressBridge.js.map +1 -0
- package/dist/integration/SchemaRefreshLaunch.d.ts +52 -0
- package/dist/integration/SchemaRefreshLaunch.d.ts.map +1 -0
- package/dist/integration/SchemaRefreshLaunch.js +50 -0
- package/dist/integration/SchemaRefreshLaunch.js.map +1 -0
- package/dist/providerConfigUnits.d.ts +20 -0
- package/dist/providerConfigUnits.d.ts.map +1 -0
- package/dist/providerConfigUnits.js +22 -0
- package/dist/providerConfigUnits.js.map +1 -0
- package/dist/resolvers/FileResolver.d.ts +34 -4
- package/dist/resolvers/FileResolver.d.ts.map +1 -1
- package/dist/resolvers/FileResolver.js +218 -28
- package/dist/resolvers/FileResolver.js.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +76 -3
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.js +299 -81
- package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
- package/dist/resolvers/QueryResolver.d.ts +5 -4
- package/dist/resolvers/QueryResolver.d.ts.map +1 -1
- package/dist/resolvers/QueryResolver.js +29 -14
- package/dist/resolvers/QueryResolver.js.map +1 -1
- package/dist/resolvers/QuerySystemUserResolver.d.ts +12 -3
- package/dist/resolvers/QuerySystemUserResolver.d.ts.map +1 -1
- package/dist/resolvers/QuerySystemUserResolver.js +33 -14
- package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts +3 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +20 -7
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/dist/resolvers/TaskGraphFrameResolver.d.ts +18 -0
- package/dist/resolvers/TaskGraphFrameResolver.d.ts.map +1 -1
- package/dist/resolvers/TaskGraphFrameResolver.js +75 -0
- package/dist/resolvers/TaskGraphFrameResolver.js.map +1 -1
- package/dist/resolvers/UserResolver.d.ts +19 -17
- package/dist/resolvers/UserResolver.d.ts.map +1 -1
- package/dist/resolvers/UserResolver.js +66 -67
- package/dist/resolvers/UserResolver.js.map +1 -1
- package/dist/resolvers/currentUserRoles.d.ts +19 -0
- package/dist/resolvers/currentUserRoles.d.ts.map +1 -0
- package/dist/resolvers/currentUserRoles.js +36 -0
- package/dist/resolvers/currentUserRoles.js.map +1 -0
- package/dist/rest/MediaAccessKeys.d.ts +17 -0
- package/dist/rest/MediaAccessKeys.d.ts.map +1 -1
- package/dist/rest/MediaAccessKeys.js +42 -1
- package/dist/rest/MediaAccessKeys.js.map +1 -1
- package/dist/rest/MediaStreamHandler.d.ts +3 -2
- package/dist/rest/MediaStreamHandler.d.ts.map +1 -1
- package/dist/rest/MediaStreamHandler.js +83 -2
- package/dist/rest/MediaStreamHandler.js.map +1 -1
- package/dist/rest/OAuthCallbackHandler.d.ts +49 -1
- package/dist/rest/OAuthCallbackHandler.d.ts.map +1 -1
- package/dist/rest/OAuthCallbackHandler.js +129 -43
- package/dist/rest/OAuthCallbackHandler.js.map +1 -1
- package/dist/rest/UploadTokenManager.d.ts +90 -0
- package/dist/rest/UploadTokenManager.d.ts.map +1 -0
- package/dist/rest/UploadTokenManager.js +156 -0
- package/dist/rest/UploadTokenManager.js.map +1 -0
- package/dist/services/IntegrationSyncWorkerService.d.ts +48 -0
- package/dist/services/IntegrationSyncWorkerService.d.ts.map +1 -0
- package/dist/services/IntegrationSyncWorkerService.js +132 -0
- package/dist/services/IntegrationSyncWorkerService.js.map +1 -0
- package/dist/services/StartTaskGraphDispatcher.d.ts +0 -10
- package/dist/services/StartTaskGraphDispatcher.d.ts.map +1 -1
- package/dist/services/StartTaskGraphDispatcher.js +21 -1
- package/dist/services/StartTaskGraphDispatcher.js.map +1 -1
- package/dist/services/TaskGraphAgentRunner.d.ts +7 -0
- package/dist/services/TaskGraphAgentRunner.d.ts.map +1 -1
- package/dist/services/TaskGraphAgentRunner.js +23 -0
- package/dist/services/TaskGraphAgentRunner.js.map +1 -1
- package/dist/services/TaskGraphContinuationDeliverer.d.ts.map +1 -1
- package/dist/services/TaskGraphContinuationDeliverer.js +12 -1
- package/dist/services/TaskGraphContinuationDeliverer.js.map +1 -1
- package/dist/types.d.ts +6 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +91 -91
- package/src/__tests__/APIKeyScopeAuth.test.ts +475 -0
- package/src/__tests__/AuthProviderEngine.test.ts +278 -0
- package/src/__tests__/EntityCRUDHandler.test.ts +378 -0
- package/src/__tests__/IntegrationSyncWorkerService.test.ts +275 -0
- package/src/__tests__/OAuthCallbackHandler.openRedirect.test.ts +117 -0
- package/src/__tests__/OAuthCallbackHandler.xss.test.ts +4 -1
- package/src/__tests__/RESTEndpointHandler.test.ts +672 -0
- package/src/__tests__/RSUProgressBridge.test.ts +250 -0
- package/src/__tests__/RealtimeBridgeResolver.test.ts +10 -2
- package/src/__tests__/SchemaRefreshLaunch.test.ts +114 -0
- package/src/__tests__/UserResolver.currentUser.test.ts +87 -0
- package/src/__tests__/ViewOperationsHandler.test.ts +277 -0
- package/src/__tests__/config-units.test.ts +74 -0
- package/src/__tests__/initializeProviders.refresh.test.ts +146 -0
- package/src/__tests__/mjapi-bootstrap.test.ts +0 -6
- package/src/__tests__/newUsers.test.ts +726 -0
- package/src/__tests__/uploadTokenManager.test.ts +159 -0
- package/src/auth/AuthProviderCatalogRouter.ts +76 -0
- package/src/auth/AuthProviderEngine.ts +253 -0
- package/src/auth/index.ts +59 -15
- package/src/auth/initializeProviders.ts +128 -7
- package/src/config.ts +55 -36
- package/src/generated/generated.ts +1911 -10551
- package/src/generic/ResolverBase.ts +11 -4
- package/src/generic/RunViewResolver.ts +31 -0
- package/src/index.ts +108 -15
- package/src/integration/RSUProgressBridge.ts +248 -0
- package/src/integration/SchemaRefreshLaunch.ts +77 -0
- package/src/providerConfigUnits.ts +21 -0
- package/src/resolvers/FileResolver.ts +200 -40
- package/src/resolvers/IntegrationDiscoveryResolver.ts +316 -68
- package/src/resolvers/QueryResolver.ts +25 -11
- package/src/resolvers/QuerySystemUserResolver.ts +28 -13
- package/src/resolvers/RunAIAgentResolver.ts +21 -6
- package/src/resolvers/TaskGraphFrameResolver.ts +67 -0
- package/src/resolvers/UserResolver.ts +99 -60
- package/src/resolvers/currentUserRoles.ts +47 -0
- package/src/rest/MediaAccessKeys.ts +47 -1
- package/src/rest/MediaStreamHandler.ts +98 -3
- package/src/rest/OAuthCallbackHandler.ts +149 -41
- package/src/rest/UploadTokenManager.ts +211 -0
- package/src/services/IntegrationSyncWorkerService.ts +141 -0
- package/src/services/StartTaskGraphDispatcher.ts +21 -1
- package/src/services/TaskGraphAgentRunner.ts +23 -0
- package/src/services/TaskGraphContinuationDeliverer.ts +11 -1
- package/src/types.ts +6 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { mkdtempSync, rmSync, readFileSync, existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { RSUProgressBridge } from '../integration/RSUProgressBridge.js';
|
|
6
|
+
import type { RSUObserverEvent } from '@memberjunction/schema-engine';
|
|
7
|
+
import type { IntegrationProgressEvent, IntegrationRunManifest, IntegrationRunResult } from '@memberjunction/integration-progress-artifacts';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The bridge is what makes an RSU run tailable: `IntegrationRunKind` has always had an 'RSU' kind
|
|
11
|
+
* and the resolver has always mapped it to an 'RSU' subscription channel, but nothing published to
|
|
12
|
+
* it. These tests drive the observer with the events the pipeline emits and assert the durable
|
|
13
|
+
* artifact that comes out — reading the real JSONL, not a mocked emitter, because the artifact IS
|
|
14
|
+
* the deliverable.
|
|
15
|
+
*/
|
|
16
|
+
describe('RSUProgressBridge', () => {
|
|
17
|
+
let rootDir: string;
|
|
18
|
+
let bridge: RSUProgressBridge;
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
rootDir = mkdtempSync(join(tmpdir(), 'rsu-bridge-'));
|
|
22
|
+
// The bridge is a BaseSingleton, so every test in this file shares ONE instance. Reset()
|
|
23
|
+
// returns it to idle (dropping an emitter or heartbeat a prior test left in flight) and
|
|
24
|
+
// Configure() repoints artifacts at this test's temp dir. Without both, one test's in-flight
|
|
25
|
+
// run would be inherited by the next and its writes would land in a deleted directory.
|
|
26
|
+
bridge = RSUProgressBridge.Instance;
|
|
27
|
+
bridge.Reset();
|
|
28
|
+
bridge.Configure({ rootDir });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterEach(async () => {
|
|
32
|
+
// Drain FIRST. The emitter writes through chained promises, and `Reset()` only drops this
|
|
33
|
+
// bridge's reference to it — it cannot cancel a write already queued. A test that starts a run
|
|
34
|
+
// without awaiting `settle()` would otherwise have its manifest.json land after the rmSync
|
|
35
|
+
// below, surfacing as an unhandled ENOENT that fails the suite while every test still reports
|
|
36
|
+
// as passing. Then Reset, so no heartbeat interval outlives the test or keeps the process up.
|
|
37
|
+
await settle();
|
|
38
|
+
bridge.Reset();
|
|
39
|
+
rmSync(rootDir, { recursive: true, force: true });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const RUN_START: RSUObserverEvent = {
|
|
43
|
+
Kind: 'run.start',
|
|
44
|
+
ItemCount: 2,
|
|
45
|
+
Descriptions: ['add Orders', 'add OrderItems'],
|
|
46
|
+
AffectedTables: ['Orders', 'OrderItems'],
|
|
47
|
+
StepTotal: 12,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** The emitter's writes are chained promises; let them drain before reading from disk. */
|
|
51
|
+
async function settle(): Promise<void> {
|
|
52
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function runDir(): string {
|
|
56
|
+
return join(rootDir, bridge.CurrentRunID ?? '');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function readEvents(dir: string): IntegrationProgressEvent[] {
|
|
60
|
+
return readFileSync(join(dir, 'progress.jsonl'), 'utf-8')
|
|
61
|
+
.split('\n')
|
|
62
|
+
.filter(Boolean)
|
|
63
|
+
.map(line => JSON.parse(line) as IntegrationProgressEvent);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
it('is a singleton, so a resolver can reach the in-flight run to tail it', async () => {
|
|
67
|
+
// The point of the singleton. Previously the bridge was constructed inside
|
|
68
|
+
// RegisterRSUProgressBridge and the only surviving reference was the observer closure handed
|
|
69
|
+
// to RuntimeSchemaManager -- so CurrentRunID, whose whole purpose is letting a caller that
|
|
70
|
+
// triggered an RSU hand its client a run to tail, was unreachable from any caller.
|
|
71
|
+
expect(RSUProgressBridge.Instance).toBe(bridge);
|
|
72
|
+
|
|
73
|
+
bridge.Observe(RUN_START);
|
|
74
|
+
expect(RSUProgressBridge.Instance.CurrentRunID).toBe(bridge.CurrentRunID);
|
|
75
|
+
expect(RSUProgressBridge.Instance.CurrentRunID).toMatch(/^rsu-/);
|
|
76
|
+
await settle();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('Reset returns the bridge to idle without emitting a terminal event', async () => {
|
|
80
|
+
bridge.Observe(RUN_START);
|
|
81
|
+
expect(bridge.CurrentRunID).not.toBeNull();
|
|
82
|
+
await settle();
|
|
83
|
+
|
|
84
|
+
bridge.Reset();
|
|
85
|
+
|
|
86
|
+
// Idle again, and the next run starts clean rather than inheriting the abandoned emitter.
|
|
87
|
+
expect(bridge.CurrentRunID).toBeNull();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('opens a run tagged RSU carrying the batch context, and exposes its ID to tail', async () => {
|
|
91
|
+
bridge.Observe(RUN_START);
|
|
92
|
+
const dir = runDir();
|
|
93
|
+
expect(bridge.CurrentRunID).toMatch(/^rsu-/);
|
|
94
|
+
await settle();
|
|
95
|
+
|
|
96
|
+
const manifest = JSON.parse(readFileSync(join(dir, 'manifest.json'), 'utf-8')) as IntegrationRunManifest;
|
|
97
|
+
// The run kind is what routes this onto the 'RSU' subscription channel.
|
|
98
|
+
expect(manifest.runKind).toBe('RSU');
|
|
99
|
+
expect(manifest.triggerType).toBe('Pipeline');
|
|
100
|
+
expect(manifest.context).toMatchObject({
|
|
101
|
+
itemCount: 2,
|
|
102
|
+
affectedTables: ['Orders', 'OrderItems'],
|
|
103
|
+
stepTotal: 12,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const events = readEvents(dir);
|
|
107
|
+
expect(events[0].eventType).toBe('run.start');
|
|
108
|
+
expect(events[0].message).toContain('2 migration(s)');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('records each step as a stage, with its position in the determinate sequence', async () => {
|
|
112
|
+
bridge.Observe(RUN_START);
|
|
113
|
+
const dir = runDir();
|
|
114
|
+
bridge.Observe({ Kind: 'step.start', Name: 'RunCodeGen', StepIndex: 4, StepTotal: 12 });
|
|
115
|
+
bridge.Observe({ Kind: 'step.end', Name: 'RunCodeGen', Status: 'success', DurationMs: 91_000, Message: 'ok' });
|
|
116
|
+
await settle();
|
|
117
|
+
|
|
118
|
+
const events = readEvents(dir);
|
|
119
|
+
const start = events.find(e => e.eventType === 'stage.start' && e.stage === 'RunCodeGen');
|
|
120
|
+
const done = events.find(e => e.eventType === 'stage.complete' && e.stage === 'RunCodeGen');
|
|
121
|
+
|
|
122
|
+
expect(start?.message).toBe('step 4 of 12');
|
|
123
|
+
expect(done).toBeDefined();
|
|
124
|
+
// Steps are not a countable unit — counts belong to migrations, emitted once at run end.
|
|
125
|
+
expect(done?.counts).toBeUndefined();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('records a failed step as a stage error without terminating the run', async () => {
|
|
129
|
+
bridge.Observe(RUN_START);
|
|
130
|
+
const dir = runDir();
|
|
131
|
+
bridge.Observe({
|
|
132
|
+
Kind: 'step.end',
|
|
133
|
+
Name: 'ExecuteMigration',
|
|
134
|
+
Status: 'failed',
|
|
135
|
+
DurationMs: 120,
|
|
136
|
+
Message: 'FK violation on Orders',
|
|
137
|
+
});
|
|
138
|
+
await settle();
|
|
139
|
+
|
|
140
|
+
const err = readEvents(dir).find(e => e.eventType === 'stage.error');
|
|
141
|
+
expect(err?.stage).toBe('ExecuteMigration');
|
|
142
|
+
expect(err?.message).toBe('FK violation on Orders');
|
|
143
|
+
expect(err?.data).toMatchObject({ code: 'RSU_STEP_FAILED' });
|
|
144
|
+
// A per-item failure does not abort the batch, so the run must still be open.
|
|
145
|
+
expect(existsSync(join(dir, 'result.json'))).toBe(false);
|
|
146
|
+
expect(bridge.CurrentRunID).not.toBeNull();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('completes the run with the migration quartet as the authoritative counts', async () => {
|
|
150
|
+
bridge.Observe(RUN_START);
|
|
151
|
+
const dir = runDir();
|
|
152
|
+
bridge.Observe({ Kind: 'run.end', Success: true, SuccessCount: 2, FailureCount: 0, TotalCount: 2 });
|
|
153
|
+
await settle();
|
|
154
|
+
|
|
155
|
+
const result = JSON.parse(readFileSync(join(dir, 'result.json'), 'utf-8')) as IntegrationRunResult;
|
|
156
|
+
expect(result.success).toBe(true);
|
|
157
|
+
expect(result.exitReason).toBe('completed');
|
|
158
|
+
// Counts are in MIGRATIONS (2), not steps.
|
|
159
|
+
expect(result.aggregateCounts).toMatchObject({ processed: 2, succeeded: 2, failed: 0 });
|
|
160
|
+
// Idle again — nothing to tail.
|
|
161
|
+
expect(bridge.CurrentRunID).toBeNull();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('fails the run naming the step that broke it', async () => {
|
|
165
|
+
bridge.Observe(RUN_START);
|
|
166
|
+
const dir = runDir();
|
|
167
|
+
bridge.Observe({
|
|
168
|
+
Kind: 'run.end',
|
|
169
|
+
Success: false,
|
|
170
|
+
SuccessCount: 1,
|
|
171
|
+
FailureCount: 1,
|
|
172
|
+
TotalCount: 2,
|
|
173
|
+
ErrorMessage: 'FK violation on Orders',
|
|
174
|
+
ErrorStep: 'ExecuteMigration',
|
|
175
|
+
});
|
|
176
|
+
await settle();
|
|
177
|
+
|
|
178
|
+
const result = JSON.parse(readFileSync(join(dir, 'result.json'), 'utf-8')) as IntegrationRunResult;
|
|
179
|
+
expect(result.success).toBe(false);
|
|
180
|
+
expect(result.exitReason).toBe('failed');
|
|
181
|
+
expect(result.errors?.[0].message).toContain('ExecuteMigration');
|
|
182
|
+
expect(result.errors?.[0].message).toContain('FK violation on Orders');
|
|
183
|
+
expect(result.errors?.[0].code).toBe('rsu-pipeline-failed');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('treats a second run.start as a NEW run and closes the abandoned one', async () => {
|
|
187
|
+
// RunPipelineBatchWithRetry re-enters the pipeline, so a retry legitimately starts a fresh
|
|
188
|
+
// run. What must not happen is the previous run staying in-flight forever.
|
|
189
|
+
bridge.Observe(RUN_START);
|
|
190
|
+
const firstDir = runDir();
|
|
191
|
+
bridge.Observe(RUN_START);
|
|
192
|
+
const secondDir = runDir();
|
|
193
|
+
await settle();
|
|
194
|
+
|
|
195
|
+
expect(secondDir).not.toBe(firstDir);
|
|
196
|
+
const abandoned = JSON.parse(readFileSync(join(firstDir, 'result.json'), 'utf-8')) as IntegrationRunResult;
|
|
197
|
+
expect(abandoned.success).toBe(false);
|
|
198
|
+
expect(abandoned.errors?.[0].code).toBe('rsu-run-abandoned');
|
|
199
|
+
// The new run is open and untouched by the abandonment.
|
|
200
|
+
expect(existsSync(join(secondDir, 'result.json'))).toBe(false);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('reports liveness while a long step is in flight, and stops at step.end', async () => {
|
|
204
|
+
// CodeGen and compile run for minutes with no intermediate observer event. Without a
|
|
205
|
+
// heartbeat the stream goes silent for the whole step and a watcher cannot distinguish
|
|
206
|
+
// "compiling" from "hung".
|
|
207
|
+
vi.useFakeTimers();
|
|
208
|
+
try {
|
|
209
|
+
bridge.Observe(RUN_START);
|
|
210
|
+
const dir = runDir();
|
|
211
|
+
bridge.Observe({ Kind: 'step.start', Name: 'CodeGen', StepIndex: 5, StepTotal: 12 });
|
|
212
|
+
|
|
213
|
+
vi.advanceTimersByTime(95_000); // three 30s ticks
|
|
214
|
+
bridge.Observe({ Kind: 'step.end', Name: 'CodeGen', Status: 'success', DurationMs: 95_000, Message: 'ok' });
|
|
215
|
+
vi.advanceTimersByTime(120_000); // no further ticks once the step closed
|
|
216
|
+
|
|
217
|
+
vi.useRealTimers();
|
|
218
|
+
await settle();
|
|
219
|
+
|
|
220
|
+
const beats = readEvents(dir).filter(e => e.eventType === 'progress.heartbeat');
|
|
221
|
+
expect(beats).toHaveLength(3);
|
|
222
|
+
expect(beats[0].stage).toBe('CodeGen');
|
|
223
|
+
expect(beats[0].message).toMatch(/still running/);
|
|
224
|
+
} finally {
|
|
225
|
+
vi.useRealTimers();
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('does not drop the position label for a zero-based step index', async () => {
|
|
230
|
+
// `stepIndex && stepTotal` would silently omit the label at index 0.
|
|
231
|
+
bridge.Observe(RUN_START);
|
|
232
|
+
const dir = runDir();
|
|
233
|
+
bridge.Observe({ Kind: 'step.start', Name: 'ValidateEnvironment', StepIndex: 0, StepTotal: 12 });
|
|
234
|
+
await settle();
|
|
235
|
+
|
|
236
|
+
const start = readEvents(dir).find(e => e.stage === 'ValidateEnvironment');
|
|
237
|
+
expect(start?.message).toBe('step 0 of 12');
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('ignores step and run.end events that arrive with no run open', async () => {
|
|
241
|
+
// Defensive: an observer registered mid-run would see a trailing run.end for a run it never
|
|
242
|
+
// saw start. That must be a no-op, not a crash.
|
|
243
|
+
expect(() => {
|
|
244
|
+
bridge.Observe({ Kind: 'step.start', Name: 'AcquireLock' });
|
|
245
|
+
bridge.Observe({ Kind: 'step.end', Name: 'AcquireLock', Status: 'success', DurationMs: 1, Message: 'ok' });
|
|
246
|
+
bridge.Observe({ Kind: 'run.end', Success: true, SuccessCount: 1, FailureCount: 0, TotalCount: 1 });
|
|
247
|
+
}).not.toThrow();
|
|
248
|
+
expect(bridge.CurrentRunID).toBeNull();
|
|
249
|
+
});
|
|
250
|
+
});
|
|
@@ -17,11 +17,19 @@ const h = vi.hoisted(() => ({
|
|
|
17
17
|
stopRecording: vi.fn(async () => ({ EgressID: 'eg-1', RoomName: 'room-1', Status: 'EGRESS_COMPLETE' })),
|
|
18
18
|
}));
|
|
19
19
|
|
|
20
|
+
// These two are instantiated with `new` by the resolver, so they must be constructible. They were
|
|
21
|
+
// `vi.fn(() => ({...}))`, which vitest 3 tolerated as a constructor but vitest 4 rejects with
|
|
22
|
+
// "is not a constructor" — classes express the intent and work under both.
|
|
20
23
|
vi.mock('@memberjunction/livekit-room-server', () => ({
|
|
21
|
-
LiveKitTokenService:
|
|
24
|
+
LiveKitTokenService: class {
|
|
25
|
+
MintClientToken = h.mintClientToken;
|
|
26
|
+
},
|
|
22
27
|
// SetSessionFactory is exercised by the resolver's module-load binding of the realtime-session factory.
|
|
23
28
|
LiveKitAgentRoomCoordinator: { Instance: { StartAgentRoomSession: vi.fn(), SetSessionFactory: vi.fn() } },
|
|
24
|
-
LiveKitEgressService:
|
|
29
|
+
LiveKitEgressService: class {
|
|
30
|
+
StartRoomRecording = h.startRecording;
|
|
31
|
+
StopRecording = h.stopRecording;
|
|
32
|
+
},
|
|
25
33
|
}));
|
|
26
34
|
|
|
27
35
|
// Mock the agent factory so importing the resolver doesn't pull the heavy @memberjunction/ai-agents graph
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { BuildCreateConnectionMessage, BuildDetachedRefreshMessage, BuildUpdateConnectionMessage } from '../integration/SchemaRefreshLaunch.js';
|
|
3
|
+
import type { SchemaRefreshSummaryLike } from '../integration/SchemaRefreshLaunch.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A detached schema refresh returns placeholder zero counts because it hasn't run yet. The one
|
|
7
|
+
* thing this message must never do is report those zeros as findings — "0 created, 0 updated"
|
|
8
|
+
* reads as "the refresh found nothing", which is the opposite of "still running".
|
|
9
|
+
*/
|
|
10
|
+
function summary(over: Partial<SchemaRefreshSummaryLike> = {}): SchemaRefreshSummaryLike {
|
|
11
|
+
return {
|
|
12
|
+
RunID: 'connector-1783000000000-abc123',
|
|
13
|
+
InProgress: false,
|
|
14
|
+
Succeeded: true,
|
|
15
|
+
ObjectsCreated: 12,
|
|
16
|
+
ObjectsUpdated: 3,
|
|
17
|
+
UnresolvedObjects: ['Widgets'],
|
|
18
|
+
...over,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('BuildCreateConnectionMessage', () => {
|
|
23
|
+
it('reports the run ID to tail — and NO counts — for a detached refresh', () => {
|
|
24
|
+
const msg = BuildCreateConnectionMessage(true, summary({
|
|
25
|
+
InProgress: true,
|
|
26
|
+
ObjectsCreated: 0,
|
|
27
|
+
ObjectsUpdated: 0,
|
|
28
|
+
UnresolvedObjects: [],
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
expect(msg).toContain('connector-1783000000000-abc123');
|
|
32
|
+
expect(msg).toMatch(/running/i);
|
|
33
|
+
// The placeholder zeros must not surface as results.
|
|
34
|
+
expect(msg).not.toMatch(/\d+ created/);
|
|
35
|
+
expect(msg).not.toMatch(/\d+ updated/);
|
|
36
|
+
expect(msg).not.toMatch(/PK-unresolved/);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('reports real counts once the refresh has completed inline', () => {
|
|
40
|
+
const msg = BuildCreateConnectionMessage(true, summary());
|
|
41
|
+
|
|
42
|
+
expect(msg).toContain('12 created');
|
|
43
|
+
expect(msg).toContain('3 updated');
|
|
44
|
+
expect(msg).toContain('1 PK-unresolved');
|
|
45
|
+
expect(msg).not.toMatch(/running/i);
|
|
46
|
+
// A completed refresh needs no run ID in the message — the counts ARE the answer.
|
|
47
|
+
expect(msg).not.toContain('connector-1783000000000-abc123');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('omits the test-passed clause when no connection test was requested', () => {
|
|
51
|
+
expect(BuildCreateConnectionMessage(false, summary())).not.toMatch(/test passed/);
|
|
52
|
+
expect(BuildCreateConnectionMessage(true, summary())).toMatch(/test passed/);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('falls back to the test-only message when no refresh was requested', () => {
|
|
56
|
+
expect(BuildCreateConnectionMessage(true, undefined)).toBe('Connection created and test passed');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('counts PK-unresolved objects, not just reports presence', () => {
|
|
60
|
+
const msg = BuildCreateConnectionMessage(false, summary({ UnresolvedObjects: ['A', 'B', 'C'] }));
|
|
61
|
+
expect(msg).toContain('3 PK-unresolved');
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A pipeline that fails at ConnectionTest RETURNS — it does not throw — with every count at zero.
|
|
67
|
+
* Verified live: a real HubSpot refresh whose credential check failed produced
|
|
68
|
+
* "Updated, schema refresh: 0 created, 0 updated, 0 PK-unresolved", indistinguishable from a clean
|
|
69
|
+
* no-op run. These tests pin the distinction.
|
|
70
|
+
*/
|
|
71
|
+
describe('failed-refresh reporting', () => {
|
|
72
|
+
const failed = summary({
|
|
73
|
+
Succeeded: false,
|
|
74
|
+
FailureMessage: 'ConnectionTest failed: No HubSpot credentials found',
|
|
75
|
+
ObjectsCreated: 0,
|
|
76
|
+
ObjectsUpdated: 0,
|
|
77
|
+
UnresolvedObjects: [],
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('never reports a failed refresh as a zero-count success (create)', () => {
|
|
81
|
+
const msg = BuildCreateConnectionMessage(true, failed);
|
|
82
|
+
expect(msg).toMatch(/FAILED/);
|
|
83
|
+
expect(msg).toContain('No HubSpot credentials found');
|
|
84
|
+
expect(msg).not.toMatch(/0 created/);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('never reports a failed refresh as a zero-count success (update)', () => {
|
|
88
|
+
const msg = BuildUpdateConnectionMessage(failed);
|
|
89
|
+
expect(msg).toMatch(/FAILED/);
|
|
90
|
+
expect(msg).toContain('No HubSpot credentials found');
|
|
91
|
+
expect(msg).not.toMatch(/0 created/);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('points at the run when the pipeline reported no reason', () => {
|
|
95
|
+
const msg = BuildUpdateConnectionMessage(summary({ Succeeded: false, FailureMessage: undefined }));
|
|
96
|
+
expect(msg).toContain('connector-1783000000000-abc123');
|
|
97
|
+
expect(msg).toMatch(/no reason reported/);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('still reports counts for a refresh that genuinely succeeded (update)', () => {
|
|
101
|
+
const msg = BuildUpdateConnectionMessage(summary());
|
|
102
|
+
expect(msg).toContain('12 created');
|
|
103
|
+
expect(msg).toContain('3 updated');
|
|
104
|
+
expect(msg).not.toMatch(/FAILED/);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('BuildDetachedRefreshMessage', () => {
|
|
109
|
+
it('names the run to tail', () => {
|
|
110
|
+
const msg = BuildDetachedRefreshMessage('connector-42-zz');
|
|
111
|
+
expect(msg).toContain('connector-42-zz');
|
|
112
|
+
expect(msg).toMatch(/running/i);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
2
|
+
import { LogError } from '@memberjunction/core';
|
|
3
|
+
import {
|
|
4
|
+
AssertRoleLoadSucceeded,
|
|
5
|
+
RequireContextUser,
|
|
6
|
+
RequireLoadedUser,
|
|
7
|
+
RequireRoleProvider,
|
|
8
|
+
UserIdExtraFilter,
|
|
9
|
+
} from '../resolvers/currentUserRoles.js';
|
|
10
|
+
|
|
11
|
+
vi.mock('@memberjunction/core', async (importOriginal) => {
|
|
12
|
+
const actual = await importOriginal<typeof import('@memberjunction/core')>();
|
|
13
|
+
return {
|
|
14
|
+
...actual,
|
|
15
|
+
LogError: vi.fn(),
|
|
16
|
+
};
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe('CurrentUser role-load guards', () => {
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
vi.clearAllMocks();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('RequireLoadedUser', () => {
|
|
25
|
+
it('returns the user when ID is present', () => {
|
|
26
|
+
const user = { ID: 'u-1', Email: 'a@b.com' };
|
|
27
|
+
expect(RequireLoadedUser(user, 'a@b.com')).toBe(user);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('throws when the email fallback finds no user', () => {
|
|
31
|
+
expect(() => RequireLoadedUser(null, 'missing@example.com'))
|
|
32
|
+
.toThrow("CurrentUser: no user found for email 'missing@example.com'");
|
|
33
|
+
expect(() => RequireLoadedUser({ Email: 'x' }, undefined))
|
|
34
|
+
.toThrow("CurrentUser: no user found for email ''");
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('RequireContextUser', () => {
|
|
39
|
+
it('returns the session user when present', () => {
|
|
40
|
+
const user = { ID: 'u-1' };
|
|
41
|
+
expect(RequireContextUser(user)).toBe(user);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('throws when GetUserFromPayload would have handed undefined to RunView', () => {
|
|
45
|
+
expect(() => RequireContextUser(undefined))
|
|
46
|
+
.toThrow('CurrentUser: authenticated context user is required to load roles');
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('RequireRoleProvider', () => {
|
|
51
|
+
it('throws when no provider is available', () => {
|
|
52
|
+
expect(() => RequireRoleProvider(null))
|
|
53
|
+
.toThrow('CurrentUser: no data provider available to load roles');
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('UserIdExtraFilter', () => {
|
|
58
|
+
it('wraps a UUID in a UserID equality filter', () => {
|
|
59
|
+
expect(UserIdExtraFilter('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'))
|
|
60
|
+
.toBe("UserID='aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('quote-escapes a value that contains a single quote', () => {
|
|
64
|
+
expect(UserIdExtraFilter("O'Brien")).toBe("UserID='O''Brien'");
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('AssertRoleLoadSucceeded', () => {
|
|
69
|
+
it('is a no-op on Success', () => {
|
|
70
|
+
expect(() => AssertRoleLoadSucceeded({ Success: true }, 'u-1')).not.toThrow();
|
|
71
|
+
expect(LogError).not.toHaveBeenCalled();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('logs and throws on failure instead of returning an empty role set', () => {
|
|
75
|
+
expect(() => AssertRoleLoadSucceeded({ Success: false, ErrorMessage: 'permission denied' }, 'u-1'))
|
|
76
|
+
.toThrow('CurrentUser: failed to load roles: permission denied');
|
|
77
|
+
expect(LogError).toHaveBeenCalledWith(
|
|
78
|
+
'CurrentUser: failed to load roles for user u-1: permission denied',
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('uses a fallback message when ErrorMessage is missing', () => {
|
|
83
|
+
expect(() => AssertRoleLoadSucceeded({ Success: false }, 'u-1'))
|
|
84
|
+
.toThrow('CurrentUser: failed to load roles: unknown error');
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
});
|