@memberjunction/server 6.2.0-edge.2 → 6.2.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/README.md +30 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/config.d.ts +2189 -71
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +123 -3
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +11 -23
- package/dist/context.js.map +1 -1
- package/dist/generated/graphql-schemas/__mj.d.ts +928 -0
- package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
- package/dist/generated/graphql-schemas/__mj.js +8482 -3649
- package/dist/generated/graphql-schemas/__mj.js.map +1 -1
- package/dist/generic/ResolverBase.d.ts +43 -3
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +142 -27
- 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 +33 -1
- package/dist/generic/RunViewResolver.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +115 -49
- package/dist/index.js.map +1 -1
- package/dist/postgresqlCompat.d.ts +8 -0
- package/dist/postgresqlCompat.d.ts.map +1 -0
- package/dist/postgresqlCompat.js +19 -0
- package/dist/postgresqlCompat.js.map +1 -0
- package/dist/postgresqlPoolSettings.d.ts +108 -0
- package/dist/postgresqlPoolSettings.d.ts.map +1 -0
- package/dist/postgresqlPoolSettings.js +134 -0
- package/dist/postgresqlPoolSettings.js.map +1 -0
- package/dist/resolvers/AdhocQueryResolver.d.ts +23 -18
- package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
- package/dist/resolvers/AdhocQueryResolver.js +77 -167
- package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
- package/dist/resolvers/EntityRecordNameResolver.d.ts +7 -23
- package/dist/resolvers/EntityRecordNameResolver.d.ts.map +1 -1
- package/dist/resolvers/EntityRecordNameResolver.js +9 -47
- package/dist/resolvers/EntityRecordNameResolver.js.map +1 -1
- package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
- package/dist/resolvers/GetDataResolver.js +9 -0
- package/dist/resolvers/GetDataResolver.js.map +1 -1
- package/dist/resolvers/HumanHandoffResolver.d.ts +107 -0
- package/dist/resolvers/HumanHandoffResolver.d.ts.map +1 -0
- package/dist/resolvers/HumanHandoffResolver.js +324 -0
- package/dist/resolvers/HumanHandoffResolver.js.map +1 -0
- package/dist/resolvers/MeetingResolver.d.ts +69 -0
- package/dist/resolvers/MeetingResolver.d.ts.map +1 -0
- package/dist/resolvers/MeetingResolver.js +381 -0
- package/dist/resolvers/MeetingResolver.js.map +1 -0
- package/dist/resolvers/RealtimeBridgeResolver.d.ts +34 -1
- package/dist/resolvers/RealtimeBridgeResolver.d.ts.map +1 -1
- package/dist/resolvers/RealtimeBridgeResolver.js +202 -7
- package/dist/resolvers/RealtimeBridgeResolver.js.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts +1 -83
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.js +4 -206
- package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
- package/dist/resolvers/adhocTimeout.d.ts +7 -0
- package/dist/resolvers/adhocTimeout.d.ts.map +1 -0
- package/dist/resolvers/adhocTimeout.js +12 -0
- package/dist/resolvers/adhocTimeout.js.map +1 -0
- package/dist/resolvers/meetings/index.d.ts +5 -0
- package/dist/resolvers/meetings/index.d.ts.map +1 -0
- package/dist/resolvers/meetings/index.js +5 -0
- package/dist/resolvers/meetings/index.js.map +1 -0
- package/dist/resolvers/meetings/meetingHelpers.d.ts +17 -0
- package/dist/resolvers/meetings/meetingHelpers.d.ts.map +1 -0
- package/dist/resolvers/meetings/meetingHelpers.js +212 -0
- package/dist/resolvers/meetings/meetingHelpers.js.map +1 -0
- package/dist/resolvers/meetings/meetingMutations.d.ts +12 -0
- package/dist/resolvers/meetings/meetingMutations.d.ts.map +1 -0
- package/dist/resolvers/meetings/meetingMutations.js +418 -0
- package/dist/resolvers/meetings/meetingMutations.js.map +1 -0
- package/dist/resolvers/meetings/meetingQueries.d.ts +7 -0
- package/dist/resolvers/meetings/meetingQueries.d.ts.map +1 -0
- package/dist/resolvers/meetings/meetingQueries.js +113 -0
- package/dist/resolvers/meetings/meetingQueries.js.map +1 -0
- package/dist/resolvers/meetings/meetingTypes.d.ts +89 -0
- package/dist/resolvers/meetings/meetingTypes.d.ts.map +1 -0
- package/dist/resolvers/meetings/meetingTypes.js +345 -0
- package/dist/resolvers/meetings/meetingTypes.js.map +1 -0
- package/dist/rest/ViewOperationsHandler.d.ts.map +1 -1
- package/dist/rest/ViewOperationsHandler.js +8 -5
- package/dist/rest/ViewOperationsHandler.js.map +1 -1
- package/dist/services/TaskGraphActionRunner.d.ts +8 -0
- package/dist/services/TaskGraphActionRunner.d.ts.map +1 -1
- package/dist/services/TaskGraphActionRunner.js +27 -1
- package/dist/services/TaskGraphActionRunner.js.map +1 -1
- package/dist/services/WorkQueueHostService.d.ts +36 -0
- package/dist/services/WorkQueueHostService.d.ts.map +1 -0
- package/dist/services/WorkQueueHostService.js +97 -0
- package/dist/services/WorkQueueHostService.js.map +1 -0
- package/dist/services/workQueueConfig.d.ts +103 -0
- package/dist/services/workQueueConfig.d.ts.map +1 -0
- package/dist/services/workQueueConfig.js +48 -0
- package/dist/services/workQueueConfig.js.map +1 -0
- package/dist/types.d.ts +5 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +95 -94
- package/src/__tests__/AdhocQueryResolver.bugs.test.ts +14 -35
- package/src/__tests__/AdhocQueryResolver.pagination.test.ts +6 -2
- package/src/__tests__/AdhocQueryResolver.scopeGuard.test.ts +3 -3
- package/src/__tests__/EntityRecordNameResolver.fieldSecurity.test.ts +27 -72
- package/src/__tests__/HumanHandoffResolver.test.ts +233 -0
- package/src/__tests__/MeetingResolver.test.ts +374 -0
- package/src/__tests__/RealtimeBridgeResolver.test.ts +240 -2
- package/src/__tests__/ResolverBase.adhocReadScope.test.ts +96 -0
- package/src/__tests__/ResolverBase.filterEscaping.test.ts +21 -2
- package/src/__tests__/ResolverBase.includeBinaryFields.test.ts +291 -0
- package/src/__tests__/TaskGraphActionRunner.durablePayload.test.ts +41 -0
- package/src/__tests__/WorkQueueHostService.test.ts +133 -0
- package/src/__tests__/adhocTimeoutClamp.test.ts +26 -0
- package/src/__tests__/postgresqlPoolSettings.test.ts +131 -0
- package/src/__tests__/translateBracketsToPG.test.ts +20 -0
- package/src/config.ts +130 -3
- package/src/context.ts +11 -24
- package/src/generated/graphql-schemas/__mj.ts +22138 -18770
- package/src/generic/ResolverBase.ts +171 -27
- package/src/generic/RunViewResolver.ts +33 -2
- package/src/index.ts +127 -50
- package/src/postgresqlCompat.ts +21 -0
- package/src/postgresqlPoolSettings.ts +213 -0
- package/src/resolvers/AdhocQueryResolver.ts +88 -188
- package/src/resolvers/EntityRecordNameResolver.ts +9 -51
- package/src/resolvers/GetDataResolver.ts +10 -0
- package/src/resolvers/HumanHandoffResolver.ts +304 -0
- package/src/resolvers/MeetingResolver.ts +438 -0
- package/src/resolvers/RealtimeBridgeResolver.ts +227 -13
- package/src/resolvers/RealtimeClientSessionResolver.ts +6 -252
- package/src/resolvers/adhocTimeout.ts +12 -0
- package/src/resolvers/meetings/index.ts +4 -0
- package/src/resolvers/meetings/meetingHelpers.ts +307 -0
- package/src/resolvers/meetings/meetingMutations.ts +535 -0
- package/src/resolvers/meetings/meetingQueries.ts +172 -0
- package/src/resolvers/meetings/meetingTypes.ts +238 -0
- package/src/rest/ViewOperationsHandler.ts +8 -5
- package/src/services/TaskGraphActionRunner.ts +29 -1
- package/src/services/WorkQueueHostService.ts +129 -0
- package/src/services/workQueueConfig.ts +52 -0
- package/src/types.ts +5 -0
- package/dist/resolvers/adhoc-query-helpers.d.ts +0 -33
- package/dist/resolvers/adhoc-query-helpers.d.ts.map +0 -1
- package/dist/resolvers/adhoc-query-helpers.js +0 -50
- package/dist/resolvers/adhoc-query-helpers.js.map +0 -1
- package/src/__tests__/adhoc-query-helpers.test.ts +0 -50
- package/src/resolvers/adhoc-query-helpers.ts +0 -67
|
@@ -0,0 +1,96 @@
|
|
|
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 { IMetadataProvider, UserInfo } from '@memberjunction/core';
|
|
6
|
+
import { ResolverBase } from '../generic/ResolverBase.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The ad-hoc SQL screen (`assertFullQueryUsesReadableEntityViews`) admits a table reference
|
|
10
|
+
* only when it resolves to an entity base view the caller can read UNSCOPED. These tests pin
|
|
11
|
+
* the three ways that resolution was looser than it looked (PR #4782 review):
|
|
12
|
+
*
|
|
13
|
+
* - a schema-qualified reference fell back to a bare-name match in another schema;
|
|
14
|
+
* - an unqualified name present in two schemas resolved to whichever loaded last;
|
|
15
|
+
* - entity CanRead was treated as unscoped read authority, though raw SQL applies neither the
|
|
16
|
+
* row-level filter nor the denied-field projection RunView does.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
type EntityShape = {
|
|
20
|
+
Name: string;
|
|
21
|
+
SchemaName: string;
|
|
22
|
+
BaseView: string;
|
|
23
|
+
canRead?: boolean;
|
|
24
|
+
rowFilter?: string;
|
|
25
|
+
deniedFields?: string[];
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function fakeProvider(entities: EntityShape[]): IMetadataProvider {
|
|
29
|
+
return {
|
|
30
|
+
Entities: entities.map((e) => ({
|
|
31
|
+
Name: e.Name,
|
|
32
|
+
SchemaName: e.SchemaName,
|
|
33
|
+
BaseView: e.BaseView,
|
|
34
|
+
GetUserPermisions: () => ({ CanRead: e.canRead ?? true }),
|
|
35
|
+
GetEffectiveRowFilterWhereClause: () => e.rowFilter ?? '',
|
|
36
|
+
GetDeniedReadFields: () => new Set(e.deniedFields ?? []),
|
|
37
|
+
})),
|
|
38
|
+
} as unknown as IMetadataProvider;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const USER = { ID: 'U1', Email: 'u@example.com' } as unknown as UserInfo;
|
|
42
|
+
|
|
43
|
+
class Probe extends ResolverBase {
|
|
44
|
+
public Screen(sql: string, provider: IMetadataProvider, user: UserInfo | undefined = USER) {
|
|
45
|
+
return this.assertFullQueryUsesReadableEntityViews(sql, provider, user, 'ad-hoc SQL');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const PUBLIC_FOO: EntityShape = { Name: 'Public Foo', SchemaName: 'public', BaseView: 'vwFoo' };
|
|
50
|
+
|
|
51
|
+
describe('assertFullQueryUsesReadableEntityViews', () => {
|
|
52
|
+
it('admits an unscoped readable base view', () => {
|
|
53
|
+
expect(() => new Probe().Screen('SELECT ID FROM [public].[vwFoo]', fakeProvider([PUBLIC_FOO]))).not.toThrow();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('does not resolve a schema-qualified reference through another schema', () => {
|
|
57
|
+
expect(() => new Probe().Screen('SELECT ID FROM secret.vwFoo', fakeProvider([PUBLIC_FOO]))).toThrow(
|
|
58
|
+
/entity base view, not 'secret.vwFoo'/,
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('refuses an unqualified view name that exists in more than one schema', () => {
|
|
63
|
+
const provider = fakeProvider([PUBLIC_FOO, { Name: 'Other Foo', SchemaName: 'other', BaseView: 'vwFoo' }]);
|
|
64
|
+
expect(() => new Probe().Screen('SELECT ID FROM vwFoo', provider)).toThrow(/more than one schema/);
|
|
65
|
+
expect(() => new Probe().Screen('SELECT ID FROM other.vwFoo', provider)).not.toThrow();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('refuses an entity the caller cannot read', () => {
|
|
69
|
+
expect(() => new Probe().Screen('SELECT ID FROM public.vwFoo', fakeProvider([{ ...PUBLIC_FOO, canRead: false }]))).toThrow(
|
|
70
|
+
/read permission/,
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('refuses an entity that is row-level-security filtered for the caller', () => {
|
|
75
|
+
const provider = fakeProvider([{ ...PUBLIC_FOO, rowFilter: "(UserID = 'U1')" }]);
|
|
76
|
+
expect(() => new Probe().Screen('SELECT ID, UserID FROM public.vwFoo', provider)).toThrow(/row-level-security/);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('refuses an entity with a field the caller is denied', () => {
|
|
80
|
+
const provider = fakeProvider([{ ...PUBLIC_FOO, deniedFields: ['secret'] }]);
|
|
81
|
+
expect(() => new Probe().Screen('SELECT ID FROM public.vwFoo', provider)).toThrow(/not permitted to read/);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('applies the scope check to every reference, including joins and subqueries', () => {
|
|
85
|
+
const provider = fakeProvider([PUBLIC_FOO, { Name: 'Bar', SchemaName: 'public', BaseView: 'vwBar', rowFilter: '(1=0)' }]);
|
|
86
|
+
expect(() =>
|
|
87
|
+
new Probe().Screen('SELECT f.ID FROM public.vwFoo f WHERE f.ID IN (SELECT FooID FROM public.vwBar)', provider),
|
|
88
|
+
).toThrow(/'Bar' is row-level-security filtered/);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('exempts CTE names the statement defines', () => {
|
|
92
|
+
expect(() =>
|
|
93
|
+
new Probe().Screen('WITH x AS (SELECT ID FROM public.vwFoo) SELECT ID FROM x', fakeProvider([PUBLIC_FOO])),
|
|
94
|
+
).not.toThrow();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -33,7 +33,13 @@ type Captured = { params: RunViewParams | null };
|
|
|
33
33
|
* its params. `Sequence` stands in for a numeric field (`NeedsQuotes` is false only for
|
|
34
34
|
* Number and Boolean TS types).
|
|
35
35
|
*/
|
|
36
|
-
function fakeProvider(captured: Captured, rows: Record<string, unknown>[] = []): DatabaseProviderBase {
|
|
36
|
+
function fakeProvider(captured: Captured, rows: Record<string, unknown>[] = [], unreadableEntities: string[] = []): DatabaseProviderBase {
|
|
37
|
+
const withPerms = (e: Record<string, unknown>) => ({
|
|
38
|
+
...e,
|
|
39
|
+
// Real EntityInfo aggregates per-user permissions; the boundary screen now consults
|
|
40
|
+
// CanRead for every subquery target, so the mock must answer it.
|
|
41
|
+
GetUserPermisions: () => ({ CanRead: !unreadableEntities.includes(e.Name as string) }),
|
|
42
|
+
});
|
|
37
43
|
return {
|
|
38
44
|
Entities: [
|
|
39
45
|
{
|
|
@@ -67,7 +73,7 @@ function fakeProvider(captured: Captured, rows: Record<string, unknown>[] = []):
|
|
|
67
73
|
{ Name: 'Committees: Motions', SchemaName: '__mj_BizAppsCommittees', BaseView: 'vwMotions', BaseTable: 'Motion', Fields: [] },
|
|
68
74
|
{ Name: 'Committees: Memberships', SchemaName: '__mj_BizAppsCommittees', BaseView: 'vwMemberships', BaseTable: 'Membership', Fields: [] },
|
|
69
75
|
{ Name: 'Committees: Terms', SchemaName: '__mj_BizAppsCommittees', BaseView: 'vwTerms', BaseTable: 'Term', Fields: [] },
|
|
70
|
-
],
|
|
76
|
+
].map(withPerms),
|
|
71
77
|
RunView: async (params: RunViewParams): Promise<RunViewResult> => {
|
|
72
78
|
captured.params = params;
|
|
73
79
|
return { Success: true, Results: rows, RowCount: rows.length, TotalRowCount: rows.length, ErrorMessage: '' } as RunViewResult;
|
|
@@ -246,6 +252,19 @@ describe('ResolverBase — GraphQL-boundary ExtraFilter AST screen', () => {
|
|
|
246
252
|
expect(captured.params?.ExtraFilter).toBe(`ID IN (SELECT ID FROM [__mj].[vwUsers] WHERE Email = 'a@b.com')`);
|
|
247
253
|
});
|
|
248
254
|
|
|
255
|
+
it('RunDynamicViewGeneric rejects a subquery into an entity the caller cannot read', async () => {
|
|
256
|
+
const captured: Captured = { params: null };
|
|
257
|
+
const input = {
|
|
258
|
+
EntityName: ENTITY_NAME,
|
|
259
|
+
ExtraFilter: `ID IN (SELECT TaskID FROM [__mj_BizAppsTasks].[vwTaskAssignments])`,
|
|
260
|
+
} as RunDynamicViewInput;
|
|
261
|
+
|
|
262
|
+
await expect(
|
|
263
|
+
new Probe().RunDynamic(input, fakeProvider(captured, [], ['MJ_BizApps_Tasks: Task Assignments']))
|
|
264
|
+
).rejects.toThrow(/read permission/);
|
|
265
|
+
expect(captured.params).toBeNull();
|
|
266
|
+
});
|
|
267
|
+
|
|
249
268
|
it('RunDynamicViewGeneric passes a benign ExtraFilter through to RunView', async () => {
|
|
250
269
|
const captured: Captured = { params: null };
|
|
251
270
|
const input = {
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// ResolverBase / RunViewResolver transitively pull 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 {
|
|
6
|
+
DatabaseProviderBase,
|
|
7
|
+
EntityInfo,
|
|
8
|
+
IMetadataProvider,
|
|
9
|
+
RunViewParams,
|
|
10
|
+
RunViewResult,
|
|
11
|
+
RunViewWithCacheCheckParams,
|
|
12
|
+
RunViewsWithCacheCheckResponse,
|
|
13
|
+
UserInfo,
|
|
14
|
+
} from '@memberjunction/core';
|
|
15
|
+
import type { MJUserViewEntityExtended } from '@memberjunction/core-entities';
|
|
16
|
+
import { ResolverBase } from '../generic/ResolverBase.js';
|
|
17
|
+
import {
|
|
18
|
+
RunViewResolver,
|
|
19
|
+
type RunDynamicViewInput,
|
|
20
|
+
type RunViewByIDInput,
|
|
21
|
+
type RunViewByNameInput,
|
|
22
|
+
type RunViewResultRow,
|
|
23
|
+
type RunViewWithCacheCheckInput,
|
|
24
|
+
} from '../generic/RunViewResolver.js';
|
|
25
|
+
import type { AppContext, UserPayload } from '../types.js';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Binary DB fields (varbinary / bytea) are base64 strings in JS and on the wire, and RunView
|
|
29
|
+
* omits them unless `IncludeBinaryFields: true`. These tests pin the server half:
|
|
30
|
+
*
|
|
31
|
+
* 1. the GraphQL flag reaches the provider's RunView / RunViews / RunViewsWithCacheCheck params
|
|
32
|
+
* on every resolver path (single by-ID / by-name / dynamic, batch, and the cache-check batch);
|
|
33
|
+
* 2. any byte array that still reaches a resolver is serialized as base64 — never as the
|
|
34
|
+
* `{"type":"Buffer","data":[...]}` that `JSON.stringify` produces for a Node Buffer.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
const ENTITY_NAME = 'MJ: Vector Things';
|
|
38
|
+
const ENTITY_ID = 'ent-1';
|
|
39
|
+
|
|
40
|
+
/** Bytes whose base64 form is easy to recognise: [1,2,3,4] → "AQIDBA==". */
|
|
41
|
+
const BYTES = [1, 2, 3, 4];
|
|
42
|
+
const BYTES_BASE64 = 'AQIDBA==';
|
|
43
|
+
|
|
44
|
+
const fakePayload = () =>
|
|
45
|
+
({ email: 'tester@example.com', userRecord: { Email: 'tester@example.com' } as UserInfo } as UserPayload);
|
|
46
|
+
|
|
47
|
+
function entityStub() {
|
|
48
|
+
return {
|
|
49
|
+
ID: ENTITY_ID,
|
|
50
|
+
Name: ENTITY_NAME,
|
|
51
|
+
BaseView: 'vwVectorThings',
|
|
52
|
+
PrimaryKeys: [{ Name: 'ID' }],
|
|
53
|
+
Fields: [
|
|
54
|
+
{ Name: 'ID', CodeName: 'ID' },
|
|
55
|
+
{ Name: 'Embedding', CodeName: 'Embedding' },
|
|
56
|
+
],
|
|
57
|
+
EncryptedFields: [],
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Provider that records every RunView / RunViews call and returns the given rows. */
|
|
62
|
+
function recordingProvider(rows: Record<string, unknown>[] = []) {
|
|
63
|
+
const runViewCalls: RunViewParams[] = [];
|
|
64
|
+
const runViewsCalls: RunViewParams[][] = [];
|
|
65
|
+
const viewInfo = { ID: 'view-1', Name: 'Saved View', Entity: ENTITY_NAME, EntityID: ENTITY_ID };
|
|
66
|
+
const provider = {
|
|
67
|
+
Entities: [entityStub()],
|
|
68
|
+
EntityByName: (name: string) => (name === ENTITY_NAME ? entityStub() : undefined),
|
|
69
|
+
RunView: async (params: RunViewParams): Promise<RunViewResult> => {
|
|
70
|
+
// RunViewByNameGeneric looks the saved view up first — answer that lookup with the view row.
|
|
71
|
+
if (params.EntityName === 'MJ: User Views') {
|
|
72
|
+
return { Success: true, Results: [viewInfo], RowCount: 1, TotalRowCount: 1, ErrorMessage: '' } as RunViewResult;
|
|
73
|
+
}
|
|
74
|
+
runViewCalls.push(params);
|
|
75
|
+
return { Success: true, Results: rows, RowCount: rows.length, TotalRowCount: rows.length, ErrorMessage: '' } as RunViewResult;
|
|
76
|
+
},
|
|
77
|
+
RunViews: async (params: RunViewParams[]): Promise<RunViewResult[]> => {
|
|
78
|
+
runViewsCalls.push(params);
|
|
79
|
+
return params.map(() => ({ Success: true, Results: rows, RowCount: rows.length, TotalRowCount: rows.length, ErrorMessage: '' } as RunViewResult));
|
|
80
|
+
},
|
|
81
|
+
GetEntityObject: async () => ({ ...viewInfo, Load: async () => true }),
|
|
82
|
+
} as unknown as DatabaseProviderBase;
|
|
83
|
+
return { provider, runViewCalls, runViewsCalls };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Skips the encrypted-field filter (it needs live Metadata) — irrelevant to these assertions. */
|
|
87
|
+
class Probe extends ResolverBase {
|
|
88
|
+
protected override async ArrayFilterEncryptedFieldsForAPI(
|
|
89
|
+
_entityName: string,
|
|
90
|
+
dataObjectArray: Record<string, unknown>[]
|
|
91
|
+
): Promise<Record<string, unknown>[]> {
|
|
92
|
+
return dataObjectArray;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
public MapOne(entityName: string, row: unknown, provider: IMetadataProvider) {
|
|
96
|
+
return this.MapFieldNamesToCodeNames(entityName, row, undefined, provider);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
describe('IncludeBinaryFields forwarding — single-view resolver paths', () => {
|
|
101
|
+
it('RunDynamicViewGeneric forwards IncludeBinaryFields: true to provider.RunView', async () => {
|
|
102
|
+
const { provider, runViewCalls } = recordingProvider();
|
|
103
|
+
await new Probe().RunDynamicViewGeneric(
|
|
104
|
+
{ EntityName: ENTITY_NAME, ExtraFilter: '', OrderBy: '', IncludeBinaryFields: true } as RunDynamicViewInput,
|
|
105
|
+
provider, fakePayload(), undefined as never,
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
expect(runViewCalls).toHaveLength(1);
|
|
109
|
+
expect(runViewCalls[0].IncludeBinaryFields).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('RunDynamicViewGeneric leaves IncludeBinaryFields undefined when the caller did not set it', async () => {
|
|
113
|
+
const { provider, runViewCalls } = recordingProvider();
|
|
114
|
+
await new Probe().RunDynamicViewGeneric(
|
|
115
|
+
{ EntityName: ENTITY_NAME, ExtraFilter: '', OrderBy: '' } as RunDynamicViewInput,
|
|
116
|
+
provider, fakePayload(), undefined as never,
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
expect(runViewCalls).toHaveLength(1);
|
|
120
|
+
expect(runViewCalls[0].IncludeBinaryFields).toBeUndefined();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('RunViewByNameGeneric forwards IncludeBinaryFields to provider.RunView', async () => {
|
|
124
|
+
const { provider, runViewCalls } = recordingProvider();
|
|
125
|
+
await new Probe().RunViewByNameGeneric(
|
|
126
|
+
{ ViewName: 'Saved View', ExtraFilter: '', OrderBy: '', IncludeBinaryFields: true } as RunViewByNameInput,
|
|
127
|
+
provider, fakePayload(), undefined as never,
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
expect(runViewCalls).toHaveLength(1);
|
|
131
|
+
expect(runViewCalls[0].EntityName).toBe(ENTITY_NAME);
|
|
132
|
+
expect(runViewCalls[0].IncludeBinaryFields).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('RunViewByIDGeneric forwards IncludeBinaryFields to provider.RunView', async () => {
|
|
136
|
+
const { provider, runViewCalls } = recordingProvider();
|
|
137
|
+
await new Probe().RunViewByIDGeneric(
|
|
138
|
+
{ ViewID: 'view-1', ExtraFilter: '', OrderBy: '', IncludeBinaryFields: false } as RunViewByIDInput,
|
|
139
|
+
provider, fakePayload(), undefined as never,
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
expect(runViewCalls).toHaveLength(1);
|
|
143
|
+
expect(runViewCalls[0].IncludeBinaryFields).toBe(false);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('RunViewGenericInternal passes binary values through as the provider returned them (base64 strings)', async () => {
|
|
147
|
+
const { provider } = recordingProvider([{ ID: 'r1', Embedding: BYTES_BASE64 }]);
|
|
148
|
+
const result = await new Probe().RunDynamicViewGeneric(
|
|
149
|
+
{ EntityName: ENTITY_NAME, ExtraFilter: '', OrderBy: '', IncludeBinaryFields: true } as RunDynamicViewInput,
|
|
150
|
+
provider, fakePayload(), undefined as never,
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
expect((result?.Results as Record<string, unknown>[])[0].Embedding).toBe(BYTES_BASE64);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe('IncludeBinaryFields forwarding — batch resolver path', () => {
|
|
158
|
+
it('RunViewsGeneric → RunViewsGenericInternal forwards the per-view flag to provider.RunViews', async () => {
|
|
159
|
+
const { provider, runViewsCalls } = recordingProvider();
|
|
160
|
+
const inputs = [
|
|
161
|
+
{ EntityName: ENTITY_NAME, ExtraFilter: '', OrderBy: '', IncludeBinaryFields: true },
|
|
162
|
+
{ EntityName: ENTITY_NAME, ExtraFilter: '', OrderBy: '' },
|
|
163
|
+
] as (RunViewByNameInput & RunViewByIDInput & RunDynamicViewInput)[];
|
|
164
|
+
|
|
165
|
+
await new Probe().RunViewsGeneric(inputs, provider, fakePayload());
|
|
166
|
+
|
|
167
|
+
expect(runViewsCalls).toHaveLength(1);
|
|
168
|
+
expect(runViewsCalls[0]).toHaveLength(2);
|
|
169
|
+
expect(runViewsCalls[0][0].IncludeBinaryFields).toBe(true);
|
|
170
|
+
expect(runViewsCalls[0][1].IncludeBinaryFields).toBeUndefined();
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe('MapFieldNamesToCodeNames — byte arrays become base64', () => {
|
|
175
|
+
const provider = {
|
|
176
|
+
EntityByName: (name: string) => (name === ENTITY_NAME ? (entityStub() as unknown as EntityInfo) : undefined),
|
|
177
|
+
} as unknown as IMetadataProvider;
|
|
178
|
+
|
|
179
|
+
it('converts a Buffer value to its base64 string', async () => {
|
|
180
|
+
const mapped = (await new Probe().MapOne(ENTITY_NAME, { ID: 'r1', Embedding: Buffer.from(BYTES) }, provider)) as Record<string, unknown>;
|
|
181
|
+
|
|
182
|
+
expect(mapped.Embedding).toBe(BYTES_BASE64);
|
|
183
|
+
expect(mapped.ID).toBe('r1');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('converts a plain Uint8Array value too', async () => {
|
|
187
|
+
const mapped = (await new Probe().MapOne(ENTITY_NAME, { ID: 'r1', Embedding: new Uint8Array(BYTES) }, provider)) as Record<string, unknown>;
|
|
188
|
+
|
|
189
|
+
expect(mapped.Embedding).toBe(BYTES_BASE64);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('leaves an already-base64 string untouched', async () => {
|
|
193
|
+
const mapped = (await new Probe().MapOne(ENTITY_NAME, { ID: 'r1', Embedding: BYTES_BASE64 }, provider)) as Record<string, unknown>;
|
|
194
|
+
|
|
195
|
+
expect(mapped.Embedding).toBe(BYTES_BASE64);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('does not mutate the caller\'s row (it may be a frozen cache entry)', async () => {
|
|
199
|
+
const buffer = Buffer.from(BYTES);
|
|
200
|
+
const source = Object.freeze({ ID: 'r1', Embedding: buffer }) as Record<string, unknown>;
|
|
201
|
+
|
|
202
|
+
const mapped = (await new Probe().MapOne(ENTITY_NAME, source, provider)) as Record<string, unknown>;
|
|
203
|
+
|
|
204
|
+
expect(mapped.Embedding).toBe(BYTES_BASE64);
|
|
205
|
+
expect(source.Embedding).toBe(buffer);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
/** Exposes the protected serializer. */
|
|
210
|
+
class ResolverProbe extends RunViewResolver {
|
|
211
|
+
public Process(rows: Record<string, unknown>[]): RunViewResultRow[] {
|
|
212
|
+
return this.processRawData(rows, ENTITY_ID, entityStub() as unknown as EntityInfo);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
describe('RunViewResolver.processRawData — Buffer → base64 in the Data JSON', () => {
|
|
217
|
+
it('serializes a Buffer value as base64, not {type:"Buffer",data:[...]}', () => {
|
|
218
|
+
const [row] = new ResolverProbe().Process([{ ID: 'r1', Embedding: Buffer.from(BYTES) }]);
|
|
219
|
+
const data = JSON.parse(row.Data) as Record<string, unknown>;
|
|
220
|
+
|
|
221
|
+
expect(data.Embedding).toBe(BYTES_BASE64);
|
|
222
|
+
expect(row.Data).not.toContain('"type":"Buffer"');
|
|
223
|
+
expect(row.PrimaryKey).toEqual([{ FieldName: 'ID', Value: 'r1' }]);
|
|
224
|
+
expect(row.EntityID).toBe(ENTITY_ID);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('serializes a Uint8Array value as base64 (not an index-keyed object)', () => {
|
|
228
|
+
const [row] = new ResolverProbe().Process([{ ID: 'r1', Embedding: new Uint8Array(BYTES) }]);
|
|
229
|
+
|
|
230
|
+
expect((JSON.parse(row.Data) as Record<string, unknown>).Embedding).toBe(BYTES_BASE64);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('leaves rows without byte arrays serialized exactly as before', () => {
|
|
234
|
+
const source = { ID: 'r1', Name: 'Plain', Embedding: BYTES_BASE64, Count: 3 };
|
|
235
|
+
const [row] = new ResolverProbe().Process([source]);
|
|
236
|
+
|
|
237
|
+
expect(row.Data).toBe(JSON.stringify(source));
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('does not mutate the source row', () => {
|
|
241
|
+
const buffer = Buffer.from(BYTES);
|
|
242
|
+
const source: Record<string, unknown> = { ID: 'r1', Embedding: buffer };
|
|
243
|
+
|
|
244
|
+
new ResolverProbe().Process([source]);
|
|
245
|
+
|
|
246
|
+
expect(source.Embedding).toBe(buffer);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
describe('RunViewResolver.RunViewsWithCacheCheck — IncludeBinaryFields + Buffer serialization', () => {
|
|
251
|
+
function cacheCheckContext(rows: Record<string, unknown>[]) {
|
|
252
|
+
const received: RunViewWithCacheCheckParams[][] = [];
|
|
253
|
+
const provider = {
|
|
254
|
+
EntityByName: (name: string) => (name === ENTITY_NAME ? entityStub() : undefined),
|
|
255
|
+
RunViewsWithCacheCheck: async (params: RunViewWithCacheCheckParams[]): Promise<RunViewsWithCacheCheckResponse> => {
|
|
256
|
+
received.push(params);
|
|
257
|
+
return {
|
|
258
|
+
success: true,
|
|
259
|
+
results: params.map((_p, i) => ({ viewIndex: i, status: 'stale', results: rows, maxUpdatedAt: 'T', rowCount: rows.length })),
|
|
260
|
+
} as RunViewsWithCacheCheckResponse;
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
const ctx = { providers: [{ type: 'Read-Only', provider }], userPayload: fakePayload() } as unknown as AppContext;
|
|
264
|
+
return { ctx, received };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
it('forwards IncludeBinaryFields into the core params (true, false and unset)', async () => {
|
|
268
|
+
const { ctx, received } = cacheCheckContext([]);
|
|
269
|
+
const input = [
|
|
270
|
+
{ params: { EntityName: ENTITY_NAME, IncludeBinaryFields: true } },
|
|
271
|
+
{ params: { EntityName: ENTITY_NAME, IncludeBinaryFields: false } },
|
|
272
|
+
{ params: { EntityName: ENTITY_NAME } },
|
|
273
|
+
] as RunViewWithCacheCheckInput[];
|
|
274
|
+
|
|
275
|
+
const out = await new RunViewResolver().RunViewsWithCacheCheck(input, ctx);
|
|
276
|
+
|
|
277
|
+
expect(out.success).toBe(true);
|
|
278
|
+
expect(received).toHaveLength(1);
|
|
279
|
+
expect(received[0].map(p => p.params.IncludeBinaryFields)).toEqual([true, false, undefined]);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('serializes Buffers in stale results as base64', async () => {
|
|
283
|
+
const { ctx } = cacheCheckContext([{ ID: 'r1', Embedding: Buffer.from(BYTES) }]);
|
|
284
|
+
const input = [{ params: { EntityName: ENTITY_NAME, IncludeBinaryFields: true } }] as RunViewWithCacheCheckInput[];
|
|
285
|
+
|
|
286
|
+
const out = await new RunViewResolver().RunViewsWithCacheCheck(input, ctx);
|
|
287
|
+
const rows = out.results[0].Results as RunViewResultRow[];
|
|
288
|
+
|
|
289
|
+
expect((JSON.parse(rows[0].Data) as Record<string, unknown>).Embedding).toBe(BYTES_BASE64);
|
|
290
|
+
});
|
|
291
|
+
});
|
|
@@ -129,3 +129,44 @@ describe('TaskGraphActionRunner — durable payload round-trips by param name (#
|
|
|
129
129
|
expect(sentParams.map((p) => p.Name).sort()).toEqual(['EntityName', 'Record']);
|
|
130
130
|
});
|
|
131
131
|
});
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* A durable step's Output is stored on the Task row as JSON. An action that hands back an output
|
|
135
|
+
* param holding a live object (an entity, an observable) used to surface much later, from the
|
|
136
|
+
* dispatcher, as a bare "Converting circular structure to JSON" with no hint of WHICH action or
|
|
137
|
+
* param — seen live with Execute Agent's AgentResult. The runner is the boundary that knows both
|
|
138
|
+
* names, so it refuses the output there and says which param it was.
|
|
139
|
+
*/
|
|
140
|
+
describe('TaskGraphActionRunner: outputs must be storable', () => {
|
|
141
|
+
beforeEach(() => vi.mocked(ActionEngineServer.Instance.RunAction).mockReset());
|
|
142
|
+
|
|
143
|
+
it('fails the step, naming the action and the param, when an output param cannot be serialized', async () => {
|
|
144
|
+
const cyclic: Record<string, unknown> = { kind: 'OperatorSubscriber' };
|
|
145
|
+
cyclic.self = cyclic;
|
|
146
|
+
vi.mocked(ActionEngineServer.Instance.RunAction).mockResolvedValue({
|
|
147
|
+
Success: true,
|
|
148
|
+
Message: 'started',
|
|
149
|
+
Params: [{ Name: 'AgentRunID', Type: 'Output', Value: 'RUN-1' }, { Name: 'AgentResult', Type: 'Output', Value: cyclic }],
|
|
150
|
+
LogEntry: { ID: 'LOG-1' },
|
|
151
|
+
} as never);
|
|
152
|
+
|
|
153
|
+
const result = await new TaskGraphActionRunner().RunActionForTask(baseRunParams({}));
|
|
154
|
+
|
|
155
|
+
expect(result.Success).toBe(false);
|
|
156
|
+
expect(result.ErrorMessage).toMatch(/Log Activity.*AgentResult.*cannot be stored/i);
|
|
157
|
+
expect(result.ActionLogID).toBe('LOG-1');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('passes plain outputs through unchanged', async () => {
|
|
161
|
+
vi.mocked(ActionEngineServer.Instance.RunAction).mockResolvedValue({
|
|
162
|
+
Success: true,
|
|
163
|
+
Message: 'ok',
|
|
164
|
+
Params: [{ Name: 'AgentRunID', Type: 'Output', Value: 'RUN-1' }],
|
|
165
|
+
LogEntry: { ID: 'LOG-2' },
|
|
166
|
+
} as never);
|
|
167
|
+
|
|
168
|
+
const result = await new TaskGraphActionRunner().RunActionForTask(baseRunParams({}));
|
|
169
|
+
|
|
170
|
+
expect(result).toMatchObject({ Success: true, Output: { AgentRunID: 'RUN-1' }, ActionLogID: 'LOG-2' });
|
|
171
|
+
});
|
|
172
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// TaskGraphProviderFactory imports config.ts (which validates DB env at module load); UserCache needs a live
|
|
4
|
+
// provider. Both are replaced; the tests pass explicit dependencies.
|
|
5
|
+
vi.mock('../config.js', () => ({ configInfo: {}, mj_core_schema: '__mj' }));
|
|
6
|
+
vi.mock('@memberjunction/generic-database-provider', async (importOriginal) => ({
|
|
7
|
+
...(await importOriginal<typeof import('@memberjunction/generic-database-provider')>()),
|
|
8
|
+
UserCache: { get Users() { return []; } },
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
import type { DatabaseProviderBase, IMetadataProvider, UserInfo } from '@memberjunction/core';
|
|
12
|
+
import type { WorkQueueEngine, WorkQueueHostConfig, WorkQueueProviderSource } from '@memberjunction/work-queue-engine';
|
|
13
|
+
import { WorkQueueSchema } from '../services/workQueueConfig.js';
|
|
14
|
+
import type { WorkQueueConfig } from '../services/workQueueConfig.js';
|
|
15
|
+
import {
|
|
16
|
+
BuildWorkQueueHostConfig, CreateWorkQueueInstanceID, IsWorkQueueHostDisabledByEnv, MJServerWorkQueueProviderSource,
|
|
17
|
+
StartWorkQueueHost, WORK_QUEUE_DISABLE_ENV,
|
|
18
|
+
} from '../services/WorkQueueHostService.js';
|
|
19
|
+
import type { WorkQueueHostStartDependencies } from '../services/WorkQueueHostService.js';
|
|
20
|
+
|
|
21
|
+
const PROVIDER = { Name: 'server-provider' } as unknown as DatabaseProviderBase;
|
|
22
|
+
const SYSTEM_USER = { ID: 'AAAAAAAA-1111-4111-8111-000000000001', Email: 'system@memberjunction.org' } as UserInfo;
|
|
23
|
+
const ENGINE = { Name: 'engine' } as unknown as WorkQueueEngine;
|
|
24
|
+
const PROVIDER_SOURCE: WorkQueueProviderSource = { CreateProvider: async (): Promise<IMetadataProvider> => PROVIDER };
|
|
25
|
+
|
|
26
|
+
interface Recorder {
|
|
27
|
+
Dependencies: WorkQueueHostStartDependencies;
|
|
28
|
+
Started: WorkQueueHostConfig[];
|
|
29
|
+
EngineCalls: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function recorder(users: UserInfo[] = [SYSTEM_USER], env: Record<string, string | undefined> = {}): Recorder {
|
|
33
|
+
const result: Recorder = {
|
|
34
|
+
Started: [],
|
|
35
|
+
EngineCalls: 0,
|
|
36
|
+
Dependencies: {
|
|
37
|
+
Env: env,
|
|
38
|
+
FindUserByEmail: email => users.find(user => user.Email === email),
|
|
39
|
+
ConfigureEngine: async (user, provider) => {
|
|
40
|
+
expect(user).toBe(SYSTEM_USER);
|
|
41
|
+
expect(provider).toBe(PROVIDER);
|
|
42
|
+
result.EngineCalls++;
|
|
43
|
+
return ENGINE;
|
|
44
|
+
},
|
|
45
|
+
CreateHost: (config, engine, user, provider, source) => {
|
|
46
|
+
expect([engine, user, provider, source]).toEqual([ENGINE, SYSTEM_USER, PROVIDER, PROVIDER_SOURCE]);
|
|
47
|
+
return { Start: async () => { result.Started.push(config); } };
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function enabled(overrides: Partial<WorkQueueConfig> = {}): WorkQueueConfig {
|
|
55
|
+
return { ...WorkQueueSchema.parse({ enabled: true }), ...overrides };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('workQueue configuration', () => {
|
|
59
|
+
it('defaults to disabled, running every MJWorker subscription once enabled', () => {
|
|
60
|
+
expect(WorkQueueSchema.parse({})).toEqual({
|
|
61
|
+
enabled: false, systemUserEmail: 'system@memberjunction.org', subscriptions: [{ name: '*', concurrency: 4 }],
|
|
62
|
+
idlePollMinMs: 250, idlePollMaxMs: 5000, shutdownDrainMs: 8000, sweeperEnabled: true, sweeperIntervalMs: 60000,
|
|
63
|
+
reconcileIntervalMs: 30000,
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('rejects an idle poll maximum below the minimum', () => {
|
|
68
|
+
expect(WorkQueueSchema.safeParse({ idlePollMinMs: 1000, idlePollMaxMs: 500 }).success).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('host configuration helpers', () => {
|
|
73
|
+
it('maps the section to a host config and disables the sweeper when asked', () => {
|
|
74
|
+
const config = enabled({ subscriptions: [{ name: 'email.unsubscribe', concurrency: 8 }], sweeperEnabled: false });
|
|
75
|
+
expect(BuildWorkQueueHostConfig(config, 'api-1')).toEqual({
|
|
76
|
+
InstanceID: 'api-1', Subscriptions: [{ Name: 'email.unsubscribe', Concurrency: 8 }], IdlePollMinMs: 250,
|
|
77
|
+
IdlePollMaxMs: 5000, ShutdownDrainMs: 8000, SweeperIntervalMs: 0, ReconcileIntervalMs: 30000,
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('builds a unique instance ID from host, pid and random entropy', () => {
|
|
82
|
+
const first = CreateWorkQueueInstanceID({ HOSTNAME: 'api-7' });
|
|
83
|
+
expect(first).toMatch(new RegExp(`^api-7-${process.pid}-[0-9a-f]{8}$`));
|
|
84
|
+
expect(CreateWorkQueueInstanceID({ HOSTNAME: 'api-7' })).not.toBe(first);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it(`treats only ${WORK_QUEUE_DISABLE_ENV}=1 as the kill switch`, () => {
|
|
88
|
+
expect(IsWorkQueueHostDisabledByEnv({ [WORK_QUEUE_DISABLE_ENV]: '1' })).toBe(true);
|
|
89
|
+
expect(IsWorkQueueHostDisabledByEnv({ [WORK_QUEUE_DISABLE_ENV]: 'true' })).toBe(false);
|
|
90
|
+
expect(IsWorkQueueHostDisabledByEnv({})).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('StartWorkQueueHost', () => {
|
|
95
|
+
it('starts nothing when the section is disabled', async () => {
|
|
96
|
+
const rec = recorder();
|
|
97
|
+
expect(await StartWorkQueueHost(WorkQueueSchema.parse({}), PROVIDER, PROVIDER_SOURCE, rec.Dependencies)).toBeNull();
|
|
98
|
+
expect(rec.EngineCalls).toBe(0);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('starts nothing when the kill switch is set', async () => {
|
|
102
|
+
const rec = recorder([SYSTEM_USER], { [WORK_QUEUE_DISABLE_ENV]: '1' });
|
|
103
|
+
expect(await StartWorkQueueHost(enabled(), PROVIDER, PROVIDER_SOURCE, rec.Dependencies)).toBeNull();
|
|
104
|
+
expect(rec.EngineCalls).toBe(0);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('refuses to start without the system user', async () => {
|
|
108
|
+
const rec = recorder([]);
|
|
109
|
+
await expect(StartWorkQueueHost(enabled(), PROVIDER, PROVIDER_SOURCE, rec.Dependencies)).rejects.toThrow('System user not found with email: system@memberjunction.org');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('names the setting to fix when the placeholder email from DEFAULT_SERVER_CONFIG is still in effect', async () => {
|
|
113
|
+
const rec = recorder();
|
|
114
|
+
await expect(StartWorkQueueHost(enabled({ systemUserEmail: 'not.set@nowhere.com' }), PROVIDER, PROVIDER_SOURCE, rec.Dependencies))
|
|
115
|
+
.rejects.toThrow('set workQueue.systemUserEmail');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('configures the engine, then creates and starts the host', async () => {
|
|
119
|
+
const rec = recorder();
|
|
120
|
+
const host = await StartWorkQueueHost(enabled(), PROVIDER, PROVIDER_SOURCE, rec.Dependencies);
|
|
121
|
+
expect(host).not.toBeNull();
|
|
122
|
+
expect(rec.EngineCalls).toBe(1);
|
|
123
|
+
expect(rec.Started).toHaveLength(1);
|
|
124
|
+
expect(rec.Started[0]).toMatchObject({ Subscriptions: [{ Name: '*', Concurrency: 4 }], SweeperIntervalMs: 60000 });
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('MJServerWorkQueueProviderSource', () => {
|
|
129
|
+
it('falls back to the shared server provider when there is no SQL Server pool', async () => {
|
|
130
|
+
const source = new MJServerWorkQueueProviderSource(null, PROVIDER);
|
|
131
|
+
expect(await source.CreateProvider()).toBe(PROVIDER);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An ad-hoc query's timeout never exceeds the server's requestTimeout, so a caller cannot hold a
|
|
3
|
+
* read-only connection longer than any other request may.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, it, expect } from 'vitest';
|
|
6
|
+
import { ClampAdhocTimeoutSeconds } from '../resolvers/adhocTimeout';
|
|
7
|
+
|
|
8
|
+
describe('ClampAdhocTimeoutSeconds', () => {
|
|
9
|
+
it('caps a longer request at requestTimeout', () => {
|
|
10
|
+
expect(ClampAdhocTimeoutSeconds(600, 30000)).toBe(30);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('keeps a shorter request', () => {
|
|
14
|
+
expect(ClampAdhocTimeoutSeconds(5, 30000)).toBe(5);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('uses 30 seconds when the caller names none, within the cap', () => {
|
|
18
|
+
expect(ClampAdhocTimeoutSeconds(undefined, 30000)).toBe(30);
|
|
19
|
+
expect(ClampAdhocTimeoutSeconds(undefined, 10000)).toBe(10);
|
|
20
|
+
expect(ClampAdhocTimeoutSeconds(0, 30000)).toBe(30);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('leaves the request alone when the server sets no limit', () => {
|
|
24
|
+
expect(ClampAdhocTimeoutSeconds(600, 0)).toBe(600);
|
|
25
|
+
});
|
|
26
|
+
});
|