@memberjunction/server 6.1.2 → 6.2.0-edge.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.tsbuildinfo +1 -1
- package/dist/auth/exampleNewUserSubClass.js +2 -1
- package/dist/auth/exampleNewUserSubClass.js.map +1 -1
- package/dist/generated/graphql-schemas/__mj.d.ts +255 -4
- package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
- package/dist/generated/graphql-schemas/__mj.js +1256 -0
- package/dist/generated/graphql-schemas/__mj.js.map +1 -1
- package/dist/generic/ResolverBase.d.ts +26 -0
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +59 -1
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -1
- package/dist/resolvers/FetchEntityVectorsResolver.js +2 -2
- package/dist/resolvers/FetchEntityVectorsResolver.js.map +1 -1
- package/dist/resolvers/QuerySystemUserResolver.d.ts.map +1 -1
- package/dist/resolvers/QuerySystemUserResolver.js +2 -1
- package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts +37 -0
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.js +98 -8
- package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +5 -4
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/dist/resolvers/SearchEntitiesResolver.d.ts.map +1 -1
- package/dist/resolvers/SearchEntitiesResolver.js +3 -1
- package/dist/resolvers/SearchEntitiesResolver.js.map +1 -1
- package/dist/resolvers/SearchKnowledgeResolver.d.ts +1 -0
- package/dist/resolvers/SearchKnowledgeResolver.d.ts.map +1 -1
- package/dist/resolvers/SearchKnowledgeResolver.js +5 -0
- package/dist/resolvers/SearchKnowledgeResolver.js.map +1 -1
- package/dist/resolvers/SearchKnowledgeSystemUserResolver.d.ts.map +1 -1
- package/dist/resolvers/SearchKnowledgeSystemUserResolver.js +1 -0
- package/dist/resolvers/SearchKnowledgeSystemUserResolver.js.map +1 -1
- package/dist/resolvers/TransactionGroupResolver.d.ts.map +1 -1
- package/dist/resolvers/TransactionGroupResolver.js +53 -4
- package/dist/resolvers/TransactionGroupResolver.js.map +1 -1
- package/dist/services/MJStorageBlobStore.d.ts +41 -0
- package/dist/services/MJStorageBlobStore.d.ts.map +1 -0
- package/dist/services/MJStorageBlobStore.js +160 -0
- package/dist/services/MJStorageBlobStore.js.map +1 -0
- package/dist/util.d.ts +6 -3
- package/dist/util.d.ts.map +1 -1
- package/dist/util.js +6 -3
- package/dist/util.js.map +1 -1
- package/package.json +92 -92
- package/src/__tests__/RealtimeClientSessionResolver.test.ts +229 -0
- package/src/__tests__/ResolverBase.isaPromotionCreate.test.ts +412 -0
- package/src/__tests__/SearchEntitiesResolver.readOnlyFallback.test.ts +78 -0
- package/src/__tests__/TransactionGroupResolver.refusals.test.ts +290 -0
- package/src/auth/exampleNewUserSubClass.ts +2 -2
- package/src/generated/graphql-schemas/__mj.ts +896 -5
- package/src/generic/ResolverBase.ts +61 -0
- package/src/index.ts +14 -1
- package/src/resolvers/FetchEntityVectorsResolver.ts +2 -2
- package/src/resolvers/QuerySystemUserResolver.ts +2 -1
- package/src/resolvers/RealtimeClientSessionResolver.ts +123 -10
- package/src/resolvers/RunAIAgentResolver.ts +5 -4
- package/src/resolvers/SearchEntitiesResolver.ts +3 -1
- package/src/resolvers/SearchKnowledgeResolver.ts +4 -0
- package/src/resolvers/SearchKnowledgeSystemUserResolver.ts +1 -0
- package/src/resolvers/TransactionGroupResolver.ts +57 -4
- package/src/services/MJStorageBlobStore.ts +209 -0
- package/src/util.ts +6 -3
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// type-graphql decorators on the resolver need the Reflect.metadata polyfill at import time.
|
|
2
|
+
import 'reflect-metadata';
|
|
3
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
4
|
+
import type { DatabaseProviderBase, EntitySearchResult, SearchEntityParams } from '@memberjunction/core';
|
|
5
|
+
import { SearchEntitiesResolver } from '../resolvers/SearchEntitiesResolver.js';
|
|
6
|
+
import type { AppContext, ProviderInfo } from '../types.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `SearchEntities` must run on a server that has no read-only database login.
|
|
10
|
+
*
|
|
11
|
+
* The read-only pool exists only when `dbReadOnlyUsername`/`dbReadOnlyPassword` are configured,
|
|
12
|
+
* and most deployments leave them unset. The resolver asked for the read-only provider without
|
|
13
|
+
* allowing a fallback, got `null`, and threw inside its own try/catch — so every client-side
|
|
14
|
+
* `SearchEntity`/`SearchEntities` call over GraphQL came back `Success: false` with no results.
|
|
15
|
+
* Nothing surfaced it: the client maps a failed batch to empty groups, which looks exactly like
|
|
16
|
+
* "no matches". Found by IT52 (SR2) during the 6.2.0-edge.0 release gate.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const USER_EMAIL = 'searcher@test';
|
|
20
|
+
|
|
21
|
+
vi.mock('@memberjunction/generic-database-provider', () => ({
|
|
22
|
+
UserCache: {
|
|
23
|
+
Instance: { get Users() { return [{ ID: 'u-1', Email: USER_EMAIL }]; } },
|
|
24
|
+
},
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
const HIT: EntitySearchResult = {
|
|
28
|
+
entityRecordDocumentId: null,
|
|
29
|
+
recordId: 'E1238F34-0000-0000-0000-000000000001',
|
|
30
|
+
score: 1,
|
|
31
|
+
matchType: 'lexical',
|
|
32
|
+
components: { lexical: 1 },
|
|
33
|
+
} as EntitySearchResult;
|
|
34
|
+
|
|
35
|
+
/** A database provider whose search returns one hit per requested entity. */
|
|
36
|
+
function searchingProvider(): DatabaseProviderBase & { SearchEntities: ReturnType<typeof vi.fn> } {
|
|
37
|
+
const search = vi.fn(async (params: SearchEntityParams[]) => params.map(() => [HIT]));
|
|
38
|
+
return { SearchEntities: search } as unknown as DatabaseProviderBase & { SearchEntities: typeof search };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function contextWith(providers: ProviderInfo[]): AppContext {
|
|
42
|
+
return { providers, userPayload: { email: USER_EMAIL } } as unknown as AppContext;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const PARAMS = [{ EntityName: 'MJ: Entities', SearchText: 'MJ: Users', Mode: 'lexical' }];
|
|
46
|
+
|
|
47
|
+
describe('SearchEntitiesResolver — provider selection', () => {
|
|
48
|
+
it('searches on the read-write provider when no read-only pool is configured', async () => {
|
|
49
|
+
const readWrite = searchingProvider();
|
|
50
|
+
|
|
51
|
+
const result = await new SearchEntitiesResolver().SearchEntities(
|
|
52
|
+
contextWith([{ provider: readWrite, type: 'Read-Write' }]),
|
|
53
|
+
PARAMS as never,
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
expect(result.ErrorMessage).toBeUndefined();
|
|
57
|
+
expect(result.Success).toBe(true);
|
|
58
|
+
expect(readWrite.SearchEntities).toHaveBeenCalledTimes(1);
|
|
59
|
+
expect(result.Groups[0].Results.map(r => r.RecordID)).toEqual(['E1238F34-0000-0000-0000-000000000001']);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('still prefers the read-only provider when one is configured', async () => {
|
|
63
|
+
const readWrite = searchingProvider();
|
|
64
|
+
const readOnly = searchingProvider();
|
|
65
|
+
|
|
66
|
+
const result = await new SearchEntitiesResolver().SearchEntities(
|
|
67
|
+
contextWith([
|
|
68
|
+
{ provider: readWrite, type: 'Read-Write' },
|
|
69
|
+
{ provider: readOnly, type: 'Read-Only' },
|
|
70
|
+
]),
|
|
71
|
+
PARAMS as never,
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
expect(result.Success).toBe(true);
|
|
75
|
+
expect(readOnly.SearchEntities).toHaveBeenCalledTimes(1);
|
|
76
|
+
expect(readWrite.SearchEntities).not.toHaveBeenCalled();
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A transaction group whose rows are refused server-side must not report success (issue #4309).
|
|
3
|
+
*
|
|
4
|
+
* `BaseEntity.Save()` and `Delete()` do NOT throw on a logical refusal — they return `false`, and
|
|
5
|
+
* the row is never enrolled, because `TransactionGroup.AddTransaction(...)` is called only from
|
|
6
|
+
* inside `ProviderToUse.Save()`/`Delete()`, which a refusal never reaches. `ExecuteTransactionGroup`
|
|
7
|
+
* discarded that boolean, so:
|
|
8
|
+
*
|
|
9
|
+
* - when EVERY row was refused, the server's group was empty, `TransactionGroupBase.Submit()`
|
|
10
|
+
* took its legitimate "nothing to do" branch and returned `true`, and the resolver reported
|
|
11
|
+
* `Success: true` while serialising each entity's never-persisted in-memory state into
|
|
12
|
+
* `ResultsJSON`;
|
|
13
|
+
* - when only SOME rows were refused, the survivors committed and the caller still saw
|
|
14
|
+
* unqualified success.
|
|
15
|
+
*
|
|
16
|
+
* Reproduced live against MJAPI before this fix: an `ExecuteTransactionGroup` whose two
|
|
17
|
+
* `MJ: User Roles` creates both omitted the NOT NULL `RoleID` returned `Success: true` with zero
|
|
18
|
+
* rows written, and `ErrorMessages` already carrying `"Role ID cannot be null"`.
|
|
19
|
+
*
|
|
20
|
+
* WHY THE PREDICATE IS THE BOOLEAN AND NOT "IS THE GROUP EMPTY". A clean row that is not dirty
|
|
21
|
+
* also fails to enrol, and `Save()` returns `true` for it — correctly, since there is nothing to
|
|
22
|
+
* write. Testing the group's size would turn that legitimate no-op into a failure; testing the
|
|
23
|
+
* return value distinguishes "refused" from "nothing to do". The last test pins this.
|
|
24
|
+
*
|
|
25
|
+
* WHY THE RESOLVER RATHER THAN `Submit()`. An empty group genuinely means "nothing to do" for a
|
|
26
|
+
* caller that enrolled nothing — `transaction-groups.TG1` pins that. The resolver is the only
|
|
27
|
+
* layer that still knows WHICH row was refused and why.
|
|
28
|
+
*/
|
|
29
|
+
import 'reflect-metadata';
|
|
30
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
31
|
+
import { BaseEntityResult } from '@memberjunction/core';
|
|
32
|
+
|
|
33
|
+
const logErrorCalls: string[] = [];
|
|
34
|
+
vi.mock('@memberjunction/core', async (importOriginal) => {
|
|
35
|
+
const actual = await importOriginal<typeof import('@memberjunction/core')>();
|
|
36
|
+
return { ...actual, LogError: (msg: unknown) => { logErrorCalls.push(String(msg)); } };
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
import {
|
|
40
|
+
TransactionResolver,
|
|
41
|
+
TransactionInputType,
|
|
42
|
+
TransactionItemInputType,
|
|
43
|
+
TransactionOperationType,
|
|
44
|
+
} from '../resolvers/TransactionGroupResolver.js';
|
|
45
|
+
import type { AppContext } from '../types.js';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A transaction group that records enrolment the way the real one does: `AddTransaction` is
|
|
49
|
+
* reachable only through a provider Save/Delete, so an entity that refuses never appears here, and
|
|
50
|
+
* `Submit()` reproduces `TransactionGroupBase`'s empty-group branch verbatim.
|
|
51
|
+
*/
|
|
52
|
+
class FakeTransactionGroup {
|
|
53
|
+
public Enrolled: string[] = [];
|
|
54
|
+
public SubmitCalled = false;
|
|
55
|
+
public SubmitResult = true;
|
|
56
|
+
|
|
57
|
+
public AddTransaction(label: string): void {
|
|
58
|
+
this.Enrolled.push(label);
|
|
59
|
+
}
|
|
60
|
+
public AddVariable(): void { /* no variables in these tests */ }
|
|
61
|
+
public async Submit(): Promise<boolean> {
|
|
62
|
+
this.SubmitCalled = true;
|
|
63
|
+
// TransactionGroupBase.Submit(): an empty group is "nothing to do" and returns true.
|
|
64
|
+
return this.Enrolled.length === 0 ? true : this.SubmitResult;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* What a refusal leaves on `LatestResult` — the shape `MJUserRoleEntityServer.refuse()` registers.
|
|
70
|
+
* A REAL `BaseEntityResult`, so `CompleteMessage` (a prototype getter that folds `Message`, `Error`
|
|
71
|
+
* and `Errors` together) behaves as it does in production rather than as the test wishes.
|
|
72
|
+
*/
|
|
73
|
+
function refusalResult(message: string): BaseEntityResult {
|
|
74
|
+
const result = new BaseEntityResult();
|
|
75
|
+
result.Success = false;
|
|
76
|
+
result.Type = 'create';
|
|
77
|
+
result.Message = message;
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function successResult(): BaseEntityResult {
|
|
82
|
+
const result = new BaseEntityResult();
|
|
83
|
+
result.Success = true;
|
|
84
|
+
result.Type = 'create';
|
|
85
|
+
return result;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
interface FakeEntityOptions {
|
|
89
|
+
/** `false` reproduces a server-side refusal: `Save()`/`Delete()` return false, nothing enrols. */
|
|
90
|
+
accepts: boolean;
|
|
91
|
+
/** True for the not-dirty case: accepted, but nothing to write, so nothing enrols either. */
|
|
92
|
+
clean?: boolean;
|
|
93
|
+
refusalMessage?: string;
|
|
94
|
+
/**
|
|
95
|
+
* What `InnerLoad()` puts on the record, mirroring a real load of an existing row. A `Delete`
|
|
96
|
+
* reports the row it removed, so without this the fake has nothing to report and a test of
|
|
97
|
+
* `ResultsJSON` could not tell an empty payload from a correct one.
|
|
98
|
+
*/
|
|
99
|
+
loadedValues?: Record<string, unknown>;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The minimum of `BaseEntity` the resolver touches. `Save()`/`Delete()` enrol into the group only
|
|
104
|
+
* when they accept AND there is something to write — exactly where `ProviderToUse.Save()` calls
|
|
105
|
+
* `TransactionGroup.AddTransaction(...)`.
|
|
106
|
+
*/
|
|
107
|
+
class FakeEntity {
|
|
108
|
+
public TransactionGroup: FakeTransactionGroup | null = null;
|
|
109
|
+
public LatestResult: BaseEntityResult | null = null;
|
|
110
|
+
public PrimaryKeys = [{ Name: 'ID' }];
|
|
111
|
+
private values: Record<string, unknown> = {};
|
|
112
|
+
|
|
113
|
+
constructor(private readonly entityName: string, private readonly options: FakeEntityOptions) {}
|
|
114
|
+
|
|
115
|
+
public SetMany(values: Record<string, unknown>): void {
|
|
116
|
+
this.values = { ...this.values, ...values };
|
|
117
|
+
}
|
|
118
|
+
public async InnerLoad(): Promise<boolean> {
|
|
119
|
+
if (this.options.loadedValues) {
|
|
120
|
+
this.values = { ...this.options.loadedValues };
|
|
121
|
+
}
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* `async`, because the REAL one is: `BaseEntity.GetDataObject` returns `Promise<any>`
|
|
126
|
+
* (`baseEntity.ts:3657`). This fake used to be synchronous, and that divergence is precisely
|
|
127
|
+
* why this suite could not see that `ExecuteTransactionGroup` was calling it without `await` —
|
|
128
|
+
* against a sync fake the resolver stored a plain object and `JSON.stringify` produced real
|
|
129
|
+
* data that production never produced. A fake that is more correct than production hides the
|
|
130
|
+
* bug it exists to catch.
|
|
131
|
+
*/
|
|
132
|
+
public async GetDataObject(): Promise<Record<string, unknown>> { return { ...this.values }; }
|
|
133
|
+
public async GetDataObjectJSON(): Promise<string> { return JSON.stringify(this.values); }
|
|
134
|
+
|
|
135
|
+
public async Save(): Promise<boolean> { return this.write(); }
|
|
136
|
+
public async Delete(): Promise<boolean> { return this.write(); }
|
|
137
|
+
|
|
138
|
+
private write(): boolean {
|
|
139
|
+
if (!this.options.accepts) {
|
|
140
|
+
this.LatestResult = refusalResult(this.options.refusalMessage ?? 'refused by a server-side guard');
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
if (!this.options.clean) {
|
|
144
|
+
this.TransactionGroup?.AddTransaction(this.entityName);
|
|
145
|
+
}
|
|
146
|
+
this.LatestResult = successResult();
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function buildContext(entities: FakeEntity[]): { context: AppContext; group: FakeTransactionGroup } {
|
|
152
|
+
const group = new FakeTransactionGroup();
|
|
153
|
+
let next = 0;
|
|
154
|
+
const provider = {
|
|
155
|
+
CreateTransactionGroup: async () => group,
|
|
156
|
+
GetEntityObject: async () => entities[next++],
|
|
157
|
+
};
|
|
158
|
+
const context = {
|
|
159
|
+
providers: [{ type: 'Read-Write', provider }],
|
|
160
|
+
userPayload: { userRecord: { Email: 'tester@example.test' } },
|
|
161
|
+
} as unknown as AppContext;
|
|
162
|
+
return { context, group };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function buildGroupInput(
|
|
166
|
+
count: number,
|
|
167
|
+
operationType: TransactionOperationType = TransactionOperationType.Create
|
|
168
|
+
): TransactionInputType {
|
|
169
|
+
const items: TransactionItemInputType[] = Array.from({ length: count }, () => ({
|
|
170
|
+
EntityName: 'MJ: User Roles',
|
|
171
|
+
EntityObjectJSON: JSON.stringify({ ID: 'a0000000-0000-0000-0000-000000000001', UserID: 'u1' }),
|
|
172
|
+
OperationType: operationType,
|
|
173
|
+
}));
|
|
174
|
+
return { Items: items, Variables: [] };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
describe('ExecuteTransactionGroup — rows refused server-side (issue #4309)', () => {
|
|
178
|
+
beforeEach(() => { logErrorCalls.length = 0; });
|
|
179
|
+
|
|
180
|
+
it('reports failure when EVERY row is refused, instead of the empty group reading as success', async () => {
|
|
181
|
+
const entities = [
|
|
182
|
+
new FakeEntity('MJ: User Roles', { accepts: false, refusalMessage: 'Role ID cannot be null' }),
|
|
183
|
+
new FakeEntity('MJ: User Roles', { accepts: false, refusalMessage: 'Role ID cannot be null' }),
|
|
184
|
+
];
|
|
185
|
+
const { context, group } = buildContext(entities);
|
|
186
|
+
|
|
187
|
+
const result = await new TransactionResolver().ExecuteTransactionGroup(buildGroupInput(2), context);
|
|
188
|
+
|
|
189
|
+
expect(result.Success).toBe(false);
|
|
190
|
+
expect(group.Enrolled).toHaveLength(0);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('carries each refusal reason back in ErrorMessages, index-aligned with Items', async () => {
|
|
194
|
+
const entities = [
|
|
195
|
+
new FakeEntity('MJ: User Roles', { accepts: false, refusalMessage: 'You may only assign a role that you hold yourself' }),
|
|
196
|
+
];
|
|
197
|
+
const { context } = buildContext(entities);
|
|
198
|
+
|
|
199
|
+
const result = await new TransactionResolver().ExecuteTransactionGroup(buildGroupInput(1), context);
|
|
200
|
+
|
|
201
|
+
expect(result.ErrorMessages[0]).toContain('You may only assign a role that you hold yourself');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('fails a PARTIALLY refused group without submitting, so the surviving rows never commit', async () => {
|
|
205
|
+
const entities = [
|
|
206
|
+
new FakeEntity('MJ: User Roles', { accepts: true }),
|
|
207
|
+
new FakeEntity('MJ: User Roles', { accepts: false, refusalMessage: 'Name cannot be null' }),
|
|
208
|
+
];
|
|
209
|
+
const { context, group } = buildContext(entities);
|
|
210
|
+
|
|
211
|
+
const result = await new TransactionResolver().ExecuteTransactionGroup(buildGroupInput(2), context);
|
|
212
|
+
|
|
213
|
+
expect(result.Success).toBe(false);
|
|
214
|
+
expect(group.SubmitCalled).toBe(false);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('reports failure when a Delete is refused', async () => {
|
|
218
|
+
const entities = [
|
|
219
|
+
new FakeEntity('MJ: User Roles', { accepts: false, refusalMessage: 'You may only revoke a role that you hold yourself' }),
|
|
220
|
+
];
|
|
221
|
+
const { context } = buildContext(entities);
|
|
222
|
+
|
|
223
|
+
const result = await new TransactionResolver().ExecuteTransactionGroup(
|
|
224
|
+
buildGroupInput(1, TransactionOperationType.Delete), context
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
expect(result.Success).toBe(false);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('logs the refusal, naming the entity and the reason — it was silent in the server log too', async () => {
|
|
231
|
+
const entities = [
|
|
232
|
+
new FakeEntity('MJ: User Roles', { accepts: false, refusalMessage: 'Role ID cannot be null' }),
|
|
233
|
+
];
|
|
234
|
+
const { context } = buildContext(entities);
|
|
235
|
+
|
|
236
|
+
await new TransactionResolver().ExecuteTransactionGroup(buildGroupInput(1), context);
|
|
237
|
+
|
|
238
|
+
expect(logErrorCalls.join('\n')).toContain('MJ: User Roles');
|
|
239
|
+
expect(logErrorCalls.join('\n')).toContain('Role ID cannot be null');
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('still reports success when every row is accepted', async () => {
|
|
243
|
+
const entities = [
|
|
244
|
+
new FakeEntity('MJ: User Roles', { accepts: true }),
|
|
245
|
+
new FakeEntity('MJ: User Roles', { accepts: true }),
|
|
246
|
+
];
|
|
247
|
+
const { context, group } = buildContext(entities);
|
|
248
|
+
|
|
249
|
+
const result = await new TransactionResolver().ExecuteTransactionGroup(buildGroupInput(2), context);
|
|
250
|
+
|
|
251
|
+
expect(result.Success).toBe(true);
|
|
252
|
+
expect(group.SubmitCalled).toBe(true);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("returns the deleted row's data in ResultsJSON, not an empty object", async () => {
|
|
256
|
+
// `GetDataObject()` is async, and ExecuteTransactionGroup pushed it WITHOUT awaiting, so
|
|
257
|
+
// PrepareReturnValue's Delete branch stringified a Promise. `JSON.stringify(Promise)` is
|
|
258
|
+
// "{}" — no throw, no type error (the array is `any[]`), just a well-formed empty payload
|
|
259
|
+
// on every Delete a transaction group performs, successful ones included.
|
|
260
|
+
//
|
|
261
|
+
// It is not only a lost payload: GraphQLDataProvider's own Delete transaction callback
|
|
262
|
+
// validates the commit with `pk.Value !== results[pk.FieldName]`, so an empty object makes
|
|
263
|
+
// every key mismatch and reports 'Transaction failed to commit' for a delete that DID
|
|
264
|
+
// commit.
|
|
265
|
+
const deletedRow = { ID: 'a0000000-0000-0000-0000-000000000001', UserID: 'u1', RoleID: 'r1' };
|
|
266
|
+
const entities = [new FakeEntity('MJ: User Roles', { accepts: true, loadedValues: deletedRow })];
|
|
267
|
+
const { context } = buildContext(entities);
|
|
268
|
+
|
|
269
|
+
const result = await new TransactionResolver().ExecuteTransactionGroup(
|
|
270
|
+
buildGroupInput(1, TransactionOperationType.Delete), context
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
expect(result.Success).toBe(true);
|
|
274
|
+
expect(JSON.parse(result.ResultsJSON[0])).toEqual(deletedRow);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('still reports success when accepted rows were CLEAN — an empty group is not itself a refusal', async () => {
|
|
278
|
+
// A not-dirty row returns true from Save() without enrolling. Testing the group's size
|
|
279
|
+
// rather than the return value would turn this legitimate no-op into a failure.
|
|
280
|
+
const entities = [
|
|
281
|
+
new FakeEntity('MJ: User Roles', { accepts: true, clean: true }),
|
|
282
|
+
];
|
|
283
|
+
const { context, group } = buildContext(entities);
|
|
284
|
+
|
|
285
|
+
const result = await new TransactionResolver().ExecuteTransactionGroup(buildGroupInput(1), context);
|
|
286
|
+
|
|
287
|
+
expect(result.Success).toBe(true);
|
|
288
|
+
expect(group.Enrolled).toHaveLength(0);
|
|
289
|
+
});
|
|
290
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { RegisterClass } from '@memberjunction/global';
|
|
1
|
+
import { EscapeSQLString, RegisterClass } from '@memberjunction/global';
|
|
2
2
|
import { Metadata, RunView, LogError, EntitySaveOptions } from '@memberjunction/core';
|
|
3
3
|
import { NewUserBase } from './newUsers.js';
|
|
4
4
|
import { ResolveConfiguredPrincipal } from './principals.js';
|
|
@@ -43,7 +43,7 @@ export class ExampleNewUserSubClass extends NewUserBase {
|
|
|
43
43
|
const viewResults = await rv.RunView(
|
|
44
44
|
{
|
|
45
45
|
EntityName: 'Persons',
|
|
46
|
-
ExtraFilter: `Email = '${email}'`,
|
|
46
|
+
ExtraFilter: `Email = '${EscapeSQLString(email)}'`,
|
|
47
47
|
},
|
|
48
48
|
contextUser
|
|
49
49
|
);
|