@antelopejs/dms 0.5.2 → 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.
Files changed (51) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/attachments/access.d.ts +1 -0
  3. package/dist/attachments/access.js +7 -3
  4. package/dist/attachments/access.js.map +1 -1
  5. package/dist/attachments/constraints.d.ts +4 -0
  6. package/dist/attachments/constraints.js +28 -0
  7. package/dist/attachments/constraints.js.map +1 -0
  8. package/dist/attachments/save.js +23 -12
  9. package/dist/attachments/save.js.map +1 -1
  10. package/dist/attachments/uploads.js +2 -0
  11. package/dist/attachments/uploads.js.map +1 -1
  12. package/dist/implementations/dms-base/search-route.js +3 -4
  13. package/dist/implementations/dms-base/search-route.js.map +1 -1
  14. package/dist/test/attachment-host/native.d.ts +25 -0
  15. package/dist/test/attachment-host/native.js +54 -1
  16. package/dist/test/attachment-host/native.js.map +1 -1
  17. package/dist/test/helpers/attachments.d.ts +1 -1
  18. package/dist/test/helpers/attachments.js +2 -2
  19. package/dist/test/helpers/attachments.js.map +1 -1
  20. package/dist/test/integration/files/field-constraints.test.d.ts +1 -0
  21. package/dist/test/integration/files/field-constraints.test.js +100 -0
  22. package/dist/test/integration/files/field-constraints.test.js.map +1 -0
  23. package/dist/test/integration/files/page-mode-form-files.test.d.ts +1 -0
  24. package/dist/test/integration/files/page-mode-form-files.test.js +114 -0
  25. package/dist/test/integration/files/page-mode-form-files.test.js.map +1 -0
  26. package/dist/test/integration/files/resource-form-files.test.d.ts +1 -0
  27. package/dist/test/integration/files/resource-form-files.test.js +121 -0
  28. package/dist/test/integration/files/resource-form-files.test.js.map +1 -0
  29. package/dist/test/integration/files/shared-controller-files.test.d.ts +1 -0
  30. package/dist/test/integration/files/shared-controller-files.test.js +136 -0
  31. package/dist/test/integration/files/shared-controller-files.test.js.map +1 -0
  32. package/dist/test/unit/implementations/dms-base/table-view-shared-controller.test.d.ts +1 -0
  33. package/dist/test/unit/implementations/dms-base/table-view-shared-controller.test.js +113 -0
  34. package/dist/test/unit/implementations/dms-base/table-view-shared-controller.test.js.map +1 -0
  35. package/dist/test/unit/interfaces/dms-base/resource-form.test.js +19 -0
  36. package/dist/test/unit/interfaces/dms-base/resource-form.test.js.map +1 -1
  37. package/dist/test/unit/interfaces/dms-base/table-view-tenant-gate.test.js +2 -2
  38. package/dist/test/unit/interfaces/dms-base/table-view-tenant-gate.test.js.map +1 -1
  39. package/frontend-vue/layers/dms-ui/app/components/form/components/File.vue +50 -21
  40. package/frontend-vue/layers/dms-ui/app/components/form/components/Image.vue +54 -21
  41. package/frontend-vue/layers/dms-ui/app/components/form/components/Relation.vue +4 -3
  42. package/frontend-vue/layers/dms-ui/app/composables/form/useUploadWithProgress.ts +44 -3
  43. package/frontend-vue/layers/dms-ui/app/utils/metadataRetry.ts +47 -0
  44. package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +3 -1
  45. package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +3 -1
  46. package/frontend-vue/tests/file-unavailable.test.ts +107 -0
  47. package/frontend-vue/tests/image-hydration-ids.test.ts +87 -0
  48. package/frontend-vue/tests/image-reference-preservation.test.ts +12 -2
  49. package/frontend-vue/tests/metadata-retry.test.ts +187 -0
  50. package/frontend-vue/tests/upload-forbidden-headers.test.ts +52 -0
  51. package/package.json +2 -2
@@ -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("error");
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.upload_failed");
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
+ });
@@ -0,0 +1,52 @@
1
+ import { afterEach, beforeEach, expect, it, vi } from "vitest";
2
+ import { useUploadWithProgress } from "../layers/dms-ui/app/composables/form/useUploadWithProgress";
3
+
4
+ const UPLOAD_URL = "https://storage.example.test/upload/token";
5
+ const setRequestHeader = vi.fn();
6
+
7
+ class FakeXMLHttpRequest {
8
+ status = 200;
9
+ upload = {};
10
+ onload: (() => void) | null = null;
11
+ open = vi.fn();
12
+ setRequestHeader = setRequestHeader;
13
+ send() {
14
+ this.onload?.();
15
+ }
16
+ }
17
+
18
+ beforeEach(() => {
19
+ vi.stubGlobal("XMLHttpRequest", FakeXMLHttpRequest);
20
+ });
21
+
22
+ afterEach(() => {
23
+ vi.unstubAllGlobals();
24
+ vi.resetAllMocks();
25
+ });
26
+
27
+ it("forwards presign headers except the ones a browser refuses to set", async () => {
28
+ const { uploadWithProgress } = useUploadWithProgress();
29
+
30
+ await uploadWithProgress(
31
+ {
32
+ uploadUrl: UPLOAD_URL,
33
+ resourceKey: "__staging__/cover.png",
34
+ headers: {
35
+ "Content-Type": "image/png",
36
+ "Content-Length": "42",
37
+ host: "storage.example.test",
38
+ Connection: "keep-alive",
39
+ "Sec-Fetch-Mode": "cors",
40
+ "Proxy-Authorization": "Basic secret",
41
+ "x-amz-meta-owner": "tenant",
42
+ },
43
+ },
44
+ new File(["bytes"], "cover.png", { type: "image/png" }),
45
+ () => undefined,
46
+ );
47
+
48
+ expect(setRequestHeader.mock.calls).toEqual([
49
+ ["Content-Type", "image/png"],
50
+ ["x-amz-meta-owner", "tenant"],
51
+ ]);
52
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antelopejs/dms",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/AntelopeJS/dms.git"
@@ -36,7 +36,7 @@
36
36
  "@antelopejs/interface-data-api": ">=0.1.9 <1.0.0",
37
37
  "@antelopejs/interface-database": ">=0.1.8 <1.0.0",
38
38
  "@antelopejs/interface-database-decorators": ">=0.1.7 <1.0.0",
39
- "@antelopejs/interface-dms": ">=0.3.3 <0.4.0",
39
+ "@antelopejs/interface-dms": ">=0.3.4 <0.4.0",
40
40
  "@antelopejs/interface-email": ">=0.0.5 <1.0.0",
41
41
  "@antelopejs/interface-file-storage": ">=0.1.3 <1.0.0",
42
42
  "archiver": "^7.0.1",