@memberjunction/server 6.1.0-edge.2 → 6.1.0-edge.4
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/agentSessions/SessionManager.d.ts +19 -5
- package/dist/agentSessions/SessionManager.d.ts.map +1 -1
- package/dist/agentSessions/SessionManager.js +27 -9
- package/dist/agentSessions/SessionManager.js.map +1 -1
- 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 +12 -0
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +57 -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/auth/scopeLimitedPrincipal.d.ts +39 -0
- package/dist/auth/scopeLimitedPrincipal.d.ts.map +1 -0
- package/dist/auth/scopeLimitedPrincipal.js +49 -0
- package/dist/auth/scopeLimitedPrincipal.js.map +1 -0
- 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/context.d.ts.map +1 -1
- package/dist/context.js +21 -1
- package/dist/context.js.map +1 -1
- package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.d.ts.map +1 -1
- package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.js +8 -7
- package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.js.map +1 -1
- package/dist/generated/generated.d.ts +619 -1597
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +3341 -17021
- package/dist/generated/generated.js.map +1 -1
- package/dist/generic/ResolverBase.d.ts +28 -1
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +57 -8
- 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 +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +147 -14
- package/dist/index.js.map +1 -1
- package/dist/integration/CustomColumnPromoter.d.ts +79 -15
- package/dist/integration/CustomColumnPromoter.d.ts.map +1 -1
- package/dist/integration/CustomColumnPromoter.js +472 -97
- package/dist/integration/CustomColumnPromoter.js.map +1 -1
- package/dist/integration/InactiveRowWarnings.d.ts +48 -0
- package/dist/integration/InactiveRowWarnings.d.ts.map +1 -0
- package/dist/integration/InactiveRowWarnings.js +57 -0
- package/dist/integration/InactiveRowWarnings.js.map +1 -0
- 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 +62 -0
- package/dist/integration/SchemaRefreshLaunch.d.ts.map +1 -0
- package/dist/integration/SchemaRefreshLaunch.js +67 -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/AdhocQueryResolver.d.ts +4 -0
- package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
- package/dist/resolvers/AdhocQueryResolver.js +20 -6
- package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
- 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/IdentityClaimRedemptionResolver.d.ts +37 -0
- package/dist/resolvers/IdentityClaimRedemptionResolver.d.ts.map +1 -0
- package/dist/resolvers/IdentityClaimRedemptionResolver.js +220 -0
- package/dist/resolvers/IdentityClaimRedemptionResolver.js.map +1 -0
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +108 -3
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.js +374 -87
- 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/resolvers/meetingRecordingRegistration.d.ts.map +1 -1
- package/dist/resolvers/meetingRecordingRegistration.js +3 -6
- package/dist/resolvers/meetingRecordingRegistration.js.map +1 -1
- 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 +14 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +92 -92
- package/src/__tests__/APIKeyScopeAuth.test.ts +475 -0
- package/src/__tests__/AdhocQueryResolver.scopeGuard.test.ts +84 -0
- package/src/__tests__/AuthProviderEngine.test.ts +278 -0
- package/src/__tests__/CustomColumnPromoterOnePass.test.ts +387 -0
- package/src/__tests__/EntityCRUDHandler.test.ts +378 -0
- package/src/__tests__/InactiveRowWarnings.test.ts +87 -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__/ReactivateRefreshMessage.test.ts +85 -0
- package/src/__tests__/RealtimeBridgeResolver.test.ts +10 -2
- package/src/__tests__/ResolverBase.filterEscaping.test.ts +147 -0
- package/src/__tests__/SchemaRefreshLaunch.test.ts +114 -0
- package/src/__tests__/SessionManager.test.ts +55 -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/agentSessions/SessionManager.ts +27 -9
- package/src/auth/AuthProviderCatalogRouter.ts +76 -0
- package/src/auth/AuthProviderEngine.ts +253 -0
- package/src/auth/index.ts +60 -15
- package/src/auth/initializeProviders.ts +128 -7
- package/src/auth/scopeLimitedPrincipal.ts +50 -0
- package/src/config.ts +55 -36
- package/src/context.ts +26 -2
- package/src/entitySubclasses/MJEntityPermissionEntityServer.server.ts +8 -7
- package/src/generated/generated.ts +2347 -10551
- package/src/generic/ResolverBase.ts +67 -8
- package/src/generic/RunViewResolver.ts +31 -0
- package/src/index.ts +165 -16
- package/src/integration/CustomColumnPromoter.ts +520 -98
- package/src/integration/InactiveRowWarnings.ts +85 -0
- package/src/integration/RSUProgressBridge.ts +248 -0
- package/src/integration/SchemaRefreshLaunch.ts +93 -0
- package/src/providerConfigUnits.ts +21 -0
- package/src/resolvers/AdhocQueryResolver.ts +21 -6
- package/src/resolvers/FileResolver.ts +200 -40
- package/src/resolvers/IdentityClaimRedemptionResolver.ts +184 -0
- package/src/resolvers/IntegrationDiscoveryResolver.ts +390 -71
- 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/resolvers/meetingRecordingRegistration.ts +3 -7
- 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 +14 -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
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { BuildReactivateMessage } from '../integration/SchemaRefreshLaunch.js';
|
|
3
|
+
import type { SchemaRefreshSummaryLike } from '../integration/SchemaRefreshLaunch.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Reactivation is committed BEFORE the refresh runs, so no refresh outcome — running, failed, or
|
|
7
|
+
* clean — may ever read as "resume didn't work". These pin that, plus the two reporting bugs the
|
|
8
|
+
* create/update paths already fixed and reactivate had kept its own copy of:
|
|
9
|
+
*
|
|
10
|
+
* 1. A detached run's placeholder zeros must not be reported as findings ("0 created, 0 updated"
|
|
11
|
+
* tells an operator the source had nothing new, when in fact nothing has been looked at yet).
|
|
12
|
+
* 2. A pipeline that fails RETURNS with every count at zero rather than throwing, so counts alone
|
|
13
|
+
* read identically to a clean no-op run.
|
|
14
|
+
*/
|
|
15
|
+
function summary(over: Partial<SchemaRefreshSummaryLike> = {}): SchemaRefreshSummaryLike {
|
|
16
|
+
return {
|
|
17
|
+
RunID: 'connector-1783000000000-abc123',
|
|
18
|
+
InProgress: false,
|
|
19
|
+
Succeeded: true,
|
|
20
|
+
ObjectsCreated: 12,
|
|
21
|
+
ObjectsUpdated: 3,
|
|
22
|
+
UnresolvedObjects: ['Widgets'],
|
|
23
|
+
...over,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('BuildReactivateMessage', () => {
|
|
28
|
+
it('leads with the reactivation on every path', () => {
|
|
29
|
+
const paths = [
|
|
30
|
+
BuildReactivateMessage(undefined),
|
|
31
|
+
BuildReactivateMessage(summary({ InProgress: true, ObjectsCreated: 0, ObjectsUpdated: 0, UnresolvedObjects: [] })),
|
|
32
|
+
BuildReactivateMessage(summary()),
|
|
33
|
+
BuildReactivateMessage(summary({ Succeeded: false, FailureMessage: 'boom' })),
|
|
34
|
+
];
|
|
35
|
+
for (const msg of paths) expect(msg).toMatch(/^Reactivated/);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('names the run to tail — and reports NO counts — for a detached refresh', () => {
|
|
39
|
+
const msg = BuildReactivateMessage(summary({
|
|
40
|
+
InProgress: true,
|
|
41
|
+
ObjectsCreated: 0,
|
|
42
|
+
ObjectsUpdated: 0,
|
|
43
|
+
UnresolvedObjects: [],
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
expect(msg).toContain('connector-1783000000000-abc123');
|
|
47
|
+
expect(msg).toMatch(/running/i);
|
|
48
|
+
expect(msg).not.toMatch(/\d+ created/);
|
|
49
|
+
expect(msg).not.toMatch(/\d+ updated/);
|
|
50
|
+
expect(msg).not.toMatch(/PK-unresolved/);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('reports real counts when the caller asked to block', () => {
|
|
54
|
+
const msg = BuildReactivateMessage(summary());
|
|
55
|
+
expect(msg).toContain('12 created');
|
|
56
|
+
expect(msg).toContain('3 updated');
|
|
57
|
+
expect(msg).toContain('1 PK-unresolved');
|
|
58
|
+
expect(msg).not.toMatch(/running/i);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('never reports a FAILED refresh as a zero-count success', () => {
|
|
62
|
+
// The old inline message formatted counts unconditionally, so a pipeline that failed at
|
|
63
|
+
// ConnectionTest produced "Reactivated, schema refresh: 0 created, 0 updated,
|
|
64
|
+
// 0 PK-unresolved" — indistinguishable from a source with nothing new.
|
|
65
|
+
const msg = BuildReactivateMessage(summary({
|
|
66
|
+
Succeeded: false,
|
|
67
|
+
FailureMessage: 'ConnectionTest failed: No Totara credentials found',
|
|
68
|
+
ObjectsCreated: 0,
|
|
69
|
+
ObjectsUpdated: 0,
|
|
70
|
+
UnresolvedObjects: [],
|
|
71
|
+
}));
|
|
72
|
+
|
|
73
|
+
expect(msg).toMatch(/FAILED/);
|
|
74
|
+
expect(msg).toContain('No Totara credentials found');
|
|
75
|
+
expect(msg).not.toMatch(/0 created/);
|
|
76
|
+
// ...and still says the connection is active, because it is.
|
|
77
|
+
expect(msg).toMatch(/^Reactivated/);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('says nothing about a refresh that was never requested', () => {
|
|
81
|
+
const msg = BuildReactivateMessage(undefined);
|
|
82
|
+
expect(msg).toBe('Reactivated');
|
|
83
|
+
expect(msg).not.toMatch(/refresh/i);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -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,147 @@
|
|
|
1
|
+
// ResolverBase transitively pulls in type-graphql decorators, which need the
|
|
2
|
+
// Reflect.metadata polyfill at import time.
|
|
3
|
+
import 'reflect-metadata';
|
|
4
|
+
import { describe, it, expect } from 'vitest';
|
|
5
|
+
import type { DatabaseProviderBase, RunViewParams, RunViewResult, UserInfo } from '@memberjunction/core';
|
|
6
|
+
import { ResolverBase } from '../generic/ResolverBase.js';
|
|
7
|
+
import type { UserPayload } from '../types.js';
|
|
8
|
+
import type { RunViewByNameInput } from '../generic/RunViewResolver.js';
|
|
9
|
+
import type { PubSubEngine } from 'type-graphql';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Both of `ResolverBase`'s own filter builders interpolated client-supplied values into the
|
|
13
|
+
* `ExtraFilter` text they hand to `RunView`:
|
|
14
|
+
*
|
|
15
|
+
* - `findBy` — reachable through `UserByEmail`, `FileByName`, `UserViewsByName`, and the
|
|
16
|
+
* other by-value single-record resolvers.
|
|
17
|
+
* - `RunViewByNameGeneric` — the inline `Name='<ViewName>'` lookup.
|
|
18
|
+
*
|
|
19
|
+
* `ExtraFilter` does pass through `SQLExpressionValidator`, which rejects stacked statements,
|
|
20
|
+
* `UNION`, comments and `WAITFOR` — so the residual exposure was a same-clause boolean
|
|
21
|
+
* tautology rather than arbitrary SQL. These tests pin the escaping that closes it, and the
|
|
22
|
+
* fail-closed rule on the unquoted (numeric/boolean) slot, where a string value would BE
|
|
23
|
+
* SQL with no quote to break out of.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const ENTITY_NAME = 'MJ: Users';
|
|
27
|
+
|
|
28
|
+
/** Captures what the resolver asked `RunView` for. */
|
|
29
|
+
type Captured = { params: RunViewParams | null };
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Minimal provider: the entity/field metadata `findBy` reads, plus a `RunView` that records
|
|
33
|
+
* its params. `Sequence` stands in for a numeric field (`NeedsQuotes` is false only for
|
|
34
|
+
* Number and Boolean TS types).
|
|
35
|
+
*/
|
|
36
|
+
function fakeProvider(captured: Captured, rows: Record<string, unknown>[] = []): DatabaseProviderBase {
|
|
37
|
+
return {
|
|
38
|
+
Entities: [
|
|
39
|
+
{
|
|
40
|
+
Name: ENTITY_NAME,
|
|
41
|
+
Fields: [
|
|
42
|
+
{ Name: 'Email', NeedsQuotes: true },
|
|
43
|
+
{ Name: 'Name', NeedsQuotes: true },
|
|
44
|
+
{ Name: 'Sequence', NeedsQuotes: false },
|
|
45
|
+
{ Name: 'IsActive', NeedsQuotes: false },
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
Name: 'MJ: User Views',
|
|
50
|
+
Fields: [{ Name: 'Name', NeedsQuotes: true }],
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
RunView: async (params: RunViewParams): Promise<RunViewResult> => {
|
|
54
|
+
captured.params = params;
|
|
55
|
+
return { Success: true, Results: rows, RowCount: rows.length, TotalRowCount: rows.length, ErrorMessage: '' } as RunViewResult;
|
|
56
|
+
},
|
|
57
|
+
} as unknown as DatabaseProviderBase;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const fakeUser = () => ({ Email: 'tester@example.com' } as UserInfo);
|
|
61
|
+
const fakePayload = () => ({ email: 'tester@example.com', userRecord: fakeUser() } as UserPayload);
|
|
62
|
+
|
|
63
|
+
/** Reaches the protected `findBy`. */
|
|
64
|
+
class Probe extends ResolverBase {
|
|
65
|
+
public FindBy(provider: DatabaseProviderBase, entity: string, params: Record<string, unknown>) {
|
|
66
|
+
return this.findBy(provider, entity, params, fakeUser());
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe('ResolverBase.findBy — ExtraFilter escaping', () => {
|
|
71
|
+
it('doubles a single quote in a quoted value', async () => {
|
|
72
|
+
const captured: Captured = { params: null };
|
|
73
|
+
await new Probe().FindBy(fakeProvider(captured), ENTITY_NAME, { Email: "o'brien@example.com" });
|
|
74
|
+
|
|
75
|
+
expect(captured.params?.ExtraFilter).toBe("Email = 'o''brien@example.com'");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('renders a tautology attempt inert instead of terminating the literal', async () => {
|
|
79
|
+
const captured: Captured = { params: null };
|
|
80
|
+
// The payload closes the literal and appends its own predicate. Escaped, every quote
|
|
81
|
+
// it carries stays *inside* the literal, so the clause matches a (non-existent)
|
|
82
|
+
// address rather than every row.
|
|
83
|
+
await new Probe().FindBy(fakeProvider(captured), ENTITY_NAME, { Email: "x' OR '1'='1" });
|
|
84
|
+
|
|
85
|
+
expect(captured.params?.ExtraFilter).toBe("Email = 'x'' OR ''1''=''1'");
|
|
86
|
+
// Nothing outside the literal: exactly two unescaped quotes, the delimiters.
|
|
87
|
+
expect(captured.params?.ExtraFilter?.replace(/''/g, '')).toBe("Email = 'x OR 1=1'");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('joins multiple params with AND, escaping each', async () => {
|
|
91
|
+
const captured: Captured = { params: null };
|
|
92
|
+
await new Probe().FindBy(fakeProvider(captured), ENTITY_NAME, { Email: "a'b", Name: "c'd" });
|
|
93
|
+
|
|
94
|
+
expect(captured.params?.ExtraFilter).toBe("Email = 'a''b' AND Name = 'c''d'");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('leaves a genuinely numeric value unquoted', async () => {
|
|
98
|
+
const captured: Captured = { params: null };
|
|
99
|
+
await new Probe().FindBy(fakeProvider(captured), ENTITY_NAME, { Sequence: 42 });
|
|
100
|
+
|
|
101
|
+
expect(captured.params?.ExtraFilter).toBe('Sequence = 42');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('rejects a string aimed at an unquoted (numeric) field rather than emitting it as SQL', async () => {
|
|
105
|
+
const captured: Captured = { params: null };
|
|
106
|
+
// There is no quote to escape in an unquoted slot — the value lands in the clause as
|
|
107
|
+
// SQL. The only safe handling is to refuse it.
|
|
108
|
+
await expect(
|
|
109
|
+
new Probe().FindBy(fakeProvider(captured), ENTITY_NAME, { Sequence: '1 OR 1=1' })
|
|
110
|
+
).rejects.toThrow(/Sequence/);
|
|
111
|
+
|
|
112
|
+
expect(captured.params).toBeNull(); // never reached RunView
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('rejects a non-finite number in an unquoted field', async () => {
|
|
116
|
+
const captured: Captured = { params: null };
|
|
117
|
+
await expect(
|
|
118
|
+
new Probe().FindBy(fakeProvider(captured), ENTITY_NAME, { Sequence: Number.NaN })
|
|
119
|
+
).rejects.toThrow(/Sequence/);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('still rejects an unknown field name', async () => {
|
|
123
|
+
// Pre-existing behavior, pinned so the escaping rework cannot quietly drop it.
|
|
124
|
+
await expect(
|
|
125
|
+
new Probe().FindBy(fakeProvider({ params: null }), ENTITY_NAME, { NotAField: 'x' })
|
|
126
|
+
).rejects.toThrow(/NotAField/);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe('ResolverBase.RunViewByNameGeneric — view-name escaping', () => {
|
|
131
|
+
it('escapes the client-supplied view name', async () => {
|
|
132
|
+
const captured: Captured = { params: null };
|
|
133
|
+
const input = { ViewName: "My View' OR '1'='1" } as RunViewByNameInput;
|
|
134
|
+
|
|
135
|
+
// Empty Results short-circuits the method (returns null) once the lookup filter is built.
|
|
136
|
+
const result = await new Probe().RunViewByNameGeneric(
|
|
137
|
+
input,
|
|
138
|
+
fakeProvider(captured),
|
|
139
|
+
fakePayload(),
|
|
140
|
+
undefined as unknown as PubSubEngine // unused: the empty-result path returns before it is passed on
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
expect(result).toBeNull();
|
|
144
|
+
expect(captured.params?.EntityName).toBe('MJ: User Views');
|
|
145
|
+
expect(captured.params?.ExtraFilter).toBe("Name='My View'' OR ''1''=''1'");
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -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
|
+
});
|