cabloy 5.1.230 → 5.1.231
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/.cabloy-version +1 -1
- package/.github/workflows/playwright-e2e.yml +4 -2
- package/.github/workflows/vona-test-sqlite3.yml +2 -1
- package/CHANGELOG.md +17 -0
- package/package.json +1 -1
- package/repo-docs/backend/model-guide.md +1 -0
- package/repo-docs/backend/websocket-usage-guide.md +3 -0
- package/repo-docs/frontend/controller-boundary-guide.md +1 -1
- package/repo-docs/frontend/ioc-and-beans.md +2 -1
- package/repo-docs/frontend/modules-and-suites.md +1 -0
- package/repo-docs/reference/repo-scripts.md +2 -0
- package/vona/env/.env +1 -0
- package/vona/packages-cli/cli/package.json +1 -1
- package/vona/packages-cli/cli-set-api/package.json +1 -1
- package/vona/packages-utils/zod-query/package.json +1 -1
- package/vona/packages-utils/zod-query/src/lib/query.ts +4 -1
- package/vona/packages-vona/vona/package.json +1 -1
- package/vona/packages-vona/vona-core/package.json +1 -1
- package/vona/packages-vona/vona-core/src/lib/bean/type.ts +3 -3
- package/vona/packages-vona/vona-core/src/lib/core/testClaim.test.ts +294 -1
- package/vona/packages-vona/vona-core/src/lib/core/testClaim.ts +47 -9
- package/vona/packages-vona/vona-core/src/lib/core/testClaimFilesystem.test.ts +163 -39
- package/vona/packages-vona/vona-core/src/lib/core/testClaimFilesystemWindows.cs +16 -6
- package/vona/packages-vona/vona-core/src/lib/core/testClaimFilesystemWindows.ts +9 -4
- package/vona/packages-vona/vona-core/src/lib/utils/index.ts +1 -0
- package/vona/packages-vona/vona-core/src/lib/utils/zod-enhance.ts +8 -1
- package/vona/packages-vona/vona-mock/package.json +1 -1
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/cartItem.tsx +1 -1
- package/vona/src/suite/a-commerce/modules/commerce-trade/test/cartOwnership.test.ts +144 -18
- package/vona/src/suite-vendor/a-auth/modules/auth-oauth/package.json +1 -1
- package/vona/src/suite-vendor/a-auth/modules/auth-oauth/src/bean/authProvider.oauth.ts +1 -0
- package/vona/src/suite-vendor/a-auth/modules/auth-oauth/src/types/index.ts +1 -0
- package/vona/src/suite-vendor/a-auth/package.json +2 -2
- package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/package.json +1 -1
- package/vona/src/suite-vendor/a-cabloy/package.json +2 -2
- package/vona/src/suite-vendor/a-vona/modules/a-core/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-openapi/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-orm/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-orm/src/types/relationsColumns.ts +7 -5
- package/vona/src/suite-vendor/a-vona/modules/a-permission/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/package.json +1 -1
- package/zova/packages-utils/zova-jsx/package.json +2 -2
- package/zova/packages-zova/zova/package.json +3 -3
- package/zova/packages-zova/zova-core/package.json +2 -2
- package/zova/packages-zova/zova-core/src/bean/resource/component/type.ts +3 -3
- package/zova/packages-zova/zova-core/src/bean/type.ts +6 -6
- package/zova/src/suite/cabloy-basic/modules/basic-button/src/bean/behavior.perform.tsx +5 -5
- package/zova/src/suite/cabloy-basic/modules/basic-date/src/.metadata/component/date.ts +3 -1
- package/zova/src/suite/cabloy-basic/modules/basic-date/src/.metadata/component/dateRange.ts +12 -4
- package/zova/src/suite/cabloy-basic/modules/basic-pageentry/src/component/blockPageEntry/controller.tsx +8 -2
- package/zova/src/suite/cabloy-basic/modules/basic-select/src/.metadata/component/select.ts +9 -3
- package/zova/src/suite-vendor/a-zova/modules/a-behavior/package.json +1 -1
- package/zova/src/suite-vendor/a-zova/modules/a-behavior/src/types/behavior.ts +3 -3
- package/zova/src/suite-vendor/a-zova/modules/a-openapi/package.json +1 -1
- package/zova/src/suite-vendor/a-zova/modules/a-openapi/src/types/detail/detailsActionBulk.ts +5 -3
- package/zova/src/suite-vendor/a-zova/modules/a-openapi/src/types/detail/detailsActionRow.ts +5 -3
- package/zova/src/suite-vendor/a-zova/modules/a-openapi/src/types/resource/formActionRow.ts +5 -3
- package/zova/src/suite-vendor/a-zova/modules/a-openapi/src/types/resource/tableActionBulk.ts +5 -3
- package/zova/src/suite-vendor/a-zova/modules/a-openapi/src/types/resource/tableActionRow.ts +5 -3
- package/zova/src/suite-vendor/a-zova/modules/a-zod/package.json +2 -2
- package/zova/src/suite-vendor/a-zova/modules/a-zova/package.json +2 -2
- package/zova/src/suite-vendor/a-zova/package.json +5 -5
|
@@ -164,61 +164,185 @@ test(
|
|
|
164
164
|
},
|
|
165
165
|
);
|
|
166
166
|
|
|
167
|
+
async function holdWindowsClaimFile(file: string) {
|
|
168
|
+
// A native handle denying FILE_SHARE_DELETE prevents replacement. Node opens
|
|
169
|
+
// with delete sharing, so hold this deliberately through a separate PS7 peer.
|
|
170
|
+
const { spawn } = await import('node:child_process');
|
|
171
|
+
const script =
|
|
172
|
+
'$h=[System.IO.File]::Open($env:VONA_CLAIM_TEST_FILE,[System.IO.FileMode]::Open,[System.IO.FileAccess]::Read,[System.IO.FileShare]::Read);[Console]::Out.WriteLine("ready");[Console]::Out.Flush();[Console]::In.ReadLine()|Out-Null;$h.Dispose()';
|
|
173
|
+
const child = spawn('pwsh', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script], {
|
|
174
|
+
windowsHide: true,
|
|
175
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
176
|
+
env: { ...process.env, VONA_CLAIM_TEST_FILE: file },
|
|
177
|
+
});
|
|
178
|
+
child.stderr.resume();
|
|
179
|
+
child.stdin.on('error', () => {});
|
|
180
|
+
const exited = new Promise<void>(resolve => child.once('close', () => resolve()));
|
|
181
|
+
const release = async () => {
|
|
182
|
+
child.stdin.end('\n');
|
|
183
|
+
const timer = setTimeout(() => child.kill(), 5000);
|
|
184
|
+
try {
|
|
185
|
+
await exited;
|
|
186
|
+
} finally {
|
|
187
|
+
clearTimeout(timer);
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
try {
|
|
191
|
+
await new Promise<void>((resolve, reject) => {
|
|
192
|
+
const timer = setTimeout(() => reject(new Error('fixture handle readiness timeout')), 15000);
|
|
193
|
+
child.once('error', error => {
|
|
194
|
+
clearTimeout(timer);
|
|
195
|
+
reject(error);
|
|
196
|
+
});
|
|
197
|
+
child.once('exit', () => {
|
|
198
|
+
clearTimeout(timer);
|
|
199
|
+
reject(new Error('fixture handle exited'));
|
|
200
|
+
});
|
|
201
|
+
let output = '';
|
|
202
|
+
child.stdout.on('data', chunk => {
|
|
203
|
+
output += String(chunk);
|
|
204
|
+
if (output.includes('ready')) {
|
|
205
|
+
clearTimeout(timer);
|
|
206
|
+
resolve();
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
return release;
|
|
211
|
+
} catch (error) {
|
|
212
|
+
await release();
|
|
213
|
+
throw error;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
167
217
|
test(
|
|
168
|
-
'Windows replacement
|
|
218
|
+
'Windows replacement retries a transient sharing conflict within the same call',
|
|
169
219
|
{ skip: process.platform !== 'win32' },
|
|
170
220
|
async () => {
|
|
171
221
|
const f = await fixture();
|
|
222
|
+
let release: (() => Promise<void>) | undefined;
|
|
223
|
+
let replacement: Promise<unknown> | undefined;
|
|
172
224
|
try {
|
|
173
225
|
await createTestClaimFile(f.file, 'previous committed inventory');
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
226
|
+
release = await holdWindowsClaimFile(f.file);
|
|
227
|
+
assert.equal(await readTestClaimFile(f.file), 'previous committed inventory');
|
|
228
|
+
let settled = false;
|
|
229
|
+
// Attach a rejection handler before polling, including the failure path.
|
|
230
|
+
replacement = replaceTestClaimFile(f.file, 'new committed inventory').then(
|
|
231
|
+
() => {
|
|
232
|
+
settled = true;
|
|
233
|
+
},
|
|
234
|
+
error => {
|
|
235
|
+
settled = true;
|
|
236
|
+
return error;
|
|
237
|
+
},
|
|
238
|
+
);
|
|
239
|
+
const deadline = Date.now() + 10000;
|
|
240
|
+
while (!(await readdir(f.directory)).some(name => name.endsWith('.tmp'))) {
|
|
241
|
+
if (settled) {
|
|
242
|
+
assert.equal(await replacement, undefined);
|
|
243
|
+
assert.fail('replacement completed before the held handle was released');
|
|
244
|
+
}
|
|
245
|
+
assert.ok(Date.now() < deadline, 'replacement temporary file was not observed');
|
|
246
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
247
|
+
}
|
|
248
|
+
await release();
|
|
249
|
+
release = undefined;
|
|
250
|
+
assert.equal(await replacement, undefined);
|
|
251
|
+
assert.equal(await readTestClaimFile(f.file), 'new committed inventory');
|
|
252
|
+
assert.deepEqual(await readdir(f.directory), ['claim.json']);
|
|
253
|
+
} finally {
|
|
254
|
+
await release?.();
|
|
255
|
+
await replacement;
|
|
256
|
+
await f.close();
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
test(
|
|
262
|
+
'Windows persistent sharing conflict fails boundedly and retains the old manifest',
|
|
263
|
+
{ skip: process.platform !== 'win32' },
|
|
264
|
+
async () => {
|
|
265
|
+
const f = await fixture();
|
|
266
|
+
let release: (() => Promise<void>) | undefined;
|
|
267
|
+
try {
|
|
268
|
+
await createTestClaimFile(f.file, 'previous committed inventory');
|
|
269
|
+
release = await holdWindowsClaimFile(f.file);
|
|
270
|
+
assert.equal(await readTestClaimFile(f.file), 'previous committed inventory');
|
|
271
|
+
const started = performance.now();
|
|
272
|
+
await assert.rejects(replaceTestClaimFile(f.file, 'uncommitted'), {
|
|
273
|
+
code: 'EBUSY',
|
|
274
|
+
nativeCode: 32,
|
|
275
|
+
message: 'Unsafe Windows test claim filesystem operation (EBUSY, nativeCode=32)',
|
|
276
|
+
});
|
|
277
|
+
const elapsed = performance.now() - started;
|
|
278
|
+
assert.ok(elapsed >= 900 && elapsed < 10000, `bounded retry took ${elapsed}ms`);
|
|
279
|
+
assert.equal(await readFile(f.file, 'utf8'), 'previous committed inventory');
|
|
280
|
+
assert.deepEqual(await readdir(f.directory), ['claim.json']);
|
|
281
|
+
await release();
|
|
282
|
+
release = undefined;
|
|
283
|
+
await replaceTestClaimFile(f.file, 'new committed inventory');
|
|
284
|
+
assert.equal(await readTestClaimFile(f.file), 'new committed inventory');
|
|
285
|
+
} finally {
|
|
286
|
+
await release?.();
|
|
287
|
+
await f.close();
|
|
288
|
+
}
|
|
289
|
+
},
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
test(
|
|
293
|
+
'Windows replacement rejects a read-only target without changing its contents or attributes',
|
|
294
|
+
{ skip: process.platform !== 'win32' },
|
|
295
|
+
async () => {
|
|
296
|
+
const f = await fixture();
|
|
297
|
+
const attributes = (script: string) =>
|
|
298
|
+
execFileSync(
|
|
180
299
|
'pwsh',
|
|
181
|
-
[
|
|
300
|
+
[
|
|
301
|
+
'-NoLogo',
|
|
302
|
+
'-NoProfile',
|
|
303
|
+
'-NonInteractive',
|
|
304
|
+
'-Command',
|
|
305
|
+
`$ErrorActionPreference='Stop';${script}`,
|
|
306
|
+
],
|
|
182
307
|
{
|
|
183
308
|
windowsHide: true,
|
|
184
|
-
|
|
309
|
+
encoding: 'utf8',
|
|
185
310
|
env: { ...process.env, VONA_CLAIM_TEST_FILE: f.file },
|
|
186
311
|
},
|
|
312
|
+
).trim();
|
|
313
|
+
let original: number | undefined;
|
|
314
|
+
try {
|
|
315
|
+
await createTestClaimFile(f.file, 'previous committed inventory');
|
|
316
|
+
original = Number(
|
|
317
|
+
attributes('[int][System.IO.File]::GetAttributes($env:VONA_CLAIM_TEST_FILE)'),
|
|
318
|
+
);
|
|
319
|
+
assert.ok(Number.isInteger(original));
|
|
320
|
+
const readOnly = (original & ~128) | 1;
|
|
321
|
+
attributes(
|
|
322
|
+
`[System.IO.File]::SetAttributes($env:VONA_CLAIM_TEST_FILE,[System.IO.FileAttributes]${readOnly})`,
|
|
323
|
+
);
|
|
324
|
+
assert.equal(await readTestClaimFile(f.file), 'previous committed inventory');
|
|
325
|
+
await assert.rejects(replaceTestClaimFile(f.file, 'uncommitted'), {
|
|
326
|
+
code: 'EACCES',
|
|
327
|
+
nativeCode: 5,
|
|
328
|
+
message: 'Unsafe Windows test claim filesystem operation (EACCES, nativeCode=5)',
|
|
329
|
+
});
|
|
330
|
+
assert.equal(await readTestClaimFile(f.file), 'previous committed inventory');
|
|
331
|
+
assert.equal(
|
|
332
|
+
Number(attributes('[int][System.IO.File]::GetAttributes($env:VONA_CLAIM_TEST_FILE)')),
|
|
333
|
+
readOnly,
|
|
187
334
|
);
|
|
335
|
+
assert.deepEqual(await readdir(f.directory), ['claim.json']);
|
|
336
|
+
} finally {
|
|
188
337
|
try {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
()
|
|
192
|
-
15000,
|
|
338
|
+
if (original !== undefined) {
|
|
339
|
+
attributes(
|
|
340
|
+
`[System.IO.File]::SetAttributes($env:VONA_CLAIM_TEST_FILE,[System.IO.FileAttributes]${original})`,
|
|
193
341
|
);
|
|
194
|
-
|
|
195
|
-
clearTimeout(timer);
|
|
196
|
-
reject(error);
|
|
197
|
-
});
|
|
198
|
-
child.once('exit', () => {
|
|
199
|
-
clearTimeout(timer);
|
|
200
|
-
reject(new Error('fixture handle exited'));
|
|
201
|
-
});
|
|
202
|
-
child.stdout.once('data', chunk => {
|
|
203
|
-
clearTimeout(timer);
|
|
204
|
-
assert.match(String(chunk), /ready/);
|
|
205
|
-
resolve();
|
|
206
|
-
});
|
|
207
|
-
});
|
|
208
|
-
await assert.rejects(replaceTestClaimFile(f.file, 'uncommitted'));
|
|
209
|
-
assert.equal(await readFile(f.file, 'utf8'), 'previous committed inventory');
|
|
210
|
-
assert.deepEqual(await readdir(f.directory), ['claim.json']);
|
|
342
|
+
}
|
|
211
343
|
} finally {
|
|
212
|
-
|
|
213
|
-
await new Promise<void>(resolve => {
|
|
214
|
-
if (child.exitCode !== null) resolve();
|
|
215
|
-
else child.once('exit', () => resolve());
|
|
216
|
-
});
|
|
344
|
+
await f.close();
|
|
217
345
|
}
|
|
218
|
-
await replaceTestClaimFile(f.file, 'new committed inventory');
|
|
219
|
-
assert.equal(await readTestClaimFile(f.file), 'new committed inventory');
|
|
220
|
-
} finally {
|
|
221
|
-
await f.close();
|
|
222
346
|
}
|
|
223
347
|
},
|
|
224
348
|
);
|
|
@@ -209,10 +209,20 @@ namespace Cabloy {
|
|
|
209
209
|
bool created = false;
|
|
210
210
|
try {
|
|
211
211
|
Write(temp, bytes); created = true;
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
212
|
+
for (int attempt = 0; ; attempt++) {
|
|
213
|
+
try {
|
|
214
|
+
// Surface denied delete sharing as a sharing violation, not a rename access denial.
|
|
215
|
+
// Close our own non-delete-sharing handle before attempting the rename.
|
|
216
|
+
using (SafeFileHandle h = Open(file, false, GenericRead | DeleteAccess)) Private(h);
|
|
217
|
+
// No COPY_ALLOWED: same-directory local-NTFS replacement of the flushed temporary file.
|
|
218
|
+
// Failure is never acknowledged.
|
|
219
|
+
Check(MoveFileExW(temp, file, 1 | 8));
|
|
220
|
+
created = false; break;
|
|
221
|
+
} catch (Win32Exception e) {
|
|
222
|
+
if ((e.NativeErrorCode != 32 && e.NativeErrorCode != 33) || attempt >= 10) throw;
|
|
223
|
+
}
|
|
224
|
+
System.Threading.Thread.Sleep(100);
|
|
225
|
+
}
|
|
216
226
|
using (SafeFileHandle h = Open(file, false, GenericRead)) { if (Read(h) != content) throw Unsafe(); }
|
|
217
227
|
} finally {
|
|
218
228
|
if (created) {
|
|
@@ -222,8 +232,8 @@ namespace Cabloy {
|
|
|
222
232
|
return result;
|
|
223
233
|
}
|
|
224
234
|
} catch (Win32Exception e) {
|
|
225
|
-
return new Dictionary<string, object> { { "ok", false }, { "code", e.NativeErrorCode == 80 || e.NativeErrorCode == 183 ? "EEXIST" : e.NativeErrorCode == 2 || e.NativeErrorCode == 3 ? "ENOENT" : "EUNSAFE" } };
|
|
226
|
-
} catch { return new Dictionary<string, object> { { "ok", false }, { "code", "EUNSAFE" } }; }
|
|
235
|
+
return new Dictionary<string, object> { { "ok", false }, { "code", e.NativeErrorCode == 80 || e.NativeErrorCode == 183 ? "EEXIST" : e.NativeErrorCode == 2 || e.NativeErrorCode == 3 ? "ENOENT" : e.NativeErrorCode == 32 || e.NativeErrorCode == 33 ? "EBUSY" : e.NativeErrorCode == 5 ? "EACCES" : "EUNSAFE" }, { "nativeCode", e.NativeErrorCode } };
|
|
236
|
+
} catch { return new Dictionary<string, object> { { "ok", false }, { "code", "EUNSAFE" }, { "nativeCode", 0 } }; }
|
|
227
237
|
}
|
|
228
238
|
}
|
|
229
239
|
}
|
|
@@ -62,12 +62,17 @@ function decode(source: string): { exists?: boolean; content?: string } {
|
|
|
62
62
|
throw new Error('Windows test claim filesystem helper returned an invalid response');
|
|
63
63
|
}
|
|
64
64
|
if (!value || value.ok !== true) {
|
|
65
|
+
const code = ['EEXIST', 'ENOENT', 'EBUSY', 'EACCES', 'EUNSAFE'].includes(value?.code)
|
|
66
|
+
? value.code
|
|
67
|
+
: 'EUNSAFE';
|
|
68
|
+
const nativeCode =
|
|
69
|
+
Number.isInteger(value?.nativeCode) && value.nativeCode >= 0 && value.nativeCode <= 65535
|
|
70
|
+
? value.nativeCode
|
|
71
|
+
: 0;
|
|
65
72
|
const error = new Error(
|
|
66
|
-
|
|
67
|
-
? 'Test claim file already exists'
|
|
68
|
-
: `Unsafe Windows test claim filesystem operation (${value?.code ?? 'unknown'})`,
|
|
73
|
+
`Unsafe Windows test claim filesystem operation (${code}, nativeCode=${nativeCode})`,
|
|
69
74
|
);
|
|
70
|
-
Object.assign(error, { code
|
|
75
|
+
Object.assign(error, { code, nativeCode });
|
|
71
76
|
throw error;
|
|
72
77
|
}
|
|
73
78
|
return value;
|
|
@@ -3,6 +3,7 @@ import type z from 'zod';
|
|
|
3
3
|
import { setLocaleAdapter, setLocaleErrors, translateError } from '@cabloy/zod-errors-custom';
|
|
4
4
|
import { ZodMetadata } from '@cabloy/zod-openapi';
|
|
5
5
|
import { setParseAdapter } from '@cabloy/zod-query';
|
|
6
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
6
7
|
|
|
7
8
|
import type { ILocaleRecord } from '../bean/resource/locale/type.ts';
|
|
8
9
|
import type { VonaApplication } from '../core/application.ts';
|
|
@@ -12,6 +13,12 @@ import { useApp } from '../framework/useApp.ts';
|
|
|
12
13
|
export type ZodLocaleError = () => { localeError: z.core.$ZodErrorMap };
|
|
13
14
|
export type ZodLocaleErrors = Record<keyof ILocaleRecord, ZodLocaleError>;
|
|
14
15
|
|
|
16
|
+
const queryCoercion = new AsyncLocalStorage<boolean>();
|
|
17
|
+
|
|
18
|
+
export function runWithZodQueryCoercion<T>(enabled: boolean, fn: () => T): T {
|
|
19
|
+
return queryCoercion.run(enabled, fn);
|
|
20
|
+
}
|
|
21
|
+
|
|
15
22
|
export function zodEnhance() {
|
|
16
23
|
setLocaleAdapter((text: string, iss?: object) => {
|
|
17
24
|
const app = useApp();
|
|
@@ -23,7 +30,7 @@ export function zodEnhance() {
|
|
|
23
30
|
iss,
|
|
24
31
|
);
|
|
25
32
|
});
|
|
26
|
-
setParseAdapter(ZodMetadata);
|
|
33
|
+
setParseAdapter(ZodMetadata, () => queryCoercion.getStore() !== false);
|
|
27
34
|
}
|
|
28
35
|
|
|
29
36
|
export function zodSetLocaleErrors(
|
|
@@ -6,7 +6,10 @@ import { catchError } from '@cabloy/utils';
|
|
|
6
6
|
import assert from 'node:assert';
|
|
7
7
|
import { randomUUID } from 'node:crypto';
|
|
8
8
|
import { after, before, describe, it } from 'node:test';
|
|
9
|
+
import { appResource } from 'vona';
|
|
9
10
|
import { acquireTestLock, app } from 'vona-mock';
|
|
11
|
+
import { $schema } from 'vona-module-a-openapiutils';
|
|
12
|
+
import { DtoCartItem, DtoCartView } from 'vona-module-commerce-trade';
|
|
10
13
|
|
|
11
14
|
const actionPath = '/commerce/trade/cart';
|
|
12
15
|
|
|
@@ -39,22 +42,32 @@ async function performAs<
|
|
|
39
42
|
|
|
40
43
|
async function registerAndSignin(name: string) {
|
|
41
44
|
const user = await app.bean.user.register({ name }, true);
|
|
42
|
-
|
|
43
|
-
|
|
45
|
+
try {
|
|
46
|
+
const token = await app.bean.passport.signinMock(name as any);
|
|
47
|
+
return { token: token.accessToken, user };
|
|
48
|
+
} catch (error) {
|
|
49
|
+
await dropUser(user.id as number);
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
44
52
|
}
|
|
45
53
|
|
|
46
54
|
async function createSellableSku(suffix: string): Promise<ISellableSkuFixture> {
|
|
55
|
+
const fixture: Partial<ISellableSkuFixture> = {};
|
|
47
56
|
await app.bean.passport.signinMock();
|
|
48
57
|
try {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
58
|
+
fixture.categoryId = await app.bean.executor.performAction(
|
|
59
|
+
'post',
|
|
60
|
+
'/commerce/catalog/category',
|
|
61
|
+
{
|
|
62
|
+
body: { name: `cart-category-${suffix}`, published: true },
|
|
63
|
+
},
|
|
64
|
+
);
|
|
65
|
+
fixture.productId = await app.bean.executor.performAction('post', '/commerce/catalog/product', {
|
|
66
|
+
body: { categoryId: fixture.categoryId, title: `cart-product-${suffix}`, published: true },
|
|
54
67
|
});
|
|
55
|
-
|
|
68
|
+
fixture.skuId = await app.bean.executor.performAction('post', '/commerce/catalog/sku', {
|
|
56
69
|
body: {
|
|
57
|
-
productId,
|
|
70
|
+
productId: fixture.productId,
|
|
58
71
|
code: `cart-sku-${suffix}`,
|
|
59
72
|
priceCents: 100,
|
|
60
73
|
lifecycle: 'active',
|
|
@@ -62,28 +75,35 @@ async function createSellableSku(suffix: string): Promise<ISellableSkuFixture> {
|
|
|
62
75
|
});
|
|
63
76
|
await app.bean.executor.performAction('post', '/commerce/trade/stockBalance/adjustStock', {
|
|
64
77
|
body: {
|
|
65
|
-
skuId,
|
|
78
|
+
skuId: fixture.skuId,
|
|
66
79
|
delta: 10,
|
|
67
80
|
reason: `cart stock ${suffix}`,
|
|
68
81
|
correlationId: `cart-stock-${suffix}`,
|
|
69
82
|
},
|
|
70
83
|
});
|
|
71
|
-
return
|
|
84
|
+
return fixture as ISellableSkuFixture;
|
|
85
|
+
} catch (error) {
|
|
86
|
+
await dropSellableSku(fixture);
|
|
87
|
+
throw error;
|
|
72
88
|
} finally {
|
|
73
89
|
await app.bean.passport.signout();
|
|
74
90
|
}
|
|
75
91
|
}
|
|
76
92
|
|
|
77
|
-
async function dropSellableSku(fixture: ISellableSkuFixture | undefined) {
|
|
93
|
+
async function dropSellableSku(fixture: Partial<ISellableSkuFixture> | undefined) {
|
|
78
94
|
if (!fixture) return;
|
|
79
95
|
const trade = app.scope('commerce-trade');
|
|
80
96
|
const catalog = app.scope('commerce-catalog');
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
97
|
+
if (fixture.skuId !== undefined) {
|
|
98
|
+
await trade.model.stockAudit.delete({ skuId: fixture.skuId });
|
|
99
|
+
await trade.model.stockReservation.delete({ skuId: fixture.skuId });
|
|
100
|
+
await trade.model.stockBalance.delete({ skuId: fixture.skuId });
|
|
101
|
+
await catalog.model.sku.delete({ id: fixture.skuId });
|
|
102
|
+
}
|
|
103
|
+
if (fixture.productId !== undefined)
|
|
104
|
+
await catalog.model.product.delete({ id: fixture.productId });
|
|
105
|
+
if (fixture.categoryId !== undefined)
|
|
106
|
+
await catalog.model.category.delete({ id: fixture.categoryId });
|
|
87
107
|
}
|
|
88
108
|
|
|
89
109
|
async function dropUser(userId: number | undefined) {
|
|
@@ -206,6 +226,112 @@ describe('cartOwnership.test.ts', { concurrency: false }, () => {
|
|
|
206
226
|
});
|
|
207
227
|
});
|
|
208
228
|
|
|
229
|
+
it('reads an empty cart without creating business records', async () => {
|
|
230
|
+
await app.bean.executor.mockCtx(async () => {
|
|
231
|
+
let customer: Awaited<ReturnType<typeof registerAndSignin>> | undefined;
|
|
232
|
+
try {
|
|
233
|
+
customer = await registerAndSignin(`cart-empty-${createTestId()}`);
|
|
234
|
+
const trade = app.scope('commerce-trade');
|
|
235
|
+
assert.equal(await trade.model.cart.get({ userId: customer.user.id }), undefined);
|
|
236
|
+
assert.deepEqual(await performAs(customer.token, 'get', actionPath), { items: [] });
|
|
237
|
+
assert.deepEqual(await performAs(customer.token, 'get', actionPath), { items: [] });
|
|
238
|
+
assert.equal(await trade.model.cart.get({ userId: customer.user.id }), undefined);
|
|
239
|
+
} finally {
|
|
240
|
+
await app.bean.passport.signout();
|
|
241
|
+
await dropUser(customer?.user.id as number | undefined);
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('keeps a sold-out cart DTO-valid without mutating its business records on reads', async () => {
|
|
247
|
+
await app.bean.executor.mockCtx(async () => {
|
|
248
|
+
const suffix = createTestId();
|
|
249
|
+
let sku: ISellableSkuFixture | undefined;
|
|
250
|
+
let customer: Awaited<ReturnType<typeof registerAndSignin>> | undefined;
|
|
251
|
+
try {
|
|
252
|
+
sku = await createSellableSku(suffix);
|
|
253
|
+
customer = await registerAndSignin(`cart-zero-${suffix}`);
|
|
254
|
+
const added = await performAs(customer.token, 'post', `${actionPath}/items`, {
|
|
255
|
+
body: cartItem(sku.skuId),
|
|
256
|
+
});
|
|
257
|
+
await app.bean.executor.newCtxIsolate(async () => {
|
|
258
|
+
await app.bean.passport.signinMock();
|
|
259
|
+
try {
|
|
260
|
+
await app.bean.executor.performAction(
|
|
261
|
+
'post',
|
|
262
|
+
'/commerce/trade/stockBalance/adjustStock',
|
|
263
|
+
{
|
|
264
|
+
body: {
|
|
265
|
+
skuId: sku!.skuId,
|
|
266
|
+
delta: -10,
|
|
267
|
+
reason: `cart depletion ${suffix}`,
|
|
268
|
+
correlationId: `cart-deplete-${suffix}`,
|
|
269
|
+
},
|
|
270
|
+
innerAccess: false,
|
|
271
|
+
},
|
|
272
|
+
);
|
|
273
|
+
} finally {
|
|
274
|
+
await app.bean.passport.signout();
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
const trade = app.scope('commerce-trade');
|
|
278
|
+
const before = {
|
|
279
|
+
cart: await trade.model.cart.getById(added.id),
|
|
280
|
+
items: await trade.model.cartItem.select({ where: { cartId: added.id } }),
|
|
281
|
+
stock: await trade.model.stockBalance.get({ skuId: sku.skuId }),
|
|
282
|
+
};
|
|
283
|
+
assert.equal(before.stock?.available, 0);
|
|
284
|
+
const current = await performAs(customer.token, 'get', actionPath);
|
|
285
|
+
assert.deepEqual(current, {
|
|
286
|
+
id: added.id,
|
|
287
|
+
items: [{ ...added.items[0], available: 0 }],
|
|
288
|
+
});
|
|
289
|
+
assert.deepEqual(await performAs(customer.token, 'get', actionPath), current);
|
|
290
|
+
assert.deepEqual(
|
|
291
|
+
{
|
|
292
|
+
cart: await trade.model.cart.getById(added.id),
|
|
293
|
+
items: await trade.model.cartItem.select({ where: { cartId: added.id } }),
|
|
294
|
+
stock: await trade.model.stockBalance.get({ skuId: sku.skuId }),
|
|
295
|
+
},
|
|
296
|
+
before,
|
|
297
|
+
);
|
|
298
|
+
assert.deepEqual(await $schema(DtoCartView).parseAsync(current), current);
|
|
299
|
+
for (const available of [1, 10]) {
|
|
300
|
+
assert.equal(
|
|
301
|
+
$schema(DtoCartItem).safeParse({ ...current.items[0], available }).success,
|
|
302
|
+
true,
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
for (const available of [-1, 0.5]) {
|
|
306
|
+
assert.equal(
|
|
307
|
+
$schema(DtoCartItem).safeParse({ ...current.items[0], available }).success,
|
|
308
|
+
false,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
assert.equal(
|
|
312
|
+
$schema(DtoCartItem).safeParse({ ...current.items[0], available: 1, quantity: 0 })
|
|
313
|
+
.success,
|
|
314
|
+
false,
|
|
315
|
+
);
|
|
316
|
+
const document = await app.bean.openapi.generateJsonOfClasses(
|
|
317
|
+
[DtoCartView, DtoCartItem],
|
|
318
|
+
'V31',
|
|
319
|
+
);
|
|
320
|
+
const itemSchema = document.components!.schemas![appResource.getBeanFullName(DtoCartItem)!];
|
|
321
|
+
assert.ok('properties' in itemSchema);
|
|
322
|
+
const availableSchema = itemSchema.properties!.available;
|
|
323
|
+
assert.ok('type' in availableSchema);
|
|
324
|
+
assert.equal(availableSchema.type, 'integer');
|
|
325
|
+
assert.equal(availableSchema.minimum, 0);
|
|
326
|
+
assert.equal(availableSchema.exclusiveMinimum, undefined);
|
|
327
|
+
} finally {
|
|
328
|
+
await app.bean.passport.signout();
|
|
329
|
+
await dropUser(customer?.user.id as number | undefined);
|
|
330
|
+
await dropSellableSku(sku);
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
|
|
209
335
|
it('treats another instance cart as absent', async () => {
|
|
210
336
|
const suffix = createTestId();
|
|
211
337
|
let itemId!: number;
|
|
@@ -43,6 +43,7 @@ const app = useApp();
|
|
|
43
43
|
clientSecret: app.meta.env.AUTH_GITHUB_CLIENTSECRET || 'Shoule specify clientSecret',
|
|
44
44
|
},
|
|
45
45
|
},
|
|
46
|
+
useMockForDev: app.meta.env.AUTH_USEMOCKFORDEV !== 'false',
|
|
46
47
|
})
|
|
47
48
|
export class AuthProviderOauth extends BeanAuthProviderOauth2Base {
|
|
48
49
|
async strategy(
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vona-suite-a-auth",
|
|
3
|
-
"version": "5.1.
|
|
3
|
+
"version": "5.1.30",
|
|
4
4
|
"gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
|
|
5
5
|
"description": "",
|
|
6
6
|
"author": "",
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"type": "module",
|
|
9
9
|
"dependencies": {
|
|
10
10
|
"vona-module-a-auth": "workspace:^",
|
|
11
|
-
"vona-module-auth-oauth": "^5.0.
|
|
11
|
+
"vona-module-auth-oauth": "^5.0.13",
|
|
12
12
|
"vona-module-auth-simple": "workspace:^"
|
|
13
13
|
},
|
|
14
14
|
"title": "a-auth"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vona-suite-a-cabloy",
|
|
3
|
-
"version": "5.1.
|
|
3
|
+
"version": "5.1.67",
|
|
4
4
|
"gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
|
|
5
5
|
"description": "",
|
|
6
6
|
"author": "",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"vona-module-a-cabloy": "workspace:^",
|
|
11
11
|
"vona-module-a-layoutprofile": "^5.0.2",
|
|
12
12
|
"vona-module-a-markdown": "^5.0.3",
|
|
13
|
-
"vona-module-a-rbac": "^5.0.
|
|
13
|
+
"vona-module-a-rbac": "^5.0.16",
|
|
14
14
|
"vona-module-a-socket": "workspace:^",
|
|
15
15
|
"vona-module-a-ssr": "workspace:^",
|
|
16
16
|
"vona-module-a-ssrhmr": "workspace:^"
|