@antelopejs/dms 0.5.1 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +54 -0
- package/dist/attachments/access.d.ts +1 -0
- package/dist/attachments/access.js +7 -3
- package/dist/attachments/access.js.map +1 -1
- package/dist/attachments/constraints.d.ts +4 -0
- package/dist/attachments/constraints.js +28 -0
- package/dist/attachments/constraints.js.map +1 -0
- package/dist/attachments/save.js +23 -12
- package/dist/attachments/save.js.map +1 -1
- package/dist/attachments/uploads.js +2 -0
- package/dist/attachments/uploads.js.map +1 -1
- package/dist/implementations/dms/page.js +69 -22
- package/dist/implementations/dms/page.js.map +1 -1
- package/dist/implementations/dms-base/search-route.js +3 -4
- package/dist/implementations/dms-base/search-route.js.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/realtime/index.js +2 -0
- package/dist/realtime/index.js.map +1 -1
- package/dist/realtime/sessions.d.ts +3 -0
- package/dist/realtime/sessions.js +5 -0
- package/dist/realtime/sessions.js.map +1 -1
- package/dist/realtime/sse.d.ts +1 -1
- package/dist/realtime/sse.js +9 -3
- package/dist/realtime/sse.js.map +1 -1
- package/dist/routes/realtime.js +8 -3
- package/dist/routes/realtime.js.map +1 -1
- package/dist/test/attachment-host/native.d.ts +25 -0
- package/dist/test/attachment-host/native.js +54 -1
- package/dist/test/attachment-host/native.js.map +1 -1
- package/dist/test/helpers/attachments.d.ts +1 -1
- package/dist/test/helpers/attachments.js +2 -2
- package/dist/test/helpers/attachments.js.map +1 -1
- package/dist/test/helpers/reload-host.d.ts +7 -2
- package/dist/test/helpers/reload-host.js +23 -4
- package/dist/test/helpers/reload-host.js.map +1 -1
- package/dist/test/integration/files/field-constraints.test.d.ts +1 -0
- package/dist/test/integration/files/field-constraints.test.js +100 -0
- package/dist/test/integration/files/field-constraints.test.js.map +1 -0
- package/dist/test/integration/files/page-mode-form-files.test.d.ts +1 -0
- package/dist/test/integration/files/page-mode-form-files.test.js +114 -0
- package/dist/test/integration/files/page-mode-form-files.test.js.map +1 -0
- package/dist/test/integration/files/resource-form-files.test.d.ts +1 -0
- package/dist/test/integration/files/resource-form-files.test.js +121 -0
- package/dist/test/integration/files/resource-form-files.test.js.map +1 -0
- package/dist/test/integration/files/shared-controller-files.test.d.ts +1 -0
- package/dist/test/integration/files/shared-controller-files.test.js +136 -0
- package/dist/test/integration/files/shared-controller-files.test.js.map +1 -0
- package/dist/test/reload-host/index.js +27 -1
- package/dist/test/reload-host/index.js.map +1 -1
- package/dist/test/unit/implementations/dms/missing-category.test.d.ts +1 -0
- package/dist/test/unit/implementations/dms/missing-category.test.js +174 -0
- package/dist/test/unit/implementations/dms/missing-category.test.js.map +1 -0
- package/dist/test/unit/implementations/dms-base/table-view-shared-controller.test.d.ts +1 -0
- package/dist/test/unit/implementations/dms-base/table-view-shared-controller.test.js +113 -0
- package/dist/test/unit/implementations/dms-base/table-view-shared-controller.test.js.map +1 -0
- package/dist/test/unit/interfaces/dms/database-initialized-hook.test.d.ts +1 -0
- package/dist/test/unit/interfaces/dms/database-initialized-hook.test.js +92 -0
- package/dist/test/unit/interfaces/dms/database-initialized-hook.test.js.map +1 -0
- package/dist/test/unit/interfaces/dms/invite-extension.test.js +59 -0
- package/dist/test/unit/interfaces/dms/invite-extension.test.js.map +1 -1
- package/dist/test/unit/interfaces/dms/module-reload-registrations.test.js +2 -2
- package/dist/test/unit/interfaces/dms/module-reload-registrations.test.js.map +1 -1
- package/dist/test/unit/interfaces/dms/page-extension-ownership.test.d.ts +1 -0
- package/dist/test/unit/interfaces/dms/page-extension-ownership.test.js +153 -0
- package/dist/test/unit/interfaces/dms/page-extension-ownership.test.js.map +1 -0
- package/dist/test/unit/interfaces/dms-base/resource-form.test.js +19 -0
- package/dist/test/unit/interfaces/dms-base/resource-form.test.js.map +1 -1
- package/dist/test/unit/interfaces/dms-base/table-view-tenant-gate.test.js +2 -2
- package/dist/test/unit/interfaces/dms-base/table-view-tenant-gate.test.js.map +1 -1
- package/dist/test/unit/realtime/stop-closes-streams.test.d.ts +1 -0
- package/dist/test/unit/realtime/stop-closes-streams.test.js +99 -0
- package/dist/test/unit/realtime/stop-closes-streams.test.js.map +1 -0
- package/dist/test/unit/skills/dms-dev-script.test.d.ts +1 -0
- package/dist/test/unit/skills/dms-dev-script.test.js +137 -0
- package/dist/test/unit/skills/dms-dev-script.test.js.map +1 -0
- package/dist/validation/auth.schema.d.ts +2 -2
- package/frontend-vue/dms.frontend.ts +4 -3
- package/frontend-vue/layers/dms-core/app/composables/realtime/useUserRealtime.ts +116 -44
- package/frontend-vue/layers/dms-layout/app/pages/[...slug].vue +2 -0
- package/frontend-vue/layers/dms-ui/app/components/form/components/File.vue +50 -21
- package/frontend-vue/layers/dms-ui/app/components/form/components/Image.vue +54 -21
- package/frontend-vue/layers/dms-ui/app/components/form/components/Relation.vue +4 -3
- package/frontend-vue/layers/dms-ui/app/composables/form/useUploadWithProgress.ts +44 -3
- package/frontend-vue/layers/dms-ui/app/plugins/shortcuts.ts +1 -1
- package/frontend-vue/layers/dms-ui/app/types/shortcuts-aggregated.d.ts +8 -0
- package/frontend-vue/layers/dms-ui/app/utils/metadataRetry.ts +47 -0
- package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +3 -1
- package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +3 -1
- package/frontend-vue/tests/file-unavailable.test.ts +107 -0
- package/frontend-vue/tests/image-hydration-ids.test.ts +87 -0
- package/frontend-vue/tests/image-reference-preservation.test.ts +12 -2
- package/frontend-vue/tests/metadata-retry.test.ts +187 -0
- package/frontend-vue/tests/realtime-reconnect.test.ts +215 -0
- package/frontend-vue/tests/shortcuts-aggregate.test.ts +41 -0
- package/frontend-vue/tests/upload-forbidden-headers.test.ts +52 -0
- package/frontend-vue/vitest.config.ts +4 -0
- package/package.json +2 -2
- package/skills/dms-dev/SKILL.md +28 -16
- package/skills/dms-dev/scripts/dms-dev.sh +275 -83
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Retry policy for reading a stored file's metadata. A refused or missing file
|
|
2
|
+
// will not come back by asking again, so only failures that can heal on their
|
|
3
|
+
// own (network, 5xx, rate limiting) are retried, and with a capped backoff.
|
|
4
|
+
|
|
5
|
+
interface FetchFailure {
|
|
6
|
+
statusCode?: number;
|
|
7
|
+
status?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const RETRY_BASE_DELAY_MS = 1000;
|
|
11
|
+
const RETRY_MAX_DELAY_MS = 30000;
|
|
12
|
+
const CLIENT_ERROR_STATUS_MIN = 400;
|
|
13
|
+
const CLIENT_ERROR_STATUS_MAX = 499;
|
|
14
|
+
const TRANSIENT_CLIENT_STATUSES = new Set([408, 425, 429]);
|
|
15
|
+
|
|
16
|
+
function failureStatus(error: unknown): number | undefined {
|
|
17
|
+
if (!error || typeof error !== "object") return undefined;
|
|
18
|
+
const { statusCode, status } = error as FetchFailure;
|
|
19
|
+
return statusCode ?? status;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A 4xx (404 gone, 403 denied, 410…) is the server's final answer about this
|
|
24
|
+
* file; request timeouts and rate limiting are the exceptions that can heal.
|
|
25
|
+
*/
|
|
26
|
+
export function isDefinitiveMetadataFailure(error: unknown): boolean {
|
|
27
|
+
const status = failureStatus(error);
|
|
28
|
+
return (
|
|
29
|
+
status !== undefined &&
|
|
30
|
+
status >= CLIENT_ERROR_STATUS_MIN &&
|
|
31
|
+
status <= CLIENT_ERROR_STATUS_MAX &&
|
|
32
|
+
!TRANSIENT_CLIENT_STATUSES.has(status)
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Delay before retrying after `failedAttempts` consecutive failures, or `null`
|
|
38
|
+
* when the failure is definitive and the read must not be retried.
|
|
39
|
+
*/
|
|
40
|
+
export function resolveMetadataRetryDelay(
|
|
41
|
+
error: unknown,
|
|
42
|
+
failedAttempts: number,
|
|
43
|
+
): number | null {
|
|
44
|
+
if (isDefinitiveMetadataFailure(error)) return null;
|
|
45
|
+
const exponent = Math.max(failedAttempts - 1, 0);
|
|
46
|
+
return Math.min(RETRY_BASE_DELAY_MS * 2 ** exponent, RETRY_MAX_DELAY_MS);
|
|
47
|
+
}
|
|
@@ -223,7 +223,8 @@
|
|
|
223
223
|
"uploaded_files": "Uploaded files",
|
|
224
224
|
"rejected_title": "File not added",
|
|
225
225
|
"rejected_size": "{name} exceeds the maximum size ({size})",
|
|
226
|
-
"rejected_type": "{name} is not an allowed file type"
|
|
226
|
+
"rejected_type": "{name} is not an allowed file type",
|
|
227
|
+
"file_unavailable": "File unavailable"
|
|
227
228
|
},
|
|
228
229
|
"image": {
|
|
229
230
|
"add": "Add",
|
|
@@ -233,6 +234,7 @@
|
|
|
233
234
|
"single_hint": "Single image",
|
|
234
235
|
"formats_any": "Any image format",
|
|
235
236
|
"upload_failed": "Upload failed",
|
|
237
|
+
"file_unavailable": "File unavailable",
|
|
236
238
|
"retry": "Retry",
|
|
237
239
|
"replace": "Replace",
|
|
238
240
|
"delete": "Delete",
|
|
@@ -224,7 +224,8 @@
|
|
|
224
224
|
"uploaded_files": "Fichiers téléversés",
|
|
225
225
|
"rejected_title": "Fichier non ajouté",
|
|
226
226
|
"rejected_size": "{name} dépasse la taille maximale ({size})",
|
|
227
|
-
"rejected_type": "{name} n'est pas un type de fichier autorisé"
|
|
227
|
+
"rejected_type": "{name} n'est pas un type de fichier autorisé",
|
|
228
|
+
"file_unavailable": "Fichier indisponible"
|
|
228
229
|
},
|
|
229
230
|
"image": {
|
|
230
231
|
"add": "Ajouter",
|
|
@@ -234,6 +235,7 @@
|
|
|
234
235
|
"single_hint": "Une seule image",
|
|
235
236
|
"formats_any": "Tous formats d'image",
|
|
236
237
|
"upload_failed": "Échec de l'envoi",
|
|
238
|
+
"file_unavailable": "Fichier indisponible",
|
|
237
239
|
"retry": "Réessayer",
|
|
238
240
|
"replace": "Remplacer",
|
|
239
241
|
"delete": "Supprimer",
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import {
|
|
3
|
+
computed,
|
|
4
|
+
createApp,
|
|
5
|
+
defineComponent,
|
|
6
|
+
h,
|
|
7
|
+
inject,
|
|
8
|
+
onScopeDispose,
|
|
9
|
+
ref,
|
|
10
|
+
watch,
|
|
11
|
+
type App,
|
|
12
|
+
} from "vue";
|
|
13
|
+
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
|
14
|
+
import File from "../layers/dms-ui/app/components/form/components/File.vue";
|
|
15
|
+
|
|
16
|
+
const STORED_KEY = "files/profile/avatar.pdf";
|
|
17
|
+
const authFetch = vi.fn();
|
|
18
|
+
|
|
19
|
+
const passthrough = defineComponent({
|
|
20
|
+
inheritAttrs: false,
|
|
21
|
+
setup:
|
|
22
|
+
(_, { slots }) =>
|
|
23
|
+
() =>
|
|
24
|
+
h("span", slots.default?.()),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
function mountFile(): { app: App; container: HTMLElement } {
|
|
28
|
+
const app = createApp({
|
|
29
|
+
render: () => h(File, { modelValue: STORED_KEY }),
|
|
30
|
+
});
|
|
31
|
+
app.config.globalProperties.$t = (key: string) => key;
|
|
32
|
+
app.component("UFileUpload", passthrough);
|
|
33
|
+
app.component("ULink", passthrough);
|
|
34
|
+
app.component("UButton", defineComponent({ setup: () => () => h("button") }));
|
|
35
|
+
app.component(
|
|
36
|
+
"UIcon",
|
|
37
|
+
defineComponent({
|
|
38
|
+
props: ["name"],
|
|
39
|
+
setup: (props) => () => h("i", { "data-icon": props.name }),
|
|
40
|
+
}),
|
|
41
|
+
);
|
|
42
|
+
const container = document.createElement("div");
|
|
43
|
+
app.mount(container);
|
|
44
|
+
return { app, container };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
vi.useFakeTimers();
|
|
49
|
+
vi.stubGlobal("ref", ref);
|
|
50
|
+
vi.stubGlobal("computed", computed);
|
|
51
|
+
vi.stubGlobal("watch", watch);
|
|
52
|
+
vi.stubGlobal("inject", inject);
|
|
53
|
+
vi.stubGlobal("onScopeDispose", onScopeDispose);
|
|
54
|
+
vi.stubGlobal("FORM_FIELD_LOADING_KEY", Symbol());
|
|
55
|
+
vi.stubGlobal("FORM_CONTENT_LANGUAGE_KEY", Symbol());
|
|
56
|
+
vi.stubGlobal("CONTENT_LANGUAGE_HEADER", "x-content-language");
|
|
57
|
+
vi.stubGlobal("useAuthFetch", () => ({ $authFetch: authFetch }));
|
|
58
|
+
vi.stubGlobal("useUploadWithProgress", () => ({
|
|
59
|
+
uploadWithProgress: vi.fn(),
|
|
60
|
+
}));
|
|
61
|
+
vi.stubGlobal("useFormField", () => ({ emitFormChange: vi.fn() }));
|
|
62
|
+
vi.stubGlobal("useI18n", () => ({ t: (key: string) => key }));
|
|
63
|
+
vi.stubGlobal("useToast", () => ({ add: vi.fn() }));
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
afterEach(() => {
|
|
67
|
+
vi.useRealTimers();
|
|
68
|
+
vi.unstubAllGlobals();
|
|
69
|
+
vi.resetAllMocks();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("marks a stored file whose metadata cannot be read as unavailable", async () => {
|
|
73
|
+
authFetch.mockRejectedValue(new Error("404"));
|
|
74
|
+
const { app, container } = mountFile();
|
|
75
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
76
|
+
|
|
77
|
+
expect(container.textContent).toContain("dms.form.file.file_unavailable");
|
|
78
|
+
expect(
|
|
79
|
+
container.querySelector('[data-icon="i-lucide-file-check"]'),
|
|
80
|
+
).toBeNull();
|
|
81
|
+
expect(
|
|
82
|
+
container.querySelector('[data-icon="i-lucide-alert-circle"]'),
|
|
83
|
+
).not.toBeNull();
|
|
84
|
+
app.unmount();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("clears the unavailable state once a retry reads the file", async () => {
|
|
88
|
+
authFetch.mockRejectedValueOnce(new Error("503")).mockResolvedValueOnce({
|
|
89
|
+
resourceKey: STORED_KEY,
|
|
90
|
+
filename: "avatar.pdf",
|
|
91
|
+
size: 2048,
|
|
92
|
+
mimetype: "application/pdf",
|
|
93
|
+
url: "https://example.test/avatar.pdf",
|
|
94
|
+
});
|
|
95
|
+
const { app, container } = mountFile();
|
|
96
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
97
|
+
expect(container.textContent).toContain("dms.form.file.file_unavailable");
|
|
98
|
+
|
|
99
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
100
|
+
|
|
101
|
+
expect(container.textContent).not.toContain("dms.form.file.file_unavailable");
|
|
102
|
+
expect(container.textContent).toContain("avatar.pdf");
|
|
103
|
+
expect(
|
|
104
|
+
container.querySelector('[data-icon="i-lucide-file-check"]'),
|
|
105
|
+
).not.toBeNull();
|
|
106
|
+
app.unmount();
|
|
107
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import {
|
|
3
|
+
computed,
|
|
4
|
+
createSSRApp,
|
|
5
|
+
defineComponent,
|
|
6
|
+
h,
|
|
7
|
+
inject,
|
|
8
|
+
onBeforeUnmount,
|
|
9
|
+
ref,
|
|
10
|
+
useId,
|
|
11
|
+
watch,
|
|
12
|
+
type App,
|
|
13
|
+
} from "vue";
|
|
14
|
+
import { renderToString } from "vue/server-renderer";
|
|
15
|
+
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
|
16
|
+
import Image from "../layers/dms-ui/app/components/form/components/Image.vue";
|
|
17
|
+
|
|
18
|
+
const STORED_IMAGE = { key: "files/profile/cover.png", alt: "Cover" };
|
|
19
|
+
|
|
20
|
+
const passthrough = defineComponent({
|
|
21
|
+
inheritAttrs: false,
|
|
22
|
+
setup:
|
|
23
|
+
(_, { slots }) =>
|
|
24
|
+
() =>
|
|
25
|
+
slots.default?.(),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
function createImageApp(): App {
|
|
29
|
+
const app = createSSRApp({
|
|
30
|
+
render: () => h(Image, { modelValue: STORED_IMAGE }),
|
|
31
|
+
});
|
|
32
|
+
app.component("UButton", defineComponent({ setup: () => () => h("button") }));
|
|
33
|
+
app.component("UIcon", defineComponent({ setup: () => () => h("span") }));
|
|
34
|
+
app.component("UInput", defineComponent({ setup: () => () => h("input") }));
|
|
35
|
+
app.component("UTooltip", passthrough);
|
|
36
|
+
app.component("UModal", defineComponent({ setup: () => () => null }));
|
|
37
|
+
return app;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
vi.stubGlobal("ref", ref);
|
|
42
|
+
vi.stubGlobal("computed", computed);
|
|
43
|
+
vi.stubGlobal("watch", watch);
|
|
44
|
+
vi.stubGlobal("inject", inject);
|
|
45
|
+
vi.stubGlobal("useId", useId);
|
|
46
|
+
vi.stubGlobal("onBeforeUnmount", onBeforeUnmount);
|
|
47
|
+
vi.stubGlobal("FORM_FIELD_LOADING_KEY", Symbol());
|
|
48
|
+
vi.stubGlobal("FORM_CONTENT_LANGUAGE_KEY", Symbol());
|
|
49
|
+
vi.stubGlobal("CONTENT_LANGUAGE_HEADER", "x-content-language");
|
|
50
|
+
vi.stubGlobal("useAuthFetch", () => ({
|
|
51
|
+
$authFetch: () => new Promise(() => undefined),
|
|
52
|
+
}));
|
|
53
|
+
vi.stubGlobal("useUploadWithProgress", () => ({
|
|
54
|
+
uploadWithProgress: vi.fn(),
|
|
55
|
+
}));
|
|
56
|
+
vi.stubGlobal("useFormField", () => ({ emitFormChange: vi.fn() }));
|
|
57
|
+
vi.stubGlobal("useI18n", () => ({ t: (key: string) => key }));
|
|
58
|
+
vi.stubGlobal("useToast", () => ({ add: vi.fn() }));
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
afterEach(() => {
|
|
62
|
+
vi.unstubAllGlobals();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("hydrates a stored single image without an attribute mismatch", async () => {
|
|
66
|
+
const html = await renderToString(createImageApp());
|
|
67
|
+
const container = document.createElement("div");
|
|
68
|
+
container.innerHTML = html;
|
|
69
|
+
const warnings: string[] = [];
|
|
70
|
+
const app = createImageApp();
|
|
71
|
+
app.config.warnHandler = (message) => warnings.push(message);
|
|
72
|
+
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
73
|
+
|
|
74
|
+
app.mount(container);
|
|
75
|
+
|
|
76
|
+
expect(warnings.filter((message) => message.includes("Hydration"))).toEqual(
|
|
77
|
+
[],
|
|
78
|
+
);
|
|
79
|
+
expect(consoleError).not.toHaveBeenCalledWith(
|
|
80
|
+
expect.stringContaining("Hydration"),
|
|
81
|
+
);
|
|
82
|
+
const label = container.querySelector("label")!;
|
|
83
|
+
expect(label.htmlFor).not.toBe("");
|
|
84
|
+
expect(container.querySelector("input:not([type])")!.id).toBe(label.htmlFor);
|
|
85
|
+
app.unmount();
|
|
86
|
+
consoleError.mockRestore();
|
|
87
|
+
});
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
createSSRApp,
|
|
6
6
|
defineComponent,
|
|
7
7
|
effectScope,
|
|
8
|
+
getCurrentInstance,
|
|
8
9
|
h,
|
|
9
10
|
nextTick,
|
|
10
11
|
onScopeDispose,
|
|
@@ -12,12 +13,17 @@ import {
|
|
|
12
13
|
reactive,
|
|
13
14
|
ref,
|
|
14
15
|
watch,
|
|
16
|
+
useId,
|
|
15
17
|
type EffectScope,
|
|
16
18
|
type Ref,
|
|
17
19
|
} from "vue";
|
|
18
20
|
import { renderToString } from "vue/server-renderer";
|
|
19
21
|
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
|
20
22
|
import Image from "../layers/dms-ui/app/components/form/components/Image.vue";
|
|
23
|
+
import {
|
|
24
|
+
isDefinitiveMetadataFailure,
|
|
25
|
+
resolveMetadataRetryDelay,
|
|
26
|
+
} from "../layers/dms-ui/app/utils/metadataRetry";
|
|
21
27
|
|
|
22
28
|
interface ImageValue {
|
|
23
29
|
key: string;
|
|
@@ -90,11 +96,14 @@ beforeEach(() => {
|
|
|
90
96
|
}),
|
|
91
97
|
);
|
|
92
98
|
vi.stubGlobal("defineProps", () => props);
|
|
99
|
+
vi.stubGlobal("isDefinitiveMetadataFailure", isDefinitiveMetadataFailure);
|
|
100
|
+
vi.stubGlobal("resolveMetadataRetryDelay", resolveMetadataRetryDelay);
|
|
93
101
|
vi.stubGlobal("defineEmits", () => emit);
|
|
94
102
|
vi.stubGlobal("ref", ref);
|
|
95
103
|
vi.stubGlobal("computed", computed);
|
|
96
104
|
vi.stubGlobal("watch", watch);
|
|
97
105
|
vi.stubGlobal("inject", () => null);
|
|
106
|
+
vi.stubGlobal("useId", () => (getCurrentInstance() ? useId() : "image"));
|
|
98
107
|
vi.stubGlobal("FORM_FIELD_LOADING_KEY", Symbol());
|
|
99
108
|
vi.stubGlobal("FORM_CONTENT_LANGUAGE_KEY", Symbol());
|
|
100
109
|
vi.stubGlobal("CONTENT_LANGUAGE_HEADER", "x-content-language");
|
|
@@ -117,7 +126,7 @@ afterEach(() => {
|
|
|
117
126
|
it("preserves a denied image when another image is removed", async () => {
|
|
118
127
|
const image = loadImage();
|
|
119
128
|
await nextTick();
|
|
120
|
-
expect(image.items.value[0]!.status).toBe("
|
|
129
|
+
expect(image.items.value[0]!.status).toBe("unavailable");
|
|
121
130
|
image.removeItem(image.items.value[1]!.id);
|
|
122
131
|
expect(emit).toHaveBeenLastCalledWith("update:modelValue", [original]);
|
|
123
132
|
});
|
|
@@ -191,7 +200,8 @@ it("keeps the denied image and its explicit deletion control in rendered markup"
|
|
|
191
200
|
);
|
|
192
201
|
app.component("UModal", defineComponent({ setup: () => () => null }));
|
|
193
202
|
const html = await renderToString(app);
|
|
194
|
-
expect(html).toContain("dms.form.image.
|
|
203
|
+
expect(html).toContain("dms.form.image.file_unavailable");
|
|
204
|
+
expect(html).not.toContain("dms.form.image.upload_failed");
|
|
195
205
|
expect(html).toContain("dms.form.image.delete</button>");
|
|
196
206
|
expect(emit).not.toHaveBeenCalled();
|
|
197
207
|
});
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import {
|
|
3
|
+
type App,
|
|
4
|
+
type Component,
|
|
5
|
+
computed,
|
|
6
|
+
createApp,
|
|
7
|
+
type FunctionalComponent,
|
|
8
|
+
h,
|
|
9
|
+
inject,
|
|
10
|
+
onBeforeUnmount,
|
|
11
|
+
onScopeDispose,
|
|
12
|
+
ref,
|
|
13
|
+
useId,
|
|
14
|
+
watch,
|
|
15
|
+
} from "vue";
|
|
16
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
17
|
+
import File from "../layers/dms-ui/app/components/form/components/File.vue";
|
|
18
|
+
import Image from "../layers/dms-ui/app/components/form/components/Image.vue";
|
|
19
|
+
import {
|
|
20
|
+
isDefinitiveMetadataFailure,
|
|
21
|
+
resolveMetadataRetryDelay,
|
|
22
|
+
} from "../layers/dms-ui/app/utils/metadataRetry";
|
|
23
|
+
|
|
24
|
+
const authFetch = vi.fn();
|
|
25
|
+
const LONG_WAIT_MS = 120000;
|
|
26
|
+
|
|
27
|
+
interface FieldCase {
|
|
28
|
+
name: string;
|
|
29
|
+
component: Component;
|
|
30
|
+
modelValue: unknown;
|
|
31
|
+
unavailableLabel: string;
|
|
32
|
+
metadata: Record<string, unknown>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const FIELD_CASES: FieldCase[] = [
|
|
36
|
+
{
|
|
37
|
+
name: "File",
|
|
38
|
+
component: File,
|
|
39
|
+
modelValue: "files/profile/avatar.pdf",
|
|
40
|
+
unavailableLabel: "dms.form.file.file_unavailable",
|
|
41
|
+
metadata: {
|
|
42
|
+
resourceKey: "files/profile/avatar.pdf",
|
|
43
|
+
filename: "avatar.pdf",
|
|
44
|
+
size: 2048,
|
|
45
|
+
mimetype: "application/pdf",
|
|
46
|
+
url: "https://example.test/avatar.pdf",
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: "Image",
|
|
51
|
+
component: Image,
|
|
52
|
+
modelValue: { key: "files/profile/cover.png", alt: "Cover" },
|
|
53
|
+
unavailableLabel: "dms.form.image.file_unavailable",
|
|
54
|
+
metadata: {
|
|
55
|
+
resourceKey: "files/profile/cover.png",
|
|
56
|
+
filename: "cover.png",
|
|
57
|
+
size: 2048,
|
|
58
|
+
mimetype: "image/png",
|
|
59
|
+
url: "https://example.test/cover.png",
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
const passthrough: FunctionalComponent = (_, { slots }) =>
|
|
65
|
+
h("span", slots.default?.());
|
|
66
|
+
|
|
67
|
+
const renderTag =
|
|
68
|
+
(tag: string): FunctionalComponent =>
|
|
69
|
+
() =>
|
|
70
|
+
h(tag);
|
|
71
|
+
|
|
72
|
+
function httpError(status: number): Error {
|
|
73
|
+
return Object.assign(new Error(`HTTP ${status}`), {
|
|
74
|
+
status,
|
|
75
|
+
statusCode: status,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function mountField(field: FieldCase): { app: App; container: HTMLElement } {
|
|
80
|
+
const app = createApp({
|
|
81
|
+
render: () => h(field.component, { modelValue: field.modelValue }),
|
|
82
|
+
});
|
|
83
|
+
app.config.globalProperties.$t = (key: string) => key;
|
|
84
|
+
for (const name of ["UFileUpload", "ULink", "UTooltip"])
|
|
85
|
+
app.component(name, passthrough);
|
|
86
|
+
app.component("UButton", renderTag("button"));
|
|
87
|
+
app.component("UIcon", renderTag("i"));
|
|
88
|
+
app.component("UInput", renderTag("input"));
|
|
89
|
+
app.component("UModal", () => null);
|
|
90
|
+
const container = document.createElement("div");
|
|
91
|
+
app.mount(container);
|
|
92
|
+
return { app, container };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
beforeEach(() => {
|
|
96
|
+
vi.useFakeTimers();
|
|
97
|
+
vi.stubGlobal("ref", ref);
|
|
98
|
+
vi.stubGlobal("computed", computed);
|
|
99
|
+
vi.stubGlobal("watch", watch);
|
|
100
|
+
vi.stubGlobal("inject", inject);
|
|
101
|
+
vi.stubGlobal("useId", useId);
|
|
102
|
+
vi.stubGlobal("onBeforeUnmount", onBeforeUnmount);
|
|
103
|
+
vi.stubGlobal("onScopeDispose", onScopeDispose);
|
|
104
|
+
vi.stubGlobal("FORM_FIELD_LOADING_KEY", Symbol());
|
|
105
|
+
vi.stubGlobal("FORM_CONTENT_LANGUAGE_KEY", Symbol());
|
|
106
|
+
vi.stubGlobal("CONTENT_LANGUAGE_HEADER", "x-content-language");
|
|
107
|
+
vi.stubGlobal("useAuthFetch", () => ({ $authFetch: authFetch }));
|
|
108
|
+
vi.stubGlobal("useUploadWithProgress", () => ({
|
|
109
|
+
uploadWithProgress: vi.fn(),
|
|
110
|
+
}));
|
|
111
|
+
vi.stubGlobal("useFormField", () => ({ emitFormChange: vi.fn() }));
|
|
112
|
+
vi.stubGlobal("useI18n", () => ({ t: (key: string) => key }));
|
|
113
|
+
vi.stubGlobal("useToast", () => ({ add: vi.fn() }));
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
afterEach(() => {
|
|
117
|
+
vi.useRealTimers();
|
|
118
|
+
vi.unstubAllGlobals();
|
|
119
|
+
vi.resetAllMocks();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe.each(FIELD_CASES)("$name stored-value metadata", (field) => {
|
|
123
|
+
it.each([403, 404, 410])(
|
|
124
|
+
"reads a file answered with %i exactly once and shows it unavailable",
|
|
125
|
+
async (status) => {
|
|
126
|
+
authFetch.mockRejectedValue(httpError(status));
|
|
127
|
+
const { app, container } = mountField(field);
|
|
128
|
+
|
|
129
|
+
await vi.advanceTimersByTimeAsync(LONG_WAIT_MS);
|
|
130
|
+
|
|
131
|
+
expect(authFetch).toHaveBeenCalledTimes(1);
|
|
132
|
+
expect(container.textContent).toContain(field.unavailableLabel);
|
|
133
|
+
app.unmount();
|
|
134
|
+
},
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
it("retries transient failures with a growing delay until the read succeeds", async () => {
|
|
138
|
+
authFetch
|
|
139
|
+
.mockRejectedValueOnce(httpError(503))
|
|
140
|
+
.mockRejectedValueOnce(new Error("network down"))
|
|
141
|
+
.mockRejectedValueOnce(httpError(429))
|
|
142
|
+
.mockResolvedValueOnce(field.metadata);
|
|
143
|
+
const { app, container } = mountField(field);
|
|
144
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
145
|
+
expect(authFetch).toHaveBeenCalledTimes(1);
|
|
146
|
+
expect(container.textContent).toContain(field.unavailableLabel);
|
|
147
|
+
|
|
148
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
149
|
+
expect(authFetch).toHaveBeenCalledTimes(2);
|
|
150
|
+
await vi.advanceTimersByTimeAsync(1999);
|
|
151
|
+
expect(authFetch).toHaveBeenCalledTimes(2);
|
|
152
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
153
|
+
expect(authFetch).toHaveBeenCalledTimes(3);
|
|
154
|
+
await vi.advanceTimersByTimeAsync(4000);
|
|
155
|
+
expect(authFetch).toHaveBeenCalledTimes(4);
|
|
156
|
+
|
|
157
|
+
expect(container.textContent).not.toContain(field.unavailableLabel);
|
|
158
|
+
await vi.advanceTimersByTimeAsync(LONG_WAIT_MS);
|
|
159
|
+
expect(authFetch).toHaveBeenCalledTimes(4);
|
|
160
|
+
app.unmount();
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("metadata retry policy", () => {
|
|
165
|
+
it("treats refusals and missing files as definitive", () => {
|
|
166
|
+
for (const status of [400, 401, 403, 404, 410])
|
|
167
|
+
expect(isDefinitiveMetadataFailure(httpError(status))).toBe(true);
|
|
168
|
+
for (const error of [
|
|
169
|
+
httpError(408),
|
|
170
|
+
httpError(429),
|
|
171
|
+
httpError(500),
|
|
172
|
+
httpError(503),
|
|
173
|
+
new Error("network down"),
|
|
174
|
+
undefined,
|
|
175
|
+
])
|
|
176
|
+
expect(isDefinitiveMetadataFailure(error)).toBe(false);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("backs off exponentially up to a cap and never retries a definitive failure", () => {
|
|
180
|
+
const transient = httpError(503);
|
|
181
|
+
expect(resolveMetadataRetryDelay(transient, 1)).toBe(1000);
|
|
182
|
+
expect(resolveMetadataRetryDelay(transient, 2)).toBe(2000);
|
|
183
|
+
expect(resolveMetadataRetryDelay(transient, 3)).toBe(4000);
|
|
184
|
+
expect(resolveMetadataRetryDelay(transient, 50)).toBe(30000);
|
|
185
|
+
expect(resolveMetadataRetryDelay(httpError(404), 1)).toBeNull();
|
|
186
|
+
});
|
|
187
|
+
});
|