@cosmicdrift/kumiko-bundled-features 0.98.0 → 0.100.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -6
- package/src/file-foundation/feature.ts +28 -141
- package/src/tags/constants.ts +6 -0
- package/src/tags/index.ts +1 -0
- package/src/tags/web/__tests__/tag-filter.test.tsx +124 -0
- package/src/tags/web/__tests__/tags-cell.test.tsx +94 -0
- package/src/tags/web/client-plugin.tsx +6 -0
- package/src/tags/web/i18n.ts +2 -2
- package/src/tags/web/index.ts +2 -0
- package/src/tags/web/tag-filter.tsx +24 -12
- package/src/tags/web/tags-cell.tsx +46 -0
- package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +0 -1
- package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +0 -1
- package/src/user-data-rights/__tests__/file-storage-unification.integration.test.ts +162 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.100.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -91,11 +91,11 @@
|
|
|
91
91
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
92
92
|
},
|
|
93
93
|
"dependencies": {
|
|
94
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
95
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
96
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
97
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
98
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
94
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.100.0",
|
|
95
|
+
"@cosmicdrift/kumiko-framework": "0.100.0",
|
|
96
|
+
"@cosmicdrift/kumiko-headless": "0.100.0",
|
|
97
|
+
"@cosmicdrift/kumiko-renderer": "0.100.0",
|
|
98
|
+
"@cosmicdrift/kumiko-renderer-web": "0.100.0",
|
|
99
99
|
"@mollie/api-client": "^4.5.0",
|
|
100
100
|
"@node-rs/argon2": "^2.0.2",
|
|
101
101
|
"@types/nodemailer": "^8.0.0",
|
|
@@ -1,103 +1,44 @@
|
|
|
1
1
|
// kumiko-feature-version: 1
|
|
2
2
|
//
|
|
3
|
-
// file-foundation as a Kumiko bundled feature —
|
|
3
|
+
// file-foundation as a Kumiko bundled feature — declares the `fileProvider`
|
|
4
|
+
// extension point + the per-tenant `provider` config key. Provider RESOLUTION
|
|
5
|
+
// (createFileProviderForTenant) + the plugin types now live in the framework
|
|
6
|
+
// (packages/framework/src/files/provider-resolver.ts) so the upload routes,
|
|
7
|
+
// `ctx.files` and the GDPR jobs resolve through ONE path — uploads, export and
|
|
8
|
+
// erasure hit the same store by construction. This feature re-exports the moved
|
|
9
|
+
// symbols unchanged so existing imports keep working.
|
|
4
10
|
//
|
|
5
|
-
// **Pattern-Vorbild:** identisch zu `mail-foundation`. Foundation
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// key `provider`.
|
|
11
|
+
// **Pattern-Vorbild:** identisch zu `mail-foundation`. Foundation deklariert
|
|
12
|
+
// extension-point `fileProvider`, Provider-Features (file-provider-s3, -inmemory,
|
|
13
|
+
// -s3-env, später -gcs/-azure-blob) registrieren sich namentlich. Tenant wählt
|
|
14
|
+
// zur Runtime via config-key `provider`.
|
|
10
15
|
//
|
|
11
|
-
// **
|
|
12
|
-
//
|
|
13
|
-
// forcePathStyle/accessKeyId) — die leben im Provider-Plugin.
|
|
14
|
-
// - Kein direkter Import von `createS3Provider`. Foundation kennt
|
|
15
|
-
// nur das `FileStorageProvider`-Interface (Type-Import, kein
|
|
16
|
-
// runtime-coupling).
|
|
17
|
-
//
|
|
18
|
-
// **Standalone:** Foundation ist ohne tier-engine nutzbar. Existing
|
|
19
|
-
// `files-provider-s3` (App-wide-Library) bleibt unangetastet.
|
|
20
|
-
//
|
|
21
|
-
// **Boot-Dependencies:** config (für provider-selector). Kein secrets,
|
|
22
|
-
// weil Foundation selbst keine Secrets hält.
|
|
16
|
+
// **Boot-Dependencies:** config (für provider-selector). Kein secrets, weil
|
|
17
|
+
// Foundation selbst keine Secrets hält.
|
|
23
18
|
|
|
24
|
-
import { requireDefined } from "@cosmicdrift/kumiko-bundled-features/foundation-shared";
|
|
25
19
|
import {
|
|
26
20
|
access,
|
|
27
|
-
type ConfigAccessor,
|
|
28
21
|
createTenantConfig,
|
|
29
22
|
defineFeature,
|
|
30
|
-
|
|
23
|
+
EXT_FILE_PROVIDER,
|
|
31
24
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
32
|
-
import type { FileStorageProvider } from "@cosmicdrift/kumiko-framework/files";
|
|
33
|
-
|
|
34
|
-
const FEATURE_NAME = "file-foundation";
|
|
35
|
-
|
|
36
|
-
// =============================================================================
|
|
37
|
-
// Plugin-Interface — what a Provider-Plugin must implement
|
|
38
|
-
// =============================================================================
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Schmaler Surface-Type fuer Provider-Plugins. HandlerContext ist zu
|
|
42
|
-
* fett (haelt tx, actor, signal etc.) — Provider sollen sich auf die
|
|
43
|
-
* read-Felder beschraenken die fuer Tenant-Config + Secret-Lookup
|
|
44
|
-
* gebraucht werden.
|
|
45
|
-
*
|
|
46
|
-
* **Warum nicht voller HandlerContext?** Im Worker-Pfad (r.job) gibt
|
|
47
|
-
* es keinen request-bezogenen `tx`/`actor`/`signal`. Wenn ein Provider
|
|
48
|
-
* `ctx.tx` lesen wuerde, wuerde der ganze Worker-Pfad zur Runtime
|
|
49
|
-
* brechen — und das wuerde NUR mit S3 und nur in production auffallen.
|
|
50
|
-
* Die schmale Surface zwingt Provider zur expliziten Erweiterung
|
|
51
|
-
* (extra-arg) statt silent ctx-feld-ausnutzen.
|
|
52
|
-
*
|
|
53
|
-
* **Felder:**
|
|
54
|
-
* config — fuer tenant-config-reads (bucket/region/endpoint/...)
|
|
55
|
-
* registry — fuer extension-Lookup in der Factory (nicht Plugin-intern)
|
|
56
|
-
* secrets — fuer tenant-secret-reads (s3.secretAccessKey)
|
|
57
|
-
* _userId — Audit-Identity fuer secret-reads. Im Handler-Pfad setzt
|
|
58
|
-
* der dispatcher das auf die Caller-User-ID; im Worker-Pfad
|
|
59
|
-
* muss der r.job-Wrap das explizit auf eine System-Identity
|
|
60
|
-
* setzen (z.B. "system:user-data-rights:run-export-jobs").
|
|
61
|
-
*/
|
|
62
|
-
export type FileProviderContext = {
|
|
63
|
-
readonly config?: ConfigAccessor;
|
|
64
|
-
readonly registry?: Registry;
|
|
65
|
-
readonly secrets?: import("@cosmicdrift/kumiko-framework/secrets").SecretsContext;
|
|
66
|
-
readonly _userId?: string | undefined;
|
|
67
|
-
};
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* File-Storage-Plugin contract. Each provider-feature (file-provider-s3,
|
|
71
|
-
* file-provider-azure-blob, ...) registers an implementation via
|
|
72
|
-
* `r.useExtension("fileProvider", "<name>", { build })`.
|
|
73
|
-
*
|
|
74
|
-
* **Plugin-Author-Warnung:** `ctx` ist EXPLIZIT ein FileProviderContext,
|
|
75
|
-
* nicht ein voller HandlerContext. Felder ausserhalb der schmalen
|
|
76
|
-
* Surface (z.B. `ctx.tx`, `ctx.actor`, `ctx.signal`, `ctx.notify`) sind
|
|
77
|
-
* im Worker-Pfad (r.job-getriggerte Provider-Builds) NICHT vorhanden.
|
|
78
|
-
* Cast `ctx as unknown as HandlerContext` macht den Compiler happy aber
|
|
79
|
-
* fliegt zur Runtime im Worker — und der Crash kommt erst in production
|
|
80
|
-
* mit dem ersten S3-Tenant. Wenn ein Plugin Felder braucht die nicht in
|
|
81
|
-
* FileProviderContext sind: lieber FileProviderContext explizit erweitern
|
|
82
|
-
* (sichtbarer breaking change) als ctx-cast.
|
|
83
|
-
*/
|
|
84
|
-
export type FileProviderPlugin = {
|
|
85
|
-
readonly build: (ctx: FileProviderContext, tenantId: string) => Promise<FileStorageProvider>;
|
|
86
|
-
};
|
|
87
25
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
26
|
+
export type {
|
|
27
|
+
FileProviderContext,
|
|
28
|
+
FileProviderPlugin,
|
|
29
|
+
} from "@cosmicdrift/kumiko-framework/files";
|
|
30
|
+
// Moved into the framework — re-exported here so `@cosmicdrift/kumiko-bundled-
|
|
31
|
+
// features/file-foundation` consumers (user-data-rights, app code) keep working.
|
|
32
|
+
export {
|
|
33
|
+
createFileProviderForTenant,
|
|
34
|
+
isFileProviderPlugin,
|
|
35
|
+
} from "@cosmicdrift/kumiko-framework/files";
|
|
93
36
|
|
|
94
|
-
|
|
95
|
-
// Feature-definition
|
|
96
|
-
// =============================================================================
|
|
37
|
+
const FEATURE_NAME = "file-foundation";
|
|
97
38
|
|
|
98
39
|
export const fileFoundationFeature = defineFeature(FEATURE_NAME, (r) => {
|
|
99
40
|
r.describe(
|
|
100
|
-
"Defines the `fileProvider` extension point and a per-tenant `provider` config key that selects which registered storage plugin to use at runtime. Call `createFileProviderForTenant(ctx, tenantId)` to get a `FileStorageProvider`
|
|
41
|
+
"Defines the `fileProvider` extension point and a per-tenant `provider` config key that selects which registered storage plugin to use at runtime. Call `createFileProviderForTenant(ctx, tenantId)` to get a `FileStorageProvider` — use this feature together with at least one `file-provider-*` feature; the `files` feature builds on top of it for tracked `FileRef` entities with GDPR hooks.",
|
|
101
42
|
);
|
|
102
43
|
r.uiHints({
|
|
103
44
|
displayLabel: "File Provider Foundation",
|
|
@@ -106,7 +47,7 @@ export const fileFoundationFeature = defineFeature(FEATURE_NAME, (r) => {
|
|
|
106
47
|
});
|
|
107
48
|
r.requires("config");
|
|
108
49
|
|
|
109
|
-
r.extendsRegistrar(
|
|
50
|
+
r.extendsRegistrar(EXT_FILE_PROVIDER, {
|
|
110
51
|
onRegister: () => {
|
|
111
52
|
// No side-effects at register-time — registry stores the usage,
|
|
112
53
|
// factory looks it up at request-time.
|
|
@@ -124,61 +65,7 @@ export const fileFoundationFeature = defineFeature(FEATURE_NAME, (r) => {
|
|
|
124
65
|
});
|
|
125
66
|
// Readiness gating: provider-plugins' required keys/secrets count only
|
|
126
67
|
// while their plugin is the one this key selects.
|
|
127
|
-
r.extensionSelector(
|
|
68
|
+
r.extensionSelector(EXT_FILE_PROVIDER, configKeys.provider);
|
|
128
69
|
|
|
129
70
|
return { configKeys };
|
|
130
71
|
});
|
|
131
|
-
|
|
132
|
-
// =============================================================================
|
|
133
|
-
// Provider-factory — looks up the registered plugin + delegates
|
|
134
|
-
// =============================================================================
|
|
135
|
-
|
|
136
|
-
export async function createFileProviderForTenant(
|
|
137
|
-
ctx: FileProviderContext,
|
|
138
|
-
tenantId: string,
|
|
139
|
-
handlerName = "file-foundation:provider-factory",
|
|
140
|
-
): Promise<FileStorageProvider> {
|
|
141
|
-
const ctxConfig = ctx.config;
|
|
142
|
-
if (!ctxConfig) {
|
|
143
|
-
throw new Error(
|
|
144
|
-
`${handlerName}: ctx.config is missing — feature requires the config-feature mounted in the registry`,
|
|
145
|
-
);
|
|
146
|
-
}
|
|
147
|
-
if (!ctx.registry) {
|
|
148
|
-
throw new Error(
|
|
149
|
-
`${handlerName}: ctx.registry is missing — required to look up registered file-provider plugins`,
|
|
150
|
-
);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
const provider = requireDefined(
|
|
154
|
-
await ctxConfig(fileFoundationFeature.exports.configKeys.provider),
|
|
155
|
-
FEATURE_NAME,
|
|
156
|
-
"provider",
|
|
157
|
-
) as string; // @cast-boundary engine-payload
|
|
158
|
-
if (provider.length === 0) {
|
|
159
|
-
const usages = ctx.registry.getExtensionUsages("fileProvider");
|
|
160
|
-
const known = usages.map((u) => u.entityName).join(", ") || "<none>";
|
|
161
|
-
throw new Error(
|
|
162
|
-
`${FEATURE_NAME}: no provider selected — set the 'provider' config-key to one of: ${known}. ` +
|
|
163
|
-
`Mount a file-provider-* feature first if no plugins are registered.`,
|
|
164
|
-
);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
const usages = ctx.registry.getExtensionUsages("fileProvider");
|
|
168
|
-
const usage = usages.find((u) => u.entityName === provider);
|
|
169
|
-
if (!usage) {
|
|
170
|
-
const known = usages.map((u) => u.entityName).join(", ") || "<none>";
|
|
171
|
-
throw new Error(
|
|
172
|
-
`${FEATURE_NAME}: provider "${provider}" not registered. Known: ${known}. ` +
|
|
173
|
-
`Mount the matching file-provider-${provider} feature.`,
|
|
174
|
-
);
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
if (!isFileProviderPlugin(usage.options)) {
|
|
178
|
-
throw new Error(
|
|
179
|
-
`${FEATURE_NAME}: provider "${provider}" registered without a build() — ` +
|
|
180
|
-
`extension options must be a FileProviderPlugin.`,
|
|
181
|
-
);
|
|
182
|
-
}
|
|
183
|
-
return usage.options.build(ctx, tenantId);
|
|
184
|
-
}
|
package/src/tags/constants.ts
CHANGED
|
@@ -19,6 +19,12 @@ export const TAGS_SECTION_EXTENSION_NAME = "TagSection";
|
|
|
19
19
|
// after mounting tagsClient(); the renderer passes it the list's screenId.
|
|
20
20
|
export const TAGS_FILTER_EXTENSION_NAME = "TagFilter";
|
|
21
21
|
|
|
22
|
+
// Registry name for the <TagsCell> column renderer. A host entityList shows an
|
|
23
|
+
// entity's tags inline by declaring a labeled virtual column:
|
|
24
|
+
// `{ field: "tags", label: "Tags", renderer: { react: { __component: TAGS_COLUMN_RENDERER_NAME } } }`
|
|
25
|
+
// after mounting tagsClient() (which registers it under clientFeatures.columnRenderers).
|
|
26
|
+
export const TAGS_COLUMN_RENDERER_NAME = "TagsCell";
|
|
27
|
+
|
|
22
28
|
// Screen-id of the standalone Tags management screen (custom screen rendering
|
|
23
29
|
// TagManager). Qualified = "tags:screen:tag-list"; the app places it via r.nav.
|
|
24
30
|
export const TAGS_SCREEN_ID = "tag-list";
|
package/src/tags/index.ts
CHANGED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
createStaticLocaleResolver,
|
|
4
|
+
LocaleProvider,
|
|
5
|
+
PrimitivesProvider,
|
|
6
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
7
|
+
import { defaultPrimitives } from "@cosmicdrift/kumiko-renderer-web";
|
|
8
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
9
|
+
import type { ReactNode } from "react";
|
|
10
|
+
import { TagsQueries } from "../../constants";
|
|
11
|
+
import { defaultTranslations } from "../i18n";
|
|
12
|
+
import { TagFilter } from "../tag-filter";
|
|
13
|
+
|
|
14
|
+
type TagRow = { id: string; name: string; color?: string };
|
|
15
|
+
type AssignmentRow = { tagId: string; entityType: string; entityId: string };
|
|
16
|
+
|
|
17
|
+
let catalogRows: readonly TagRow[] = [];
|
|
18
|
+
let assignmentRows: readonly AssignmentRow[] = [];
|
|
19
|
+
const setFilterSpy = mock((_field: string, _values: readonly string[]) => {});
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
catalogRows = [
|
|
23
|
+
{ id: "t1", name: "urgent", color: "#ef4444" },
|
|
24
|
+
{ id: "t2", name: "backend", color: "#3b82f6" },
|
|
25
|
+
];
|
|
26
|
+
assignmentRows = [
|
|
27
|
+
{ tagId: "t1", entityType: "note", entityId: "n1" },
|
|
28
|
+
{ tagId: "t2", entityType: "note", entityId: "n2" },
|
|
29
|
+
];
|
|
30
|
+
setFilterSpy.mockClear();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const useQuerySpy = mock((type: string) => ({
|
|
34
|
+
data: type === TagsQueries.tagList ? { rows: catalogRows } : { rows: assignmentRows },
|
|
35
|
+
loading: false,
|
|
36
|
+
error: null,
|
|
37
|
+
refetch: mock(async () => {}),
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
const actual_renderer = await import("@cosmicdrift/kumiko-renderer");
|
|
41
|
+
mock.module("@cosmicdrift/kumiko-renderer", () => ({
|
|
42
|
+
...actual_renderer,
|
|
43
|
+
useQuery: useQuerySpy,
|
|
44
|
+
useListUrlState: () => ({
|
|
45
|
+
sort: null,
|
|
46
|
+
q: "",
|
|
47
|
+
page: 1,
|
|
48
|
+
filters: {},
|
|
49
|
+
setSort: mock(() => {}),
|
|
50
|
+
setQ: mock(() => {}),
|
|
51
|
+
setPage: mock(() => {}),
|
|
52
|
+
setFilter: setFilterSpy,
|
|
53
|
+
clearFilters: mock(() => {}),
|
|
54
|
+
}),
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
// Headless picker stub: a button that reports a tag selection back through the
|
|
58
|
+
// real onChange contract — no Dialog/cmdk popover to drive.
|
|
59
|
+
const StubPicker = ({
|
|
60
|
+
onChange,
|
|
61
|
+
}: {
|
|
62
|
+
readonly value: readonly string[];
|
|
63
|
+
readonly onChange: (next: readonly string[]) => void;
|
|
64
|
+
readonly entityType: string;
|
|
65
|
+
readonly open: boolean;
|
|
66
|
+
readonly onOpenChange: (open: boolean) => void;
|
|
67
|
+
}): ReactNode => (
|
|
68
|
+
<button type="button" data-testid="picker-pick-t1" onClick={() => onChange(["t1"])}>
|
|
69
|
+
pick urgent
|
|
70
|
+
</button>
|
|
71
|
+
);
|
|
72
|
+
mock.module("../tag-picker", () => ({ TagPicker: StubPicker }));
|
|
73
|
+
|
|
74
|
+
function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
|
|
75
|
+
return (
|
|
76
|
+
<LocaleProvider resolver={createStaticLocaleResolver()} fallbackBundles={[defaultTranslations]}>
|
|
77
|
+
<PrimitivesProvider value={defaultPrimitives}>{children}</PrimitivesProvider>
|
|
78
|
+
</LocaleProvider>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
describe("TagFilter", () => {
|
|
83
|
+
test("idle → just the filter button, no chips, no clear", () => {
|
|
84
|
+
render(
|
|
85
|
+
<Wrapper>
|
|
86
|
+
<TagFilter entityName="note" screenId="note-list" />
|
|
87
|
+
</Wrapper>,
|
|
88
|
+
);
|
|
89
|
+
expect(screen.getByTestId("tag-filter-open")).toBeTruthy();
|
|
90
|
+
expect(screen.queryByTestId("tag-chip")).toBeNull();
|
|
91
|
+
expect(screen.queryByTestId("tag-filter-clear")).toBeNull();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("picking a tag → narrows the list to its entity ids + shows the selected chip + clear", () => {
|
|
95
|
+
render(
|
|
96
|
+
<Wrapper>
|
|
97
|
+
<TagFilter entityName="note" screenId="note-list" />
|
|
98
|
+
</Wrapper>,
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
fireEvent.click(screen.getByTestId("picker-pick-t1"));
|
|
102
|
+
|
|
103
|
+
// urgent (t1) is assigned to n1 → list narrows to that id set
|
|
104
|
+
expect(setFilterSpy).toHaveBeenCalledWith("id", ["n1"]);
|
|
105
|
+
// the active selection is visible as a chip + a clear affordance
|
|
106
|
+
expect(screen.getByText("urgent")).toBeTruthy();
|
|
107
|
+
expect(screen.getByTestId("tag-filter-clear")).toBeTruthy();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("clear → drops the filter and the chip", () => {
|
|
111
|
+
render(
|
|
112
|
+
<Wrapper>
|
|
113
|
+
<TagFilter entityName="note" screenId="note-list" />
|
|
114
|
+
</Wrapper>,
|
|
115
|
+
);
|
|
116
|
+
fireEvent.click(screen.getByTestId("picker-pick-t1"));
|
|
117
|
+
setFilterSpy.mockClear();
|
|
118
|
+
|
|
119
|
+
fireEvent.click(screen.getByTestId("tag-filter-clear"));
|
|
120
|
+
|
|
121
|
+
expect(setFilterSpy).toHaveBeenCalledWith("id", []);
|
|
122
|
+
expect(screen.queryByText("urgent")).toBeNull();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
createStaticLocaleResolver,
|
|
4
|
+
LocaleProvider,
|
|
5
|
+
PrimitivesProvider,
|
|
6
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
7
|
+
import { defaultPrimitives } from "@cosmicdrift/kumiko-renderer-web";
|
|
8
|
+
import { render, screen } from "@testing-library/react";
|
|
9
|
+
import type { ReactNode } from "react";
|
|
10
|
+
import { TagsQueries } from "../../constants";
|
|
11
|
+
import { defaultTranslations } from "../i18n";
|
|
12
|
+
import { TagsCell } from "../tags-cell";
|
|
13
|
+
|
|
14
|
+
type TagRow = { id: string; name: string; color?: string };
|
|
15
|
+
type AssignmentRow = { tagId: string; entityType: string; entityId: string };
|
|
16
|
+
|
|
17
|
+
let catalogRows: readonly TagRow[] = [];
|
|
18
|
+
let assignmentRows: readonly AssignmentRow[] = [];
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
catalogRows = [];
|
|
22
|
+
assignmentRows = [];
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const useQuerySpy = mock((type: string) => ({
|
|
26
|
+
data: type === TagsQueries.tagList ? { rows: catalogRows } : { rows: assignmentRows },
|
|
27
|
+
loading: false,
|
|
28
|
+
error: null,
|
|
29
|
+
refetch: mock(async () => {}),
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
const actual_renderer = await import("@cosmicdrift/kumiko-renderer");
|
|
33
|
+
mock.module("@cosmicdrift/kumiko-renderer", () => ({
|
|
34
|
+
...actual_renderer,
|
|
35
|
+
useQuery: useQuerySpy,
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
|
|
39
|
+
return (
|
|
40
|
+
<LocaleProvider resolver={createStaticLocaleResolver()} fallbackBundles={[defaultTranslations]}>
|
|
41
|
+
<PrimitivesProvider value={defaultPrimitives}>{children}</PrimitivesProvider>
|
|
42
|
+
</LocaleProvider>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function renderCell(rowId: string): void {
|
|
47
|
+
render(
|
|
48
|
+
<Wrapper>
|
|
49
|
+
<TagsCell value={rowId} row={{ id: rowId }} column={{ field: "tags" }} />
|
|
50
|
+
</Wrapper>,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe("TagsCell", () => {
|
|
55
|
+
test("renders a chip per tag assigned to the row's id, joined to the catalog", () => {
|
|
56
|
+
catalogRows = [
|
|
57
|
+
{ id: "t1", name: "important", color: "#ef4444" },
|
|
58
|
+
{ id: "t2", name: "project-x", color: "#3b82f6" },
|
|
59
|
+
];
|
|
60
|
+
assignmentRows = [
|
|
61
|
+
{ tagId: "t1", entityType: "note", entityId: "n1" },
|
|
62
|
+
{ tagId: "t2", entityType: "note", entityId: "n1" },
|
|
63
|
+
{ tagId: "t1", entityType: "note", entityId: "n2" }, // a different row
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
renderCell("n1");
|
|
67
|
+
|
|
68
|
+
expect(screen.getByTestId("tags-cell")).toBeTruthy();
|
|
69
|
+
expect(screen.getByText("important")).toBeTruthy();
|
|
70
|
+
expect(screen.getByText("project-x")).toBeTruthy();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("renders nothing when the row has no assigned tags", () => {
|
|
74
|
+
catalogRows = [{ id: "t1", name: "important" }];
|
|
75
|
+
assignmentRows = [{ tagId: "t1", entityType: "note", entityId: "other-row" }];
|
|
76
|
+
|
|
77
|
+
renderCell("n1");
|
|
78
|
+
|
|
79
|
+
expect(screen.queryByTestId("tags-cell")).toBeNull();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("renders nothing for an empty row id", () => {
|
|
83
|
+
catalogRows = [{ id: "t1", name: "important" }];
|
|
84
|
+
assignmentRows = [{ tagId: "t1", entityType: "note", entityId: "n1" }];
|
|
85
|
+
|
|
86
|
+
render(
|
|
87
|
+
<Wrapper>
|
|
88
|
+
<TagsCell value="" row={{}} column={{ field: "tags" }} />
|
|
89
|
+
</Wrapper>,
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
expect(screen.queryByTestId("tags-cell")).toBeNull();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
|
|
4
4
|
import {
|
|
5
|
+
TAGS_COLUMN_RENDERER_NAME,
|
|
5
6
|
TAGS_FEATURE_NAME,
|
|
6
7
|
TAGS_FILTER_EXTENSION_NAME,
|
|
7
8
|
TAGS_SCREEN_ID,
|
|
@@ -11,6 +12,7 @@ import { defaultTranslations } from "./i18n";
|
|
|
11
12
|
import { TagFilter } from "./tag-filter";
|
|
12
13
|
import { TagManager } from "./tag-manager";
|
|
13
14
|
import { TagSection } from "./tag-section";
|
|
15
|
+
import { TagsCell } from "./tags-cell";
|
|
14
16
|
|
|
15
17
|
export function tagsClient(): ClientFeatureDefinition {
|
|
16
18
|
return {
|
|
@@ -20,6 +22,10 @@ export function tagsClient(): ClientFeatureDefinition {
|
|
|
20
22
|
// Header-slot tag filter for any entityList toolbar.
|
|
21
23
|
[TAGS_FILTER_EXTENSION_NAME]: TagFilter,
|
|
22
24
|
},
|
|
25
|
+
// Inline tag chips on any entityList row, via a labeled virtual column.
|
|
26
|
+
columnRenderers: {
|
|
27
|
+
[TAGS_COLUMN_RENDERER_NAME]: TagsCell,
|
|
28
|
+
},
|
|
23
29
|
// Standalone Tags management screen (custom screen → TagManager).
|
|
24
30
|
components: {
|
|
25
31
|
[TAGS_SCREEN_ID]: TagManager,
|
package/src/tags/web/i18n.ts
CHANGED
|
@@ -30,7 +30,7 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
30
30
|
"tags.picker.title": "Tags",
|
|
31
31
|
"tags.picker.done": "Fertig",
|
|
32
32
|
"tags.filter.label": "Nach Tag filtern",
|
|
33
|
-
"tags.filter.
|
|
33
|
+
"tags.filter.clear": "Filter zurücksetzen",
|
|
34
34
|
},
|
|
35
35
|
en: {
|
|
36
36
|
"tags.section.createMode": "Save the entity first to add tags.",
|
|
@@ -55,6 +55,6 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
55
55
|
"tags.picker.title": "Tags",
|
|
56
56
|
"tags.picker.done": "Done",
|
|
57
57
|
"tags.filter.label": "Filter by tag",
|
|
58
|
-
"tags.filter.
|
|
58
|
+
"tags.filter.clear": "Clear",
|
|
59
59
|
},
|
|
60
60
|
};
|
package/src/tags/web/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// @runtime client
|
|
2
2
|
export {
|
|
3
|
+
TAGS_COLUMN_RENDERER_NAME,
|
|
3
4
|
TAGS_FILTER_EXTENSION_NAME,
|
|
4
5
|
TAGS_SCREEN_ID,
|
|
5
6
|
TAGS_SECTION_EXTENSION_NAME,
|
|
@@ -13,3 +14,4 @@ export { TagFilter } from "./tag-filter";
|
|
|
13
14
|
export { TagManager } from "./tag-manager";
|
|
14
15
|
export { TagPicker } from "./tag-picker";
|
|
15
16
|
export { TagSection } from "./tag-section";
|
|
17
|
+
export { TagsCell } from "./tags-cell";
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
// @runtime client
|
|
2
2
|
// TagFilter — a drop-in tag filter for ANY entityList toolbar. Register it as
|
|
3
|
-
// the list's header slot (`screen.slots.header`)
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
3
|
+
// the list's header slot (`screen.slots.header`); the renderer passes it the
|
|
4
|
+
// list's screenId, and picking tags resolves the matching row ids and narrows
|
|
5
|
+
// the list via an id-set URL filter (`<screenId>.f.id=…`, applied as
|
|
6
|
+
// `{ field: "id", op: "in" }`). No host schema change: any list gets
|
|
7
|
+
// tag-filtering by mounting this in its header.
|
|
8
|
+
//
|
|
9
|
+
// The active selection is shown inline as colored chips next to the button, with
|
|
10
|
+
// a clear affordance — so the filter is visible, not hidden behind a count.
|
|
8
11
|
//
|
|
9
12
|
// ponytail: resolves ids from the assignment list (first 500 rows) + an id-IN
|
|
10
13
|
// URL filter — fine for typical tag volumes. For huge assignment sets add a
|
|
@@ -18,6 +21,7 @@ import {
|
|
|
18
21
|
} from "@cosmicdrift/kumiko-renderer";
|
|
19
22
|
import { type ReactNode, useState } from "react";
|
|
20
23
|
import { TagsQueries } from "../constants";
|
|
24
|
+
import { TagChip } from "./tag-chip";
|
|
21
25
|
import { TagPicker } from "./tag-picker";
|
|
22
26
|
|
|
23
27
|
// Tags chosen but zero matching entities → filter to a value that matches no row
|
|
@@ -29,6 +33,7 @@ type AssignmentRow = {
|
|
|
29
33
|
readonly entityType: string;
|
|
30
34
|
readonly entityId: string;
|
|
31
35
|
};
|
|
36
|
+
type TagRow = { readonly id: string; readonly name: string; readonly color?: string | null };
|
|
32
37
|
|
|
33
38
|
export function TagFilter({
|
|
34
39
|
entityName,
|
|
@@ -42,6 +47,7 @@ export function TagFilter({
|
|
|
42
47
|
const t = useTranslation();
|
|
43
48
|
const [open, setOpen] = useState(false);
|
|
44
49
|
const [selected, setSelected] = useState<readonly string[]>([]);
|
|
50
|
+
const catalog = useQuery<{ rows: readonly TagRow[] }>(TagsQueries.tagList, {});
|
|
45
51
|
const assignments = useQuery<{ rows: readonly AssignmentRow[] }>(TagsQueries.assignmentList, {
|
|
46
52
|
limit: 500,
|
|
47
53
|
});
|
|
@@ -66,16 +72,22 @@ export function TagFilter({
|
|
|
66
72
|
urlState.setFilter("id", ids.length > 0 ? ids : [NO_MATCH]);
|
|
67
73
|
};
|
|
68
74
|
|
|
69
|
-
const
|
|
70
|
-
selected.length > 0
|
|
71
|
-
? t("tags.filter.active", { count: selected.length })
|
|
72
|
-
: t("tags.filter.label");
|
|
75
|
+
const byId = new Map((catalog.data?.rows ?? []).map((tg) => [tg.id, tg]));
|
|
73
76
|
|
|
74
77
|
return (
|
|
75
|
-
|
|
78
|
+
<div className="flex flex-wrap items-center gap-1">
|
|
76
79
|
<Button variant="secondary" onClick={() => setOpen(true)} testId="tag-filter-open">
|
|
77
|
-
{label}
|
|
80
|
+
{t("tags.filter.label")}
|
|
78
81
|
</Button>
|
|
82
|
+
{selected.map((id) => {
|
|
83
|
+
const tag = byId.get(id);
|
|
84
|
+
return <TagChip key={id} name={tag?.name ?? id} color={tag?.color} />;
|
|
85
|
+
})}
|
|
86
|
+
{selected.length > 0 && (
|
|
87
|
+
<Button variant="secondary" onClick={() => applyFilter([])} testId="tag-filter-clear">
|
|
88
|
+
{t("tags.filter.clear")}
|
|
89
|
+
</Button>
|
|
90
|
+
)}
|
|
79
91
|
<TagPicker
|
|
80
92
|
entityType={entityName}
|
|
81
93
|
value={selected}
|
|
@@ -83,6 +95,6 @@ export function TagFilter({
|
|
|
83
95
|
open={open}
|
|
84
96
|
onOpenChange={setOpen}
|
|
85
97
|
/>
|
|
86
|
-
|
|
98
|
+
</div>
|
|
87
99
|
);
|
|
88
100
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// TagsCell — a reusable columnRenderer that shows an entity's tags as colored
|
|
3
|
+
// chips inline in a list row. Drop it into ANY entityList by declaring a labeled
|
|
4
|
+
// virtual column (no host-schema change), after mounting tagsClient():
|
|
5
|
+
// columns: [
|
|
6
|
+
// "title",
|
|
7
|
+
// { field: "tags", label: "Tags",
|
|
8
|
+
// renderer: { react: { __component: TAGS_COLUMN_RENDERER_NAME } } },
|
|
9
|
+
// ]
|
|
10
|
+
// It reads the row's id (entity ids are unique, so entityType is unnecessary)
|
|
11
|
+
// and renders a TagChip per assigned tag; renders nothing when the row has none.
|
|
12
|
+
//
|
|
13
|
+
// ponytail: bulk-loads the assignment list (first 500) + the catalog once and
|
|
14
|
+
// filters per row — kumiko's useQuery dedupes the identical (type, payload)
|
|
15
|
+
// calls, so the whole column shares two queries, not two-per-row. For huge
|
|
16
|
+
// assignment sets add a server-side entitiesByTag query + join filter.
|
|
17
|
+
|
|
18
|
+
import { type ColumnRendererProps, useQuery } from "@cosmicdrift/kumiko-renderer";
|
|
19
|
+
import type { ReactNode } from "react";
|
|
20
|
+
import { TagsQueries } from "../constants";
|
|
21
|
+
import { TagChip } from "./tag-chip";
|
|
22
|
+
|
|
23
|
+
type TagRow = { readonly id: string; readonly name: string; readonly color?: string | null };
|
|
24
|
+
type AssignmentRow = { readonly tagId: string; readonly entityId: string };
|
|
25
|
+
|
|
26
|
+
export function TagsCell({ row }: ColumnRendererProps): ReactNode {
|
|
27
|
+
const entityId = String(row["id"] ?? "");
|
|
28
|
+
const catalog = useQuery<{ rows: readonly TagRow[] }>(TagsQueries.tagList, {});
|
|
29
|
+
const assignments = useQuery<{ rows: readonly AssignmentRow[] }>(TagsQueries.assignmentList, {
|
|
30
|
+
limit: 500,
|
|
31
|
+
});
|
|
32
|
+
if (entityId === "") return null;
|
|
33
|
+
const byId = new Map((catalog.data?.rows ?? []).map((t) => [t.id, t]));
|
|
34
|
+
const tagIds = (assignments.data?.rows ?? [])
|
|
35
|
+
.filter((a) => a.entityId === entityId)
|
|
36
|
+
.map((a) => a.tagId);
|
|
37
|
+
if (tagIds.length === 0) return null;
|
|
38
|
+
return (
|
|
39
|
+
<div className="flex flex-wrap gap-1" data-testid="tags-cell">
|
|
40
|
+
{tagIds.map((id) => {
|
|
41
|
+
const tag = byId.get(id);
|
|
42
|
+
return <TagChip key={id} name={tag?.name ?? id} color={tag?.color} />;
|
|
43
|
+
})}
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -80,7 +80,6 @@ beforeAll(async () => {
|
|
|
80
80
|
createUserDataRightsFeature(),
|
|
81
81
|
createUserDataRightsDefaultsFeature(),
|
|
82
82
|
],
|
|
83
|
-
files: { storageProvider: provider },
|
|
84
83
|
extraContext: ({ registry }) => ({
|
|
85
84
|
configResolver: resolver,
|
|
86
85
|
_configAccessorFactory: createConfigAccessorFactory(registry, resolver),
|
|
@@ -90,7 +90,6 @@ beforeAll(async () => {
|
|
|
90
90
|
createUserDataRightsFeature(),
|
|
91
91
|
createUserDataRightsDefaultsFeature(),
|
|
92
92
|
],
|
|
93
|
-
files: { storageProvider: flakyProvider },
|
|
94
93
|
extraContext: ({ registry }) => ({
|
|
95
94
|
configResolver: resolver,
|
|
96
95
|
_configAccessorFactory: createConfigAccessorFactory(registry, resolver),
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// #608 trap-closed regression: uploads and erasure resolve the file provider
|
|
2
|
+
// through ONE source (file-foundation), end-to-end through the real server.
|
|
3
|
+
//
|
|
4
|
+
// The stack mounts file-foundation + a provider plugin but does NOT inject a
|
|
5
|
+
// resolver (no `files:` option) — so buildServer must build the upload-route
|
|
6
|
+
// resolver itself from the mounted features, exactly as production does. A file
|
|
7
|
+
// uploaded through POST /api/files must land in the SAME store the GDPR forget
|
|
8
|
+
// pipeline deletes from. Before the unification the upload route wrote through a
|
|
9
|
+
// separately-wired provider while erasure deleted through file-foundation — so a
|
|
10
|
+
// regression to a static `storageProvider` makes the `provider.exists(...)`
|
|
11
|
+
// assertion below go red.
|
|
12
|
+
|
|
13
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
14
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
15
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
16
|
+
import { type SessionUser, SYSTEM_USER_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
17
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
18
|
+
import {
|
|
19
|
+
createInMemoryFileProvider,
|
|
20
|
+
type FileStorageProvider,
|
|
21
|
+
fileRefsTable,
|
|
22
|
+
type InMemoryFileProvider,
|
|
23
|
+
} from "@cosmicdrift/kumiko-framework/files";
|
|
24
|
+
import {
|
|
25
|
+
setupTestStack,
|
|
26
|
+
type TestStack,
|
|
27
|
+
unsafeCreateEntityTable,
|
|
28
|
+
unsafePushTables,
|
|
29
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
30
|
+
import {
|
|
31
|
+
buildMultipartBody,
|
|
32
|
+
patchFileInstanceofForBunTest,
|
|
33
|
+
resetTestTables,
|
|
34
|
+
} from "@cosmicdrift/kumiko-framework/testing";
|
|
35
|
+
import { createComplianceProfilesFeature } from "../../compliance-profiles";
|
|
36
|
+
import { createConfigFeature } from "../../config";
|
|
37
|
+
import { createConfigAccessorFactory } from "../../config/feature";
|
|
38
|
+
import { createConfigResolver } from "../../config/resolver";
|
|
39
|
+
import { configValuesTable } from "../../config/table";
|
|
40
|
+
import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
|
|
41
|
+
import { fileFoundationFeature } from "../../file-foundation";
|
|
42
|
+
import { createFilesFeature } from "../../files";
|
|
43
|
+
import { createSessionsFeature } from "../../sessions";
|
|
44
|
+
import { createUserFeature, userEntity, userTable } from "../../user";
|
|
45
|
+
import { createUserDataRightsDefaultsFeature } from "../../user-data-rights-defaults";
|
|
46
|
+
import { createUserDataRightsFeature } from "../feature";
|
|
47
|
+
import { makeTenantStorageProviderResolver } from "../lib/storage-provider-resolver";
|
|
48
|
+
import { runForgetCleanup } from "../run-forget-cleanup";
|
|
49
|
+
import {
|
|
50
|
+
createForgetSeeders,
|
|
51
|
+
createTestFileProviderFeature,
|
|
52
|
+
type ForgetSeeders,
|
|
53
|
+
nowInstant,
|
|
54
|
+
READ_TENANT_MEMBERSHIPS_DDL,
|
|
55
|
+
TENANT_SYSTEM,
|
|
56
|
+
} from "./forget-test-helpers";
|
|
57
|
+
|
|
58
|
+
const FILE_PROVIDER_CONFIG_KEY = "file-foundation:config:provider";
|
|
59
|
+
|
|
60
|
+
let stack: TestStack;
|
|
61
|
+
let db: DbConnection;
|
|
62
|
+
let provider: InMemoryFileProvider;
|
|
63
|
+
let seed: ForgetSeeders;
|
|
64
|
+
let buildStorageProvider: (tenantId: string) => Promise<FileStorageProvider>;
|
|
65
|
+
|
|
66
|
+
function uuid(suffix: number): string {
|
|
67
|
+
return `cccccccc-cccc-4ccc-8ccc-${suffix.toString(16).padStart(12, "0")}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function uploadAs(user: SessionUser, fileName: string, bytes: Uint8Array): Promise<Response> {
|
|
71
|
+
const token = await stack.jwt.sign(user);
|
|
72
|
+
const fd = new FormData();
|
|
73
|
+
fd.append("file", new File([Buffer.from(bytes)], fileName, { type: "application/pdf" }));
|
|
74
|
+
const { body, contentType } = await buildMultipartBody(fd);
|
|
75
|
+
return stack.app.request("/api/files", {
|
|
76
|
+
method: "POST",
|
|
77
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType },
|
|
78
|
+
body,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
beforeAll(async () => {
|
|
83
|
+
patchFileInstanceofForBunTest();
|
|
84
|
+
provider = createInMemoryFileProvider();
|
|
85
|
+
// Select the test plugin app-wide via a config app-override (no admin write).
|
|
86
|
+
const appOverrides = new Map<string, string>([[FILE_PROVIDER_CONFIG_KEY, "test"]]);
|
|
87
|
+
const resolver = createConfigResolver({ appOverrides });
|
|
88
|
+
stack = await setupTestStack({
|
|
89
|
+
features: [
|
|
90
|
+
createConfigFeature(),
|
|
91
|
+
createUserFeature(),
|
|
92
|
+
createFilesFeature(),
|
|
93
|
+
fileFoundationFeature,
|
|
94
|
+
createTestFileProviderFeature(provider, "test"),
|
|
95
|
+
createDataRetentionFeature(),
|
|
96
|
+
createComplianceProfilesFeature(),
|
|
97
|
+
createSessionsFeature(),
|
|
98
|
+
createUserDataRightsFeature(),
|
|
99
|
+
createUserDataRightsDefaultsFeature(),
|
|
100
|
+
],
|
|
101
|
+
// No `files:` option on purpose — buildServer must resolve the upload
|
|
102
|
+
// provider through the mounted file-foundation, like production.
|
|
103
|
+
extraContext: ({ registry }) => ({
|
|
104
|
+
configResolver: resolver,
|
|
105
|
+
_configAccessorFactory: createConfigAccessorFactory(registry, resolver),
|
|
106
|
+
}),
|
|
107
|
+
});
|
|
108
|
+
db = stack.db;
|
|
109
|
+
seed = createForgetSeeders(db, provider);
|
|
110
|
+
buildStorageProvider = makeTenantStorageProviderResolver({
|
|
111
|
+
registry: stack.registry,
|
|
112
|
+
configResolver: resolver,
|
|
113
|
+
secrets: undefined,
|
|
114
|
+
db,
|
|
115
|
+
userId: SYSTEM_USER_ID,
|
|
116
|
+
handlerName: "test-608-e2e",
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
await unsafeCreateEntityTable(db, userEntity);
|
|
120
|
+
await unsafeCreateEntityTable(db, tenantRetentionOverrideEntity);
|
|
121
|
+
await unsafePushTables(db, { fileRefsTable, configValuesTable });
|
|
122
|
+
await createEventsTable(db);
|
|
123
|
+
await asRawClient(db).unsafe(READ_TENANT_MEMBERSHIPS_DDL);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
afterAll(async () => {
|
|
127
|
+
await stack.cleanup();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
beforeEach(async () => {
|
|
131
|
+
provider.clear();
|
|
132
|
+
await resetTestTables(db, [userTable, "read_tenant_memberships", fileRefsTable]);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("#608 unified file-storage :: route upload + GDPR erasure hit one store", () => {
|
|
136
|
+
test("a file uploaded via /api/files lands in the file-foundation store and forget erases it", async () => {
|
|
137
|
+
const userId = uuid(1);
|
|
138
|
+
// The uploader is also the forget subject (DeletionRequested + grace passed).
|
|
139
|
+
await seed.seedForgetUser(userId);
|
|
140
|
+
await seed.seedMembership(userId, TENANT_SYSTEM);
|
|
141
|
+
const uploader: SessionUser = { id: userId, tenantId: TENANT_SYSTEM, roles: ["Member"] };
|
|
142
|
+
|
|
143
|
+
const res = await uploadAs(uploader, "secret.pdf", new Uint8Array([10, 20, 30, 40]));
|
|
144
|
+
expect(res.status).toBe(201);
|
|
145
|
+
const { storageKey } = (await res.json()) as { storageKey: string };
|
|
146
|
+
|
|
147
|
+
// The upload route resolved its provider through file-foundation — the bytes
|
|
148
|
+
// are in `provider`. A static/separate upload provider would miss here.
|
|
149
|
+
expect(await provider.exists(storageKey)).toBe(true);
|
|
150
|
+
|
|
151
|
+
// Erasure resolves through the SAME source and deletes the very same bytes —
|
|
152
|
+
// no false "done", no orphaned binary.
|
|
153
|
+
const result = await runForgetCleanup({
|
|
154
|
+
db,
|
|
155
|
+
registry: stack.registry,
|
|
156
|
+
now: nowInstant(),
|
|
157
|
+
buildStorageProvider,
|
|
158
|
+
});
|
|
159
|
+
expect(result.processedUserIds).toContain(userId);
|
|
160
|
+
expect(await provider.exists(storageKey)).toBe(false);
|
|
161
|
+
});
|
|
162
|
+
});
|