@uipath/data-fabric-tool 1.198.0 → 1.199.0-preview.104

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.
@@ -0,0 +1,646 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
5
+ import {
6
+ type E2EManifest,
7
+ MANIFEST_PATH,
8
+ } from "../../../tests/e2e-shared/global-setup";
9
+ import {
10
+ EXEC_TIMEOUT,
11
+ extractJson,
12
+ localCliPath,
13
+ localCliRawWithArgs,
14
+ readManifest,
15
+ SETUP_TIMEOUT,
16
+ } from "../../../tests/e2e-shared/helpers";
17
+
18
+ const manifest = readManifest<E2EManifest>(MANIFEST_PATH);
19
+ const { tempDir } = manifest.sharedInstall;
20
+
21
+ // Owning e2e suite for scenarios/data-fabric-tool/records-management.md.
22
+ //
23
+ // Same runtime pattern as the other data-fabric e2es: `beforeAll` probes DF
24
+ // access, creates a scratch entity (fields: note/amount/status), seeds 3
25
+ // records; `afterAll` deletes the entity. Every mutating scenario creates
26
+ // its own transient records and cleans them up inline. S13 (MULTILINE_MAX)
27
+ // needs the tenant's MultilineMax feature flag, which the shared install
28
+ // doesn't guarantee — stays as it.skip.
29
+
30
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic CLI JSON
31
+ type Json = any;
32
+
33
+ const AUTH_MARKERS = [
34
+ "forbidden",
35
+ "403",
36
+ "401",
37
+ "unauthorized",
38
+ "not logged in",
39
+ "auth_token",
40
+ "authentication",
41
+ "insufficient_scope",
42
+ ];
43
+
44
+ const SCRATCH_DIR = join(tempDir, `df-records-e2e-${process.pid}`);
45
+
46
+ function runDf(argv: string[]): { stdout: string; exitCode: number } {
47
+ const res = localCliRawWithArgs(tempDir, [
48
+ "df",
49
+ ...argv,
50
+ "--output",
51
+ "json",
52
+ ]);
53
+ return { stdout: res.stdout, exitCode: res.exitCode };
54
+ }
55
+
56
+ // Same shape as runDf but pipes `stdin` to the CLI so tests can exercise
57
+ // `--body -` / `--file -`. execFileSync forwards `input` to stdin.
58
+ function runDfWithStdin(
59
+ argv: string[],
60
+ stdin: string,
61
+ ): { stdout: string; exitCode: number } {
62
+ try {
63
+ const stdout = execFileSync(
64
+ process.execPath,
65
+ [localCliPath(tempDir), "df", ...argv, "--output", "json"],
66
+ {
67
+ cwd: tempDir,
68
+ encoding: "utf-8",
69
+ input: stdin,
70
+ timeout: EXEC_TIMEOUT,
71
+ maxBuffer: 10 * 1024 * 1024,
72
+ stdio: ["pipe", "pipe", "pipe"],
73
+ },
74
+ ).trim();
75
+ return { stdout, exitCode: 0 };
76
+ } catch (err: unknown) {
77
+ const e = err as {
78
+ stdout?: { toString(): string };
79
+ status?: number;
80
+ };
81
+ return {
82
+ stdout: e.stdout?.toString().trim() ?? "",
83
+ exitCode: e.status ?? 1,
84
+ };
85
+ }
86
+ }
87
+
88
+ function cli(argv: string[]): Json {
89
+ const res = runDf(argv);
90
+ if (res.exitCode !== 0) {
91
+ throw new Error(
92
+ `[df ${argv.join(" ")}] exited ${res.exitCode}\n${res.stdout}`,
93
+ );
94
+ }
95
+ return extractJson(res.stdout) as Json;
96
+ }
97
+
98
+ function tryParse(stdout: string): Json {
99
+ try {
100
+ return extractJson(stdout) as Json;
101
+ } catch {
102
+ return {};
103
+ }
104
+ }
105
+
106
+ function isAuthDenial(res: { stdout: string; exitCode: number }): boolean {
107
+ if (res.exitCode === 0) return false;
108
+ const parsed = tryParse(res.stdout);
109
+ const hay =
110
+ `${parsed.Message ?? ""} ${parsed.Instructions ?? ""}`.toLowerCase();
111
+ return AUTH_MARKERS.some((m) => hay.includes(m));
112
+ }
113
+
114
+ function expectFailure(
115
+ result: { stdout: string; exitCode: number },
116
+ label: string,
117
+ ): Json {
118
+ if (result.exitCode !== 1) {
119
+ throw new Error(
120
+ `[${label}] expected exit 1, got ${result.exitCode}. stdout:\n${result.stdout}`,
121
+ );
122
+ }
123
+ const parsed = extractJson(result.stdout) as Json;
124
+ expect(parsed.Result, label).toBe("Failure");
125
+ return parsed;
126
+ }
127
+
128
+ let dfAccessDenied = false;
129
+ let scratchEntityId: string | undefined;
130
+ let seedRecordIds: string[] = [];
131
+ // Unique-per-run suffix matches the repo pattern used by orchestrator-tool
132
+ // e2es to avoid collisions with orphans a prior failed run left behind.
133
+ const RUN_ID = `${process.pid}_${Date.now()}`;
134
+ const scratchEntityName = `E2eDfRecords${RUN_ID}`;
135
+
136
+ describe("df records — records management", () => {
137
+ beforeAll(() => {
138
+ const probe = runDf(["entities", "list"]);
139
+ if (probe.exitCode !== 0) {
140
+ dfAccessDenied = isAuthDenial(probe);
141
+ if (!dfAccessDenied) {
142
+ console.warn(
143
+ `[df records e2e] Probe failed for a non-auth reason; live scenarios will run and assert.\n${probe.stdout}`,
144
+ );
145
+ }
146
+ return;
147
+ }
148
+
149
+ mkdirSync(SCRATCH_DIR, { recursive: true });
150
+
151
+ // Scratch entity — doubles as the write-scope probe. Fields cover the
152
+ // three shapes records tests need: text (note), decimal (amount),
153
+ // categorical (status).
154
+ const createEntity = runDf([
155
+ "entities",
156
+ "create",
157
+ scratchEntityName,
158
+ "--body",
159
+ '{"fields":[{"fieldName":"note","type":"STRING"},{"fieldName":"amount","type":"DECIMAL","decimalPrecision":2},{"fieldName":"status","type":"STRING"}]}',
160
+ ]);
161
+ if (isAuthDenial(createEntity)) {
162
+ dfAccessDenied = true;
163
+ return;
164
+ }
165
+ if (createEntity.exitCode !== 0) return;
166
+ scratchEntityId = (extractJson(createEntity.stdout) as Json).Data?.Id;
167
+ if (!scratchEntityId) return;
168
+
169
+ // Seed 3 records — enough for list / get / query to have something
170
+ // real to assert against, without depending on per-test ordering.
171
+ const seed = runDf([
172
+ "records",
173
+ "insert",
174
+ scratchEntityId,
175
+ "--body",
176
+ '[{"note":"Alpha","amount":100,"status":"Open"},{"note":"Beta","amount":200,"status":"Open"},{"note":"Gamma","amount":300,"status":"Closed"}]',
177
+ ]);
178
+ if (seed.exitCode === 0) {
179
+ const data = (extractJson(seed.stdout) as Json).Data;
180
+ const records: Json[] = data?.SuccessRecords ?? [];
181
+ seedRecordIds = records
182
+ .map((r: Json) => r.Id)
183
+ .filter((id: unknown): id is string => typeof id === "string");
184
+ }
185
+ }, SETUP_TIMEOUT);
186
+
187
+ afterAll(() => {
188
+ if (scratchEntityId) {
189
+ runDf([
190
+ "entities",
191
+ "delete",
192
+ scratchEntityId,
193
+ "--yes",
194
+ "--reason",
195
+ "e2e cleanup",
196
+ ]);
197
+ }
198
+ if (existsSync(SCRATCH_DIR)) {
199
+ rmSync(SCRATCH_DIR, { recursive: true, force: true });
200
+ }
201
+ });
202
+
203
+ // ── S1 ── list returns a paginated page ─────────────────────────────────
204
+ it(
205
+ "S1: list records returns a paginated page",
206
+ (ctx) => {
207
+ if (dfAccessDenied) return ctx.skip();
208
+ if (!scratchEntityId) return ctx.skip();
209
+ const out = cli([
210
+ "records",
211
+ "list",
212
+ scratchEntityId,
213
+ "--limit",
214
+ "50",
215
+ ]);
216
+ expect(out.Result).toBe("Success");
217
+ expect(out.Code).toBe("RecordList");
218
+ // records list wraps the page: { Items: [...], TotalCount,
219
+ // HasNextPage, CurrentPage, TotalPages, SupportsPageJump }.
220
+ expect(Array.isArray(out.Data.Items)).toBe(true);
221
+ expect(out.Data.Items.length).toBeGreaterThanOrEqual(
222
+ seedRecordIds.length,
223
+ );
224
+ expect(typeof out.Data.TotalCount).toBe("number");
225
+ },
226
+ EXEC_TIMEOUT,
227
+ );
228
+
229
+ // ── S2 ── get by id ─────────────────────────────────────────────────────
230
+ it(
231
+ "S2: get a single record by id",
232
+ (ctx) => {
233
+ if (dfAccessDenied) return ctx.skip();
234
+ if (!scratchEntityId || seedRecordIds.length === 0) {
235
+ return ctx.skip();
236
+ }
237
+ const id = seedRecordIds[0];
238
+ const out = cli(["records", "get", scratchEntityId, id]);
239
+ expect(out.Result).toBe("Success");
240
+ expect(out.Code).toBe("RecordDetails");
241
+ expect(out.Data.Id).toBe(id);
242
+
243
+ // Unknown record surfaces a clear failure.
244
+ const unknown = runDf([
245
+ "records",
246
+ "get",
247
+ scratchEntityId,
248
+ "00000000-0000-0000-0000-000000000099",
249
+ ]);
250
+ expect(unknown.exitCode).toBe(1);
251
+ const parsed = extractJson(unknown.stdout) as Json;
252
+ expect(parsed.Result).toBe("Failure");
253
+ },
254
+ EXEC_TIMEOUT,
255
+ );
256
+
257
+ // ── S3 ── insert one, cleanup inline ────────────────────────────────────
258
+ it(
259
+ "S3: insert one record",
260
+ (ctx) => {
261
+ if (dfAccessDenied) return ctx.skip();
262
+ if (!scratchEntityId) return ctx.skip();
263
+ const inserted = cli([
264
+ "records",
265
+ "insert",
266
+ scratchEntityId,
267
+ "--body",
268
+ '{"note":"S3","amount":1500,"status":"New"}',
269
+ ]);
270
+ try {
271
+ expect(inserted.Result).toBe("Success");
272
+ expect(inserted.Code).toBe("RecordInserted");
273
+ expect(typeof inserted.Data.Id).toBe("string");
274
+ } finally {
275
+ if (inserted.Data?.Id) {
276
+ runDf([
277
+ "records",
278
+ "delete",
279
+ scratchEntityId,
280
+ inserted.Data.Id,
281
+ "--yes",
282
+ "--reason",
283
+ "S3 cleanup",
284
+ ]);
285
+ }
286
+ }
287
+ },
288
+ EXEC_TIMEOUT,
289
+ );
290
+
291
+ // ── S4 ── insert many (array payload → RecordsBatchInserted) ────────────
292
+ it(
293
+ "S4: insert many records in one call",
294
+ (ctx) => {
295
+ if (dfAccessDenied) return ctx.skip();
296
+ if (!scratchEntityId) return ctx.skip();
297
+ const payloadPath = join(SCRATCH_DIR, "s4.json");
298
+ writeFileSync(
299
+ payloadPath,
300
+ JSON.stringify([
301
+ { note: "S4a", amount: 1, status: "Open" },
302
+ { note: "S4b", amount: 2, status: "Open" },
303
+ ]),
304
+ );
305
+ const out = cli([
306
+ "records",
307
+ "insert",
308
+ scratchEntityId,
309
+ "--file",
310
+ payloadPath,
311
+ ]);
312
+ const ids: string[] = (out.Data?.SuccessRecords as Json[])
313
+ .map((r: Json) => r.Id)
314
+ .filter((id: unknown): id is string => typeof id === "string");
315
+ try {
316
+ expect(out.Result).toBe("Success");
317
+ expect(out.Code).toBe("RecordsBatchInserted");
318
+ expect(out.Data.SuccessCount).toBeGreaterThanOrEqual(2);
319
+ } finally {
320
+ if (ids.length > 0) {
321
+ runDf([
322
+ "records",
323
+ "delete",
324
+ scratchEntityId,
325
+ ...ids,
326
+ "--yes",
327
+ "--reason",
328
+ "S4 cleanup",
329
+ ]);
330
+ }
331
+ }
332
+ },
333
+ EXEC_TIMEOUT,
334
+ );
335
+
336
+ // ── S5 ── update (single → RecordUpdated) ───────────────────────────────
337
+ it(
338
+ "S5: update a record (must include Id)",
339
+ (ctx) => {
340
+ if (dfAccessDenied) return ctx.skip();
341
+ if (!scratchEntityId) return ctx.skip();
342
+ const inserted = cli([
343
+ "records",
344
+ "insert",
345
+ scratchEntityId,
346
+ "--body",
347
+ '{"note":"S5","amount":10,"status":"Draft"}',
348
+ ]);
349
+ const id: string = inserted.Data.Id;
350
+ try {
351
+ const updated = cli([
352
+ "records",
353
+ "update",
354
+ scratchEntityId,
355
+ "--body",
356
+ JSON.stringify({ Id: id, status: "Paid" }),
357
+ ]);
358
+ expect(updated.Result).toBe("Success");
359
+ expect(updated.Code).toBe("RecordUpdated");
360
+ // Fetch and confirm the change actually persisted. Formatter
361
+ // transforms record field keys to PascalCase (status → Status).
362
+ const fresh = cli(["records", "get", scratchEntityId, id]);
363
+ expect(fresh.Data.Status).toBe("Paid");
364
+ } finally {
365
+ runDf([
366
+ "records",
367
+ "delete",
368
+ scratchEntityId,
369
+ id,
370
+ "--yes",
371
+ "--reason",
372
+ "S5 cleanup",
373
+ ]);
374
+ }
375
+ },
376
+ EXEC_TIMEOUT,
377
+ );
378
+
379
+ // ── S6 ── inline body reaches SDK call ──────────────────────────────────
380
+ it(
381
+ "S6: inline body parses a json payload",
382
+ (ctx) => {
383
+ if (dfAccessDenied) return ctx.skip();
384
+ if (!scratchEntityId) return ctx.skip();
385
+ const inserted = cli([
386
+ "records",
387
+ "insert",
388
+ scratchEntityId,
389
+ "--body",
390
+ '{"note":"S6","amount":42}',
391
+ ]);
392
+ try {
393
+ expect(inserted.Result).toBe("Success");
394
+ expect(inserted.Code).toBe("RecordInserted");
395
+ } finally {
396
+ if (inserted.Data?.Id) {
397
+ runDf([
398
+ "records",
399
+ "delete",
400
+ scratchEntityId,
401
+ inserted.Data.Id,
402
+ "--yes",
403
+ "--reason",
404
+ "S6 cleanup",
405
+ ]);
406
+ }
407
+ }
408
+ },
409
+ EXEC_TIMEOUT,
410
+ );
411
+
412
+ // ── S7 ── --file source parses a json payload ───────────────────────────
413
+ it(
414
+ "S7: file source reads a json payload",
415
+ (ctx) => {
416
+ if (dfAccessDenied) return ctx.skip();
417
+ if (!scratchEntityId) return ctx.skip();
418
+ const payloadPath = join(SCRATCH_DIR, "s7.json");
419
+ writeFileSync(
420
+ payloadPath,
421
+ JSON.stringify({ note: "S7", amount: 7, status: "Open" }),
422
+ );
423
+ const inserted = cli([
424
+ "records",
425
+ "insert",
426
+ scratchEntityId,
427
+ "--file",
428
+ payloadPath,
429
+ ]);
430
+ try {
431
+ expect(inserted.Result).toBe("Success");
432
+ expect(inserted.Code).toBe("RecordInserted");
433
+ } finally {
434
+ if (inserted.Data?.Id) {
435
+ runDf([
436
+ "records",
437
+ "delete",
438
+ scratchEntityId,
439
+ inserted.Data.Id,
440
+ "--yes",
441
+ "--reason",
442
+ "S7 cleanup",
443
+ ]);
444
+ }
445
+ }
446
+ },
447
+ EXEC_TIMEOUT,
448
+ );
449
+
450
+ // ── S8 ── stdin with `--body -` (Windows-safe path) ─────────────────────
451
+ it(
452
+ "S8: body reads from stdin when value is `-`",
453
+ (ctx) => {
454
+ if (dfAccessDenied) return ctx.skip();
455
+ if (!scratchEntityId) return ctx.skip();
456
+ // Include `&` in the payload — this is the char that cmd.exe
457
+ // splits on. Reading via stdin bypasses the shell.
458
+ const res = runDfWithStdin(
459
+ ["records", "insert", scratchEntityId, "--body", "-"],
460
+ '{"note":"S8&Suffix","amount":8}',
461
+ );
462
+ expect(res.exitCode).toBe(0);
463
+ const inserted = extractJson(res.stdout) as Json;
464
+ try {
465
+ expect(inserted.Result).toBe("Success");
466
+ expect(inserted.Code).toBe("RecordInserted");
467
+ // Formatter transforms record field keys to PascalCase.
468
+ expect(inserted.Data.Note).toBe("S8&Suffix");
469
+ } finally {
470
+ if (inserted.Data?.Id) {
471
+ runDf([
472
+ "records",
473
+ "delete",
474
+ scratchEntityId,
475
+ inserted.Data.Id,
476
+ "--yes",
477
+ "--reason",
478
+ "S8 cleanup",
479
+ ]);
480
+ }
481
+ }
482
+ },
483
+ EXEC_TIMEOUT,
484
+ );
485
+
486
+ // ── S9 ── pre-network input edge cases ──────────────────────────────────
487
+ it(
488
+ "S9: input edge cases fail clearly",
489
+ () => {
490
+ // A: empty stdin with --body - → parse fails before any SDK call.
491
+ const empty = runDfWithStdin(
492
+ ["records", "insert", "pre-network", "--body", "-"],
493
+ "",
494
+ );
495
+ const emptyParsed = expectFailure(empty, "empty stdin");
496
+ expect(emptyParsed.Message).toBe("Error parsing input data");
497
+ // readStdin returns null on TTY / empty pipe → "Expected JSON on
498
+ // stdin…"; if it returns "" (readable stream ended) → parseJson
499
+ // throws "Invalid JSON input:". Either is acceptable — both prove
500
+ // the CLI didn't proceed past parsing.
501
+ expect(emptyParsed.Instructions).toMatch(
502
+ /(Expected JSON on stdin|Invalid JSON input)/,
503
+ );
504
+
505
+ // B: malformed inline body.
506
+ const bad = runDf([
507
+ "records",
508
+ "insert",
509
+ "pre-network",
510
+ "--body",
511
+ "{bad",
512
+ ]);
513
+ const badParsed = expectFailure(bad, "bad JSON body");
514
+ expect(badParsed.Message).toBe("Error parsing input data");
515
+ expect(badParsed.Instructions).toContain("Invalid JSON input");
516
+
517
+ // C: neither --body nor --file → the missing-input message.
518
+ const missing = runDf(["records", "insert", "pre-network"]);
519
+ const missingParsed = expectFailure(missing, "no --body/--file");
520
+ expect(missingParsed.Message).toBe("Error parsing input data");
521
+ expect(missingParsed.Instructions).toContain(
522
+ "--file <path> or --body <json>",
523
+ );
524
+ },
525
+ EXEC_TIMEOUT,
526
+ );
527
+
528
+ // ── S10 ── query with filterGroup ───────────────────────────────────────
529
+ it(
530
+ "S10: query records with a filter/aggregate body",
531
+ (ctx) => {
532
+ if (dfAccessDenied) return ctx.skip();
533
+ if (!scratchEntityId) return ctx.skip();
534
+ const body = JSON.stringify({
535
+ filterGroup: {
536
+ queryFilters: [
537
+ {
538
+ fieldName: "status",
539
+ operator: "=",
540
+ value: "Open",
541
+ },
542
+ ],
543
+ },
544
+ selectedFields: ["note", "amount", "status"],
545
+ });
546
+ const out = cli([
547
+ "records",
548
+ "query",
549
+ scratchEntityId,
550
+ "--body",
551
+ body,
552
+ "--limit",
553
+ "50",
554
+ ]);
555
+ expect(out.Result).toBe("Success");
556
+ expect(out.Code).toBe("RecordQuery");
557
+ },
558
+ EXEC_TIMEOUT,
559
+ );
560
+
561
+ // ── S11 ── CSV import ───────────────────────────────────────────────────
562
+ it(
563
+ "S11: import records from a CSV file",
564
+ (ctx) => {
565
+ if (dfAccessDenied) return ctx.skip();
566
+ if (!scratchEntityId) return ctx.skip();
567
+ const csvPath = join(SCRATCH_DIR, "s11.csv");
568
+ writeFileSync(
569
+ csvPath,
570
+ "note,amount,status\nS11a,11,Open\nS11b,12,Closed\n",
571
+ );
572
+ const out = cli([
573
+ "records",
574
+ "import",
575
+ scratchEntityId,
576
+ "--file",
577
+ csvPath,
578
+ ]);
579
+ expect(out.Result).toBe("Success");
580
+ expect(out.Code).toBe("RecordsImported");
581
+ expect(out.Data.TotalRecords).toBeGreaterThanOrEqual(2);
582
+ // Import writes rows directly; there's no per-row Id in the
583
+ // response, so cleanup happens via afterAll (entity delete
584
+ // cascades to all its records).
585
+ },
586
+ EXEC_TIMEOUT,
587
+ );
588
+
589
+ // ── S12 ── delete confirmation guard + happy path ───────────────────────
590
+ it(
591
+ "S12: delete records requires explicit confirmation",
592
+ (ctx) => {
593
+ // Confirmation guard is pre-network — always runs.
594
+ const noFlags = expectFailure(
595
+ runDf(["records", "delete", "pre-network", "pre-network"]),
596
+ "no flags",
597
+ );
598
+ expect(noFlags.Message).toContain("Confirmation required");
599
+
600
+ const yesNoReason = expectFailure(
601
+ runDf([
602
+ "records",
603
+ "delete",
604
+ "pre-network",
605
+ "pre-network",
606
+ "--yes",
607
+ ]),
608
+ "--yes without --reason",
609
+ );
610
+ expect(yesNoReason.Message).toBe(
611
+ "Reason required for destructive operation",
612
+ );
613
+
614
+ // Happy path: needs live access + a record to delete.
615
+ if (dfAccessDenied) return;
616
+ if (!scratchEntityId) return;
617
+ const inserted = cli([
618
+ "records",
619
+ "insert",
620
+ scratchEntityId,
621
+ "--body",
622
+ '{"note":"S12","amount":0}',
623
+ ]);
624
+ const id: string = inserted.Data.Id;
625
+ const deleted = cli([
626
+ "records",
627
+ "delete",
628
+ scratchEntityId,
629
+ id,
630
+ "--yes",
631
+ "--reason",
632
+ "S12 cleanup",
633
+ ]);
634
+ expect(deleted.Result).toBe("Success");
635
+ expect(deleted.Code).toBe("RecordsDeleted");
636
+ expect(deleted.Data.Reason).toBe("S12 cleanup");
637
+ expect(deleted.Data.SuccessCount).toBeGreaterThanOrEqual(1);
638
+ },
639
+ EXEC_TIMEOUT,
640
+ );
641
+
642
+ // ── S13 ── MULTILINE_MAX behavior needs a MultilineMax-enabled tenant ──
643
+ // Verified live on alpha 2026-07-13 (see scenario file) but requires the
644
+ // MultilineMax feature flag that the shared install can't guarantee.
645
+ it.skip("S13: MULTILINE_MAX fields: size marker on list/query, full content on get", () => {});
646
+ });