@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
|
@@ -3196,3 +3196,232 @@ describe('RealtimeClientSessionResolver — colleague authorization (allowedAgen
|
|
|
3196
3196
|
expect(hasPermissionMock).not.toHaveBeenCalled();
|
|
3197
3197
|
});
|
|
3198
3198
|
});
|
|
3199
|
+
|
|
3200
|
+
/**
|
|
3201
|
+
* The other half of the voice/text context flow.
|
|
3202
|
+
*
|
|
3203
|
+
* Voice turns were always persisted as `MJ: Conversation Details`, so anything SAID reached a later
|
|
3204
|
+
* typed turn. Nothing TYPED reached a voice session: `ConversationMessages` was a hardcoded `[]`
|
|
3205
|
+
* with an MVP note. A call started mid-thread therefore opened with no idea what the thread was
|
|
3206
|
+
* about — and the symptom is not an error, it is the agent asking the user to repeat something they
|
|
3207
|
+
* just wrote.
|
|
3208
|
+
*/
|
|
3209
|
+
describe('RealtimeClientSessionResolver — conversation-history hydration', () => {
|
|
3210
|
+
/** A `MJ: Conversation Details` row as the history query reads it. */
|
|
3211
|
+
interface HistoryRow {
|
|
3212
|
+
ID: string;
|
|
3213
|
+
Role: string;
|
|
3214
|
+
Message: string | null;
|
|
3215
|
+
HiddenToUser: boolean;
|
|
3216
|
+
AgentSessionID: string | null;
|
|
3217
|
+
}
|
|
3218
|
+
|
|
3219
|
+
function row(
|
|
3220
|
+
role: 'User' | 'AI' | 'Error',
|
|
3221
|
+
message: string | null,
|
|
3222
|
+
over: Partial<HistoryRow> = {},
|
|
3223
|
+
): HistoryRow {
|
|
3224
|
+
return { ID: `h-${message ?? 'x'}`, Role: role, Message: message, HiddenToUser: false, AgentSessionID: null, ...over };
|
|
3225
|
+
}
|
|
3226
|
+
|
|
3227
|
+
/**
|
|
3228
|
+
* Provider that answers BOTH Conversation Details queries separately: the prior-leg transcript
|
|
3229
|
+
* (filtered by `AgentSessionID IN (…)`) and the conversation history (`ConversationID=…`).
|
|
3230
|
+
* Routing on the filter is what keeps the two from being confused for one another.
|
|
3231
|
+
*/
|
|
3232
|
+
function makeProvider(opts: {
|
|
3233
|
+
chain?: Record<string, { UserID: string; LastSessionID?: string | null }>;
|
|
3234
|
+
legRows?: Array<{ ID: string; Role: string; Message: string | null; HiddenToUser: boolean }>;
|
|
3235
|
+
historyRows?: HistoryRow[];
|
|
3236
|
+
historyFails?: boolean;
|
|
3237
|
+
}): { provider: unknown; runView: ReturnType<typeof vi.fn> } {
|
|
3238
|
+
const runView = vi.fn(async (params: { EntityName: string; ExtraFilter?: string }) => {
|
|
3239
|
+
if (params.EntityName === 'MJ: Conversation Details') {
|
|
3240
|
+
if (params.ExtraFilter?.startsWith('AgentSessionID IN')) {
|
|
3241
|
+
return { Success: true, Results: opts.legRows ?? [] };
|
|
3242
|
+
}
|
|
3243
|
+
return opts.historyFails
|
|
3244
|
+
? { Success: false, ErrorMessage: 'db down', Results: [] }
|
|
3245
|
+
: { Success: true, Results: opts.historyRows ?? [] };
|
|
3246
|
+
}
|
|
3247
|
+
return { Success: true, Results: [] };
|
|
3248
|
+
});
|
|
3249
|
+
const provider = {
|
|
3250
|
+
GetEntityObject: vi.fn(async () => {
|
|
3251
|
+
const entity = makeSessionEntity();
|
|
3252
|
+
entity.Load = vi.fn(async (id: string) => {
|
|
3253
|
+
const r = opts.chain?.[id];
|
|
3254
|
+
if (!r) return false;
|
|
3255
|
+
entity.ID = id;
|
|
3256
|
+
entity.UserID = r.UserID;
|
|
3257
|
+
entity.LastSessionID = r.LastSessionID ?? null;
|
|
3258
|
+
return true;
|
|
3259
|
+
});
|
|
3260
|
+
return entity;
|
|
3261
|
+
}),
|
|
3262
|
+
RunView: runView,
|
|
3263
|
+
};
|
|
3264
|
+
return { provider, runView };
|
|
3265
|
+
}
|
|
3266
|
+
|
|
3267
|
+
function historyArg(): Array<{ role: string; content: unknown }> {
|
|
3268
|
+
return (prepareClientSessionMock.mock.calls[0][0] as { ConversationMessages: Array<{ role: string; content: unknown }> })
|
|
3269
|
+
.ConversationMessages;
|
|
3270
|
+
}
|
|
3271
|
+
|
|
3272
|
+
/** A successful prepare, so the start reaches the point where the history is threaded. */
|
|
3273
|
+
const prepOk = {
|
|
3274
|
+
Success: true,
|
|
3275
|
+
ClientConfig: {
|
|
3276
|
+
Provider: 'openai',
|
|
3277
|
+
Model: 'gpt-realtime',
|
|
3278
|
+
EphemeralToken: 'ek_abc',
|
|
3279
|
+
ExpiresAt: '2026-01-01T00:00:00Z',
|
|
3280
|
+
SessionConfig: {},
|
|
3281
|
+
},
|
|
3282
|
+
};
|
|
3283
|
+
|
|
3284
|
+
beforeEach(() => {
|
|
3285
|
+
hasPermissionMock.mockResolvedValue(true);
|
|
3286
|
+
createSessionMock.mockResolvedValue(makeSessionEntity({ ID: 'session-new' }));
|
|
3287
|
+
prepareClientSessionMock.mockResolvedValue(prepOk);
|
|
3288
|
+
});
|
|
3289
|
+
|
|
3290
|
+
it('hydrates the typed conversation as role-mapped chat messages', async () => {
|
|
3291
|
+
const { provider } = makeProvider({
|
|
3292
|
+
historyRows: [row('User', 'what does my pipeline look like'), row('AI', 'eleven open deals')],
|
|
3293
|
+
});
|
|
3294
|
+
currentProvider = provider;
|
|
3295
|
+
|
|
3296
|
+
await makeResolver().StartRealtimeClientSession('target-1', makeCtx(), 'conv-1');
|
|
3297
|
+
|
|
3298
|
+
expect(historyArg()).toEqual([
|
|
3299
|
+
{ role: 'user', content: 'what does my pipeline look like' },
|
|
3300
|
+
{ role: 'assistant', content: 'eleven open deals' },
|
|
3301
|
+
]);
|
|
3302
|
+
});
|
|
3303
|
+
|
|
3304
|
+
it('scopes the read to the session\'s conversation', async () => {
|
|
3305
|
+
const { provider, runView } = makeProvider({ historyRows: [row('User', 'hi')] });
|
|
3306
|
+
currentProvider = provider;
|
|
3307
|
+
|
|
3308
|
+
await makeResolver().StartRealtimeClientSession('target-1', makeCtx(), "conv-o'brien");
|
|
3309
|
+
|
|
3310
|
+
const call = runView.mock.calls.find(
|
|
3311
|
+
(c) => (c[0] as { ExtraFilter?: string }).ExtraFilter?.startsWith('ConversationID='),
|
|
3312
|
+
);
|
|
3313
|
+
// The apostrophe must be doubled, not passed through — this string is concatenated into SQL.
|
|
3314
|
+
expect((call![0] as { ExtraFilter: string }).ExtraFilter).toBe("ConversationID='conv-o''brien'");
|
|
3315
|
+
});
|
|
3316
|
+
|
|
3317
|
+
it('skips hidden rows, error rows, and empty messages', async () => {
|
|
3318
|
+
const { provider } = makeProvider({
|
|
3319
|
+
historyRows: [
|
|
3320
|
+
row('User', 'kept'),
|
|
3321
|
+
row('User', 'system anchor', { HiddenToUser: true }),
|
|
3322
|
+
row('Error', 'the agent blew up'),
|
|
3323
|
+
row('AI', ' '),
|
|
3324
|
+
row('AI', null),
|
|
3325
|
+
],
|
|
3326
|
+
});
|
|
3327
|
+
currentProvider = provider;
|
|
3328
|
+
|
|
3329
|
+
await makeResolver().StartRealtimeClientSession('target-1', makeCtx(), 'conv-1');
|
|
3330
|
+
|
|
3331
|
+
expect(historyArg()).toEqual([{ role: 'user', content: 'kept' }]);
|
|
3332
|
+
});
|
|
3333
|
+
|
|
3334
|
+
it('KEEPS turns from an earlier voice call that is not being resumed', async () => {
|
|
3335
|
+
// A call the user had last week is part of this conversation. The model should know it —
|
|
3336
|
+
// only the legs framed as the resumed transcript are removed, and there are none here.
|
|
3337
|
+
const { provider } = makeProvider({
|
|
3338
|
+
historyRows: [row('User', 'said last week', { AgentSessionID: 'sess-old' }), row('User', 'typed today')],
|
|
3339
|
+
});
|
|
3340
|
+
currentProvider = provider;
|
|
3341
|
+
|
|
3342
|
+
await makeResolver().StartRealtimeClientSession('target-1', makeCtx(), 'conv-1');
|
|
3343
|
+
|
|
3344
|
+
expect(historyArg().map((m) => m.content)).toEqual(['said last week', 'typed today']);
|
|
3345
|
+
});
|
|
3346
|
+
|
|
3347
|
+
it('DROPS turns the resumed transcript already framed, so the prompt carries one account of them', async () => {
|
|
3348
|
+
// The whole reason the leg ids travel with the prior transcript. A voice turn is an ordinary
|
|
3349
|
+
// Conversation Detail, so without this the resumed leg arrives twice in one prompt.
|
|
3350
|
+
const { provider } = makeProvider({
|
|
3351
|
+
chain: { 'prior-1': { UserID: 'user-1' } },
|
|
3352
|
+
legRows: [{ ID: 'l-1', Role: 'User', Message: 'said on the last call', HiddenToUser: false }],
|
|
3353
|
+
historyRows: [
|
|
3354
|
+
row('User', 'said on the last call', { AgentSessionID: 'prior-1' }),
|
|
3355
|
+
row('User', 'typed afterwards'),
|
|
3356
|
+
],
|
|
3357
|
+
});
|
|
3358
|
+
currentProvider = provider;
|
|
3359
|
+
|
|
3360
|
+
await makeResolver().StartRealtimeClientSession('target-1', makeCtx(), 'conv-1', 'prior-1');
|
|
3361
|
+
|
|
3362
|
+
expect(historyArg().map((m) => m.content)).toEqual(['typed afterwards']);
|
|
3363
|
+
const prior = (prepareClientSessionMock.mock.calls[0][0] as { PriorTranscript?: string }).PriorTranscript;
|
|
3364
|
+
expect(prior).toBe('User: said on the last call');
|
|
3365
|
+
});
|
|
3366
|
+
|
|
3367
|
+
it('matches covered session ids case-insensitively', async () => {
|
|
3368
|
+
// SQL Server returns uppercase ids and PostgreSQL lowercase; a case-sensitive compare would
|
|
3369
|
+
// double-inject the resumed leg on one of the two.
|
|
3370
|
+
const { provider } = makeProvider({
|
|
3371
|
+
chain: { 'PRIOR-1': { UserID: 'user-1' } },
|
|
3372
|
+
legRows: [{ ID: 'l-1', Role: 'User', Message: 'said on the last call', HiddenToUser: false }],
|
|
3373
|
+
historyRows: [row('User', 'said on the last call', { AgentSessionID: 'prior-1' })],
|
|
3374
|
+
});
|
|
3375
|
+
currentProvider = provider;
|
|
3376
|
+
|
|
3377
|
+
await makeResolver().StartRealtimeClientSession('target-1', makeCtx(), 'conv-1', 'PRIOR-1');
|
|
3378
|
+
|
|
3379
|
+
expect(historyArg()).toEqual([]);
|
|
3380
|
+
});
|
|
3381
|
+
|
|
3382
|
+
it('keeps only the NEWEST turns when the conversation is long', async () => {
|
|
3383
|
+
const many = Array.from({ length: 45 }, (_, i) => row('User', `turn ${i}`, { ID: `h-${i}` }));
|
|
3384
|
+
const { provider } = makeProvider({ historyRows: many });
|
|
3385
|
+
currentProvider = provider;
|
|
3386
|
+
|
|
3387
|
+
await makeResolver().StartRealtimeClientSession('target-1', makeCtx(), 'conv-1');
|
|
3388
|
+
|
|
3389
|
+
const history = historyArg();
|
|
3390
|
+
expect(history).toHaveLength(30);
|
|
3391
|
+
expect(history[0].content).toBe('turn 15');
|
|
3392
|
+
expect(history[29].content).toBe('turn 44');
|
|
3393
|
+
});
|
|
3394
|
+
|
|
3395
|
+
it('drops the OLDEST turns first when the character budget is exceeded', async () => {
|
|
3396
|
+
// 5 turns x 3000 chars = 15000, over the 8000 budget: the freshest context is what survives.
|
|
3397
|
+
const big = Array.from({ length: 5 }, (_, i) => row('User', `${i}`.repeat(3000), { ID: `h-${i}` }));
|
|
3398
|
+
const { provider } = makeProvider({ historyRows: big });
|
|
3399
|
+
currentProvider = provider;
|
|
3400
|
+
|
|
3401
|
+
await makeResolver().StartRealtimeClientSession('target-1', makeCtx(), 'conv-1');
|
|
3402
|
+
|
|
3403
|
+
const history = historyArg();
|
|
3404
|
+
expect(history).toHaveLength(2);
|
|
3405
|
+
expect(String(history[1].content).startsWith('4')).toBe(true);
|
|
3406
|
+
});
|
|
3407
|
+
|
|
3408
|
+
it('starts the session COLD rather than failing when the history read fails', async () => {
|
|
3409
|
+
const { provider } = makeProvider({ historyFails: true });
|
|
3410
|
+
currentProvider = provider;
|
|
3411
|
+
|
|
3412
|
+
const result = await makeResolver().StartRealtimeClientSession('target-1', makeCtx(), 'conv-1');
|
|
3413
|
+
|
|
3414
|
+
expect(result.AgentSessionId).toBe('session-new');
|
|
3415
|
+
expect(historyArg()).toEqual([]);
|
|
3416
|
+
});
|
|
3417
|
+
|
|
3418
|
+
it('passes an empty history, and runs no query, when the session has no conversation', async () => {
|
|
3419
|
+
const { provider, runView } = makeProvider({ historyRows: [row('User', 'should never be read')] });
|
|
3420
|
+
currentProvider = provider;
|
|
3421
|
+
|
|
3422
|
+
await makeResolver().StartRealtimeClientSession('target-1', makeCtx());
|
|
3423
|
+
|
|
3424
|
+
expect(historyArg()).toEqual([]);
|
|
3425
|
+
expect(runView.mock.calls.some((c) => (c[0] as { ExtraFilter?: string }).ExtraFilter?.startsWith('ConversationID='))).toBe(false);
|
|
3426
|
+
});
|
|
3427
|
+
});
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IS-A PROMOTION over the wire — the server half.
|
|
3
|
+
*
|
|
4
|
+
* Promotion = an EXISTING parent row gains a subtype ("this Animal is now also a Dog"). Over
|
|
5
|
+
* GraphQL it arrives as the child's CREATE mutation carrying the EXISTING parent's primary key,
|
|
6
|
+
* because in IS-A the shared key IS the relationship — the child has no key of its own to mint.
|
|
7
|
+
*
|
|
8
|
+
* The defect: `ResolverBase.CreateRecord` did `GetEntityObject` + `NewRecord()` + `SetMany(input)`.
|
|
9
|
+
* `NewRecord()` recurses into the parent chain and flags the PARENT as new too; `SetMany` then only
|
|
10
|
+
* overwrites the key. The parent therefore saved as a CREATE — a second copy of a row that already
|
|
11
|
+
* exists — instead of the child being attached to the existing row.
|
|
12
|
+
*
|
|
13
|
+
* These tests drive the REAL `CreateRecord` with REAL `BaseEntity` / `EntityInfo` objects over a
|
|
14
|
+
* fake provider that (a) constructs entities exactly as `ProviderBase.GetEntityObject` does
|
|
15
|
+
* (construct → Config → InitializeParentEntity → NewRecord), (b) holds one existing Animal row for
|
|
16
|
+
* `Load`, and (c) records every `Save` it receives — entity, create-vs-update, key — which is the
|
|
17
|
+
* observable the bug corrupts.
|
|
18
|
+
*/
|
|
19
|
+
// ResolverBase transitively pulls in type-graphql decorators, which need the
|
|
20
|
+
// Reflect.metadata polyfill at import time.
|
|
21
|
+
import 'reflect-metadata';
|
|
22
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
23
|
+
import {
|
|
24
|
+
BaseEntity,
|
|
25
|
+
CompositeKey,
|
|
26
|
+
DatabaseProviderBase,
|
|
27
|
+
EntityInfo,
|
|
28
|
+
EntitySaveOptions,
|
|
29
|
+
FieldPermissionAccess,
|
|
30
|
+
IMetadataProvider,
|
|
31
|
+
Metadata,
|
|
32
|
+
UserInfo,
|
|
33
|
+
} from '@memberjunction/core';
|
|
34
|
+
import type { PubSubEngine } from 'type-graphql';
|
|
35
|
+
import { ResolverBase } from '../generic/ResolverBase.js';
|
|
36
|
+
import type { UserPayload } from '../types.js';
|
|
37
|
+
|
|
38
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
39
|
+
// Fixtures — a real two-level IS-A pair: Animals (root) ← Dogs (child), plus a standalone entity
|
|
40
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
const ROLE_ID = 'D1D2D3D4-0000-4000-8000-00000000R0LE';
|
|
43
|
+
const USER_ID = 'D1D2D3D4-0000-4000-8000-00000000USER';
|
|
44
|
+
const ANIMAL_ENTITY_ID = 'D1D2D3D4-0000-4000-8000-000000000001';
|
|
45
|
+
const DOG_ENTITY_ID = 'D1D2D3D4-0000-4000-8000-000000000002';
|
|
46
|
+
const TOY_ENTITY_ID = 'D1D2D3D4-0000-4000-8000-000000000003';
|
|
47
|
+
/** The Animal row that already exists in the database. */
|
|
48
|
+
const EXISTING_ANIMAL_ID = 'E1E2E3E4-0000-4000-8000-00000000AAAA';
|
|
49
|
+
/** A well-formed key that matches NO row. */
|
|
50
|
+
const MISSING_ANIMAL_ID = 'E1E2E3E4-0000-4000-8000-00000000FFFF';
|
|
51
|
+
|
|
52
|
+
interface FieldSpec {
|
|
53
|
+
EntityID: string;
|
|
54
|
+
Name: string;
|
|
55
|
+
Type: string;
|
|
56
|
+
Sequence: number;
|
|
57
|
+
IsPrimaryKey?: boolean;
|
|
58
|
+
AllowsNull?: boolean;
|
|
59
|
+
AllowUpdateAPI?: boolean;
|
|
60
|
+
IsVirtual?: boolean;
|
|
61
|
+
/** Field-level security rows (only read on an entity with EnableFieldLevelSecurity). */
|
|
62
|
+
EntityFieldPermissions?: Record<string, unknown>[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function field(spec: FieldSpec): Record<string, unknown> {
|
|
66
|
+
return {
|
|
67
|
+
ID: `EF-${spec.EntityID}-${spec.Sequence}`,
|
|
68
|
+
EntityID: spec.EntityID,
|
|
69
|
+
Name: spec.Name,
|
|
70
|
+
Type: spec.Type,
|
|
71
|
+
Length: null,
|
|
72
|
+
IsPrimaryKey: spec.IsPrimaryKey ?? false,
|
|
73
|
+
IsSoftPrimaryKey: false,
|
|
74
|
+
IsSoftForeignKey: false,
|
|
75
|
+
AllowsNull: spec.AllowsNull ?? true,
|
|
76
|
+
AllowUpdateAPI: spec.AllowUpdateAPI ?? false,
|
|
77
|
+
DefaultValue: null,
|
|
78
|
+
AutoIncrement: false,
|
|
79
|
+
IsVirtual: spec.IsVirtual ?? false,
|
|
80
|
+
IsNameField: false,
|
|
81
|
+
ValueListType: 'None',
|
|
82
|
+
Sequence: spec.Sequence,
|
|
83
|
+
Status: 'Active',
|
|
84
|
+
EntityFieldValues: [],
|
|
85
|
+
EntityFieldPermissions: spec.EntityFieldPermissions ?? [],
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* One field-level permission row for the test role. On an FLS-enabled entity a field with NO rows
|
|
91
|
+
* is denied, so every non-key field of such an entity gets an explicit row.
|
|
92
|
+
*/
|
|
93
|
+
function fieldPermission(fieldName: string, access: { read?: string; update?: string; create?: string }): Record<string, unknown> {
|
|
94
|
+
const sequence: Record<string, number> = { Name: 2, MicrochipNumber: 3 }; // matches field()'s `EF-<entity>-<sequence>` ids
|
|
95
|
+
return {
|
|
96
|
+
ID: `fperm-${fieldName}`,
|
|
97
|
+
EntityFieldID: `EF-${ANIMAL_ENTITY_ID}-${sequence[fieldName]}`,
|
|
98
|
+
RoleID: ROLE_ID,
|
|
99
|
+
ReadAccess: access.read ?? FieldPermissionAccess.Allow,
|
|
100
|
+
UpdateAccess: access.update ?? FieldPermissionAccess.Allow,
|
|
101
|
+
CreateAccess: access.create ?? FieldPermissionAccess.Allow,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function permission(entityID: string): Record<string, unknown> {
|
|
106
|
+
return { ID: `perm-${entityID}`, EntityID: entityID, RoleID: ROLE_ID, CanCreate: true, CanRead: true, CanUpdate: true, CanDelete: true };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function entityData(overrides: {
|
|
110
|
+
ID: string; Name: string; BaseTable: string; ParentID: string | null; EntityFields: Record<string, unknown>[];
|
|
111
|
+
EnableFieldLevelSecurity?: boolean; AllowMultipleSubtypes?: boolean;
|
|
112
|
+
}): Record<string, unknown> {
|
|
113
|
+
return {
|
|
114
|
+
ID: overrides.ID,
|
|
115
|
+
Name: overrides.Name,
|
|
116
|
+
BaseTable: overrides.BaseTable,
|
|
117
|
+
BaseView: `vw${overrides.BaseTable}s`,
|
|
118
|
+
SchemaName: 'Shelter',
|
|
119
|
+
VirtualEntity: false,
|
|
120
|
+
AllowCreateAPI: true,
|
|
121
|
+
AllowUpdateAPI: true,
|
|
122
|
+
AllowDeleteAPI: true,
|
|
123
|
+
IncludeInAPI: true,
|
|
124
|
+
AllowMultipleSubtypes: overrides.AllowMultipleSubtypes ?? false,
|
|
125
|
+
EnableFieldLevelSecurity: overrides.EnableFieldLevelSecurity ?? false,
|
|
126
|
+
ParentID: overrides.ParentID,
|
|
127
|
+
Status: 'Active',
|
|
128
|
+
EntityFields: overrides.EntityFields,
|
|
129
|
+
EntityPermissions: [permission(overrides.ID)],
|
|
130
|
+
EntityRelationships: [],
|
|
131
|
+
EntitySettings: [],
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Fixture options. `nameLockedByFls`: Animals runs with field-level security ON and the test role
|
|
137
|
+
* may READ but neither UPDATE nor CREATE `Name` (every other field stays open). `overlapping`:
|
|
138
|
+
* Animals allows multiple subtypes.
|
|
139
|
+
*/
|
|
140
|
+
function buildEntities(options: { nameLockedByFls?: boolean; overlapping?: boolean } = {}): EntityInfo[] {
|
|
141
|
+
const fls = options.nameLockedByFls === true;
|
|
142
|
+
const animals = entityData({
|
|
143
|
+
ID: ANIMAL_ENTITY_ID, Name: 'Animals', BaseTable: 'Animal', ParentID: null,
|
|
144
|
+
EnableFieldLevelSecurity: fls,
|
|
145
|
+
AllowMultipleSubtypes: options.overlapping === true,
|
|
146
|
+
EntityFields: [
|
|
147
|
+
field({ EntityID: ANIMAL_ENTITY_ID, Name: 'ID', Type: 'uniqueidentifier', IsPrimaryKey: true, AllowsNull: false, Sequence: 1 }),
|
|
148
|
+
field({ EntityID: ANIMAL_ENTITY_ID, Name: 'Name', Type: 'nvarchar', AllowsNull: false, AllowUpdateAPI: true, Sequence: 2,
|
|
149
|
+
EntityFieldPermissions: fls ? [fieldPermission('Name', { update: FieldPermissionAccess.Deny, create: FieldPermissionAccess.Deny })] : [] }),
|
|
150
|
+
field({ EntityID: ANIMAL_ENTITY_ID, Name: 'MicrochipNumber', Type: 'nvarchar', AllowsNull: true, AllowUpdateAPI: true, Sequence: 3,
|
|
151
|
+
EntityFieldPermissions: fls ? [fieldPermission('MicrochipNumber', {})] : [] }),
|
|
152
|
+
],
|
|
153
|
+
});
|
|
154
|
+
// Mirrors what CodeGen emits for an IS-A child: its OWN `ID` PK (shared with the parent), its
|
|
155
|
+
// own columns, and the parent's data fields as virtual, updatable mirrors.
|
|
156
|
+
const dogs = entityData({
|
|
157
|
+
ID: DOG_ENTITY_ID, Name: 'Dogs', BaseTable: 'Dog', ParentID: ANIMAL_ENTITY_ID,
|
|
158
|
+
EntityFields: [
|
|
159
|
+
field({ EntityID: DOG_ENTITY_ID, Name: 'ID', Type: 'uniqueidentifier', IsPrimaryKey: true, AllowsNull: false, Sequence: 1 }),
|
|
160
|
+
field({ EntityID: DOG_ENTITY_ID, Name: 'IsHouseTrained', Type: 'bit', AllowsNull: true, AllowUpdateAPI: true, Sequence: 2 }),
|
|
161
|
+
field({ EntityID: DOG_ENTITY_ID, Name: 'Name', Type: 'nvarchar', AllowsNull: false, AllowUpdateAPI: true, IsVirtual: true, Sequence: 3 }),
|
|
162
|
+
field({ EntityID: DOG_ENTITY_ID, Name: 'MicrochipNumber', Type: 'nvarchar', AllowsNull: true, AllowUpdateAPI: true, IsVirtual: true, Sequence: 4 }),
|
|
163
|
+
],
|
|
164
|
+
});
|
|
165
|
+
const toys = entityData({
|
|
166
|
+
ID: TOY_ENTITY_ID, Name: 'Toys', BaseTable: 'Toy', ParentID: null,
|
|
167
|
+
EntityFields: [
|
|
168
|
+
field({ EntityID: TOY_ENTITY_ID, Name: 'ID', Type: 'uniqueidentifier', IsPrimaryKey: true, AllowsNull: false, Sequence: 1 }),
|
|
169
|
+
field({ EntityID: TOY_ENTITY_ID, Name: 'Name', Type: 'nvarchar', AllowsNull: false, AllowUpdateAPI: true, Sequence: 2 }),
|
|
170
|
+
],
|
|
171
|
+
});
|
|
172
|
+
return [animals, dogs, toys].map(d => new EntityInfo(d));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function buildUser(): UserInfo {
|
|
176
|
+
return new UserInfo(null, {
|
|
177
|
+
ID: USER_ID, Name: 'Test User', Email: 'test.user@example.com',
|
|
178
|
+
FirstName: 'Test', LastName: 'User', IsActive: true,
|
|
179
|
+
UserRoles: [{ UserID: USER_ID, RoleID: ROLE_ID, RoleName: 'Admin' }],
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
184
|
+
// Fake provider — real entity construction, an in-memory row store, a Save log
|
|
185
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
/** Concrete BaseEntity (mirrors a generated entity class). */
|
|
188
|
+
class TestEntity extends BaseEntity {}
|
|
189
|
+
|
|
190
|
+
interface SaveRecord {
|
|
191
|
+
Entity: string;
|
|
192
|
+
Type: 'create' | 'update';
|
|
193
|
+
ID: unknown;
|
|
194
|
+
Values: Record<string, unknown>;
|
|
195
|
+
/** Fields field-level security told the provider to OMIT from an INSERT (CreateSuppressed). */
|
|
196
|
+
SuppressedOnCreate: string[];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
class FakeIsaProvider {
|
|
200
|
+
public readonly Entities: EntityInfo[];
|
|
201
|
+
public readonly CurrentUser: UserInfo;
|
|
202
|
+
/** Rows the "database" holds, keyed by entity name then primary key. */
|
|
203
|
+
public rows: Record<string, Record<string, Record<string, unknown>>> = {};
|
|
204
|
+
/** Every Save the ORM handed us, in order — the observable the defect corrupts. */
|
|
205
|
+
public saves: SaveRecord[] = [];
|
|
206
|
+
/** Every Load the ORM asked for, in order. */
|
|
207
|
+
public loads: { Entity: string; ID: unknown }[] = [];
|
|
208
|
+
|
|
209
|
+
constructor(entities: EntityInfo[], user: UserInfo) {
|
|
210
|
+
this.Entities = entities;
|
|
211
|
+
this.CurrentUser = user;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
public EntityByName(name: string): EntityInfo | undefined {
|
|
215
|
+
return this.Entities.find(e => e.Name.trim().toLowerCase() === name.trim().toLowerCase());
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
public EntityByID(id: string): EntityInfo | undefined {
|
|
219
|
+
return this.Entities.find(e => e.ID === id);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** The same construction sequence as ProviderBase.GetEntityObject (minus the class factory). */
|
|
223
|
+
public async GetEntityObject(entityName: string, contextUser?: UserInfo): Promise<BaseEntity> {
|
|
224
|
+
const info = this.EntityByName(entityName);
|
|
225
|
+
if (!info) throw new Error(`Entity ${entityName} not found`);
|
|
226
|
+
const entity = new TestEntity(info, this as unknown as ConstructorParameters<typeof TestEntity>[1]);
|
|
227
|
+
await entity.Config(contextUser ?? this.CurrentUser);
|
|
228
|
+
await entity.InitializeParentEntity();
|
|
229
|
+
entity.NewRecord();
|
|
230
|
+
return entity;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
public async Load(entity: BaseEntity, key: CompositeKey): Promise<Record<string, unknown> | null> {
|
|
234
|
+
const id = key.GetValueByFieldName('ID') as string;
|
|
235
|
+
this.loads.push({ Entity: entity.EntityInfo.Name, ID: id });
|
|
236
|
+
const row = this.rows[entity.EntityInfo.Name]?.[id];
|
|
237
|
+
return row ? { ...row } : null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
public async Save(entity: BaseEntity, _user: UserInfo, _options: EntitySaveOptions): Promise<Record<string, unknown>> {
|
|
241
|
+
this.saves.push({
|
|
242
|
+
Entity: entity.EntityInfo.Name,
|
|
243
|
+
Type: entity.IsSaved ? 'update' : 'create',
|
|
244
|
+
ID: entity.Get('ID'),
|
|
245
|
+
Values: entity.GetAll(),
|
|
246
|
+
SuppressedOnCreate: entity.Fields.filter(f => f.CreateSuppressed).map(f => f.Name),
|
|
247
|
+
});
|
|
248
|
+
return entity.GetAll();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
public SetCachedRecordName(): void { /* cache is irrelevant here */ }
|
|
252
|
+
public GetCachedRecordName(): string | null { return null; }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Exposes the REAL protected CreateRecord; pub/sub wiring is out of scope for a unit test. */
|
|
256
|
+
class CreateProbe extends ResolverBase {
|
|
257
|
+
public Create(entityName: string, input: Record<string, unknown>, provider: FakeIsaProvider, payload: UserPayload): Promise<Record<string, unknown> | null> {
|
|
258
|
+
return this.CreateRecord(
|
|
259
|
+
entityName,
|
|
260
|
+
input,
|
|
261
|
+
provider as unknown as DatabaseProviderBase,
|
|
262
|
+
payload,
|
|
263
|
+
undefined as unknown as PubSubEngine
|
|
264
|
+
) as Promise<Record<string, unknown> | null>;
|
|
265
|
+
}
|
|
266
|
+
protected override ListenForEntityMessages(): void { /* no pub/sub in a unit test */ }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
describe('ResolverBase.CreateRecord — IS-A promotion attaches the new child to the EXISTING parent', () => {
|
|
270
|
+
let provider: FakeIsaProvider;
|
|
271
|
+
let payload: UserPayload;
|
|
272
|
+
let previousGlobalProvider: IMetadataProvider;
|
|
273
|
+
|
|
274
|
+
/** (Re)build the fake provider over a fixture variant and publish it as the global provider. */
|
|
275
|
+
function useFixture(options: { nameLockedByFls?: boolean; overlapping?: boolean } = {}): void {
|
|
276
|
+
const user = buildUser();
|
|
277
|
+
provider = new FakeIsaProvider(buildEntities(options), user);
|
|
278
|
+
provider.rows = {
|
|
279
|
+
Animals: {
|
|
280
|
+
[EXISTING_ANIMAL_ID]: { ID: EXISTING_ANIMAL_ID, Name: 'Biscuit', MicrochipNumber: '985112000000001' },
|
|
281
|
+
},
|
|
282
|
+
Dogs: {},
|
|
283
|
+
};
|
|
284
|
+
Metadata.Provider = provider as unknown as IMetadataProvider;
|
|
285
|
+
payload = { email: user.Email, userRecord: user, sessionId: 'test-session' };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
beforeEach(() => {
|
|
289
|
+
// EntityInfo resolves ParentEntityInfo / ChildEntities through the GLOBAL provider (a
|
|
290
|
+
// stateless info-class proxy), and MapFieldNamesToCodeNames falls back to it too.
|
|
291
|
+
previousGlobalProvider = Metadata.Provider;
|
|
292
|
+
useFixture();
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
afterEach(() => {
|
|
296
|
+
Metadata.Provider = previousGlobalProvider;
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('promotion: an existing parent key on a child create UPDATES nothing on the parent and INSERTs only the child', async () => {
|
|
300
|
+
const result = await new CreateProbe().Create('Dogs', { ID: EXISTING_ANIMAL_ID, IsHouseTrained: true }, provider, payload);
|
|
301
|
+
|
|
302
|
+
// The parent row was looked up by the incoming key...
|
|
303
|
+
expect(provider.loads).toEqual([{ Entity: 'Animals', ID: EXISTING_ANIMAL_ID }]);
|
|
304
|
+
// ...and NOT re-created. Before the fix this list began with
|
|
305
|
+
// { Entity: 'Animals', Type: 'create' } — a duplicate copy of Biscuit.
|
|
306
|
+
expect(provider.saves.filter(s => s.Entity === 'Animals')).toEqual([]);
|
|
307
|
+
// The child is created on the SHARED key.
|
|
308
|
+
expect(provider.saves).toEqual([
|
|
309
|
+
expect.objectContaining({ Entity: 'Dogs', Type: 'create', ID: EXISTING_ANIMAL_ID }),
|
|
310
|
+
]);
|
|
311
|
+
expect(provider.saves[0].Values['IsHouseTrained']).toBe(true);
|
|
312
|
+
expect(result?.['ID']).toBe(EXISTING_ANIMAL_ID);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('promotion: parent fields sent alongside the key are applied as an UPDATE of the existing parent', async () => {
|
|
316
|
+
await new CreateProbe().Create('Dogs', { ID: EXISTING_ANIMAL_ID, Name: 'Biscuit II', IsHouseTrained: true }, provider, payload);
|
|
317
|
+
|
|
318
|
+
expect(provider.saves).toEqual([
|
|
319
|
+
expect.objectContaining({ Entity: 'Animals', Type: 'update', ID: EXISTING_ANIMAL_ID }),
|
|
320
|
+
expect.objectContaining({ Entity: 'Dogs', Type: 'create', ID: EXISTING_ANIMAL_ID }),
|
|
321
|
+
]);
|
|
322
|
+
expect(provider.saves[0].Values['Name']).toBe('Biscuit II');
|
|
323
|
+
// The loaded value the input did NOT touch survives — the parent was loaded, not blanked.
|
|
324
|
+
expect(provider.saves[0].Values['MicrochipNumber']).toBe('985112000000001');
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('whole-chain create: no key in the input creates parent AND child on one fresh key, without a lookup', async () => {
|
|
328
|
+
await new CreateProbe().Create('Dogs', { Name: 'Pretzel', IsHouseTrained: false }, provider, payload);
|
|
329
|
+
|
|
330
|
+
expect(provider.loads).toEqual([]);
|
|
331
|
+
expect(provider.saves.map(s => [s.Entity, s.Type])).toEqual([['Animals', 'create'], ['Dogs', 'create']]);
|
|
332
|
+
const sharedKey = provider.saves[0].ID;
|
|
333
|
+
expect(typeof sharedKey).toBe('string');
|
|
334
|
+
expect(sharedKey).not.toBe(EXISTING_ANIMAL_ID);
|
|
335
|
+
expect(provider.saves[1].ID).toBe(sharedKey);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('whole-chain create with a caller-supplied key that matches no row still creates the full chain on that key', async () => {
|
|
339
|
+
await new CreateProbe().Create('Dogs', { ID: MISSING_ANIMAL_ID, Name: 'Ghost', IsHouseTrained: false }, provider, payload);
|
|
340
|
+
|
|
341
|
+
expect(provider.loads).toEqual([{ Entity: 'Animals', ID: MISSING_ANIMAL_ID }]);
|
|
342
|
+
expect(provider.saves).toEqual([
|
|
343
|
+
expect.objectContaining({ Entity: 'Animals', Type: 'create', ID: MISSING_ANIMAL_ID }),
|
|
344
|
+
expect.objectContaining({ Entity: 'Dogs', Type: 'create', ID: MISSING_ANIMAL_ID }),
|
|
345
|
+
]);
|
|
346
|
+
expect(provider.saves[0].Values['Name']).toBe('Ghost');
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('non-IS-A create is untouched: no lookup, one create', async () => {
|
|
350
|
+
await new CreateProbe().Create('Toys', { Name: 'Squeaky Bone' }, provider, payload);
|
|
351
|
+
|
|
352
|
+
expect(provider.loads).toEqual([]);
|
|
353
|
+
expect(provider.saves.map(s => [s.Entity, s.Type])).toEqual([['Toys', 'create']]);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('overlapping subtypes (AllowMultipleSubtypes): promotion still attaches to the existing parent and creates only the child', async () => {
|
|
357
|
+
// Overlapping hierarchies exist precisely to be added to incrementally, so promotion is the
|
|
358
|
+
// natural operation there. The disjoint guard does not run; the attach path is the same.
|
|
359
|
+
useFixture({ overlapping: true });
|
|
360
|
+
|
|
361
|
+
const result = await new CreateProbe().Create('Dogs', { ID: EXISTING_ANIMAL_ID, IsHouseTrained: true }, provider, payload);
|
|
362
|
+
|
|
363
|
+
expect(provider.loads).toEqual([{ Entity: 'Animals', ID: EXISTING_ANIMAL_ID }]);
|
|
364
|
+
expect(provider.saves.filter(s => s.Entity === 'Animals')).toEqual([]);
|
|
365
|
+
expect(provider.saves).toEqual([
|
|
366
|
+
expect.objectContaining({ Entity: 'Dogs', Type: 'create', ID: EXISTING_ANIMAL_ID }),
|
|
367
|
+
]);
|
|
368
|
+
expect(result?.['ID']).toBe(EXISTING_ANIMAL_ID);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
describe('field-level security: the same parent field on a child create meets two different verbs', () => {
|
|
372
|
+
// With FLS on, parent fields sent on a child-create input are enforced by whichever verb the
|
|
373
|
+
// PARENT's save runs — and that is decided by whether the parent row already exists:
|
|
374
|
+
// promotion → the parent is LOADED, saves as an UPDATE → CheckFieldLevelUpdatePermissions REJECTS;
|
|
375
|
+
// whole-chain create → the parent is NEW, saves as a CREATE → ApplyFieldLevelCreateSuppression OMITS.
|
|
376
|
+
// That fork is deliberate (a promotion really is an update of the parent) and these pin it.
|
|
377
|
+
|
|
378
|
+
it('promotion: a parent field the user cannot UPDATE rejects the whole save, before any SQL', async () => {
|
|
379
|
+
useFixture({ nameLockedByFls: true });
|
|
380
|
+
|
|
381
|
+
await expect(
|
|
382
|
+
new CreateProbe().Create('Dogs', { ID: EXISTING_ANIMAL_ID, Name: 'Biscuit II', IsHouseTrained: true }, provider, payload)
|
|
383
|
+
).rejects.toThrow(/permission to update field 'Name'/);
|
|
384
|
+
|
|
385
|
+
expect(provider.loads).toEqual([{ Entity: 'Animals', ID: EXISTING_ANIMAL_ID }]);
|
|
386
|
+
expect(provider.saves).toEqual([]);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it('promotion: parent fields the user CAN update still go through as an UPDATE', async () => {
|
|
390
|
+
useFixture({ nameLockedByFls: true });
|
|
391
|
+
|
|
392
|
+
await new CreateProbe().Create('Dogs', { ID: EXISTING_ANIMAL_ID, MicrochipNumber: '985112000000002', IsHouseTrained: true }, provider, payload);
|
|
393
|
+
|
|
394
|
+
expect(provider.saves).toEqual([
|
|
395
|
+
expect.objectContaining({ Entity: 'Animals', Type: 'update', ID: EXISTING_ANIMAL_ID }),
|
|
396
|
+
expect.objectContaining({ Entity: 'Dogs', Type: 'create', ID: EXISTING_ANIMAL_ID }),
|
|
397
|
+
]);
|
|
398
|
+
expect(provider.saves[0].Values['MicrochipNumber']).toBe('985112000000002');
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it('whole-chain create: the same parent field the user cannot CREATE is silently OMITTED, and the create proceeds', async () => {
|
|
402
|
+
useFixture({ nameLockedByFls: true });
|
|
403
|
+
|
|
404
|
+
await new CreateProbe().Create('Dogs', { Name: 'Pretzel', MicrochipNumber: '985112000000003', IsHouseTrained: false }, provider, payload);
|
|
405
|
+
|
|
406
|
+
expect(provider.loads).toEqual([]);
|
|
407
|
+
expect(provider.saves.map(s => [s.Entity, s.Type])).toEqual([['Animals', 'create'], ['Dogs', 'create']]);
|
|
408
|
+
expect(provider.saves[0].SuppressedOnCreate).toEqual(['Name']);
|
|
409
|
+
expect(provider.saves[0].Values['MicrochipNumber']).toBe('985112000000003');
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
});
|