@podosoft/podokit 1.1.5 → 1.2.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/dist/deploy-render.js +5 -0
- package/dist/templates/fullstack/apps/api/package.json +2 -2
- package/dist/templates/fullstack/apps/api/src/app.spec.ts +81 -0
- package/dist/templates/fullstack/apps/api/src/app.ts +53 -5
- package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +6 -0
- package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +9 -1
- package/dist/templates/fullstack/apps/api/src/core/services.spec.ts +30 -0
- package/dist/templates/fullstack/apps/api/src/core/services.ts +19 -3
- package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +149 -4
- package/dist/templates/fullstack/apps/api/src/database/database.ts +66 -13
- package/dist/templates/fullstack/apps/api/src/health/health.plugin.spec.ts +86 -0
- package/dist/templates/fullstack/apps/api/src/health/health.plugin.ts +4 -9
- package/dist/templates/fullstack/apps/api/src/health/readiness.service.spec.ts +141 -1
- package/dist/templates/fullstack/apps/api/src/health/readiness.service.ts +86 -12
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +188 -1
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +30 -0
- package/dist/templates/fullstack/apps/web/src/routes/api/[...path]/+server.ts +3 -3
- package/dist/templates/fullstack/apps/web/src/routes/api/auth/[...all]/+server.ts +3 -3
- package/dist/templates/fullstack/compose.dev.yaml +21 -0
- package/dist/templates/fullstack/dot-env.docker +3 -0
- package/dist/templates/fullstack/dot-env.example +14 -0
- package/dist/templates/fullstack/infra/k3s/api-deployment.yaml +2 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.spec.ts +25 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.ts +26 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.routes.spec.ts +122 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.routes.ts +46 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.spec.ts +187 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.ts +160 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +17 -28
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.spec.ts +3 -5
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.ts +6 -17
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-assets/site-assets.routes.spec.ts +167 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-assets/site-assets.routes.ts +50 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.spec.ts +158 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.ts +78 -28
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/app.d.ts +3 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +124 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +19 -11
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-data.server.ts +6 -32
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/sessions.test.ts +74 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/sessions.ts +73 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.test.ts +75 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.ts +71 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/audit-log.test.ts +68 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/audit-log.ts +108 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +14 -8
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/session-idle-timeout.svelte +74 -49
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +6 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +6 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.test.ts +29 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +23 -6
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/oauth-query.test.ts +21 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/oauth-query.ts +15 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/capabilities.test.ts +98 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/capabilities.ts +54 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/protected-layout.ts +7 -50
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/audit/+page.svelte +88 -45
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/sessions/+page.svelte +76 -75
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.server.ts +15 -14
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.svelte +2 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/users/+page.svelte +86 -38
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +10 -17
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.ts +10 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/oauth2/consent/+page.svelte +4 -3
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.svelte +14 -8
- package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +2 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/admin-sessions.api.spec.ts +124 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +75 -12
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +34 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +7 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +72 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +36 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/audit.ui.spec.ts +41 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +32 -13
- package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +13 -4
- package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +46 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +64 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +24 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +8 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +20 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/README.md +46 -5
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-events.ts +19 -7
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +2 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.migration.spec.ts +81 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.module.ts +13 -11
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.routes.spec.ts +115 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.routes.ts +44 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +421 -43
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +382 -35
- package/dist/templates/modules/audit-log/files/apps/api/src/migrations/1792000000000-IndexAuditLogFilters.ts +35 -0
- package/dist/templates/modules/audit-log/module.manifest.json +7 -4
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +20 -5
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +24 -29
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +28 -13
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.openapi.spec.ts +56 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.openapi.ts +22 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.spec.ts +81 -5
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +26 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +17 -9
- package/dist/templates/modules/auth/files/apps/api/src/auth/config-events.spec.ts +38 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/config-events.ts +43 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.spec.ts +24 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +31 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.spec.ts +58 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +46 -57
- package/dist/templates/modules/auth/files/apps/api/src/auth/schema-validation.ts +13 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/skip-schema-validation.ts +5 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +7 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/config-param.spec.ts +23 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/config-param.ts +10 -0
- package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +3 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +10 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.spec.ts +81 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.ts +37 -34
- package/dist/templates/modules/auth/module.manifest.json +2 -2
- package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.spec.ts +136 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +34 -13
- package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-keys.spec.ts +60 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-keys.ts +51 -0
- package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +70 -9
- package/dist/templates/modules/file-upload/module.manifest.json +9 -3
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/job-progress.module.ts +26 -32
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.spec.ts +16 -1
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.processor.ts +31 -4
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.spec.ts +145 -0
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.ts +37 -0
- package/dist/templates/modules/job-progress/files/tests/api/progress.api.spec.ts +94 -4
- package/dist/templates/modules/job-progress/module.manifest.json +3 -3
- package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.spec.ts +54 -0
- package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.ts +47 -25
- package/dist/templates/modules/mailer/module.manifest.json +1 -1
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.spec.ts +86 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.ts +159 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-demo.ts +38 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +172 -4
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +110 -33
- package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +88 -0
- package/dist/templates/modules/object-storage-local/module.manifest.json +6 -1
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.spec.ts +80 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +13 -7
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.spec.ts +156 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.ts +38 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.module.ts +39 -21
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +101 -2
- package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +84 -9
- package/dist/templates/modules/object-storage-s3/module.manifest.json +8 -3
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +9 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +7 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +139 -21
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +80 -11
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +12 -6
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +15 -2
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +46 -0
- package/dist/templates/modules/rate-limit/module.manifest.json +4 -3
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.spec.ts +125 -0
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +27 -13
- package/dist/templates/modules/sse/files/apps/api/src/events/event-envelope.ts +123 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/event-stream.ts +191 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/event-topics.ts +64 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.module.spec.ts +203 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +107 -14
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +372 -18
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +136 -40
- package/dist/templates/modules/sse/files/apps/web/src/lib/events.test.ts +216 -0
- package/dist/templates/modules/sse/files/apps/web/src/lib/events.ts +207 -0
- package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +181 -70
- package/dist/templates/modules/sse/module.manifest.json +17 -4
- package/package.json +2 -2
|
@@ -5,7 +5,11 @@ import {
|
|
|
5
5
|
} from "@podosoft/podokit-contracts";
|
|
6
6
|
import { OBJECT_STORAGE } from "@podosoft/podokit-runtime";
|
|
7
7
|
import { Elysia, t } from "elysia";
|
|
8
|
+
import { adminSessionRoutes } from "./admin-sessions/admin-sessions.routes";
|
|
9
|
+
import { createSessionRevoker, type RevokeUserSessionApi } from "./admin-sessions/admin-sessions.revoker";
|
|
10
|
+
import { AdminSessionsService } from "./admin-sessions/admin-sessions.service";
|
|
8
11
|
import { AUTH } from "./auth/auth.module";
|
|
12
|
+
import { getAuth } from "./auth/auth-provider";
|
|
9
13
|
import {
|
|
10
14
|
ACCESS_POLICY,
|
|
11
15
|
DATABASE,
|
|
@@ -16,6 +20,7 @@ import {
|
|
|
16
20
|
} from "./core/services";
|
|
17
21
|
import { ProfileImageService } from "./profile-image/profile-image.service";
|
|
18
22
|
import { PROFILE_IMAGE_POLICY } from "@podosoft/podokit-contracts";
|
|
23
|
+
import { siteAssetRoutes } from "./site-assets/site-assets.routes";
|
|
19
24
|
import {
|
|
20
25
|
PUBLIC_SITE_KEYS,
|
|
21
26
|
SiteSettingsService,
|
|
@@ -24,6 +29,7 @@ import { validateSiteSetting } from "./site-settings/site-settings.validation";
|
|
|
24
29
|
|
|
25
30
|
export const SITE_SETTINGS = Symbol("site-settings") as ServiceKey<SiteSettingsService>;
|
|
26
31
|
export const PROFILE_IMAGES = Symbol("profile-images") as ServiceKey<ProfileImageService>;
|
|
32
|
+
export const ADMIN_SESSIONS = Symbol("admin-sessions") as ServiceKey<AdminSessionsService>;
|
|
27
33
|
|
|
28
34
|
const ICON_TYPES = new Set([
|
|
29
35
|
"image/svg+xml",
|
|
@@ -37,25 +43,25 @@ function currentImage(user: Record<string, unknown>): string | null {
|
|
|
37
43
|
return typeof user.image === "string" ? user.image : null;
|
|
38
44
|
}
|
|
39
45
|
|
|
40
|
-
function responseBody(buffer: Buffer): ArrayBuffer {
|
|
41
|
-
return buffer.buffer.slice(
|
|
42
|
-
buffer.byteOffset,
|
|
43
|
-
buffer.byteOffset + buffer.byteLength,
|
|
44
|
-
) as ArrayBuffer;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
46
|
const adminPlugin: AppPlugin = ({ services }) => {
|
|
48
47
|
const auth = services.resolve(AUTH);
|
|
49
48
|
const site = services.resolve(SITE_SETTINGS);
|
|
50
49
|
const profileImages = services.resolve(PROFILE_IMAGES);
|
|
51
50
|
|
|
52
51
|
return new Elysia({ name: "podokit.admin" })
|
|
52
|
+
.use(adminSessionRoutes({
|
|
53
|
+
auth,
|
|
54
|
+
sessions: services.resolve(ADMIN_SESSIONS),
|
|
55
|
+
revoke: createSessionRevoker(() => getAuth().api as unknown as RevokeUserSessionApi),
|
|
56
|
+
}))
|
|
57
|
+
.use(siteAssetRoutes(site, profileImages))
|
|
53
58
|
.get("/site/settings", async () => {
|
|
59
|
+
// The favicon fields are site.* rows too, so one read serves the response.
|
|
54
60
|
const all = await site.getAll();
|
|
55
61
|
const response: Record<string, string | boolean | null> = {};
|
|
56
62
|
for (const key of PUBLIC_SITE_KEYS) response[key] = all[key] ?? null;
|
|
57
|
-
response.hasFavicon =
|
|
58
|
-
response.faviconVersion =
|
|
63
|
+
response.hasFavicon = all.faviconContentType !== undefined;
|
|
64
|
+
response.faviconVersion = all.faviconUpdatedAt ?? null;
|
|
59
65
|
return response;
|
|
60
66
|
}, {
|
|
61
67
|
detail: { tags: ["site"], summary: "Get public site settings" },
|
|
@@ -87,15 +93,6 @@ const adminPlugin: AppPlugin = ({ services }) => {
|
|
|
87
93
|
body: t.Object({ file: t.File() }),
|
|
88
94
|
detail: { tags: ["site"], summary: "Upload the site favicon" },
|
|
89
95
|
})
|
|
90
|
-
.get("/site/favicon", async () => {
|
|
91
|
-
const icon = await site.getFavicon();
|
|
92
|
-
if (!icon) throw new AppException("FAVICON_NOT_FOUND", "No favicon set", 404);
|
|
93
|
-
return new Response(responseBody(icon.body), {
|
|
94
|
-
headers: { "content-type": icon.contentType },
|
|
95
|
-
});
|
|
96
|
-
}, {
|
|
97
|
-
detail: { tags: ["site"], summary: "Get the site favicon" },
|
|
98
|
-
})
|
|
99
96
|
.post("/account/profile-image", async ({ request, body, set }) => {
|
|
100
97
|
const session = await auth.requireSession(request);
|
|
101
98
|
const file = body.file;
|
|
@@ -122,22 +119,14 @@ const adminPlugin: AppPlugin = ({ services }) => {
|
|
|
122
119
|
return profileImages.remove(currentImage(session.user), request.headers);
|
|
123
120
|
}, {
|
|
124
121
|
detail: { tags: ["account"], summary: "Remove the current user's profile image" },
|
|
125
|
-
})
|
|
126
|
-
.get("/profile-images/:fileName", async ({ params, set }) => {
|
|
127
|
-
const image = await profileImages.get(params.fileName);
|
|
128
|
-
set.headers["cache-control"] = "public, max-age=31536000, immutable";
|
|
129
|
-
return new Response(responseBody(image.body), {
|
|
130
|
-
headers: { "content-type": image.contentType },
|
|
131
|
-
});
|
|
132
|
-
}, {
|
|
133
|
-
params: t.Object({ fileName: t.String({ minLength: 1 }) }),
|
|
134
|
-
detail: { tags: ["account"], summary: "Get a public profile image" },
|
|
135
122
|
});
|
|
136
123
|
};
|
|
137
124
|
|
|
138
125
|
export const adminModule: PodokitModule = {
|
|
139
126
|
name: "admin-dashboard",
|
|
140
127
|
configure: (_env, services): void => {
|
|
128
|
+
const database = services.resolve(DATABASE);
|
|
129
|
+
services.register(ADMIN_SESSIONS, new AdminSessionsService(database.sql, database.provider));
|
|
141
130
|
const storage = services.resolve(OBJECT_STORAGE);
|
|
142
131
|
const site = new SiteSettingsService(services.resolve(DATABASE).sql, storage);
|
|
143
132
|
const profileImages = new ProfileImageService(storage, services.resolve(LOGGER));
|
|
@@ -7,11 +7,9 @@ import { ProfileImageService } from "./profile-image.service";
|
|
|
7
7
|
describe("ProfileImageService account deletion cleanup", () => {
|
|
8
8
|
const storage = {
|
|
9
9
|
put: mock(async () => undefined),
|
|
10
|
-
get: mock(async () =>
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
body: (async function* (): AsyncGenerator<Uint8Array> { yield Buffer.alloc(0); })(),
|
|
14
|
-
})),
|
|
10
|
+
get: mock(async () => null),
|
|
11
|
+
stat: mock(async () => null),
|
|
12
|
+
list: mock(async () => ({ keys: [] })),
|
|
15
13
|
exists: mock(async () => true),
|
|
16
14
|
getDownloadUrl: mock(async () => "https://example.com/image"),
|
|
17
15
|
delete: mock(async () => undefined),
|
|
@@ -21,7 +21,7 @@ const CONTENT_TYPES: Record<string, string> = {
|
|
|
21
21
|
};
|
|
22
22
|
|
|
23
23
|
export interface StoredProfileImage {
|
|
24
|
-
|
|
24
|
+
object: ObjectData;
|
|
25
25
|
contentType: string;
|
|
26
26
|
}
|
|
27
27
|
|
|
@@ -33,12 +33,6 @@ export type UpdateAuthUser = (
|
|
|
33
33
|
const updateAuthUser: UpdateAuthUser = (image, headers) =>
|
|
34
34
|
getAuth().api.updateUser({ body: { image }, headers });
|
|
35
35
|
|
|
36
|
-
async function objectBuffer(object: ObjectData): Promise<Buffer> {
|
|
37
|
-
const chunks: Uint8Array[] = [];
|
|
38
|
-
for await (const chunk of object.body) chunks.push(chunk);
|
|
39
|
-
return Buffer.concat(chunks);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
36
|
export class ProfileImageService {
|
|
43
37
|
private unregisterUserDeleted?: () => void;
|
|
44
38
|
|
|
@@ -86,18 +80,13 @@ export class ProfileImageService {
|
|
|
86
80
|
return { image: null };
|
|
87
81
|
}
|
|
88
82
|
|
|
83
|
+
/** The image with its content unread. */
|
|
89
84
|
async get(fileName: string): Promise<StoredProfileImage> {
|
|
90
85
|
const key = this.keyFromFileName(fileName);
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
body: await objectBuffer(await this.storage.get(key)),
|
|
96
|
-
contentType: CONTENT_TYPES[extension] ?? "application/octet-stream",
|
|
97
|
-
};
|
|
98
|
-
} catch {
|
|
99
|
-
throw new AppException(PROFILE_IMAGE_NOT_FOUND, "Profile image not found.", 404);
|
|
100
|
-
}
|
|
86
|
+
const object = key ? await this.storage.get(key) : null;
|
|
87
|
+
if (!object) throw new AppException(PROFILE_IMAGE_NOT_FOUND, "Profile image not found.", 404);
|
|
88
|
+
const extension = fileName.slice(fileName.lastIndexOf(".") + 1);
|
|
89
|
+
return { object, contentType: CONTENT_TYPES[extension] ?? "application/octet-stream" };
|
|
101
90
|
}
|
|
102
91
|
|
|
103
92
|
private keyFromFileName(fileName: string): string | null {
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { afterEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { AppException } from "@podosoft/podokit-contracts";
|
|
6
|
+
import { LocalObjectStore, type ObjectData } from "@podosoft/podokit-runtime";
|
|
7
|
+
import { Elysia } from "elysia";
|
|
8
|
+
import type { Logger } from "pino";
|
|
9
|
+
import { ProfileImageService } from "../profile-image/profile-image.service";
|
|
10
|
+
import type { SiteFavicon } from "../site-settings/site-settings.service";
|
|
11
|
+
import { faviconCacheControl, IMMUTABLE_CACHE_CONTROL, siteAssetRoutes } from "./site-assets.routes";
|
|
12
|
+
|
|
13
|
+
const PNG = Buffer.from(
|
|
14
|
+
"iVBORw0KGgoAAAANSUhEUgAAAAIAAAADAQMAAACDJEzCAAAAIGNIUk0AAHomAACAhAAA+gAAAIDoAAB1MAAA6mAAADqYAAAXcJy6UTwAAAAGUExURWYzmf///129H+IAAAABYktHRAH/Ai3eAAAAB3RJTUUH6gcVCSUTzXBDTwAAACV0RVh0ZGF0ZTpjcmVhdGUAMjAyNi0wNy0yMVQwOTozNzoxOSswMDowMOuehLIAAAAldEVYdGRhdGU6bW9kaWZ5ADIwMjYtMDctMjFUMDk6Mzc6MTkrMDA6MDCawzwOAAAAKHRFWHRkYXRlOnRpbWVzdGFtcAAyMDI2LTA3LTIxVDA5OjM3OjE5KzAwOjAwzdYd0QAAAAtJREFUCNdjYAABAAAGAAFm9MlsAAAAAElFTkSuQmCC",
|
|
15
|
+
"base64",
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
const directories: string[] = [];
|
|
19
|
+
|
|
20
|
+
afterEach(async () => {
|
|
21
|
+
await Promise.all(directories.splice(0).map((path) => rm(path, { recursive: true, force: true })));
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
async function localStore(): Promise<LocalObjectStore> {
|
|
25
|
+
const directory = await mkdtemp(join(tmpdir(), "podokit-site-assets-"));
|
|
26
|
+
directories.push(directory);
|
|
27
|
+
return new LocalObjectStore({ rootDirectory: directory });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface TrackedObject {
|
|
31
|
+
object: ObjectData;
|
|
32
|
+
read: () => boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function trackedObject(content: string): TrackedObject {
|
|
36
|
+
let read = false;
|
|
37
|
+
const bytes = Buffer.from(content);
|
|
38
|
+
return {
|
|
39
|
+
object: {
|
|
40
|
+
key: "site/favicon",
|
|
41
|
+
size: bytes.byteLength,
|
|
42
|
+
contentType: "application/octet-stream",
|
|
43
|
+
etag: "\"favicon-1\"",
|
|
44
|
+
lastModified: new Date("2026-01-02T03:04:05Z"),
|
|
45
|
+
body: new ReadableStream<Uint8Array>({
|
|
46
|
+
pull(controller): void {
|
|
47
|
+
read = true;
|
|
48
|
+
controller.enqueue(bytes);
|
|
49
|
+
controller.close();
|
|
50
|
+
},
|
|
51
|
+
}, { highWaterMark: 0 }),
|
|
52
|
+
},
|
|
53
|
+
read: () => read,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type Get = (path: string, headers?: Record<string, string>) => Promise<Response>;
|
|
58
|
+
|
|
59
|
+
function app(
|
|
60
|
+
favicon: () => SiteFavicon | null,
|
|
61
|
+
profileImages: Pick<ProfileImageService, "get">,
|
|
62
|
+
): Get {
|
|
63
|
+
const target = new Elysia()
|
|
64
|
+
.onError(({ error, set }) => {
|
|
65
|
+
if (!(error instanceof AppException)) return undefined;
|
|
66
|
+
set.status = error.statusCode;
|
|
67
|
+
return { code: error.code };
|
|
68
|
+
})
|
|
69
|
+
.use(siteAssetRoutes({ getFavicon: async () => favicon() }, profileImages));
|
|
70
|
+
return async (path, headers = {}) => target.handle(new Request(`http://localhost${path}`, { headers }));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const noProfileImages: Pick<ProfileImageService, "get"> = {
|
|
74
|
+
get: () => Promise.reject(new Error("unused")),
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
describe("GET /site/favicon", () => {
|
|
78
|
+
test("streams the favicon with the validated type and its validators", async () => {
|
|
79
|
+
const icon = trackedObject("<svg/>");
|
|
80
|
+
const get = app(() => ({ object: icon.object, contentType: "image/svg+xml", version: "1700" }), noProfileImages);
|
|
81
|
+
const response = await get("/site/favicon");
|
|
82
|
+
|
|
83
|
+
expect(response.status).toBe(200);
|
|
84
|
+
expect(response.headers.get("content-type")).toBe("image/svg+xml");
|
|
85
|
+
expect(response.headers.get("content-length")).toBe("6");
|
|
86
|
+
expect(response.headers.get("etag")).toBe("\"favicon-1\"");
|
|
87
|
+
expect(response.headers.get("last-modified")).toBe("Fri, 02 Jan 2026 03:04:05 GMT");
|
|
88
|
+
expect(await response.text()).toBe("<svg/>");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("answers a current copy with 304 without reading storage", async () => {
|
|
92
|
+
const icon = trackedObject("<svg/>");
|
|
93
|
+
const get = app(() => ({ object: icon.object, contentType: "image/svg+xml", version: "1700" }), noProfileImages);
|
|
94
|
+
const response = await get("/site/favicon", { "if-none-match": "\"favicon-1\"" });
|
|
95
|
+
|
|
96
|
+
expect(response.status).toBe(304);
|
|
97
|
+
expect(await response.text()).toBe("");
|
|
98
|
+
expect(icon.read()).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("caches the current version for good and revalidates any other URL", async () => {
|
|
102
|
+
const get = (path: string, headers: Record<string, string> = {}): Promise<Response> => {
|
|
103
|
+
const icon = trackedObject("<svg/>");
|
|
104
|
+
return app(() => ({ object: icon.object, contentType: "image/svg+xml", version: "1700" }), noProfileImages)(
|
|
105
|
+
path,
|
|
106
|
+
headers,
|
|
107
|
+
);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const current = await get("/site/favicon?v=1700");
|
|
111
|
+
expect(current.status).toBe(200);
|
|
112
|
+
expect(current.headers.get("cache-control")).toBe(IMMUTABLE_CACHE_CONTROL);
|
|
113
|
+
|
|
114
|
+
for (const path of ["/site/favicon?v=1600", "/site/favicon?v=", "/site/favicon"]) {
|
|
115
|
+
const response = await get(path);
|
|
116
|
+
expect(response.status, path).toBe(200);
|
|
117
|
+
expect(response.headers.get("cache-control"), path).toBe("no-cache");
|
|
118
|
+
expect(response.headers.get("etag"), path).toBe("\"favicon-1\"");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const revalidated = await get("/site/favicon?v=1600", { "if-none-match": "\"favicon-1\"" });
|
|
122
|
+
expect(revalidated.status).toBe(304);
|
|
123
|
+
expect(revalidated.headers.get("cache-control")).toBe("no-cache");
|
|
124
|
+
expect(faviconCacheControl(null, "")).toBe("no-cache");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("returns 404 when no favicon is set", async () => {
|
|
128
|
+
const response = await app(() => null, noProfileImages)("/site/favicon");
|
|
129
|
+
expect(response.status).toBe(404);
|
|
130
|
+
expect(await response.json()).toEqual({ code: "FAVICON_NOT_FOUND" });
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("GET /profile-images/:fileName", () => {
|
|
135
|
+
test("streams exactly the uploaded bytes as an immutable resource", async () => {
|
|
136
|
+
const store = await localStore();
|
|
137
|
+
const logger = { warn: mock(() => undefined) } as unknown as Logger;
|
|
138
|
+
const images = new ProfileImageService(store, logger, async () => undefined);
|
|
139
|
+
const { image } = await images.upload(null, { buffer: PNG, mimetype: "image/png", size: PNG.byteLength }, new Headers());
|
|
140
|
+
const path = image?.replace(/^\/api/, "") ?? "";
|
|
141
|
+
const get = app(() => null, images);
|
|
142
|
+
|
|
143
|
+
const response = await get(path);
|
|
144
|
+
expect(response.status).toBe(200);
|
|
145
|
+
expect(response.headers.get("content-type")).toBe("image/png");
|
|
146
|
+
expect(response.headers.get("cache-control")).toBe(IMMUTABLE_CACHE_CONTROL);
|
|
147
|
+
expect(response.headers.get("content-length")).toBe(String(PNG.byteLength));
|
|
148
|
+
expect(Buffer.from(await response.arrayBuffer()).equals(PNG)).toBe(true);
|
|
149
|
+
|
|
150
|
+
const revalidated = await get(path, { "if-none-match": response.headers.get("etag") ?? "" });
|
|
151
|
+
expect(revalidated.status).toBe(304);
|
|
152
|
+
expect(revalidated.headers.get("cache-control")).toBe(IMMUTABLE_CACHE_CONTROL);
|
|
153
|
+
|
|
154
|
+
await images.remove(image ?? null, new Headers());
|
|
155
|
+
expect((await get(path)).status).toBe(404);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("returns 404 for names that are not managed images", async () => {
|
|
159
|
+
const store = await localStore();
|
|
160
|
+
const logger = { warn: mock(() => undefined) } as unknown as Logger;
|
|
161
|
+
const get = app(() => null, new ProfileImageService(store, logger, async () => undefined));
|
|
162
|
+
|
|
163
|
+
for (const name of ["missing.png", "123e4567-e89b-42d3-a456-426614174000.png", "..%2Fsite%2Ffavicon"]) {
|
|
164
|
+
expect((await get(`/profile-images/${name}`)).status).toBe(404);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
});
|
package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-assets/site-assets.routes.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { AppException } from "@podosoft/podokit-contracts";
|
|
2
|
+
import { objectResponse } from "@podosoft/podokit-runtime";
|
|
3
|
+
import { Elysia, t } from "elysia";
|
|
4
|
+
import type { ElysiaPlugin } from "../core/services";
|
|
5
|
+
import type { ProfileImageService } from "../profile-image/profile-image.service";
|
|
6
|
+
import type { SiteSettingsService } from "../site-settings/site-settings.service";
|
|
7
|
+
|
|
8
|
+
export const IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Pages link the favicon as `?v=<version>`, and a new upload changes the
|
|
12
|
+
* version, so that URL can be cached for good. Any other URL revalidates.
|
|
13
|
+
*/
|
|
14
|
+
export function faviconCacheControl(current: string | null, requested: string | undefined): string {
|
|
15
|
+
return current !== null && requested === current ? IMMUTABLE_CACHE_CONTROL : "no-cache";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Public images streamed from object storage with validators for 304 replies. */
|
|
19
|
+
export function siteAssetRoutes(
|
|
20
|
+
site: Pick<SiteSettingsService, "getFavicon">,
|
|
21
|
+
profileImages: Pick<ProfileImageService, "get">,
|
|
22
|
+
): ElysiaPlugin {
|
|
23
|
+
return new Elysia({ name: "podokit.admin.site-assets" })
|
|
24
|
+
.get("/site/favicon", async ({ request, query }) => {
|
|
25
|
+
const icon = await site.getFavicon();
|
|
26
|
+
if (!icon) throw new AppException("FAVICON_NOT_FOUND", "No favicon set", 404);
|
|
27
|
+
return objectResponse(request, icon.object, {
|
|
28
|
+
contentType: icon.contentType,
|
|
29
|
+
cacheControl: faviconCacheControl(icon.version, query.v),
|
|
30
|
+
});
|
|
31
|
+
}, {
|
|
32
|
+
query: t.Object({ v: t.Optional(t.String()) }),
|
|
33
|
+
detail: {
|
|
34
|
+
tags: ["site"],
|
|
35
|
+
summary: "Get the site favicon",
|
|
36
|
+
description: "Cached permanently when `v` is the current `faviconVersion`; revalidated otherwise.",
|
|
37
|
+
},
|
|
38
|
+
})
|
|
39
|
+
.get("/profile-images/:fileName", async ({ request, params }) => {
|
|
40
|
+
const image = await profileImages.get(params.fileName);
|
|
41
|
+
// Every upload gets a new random file name, so a name never changes content.
|
|
42
|
+
return objectResponse(request, image.object, {
|
|
43
|
+
contentType: image.contentType,
|
|
44
|
+
cacheControl: IMMUTABLE_CACHE_CONTROL,
|
|
45
|
+
});
|
|
46
|
+
}, {
|
|
47
|
+
params: t.Object({ fileName: t.String({ minLength: 1 }) }),
|
|
48
|
+
detail: { tags: ["account"], summary: "Get a public profile image" },
|
|
49
|
+
});
|
|
50
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { SQL } from "bun";
|
|
3
|
+
import type { ObjectData, ObjectStore } from "@podosoft/podokit-runtime";
|
|
4
|
+
import { SiteSettingsService } from "./site-settings.service";
|
|
5
|
+
|
|
6
|
+
const favicon: ObjectData = {
|
|
7
|
+
key: "site/favicon",
|
|
8
|
+
size: 1,
|
|
9
|
+
etag: "\"icon\"",
|
|
10
|
+
lastModified: new Date(0),
|
|
11
|
+
body: new ReadableStream<Uint8Array>(),
|
|
12
|
+
};
|
|
13
|
+
const storage = {
|
|
14
|
+
put: async (): Promise<void> => undefined,
|
|
15
|
+
get: async (key: string): Promise<ObjectData | null> => (key === "site/favicon" ? favicon : null),
|
|
16
|
+
} as unknown as ObjectStore;
|
|
17
|
+
|
|
18
|
+
interface Harness {
|
|
19
|
+
service: SiteSettingsService;
|
|
20
|
+
reads: () => number;
|
|
21
|
+
holdNextRead: () => () => void;
|
|
22
|
+
advance: (milliseconds: number) => void;
|
|
23
|
+
failNextWrite: () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("SiteSettingsService", () => {
|
|
27
|
+
let db: SQL;
|
|
28
|
+
let harness: Harness;
|
|
29
|
+
|
|
30
|
+
beforeEach(async () => {
|
|
31
|
+
db = new SQL(":memory:");
|
|
32
|
+
await db`CREATE TABLE "app_setting" ("key" TEXT PRIMARY KEY, "value" TEXT NOT NULL, "updatedAt" TEXT)`;
|
|
33
|
+
await db`INSERT INTO "app_setting" ("key", "value") VALUES ('site.name', 'Before'), ('require2fa', 'false')`;
|
|
34
|
+
|
|
35
|
+
let reads = 0;
|
|
36
|
+
let clock = 1_000;
|
|
37
|
+
let hold: Promise<void> | null = null;
|
|
38
|
+
let failWrite = false;
|
|
39
|
+
// Counts reads and can hold a read's result to interleave it with a write.
|
|
40
|
+
const counted = async (strings: TemplateStringsArray, ...values: unknown[]): Promise<unknown> => {
|
|
41
|
+
reads += 1;
|
|
42
|
+
const gate = hold;
|
|
43
|
+
hold = null;
|
|
44
|
+
const rows: unknown = await db(strings, ...values);
|
|
45
|
+
if (gate) await gate;
|
|
46
|
+
return rows;
|
|
47
|
+
};
|
|
48
|
+
const sql = Object.assign(counted, {
|
|
49
|
+
begin: <T>(work: (tx: SQL) => Promise<T>): Promise<T> =>
|
|
50
|
+
db.begin(async (tx) => {
|
|
51
|
+
const result = await work(tx as unknown as SQL);
|
|
52
|
+
if (failWrite) {
|
|
53
|
+
failWrite = false;
|
|
54
|
+
throw new Error("write failed");
|
|
55
|
+
}
|
|
56
|
+
return result;
|
|
57
|
+
}),
|
|
58
|
+
});
|
|
59
|
+
harness = {
|
|
60
|
+
service: new SiteSettingsService(sql as unknown as SQL, storage, () => clock),
|
|
61
|
+
reads: () => reads,
|
|
62
|
+
holdNextRead: () => {
|
|
63
|
+
let release: () => void = () => undefined;
|
|
64
|
+
hold = new Promise((resolve) => { release = resolve; });
|
|
65
|
+
return release;
|
|
66
|
+
},
|
|
67
|
+
advance: (milliseconds) => { clock += milliseconds; },
|
|
68
|
+
failNextWrite: () => { failWrite = true; },
|
|
69
|
+
};
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
afterEach(async () => {
|
|
73
|
+
await db.close();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("serves concurrent and repeated reads from one query", async () => {
|
|
77
|
+
const results = await Promise.all([
|
|
78
|
+
harness.service.getAll(),
|
|
79
|
+
harness.service.getAll(),
|
|
80
|
+
harness.service.get("name"),
|
|
81
|
+
harness.service.faviconVersion(),
|
|
82
|
+
]);
|
|
83
|
+
await harness.service.getAll();
|
|
84
|
+
|
|
85
|
+
expect(harness.reads()).toBe(1);
|
|
86
|
+
expect(results[0]).toEqual({ name: "Before" });
|
|
87
|
+
expect(results[2]).toBe("Before");
|
|
88
|
+
expect(results[3]).toBeNull();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("reads again after the snapshot expires", async () => {
|
|
92
|
+
await harness.service.getAll();
|
|
93
|
+
harness.advance(2_999);
|
|
94
|
+
await harness.service.getAll();
|
|
95
|
+
expect(harness.reads()).toBe(1);
|
|
96
|
+
|
|
97
|
+
harness.advance(1);
|
|
98
|
+
await harness.service.getAll();
|
|
99
|
+
expect(harness.reads()).toBe(2);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("returns a write to the next read immediately", async () => {
|
|
103
|
+
await harness.service.getAll();
|
|
104
|
+
|
|
105
|
+
const saved = await harness.service.setMany({ name: "After", locale: "ko" });
|
|
106
|
+
|
|
107
|
+
expect(saved).toEqual({ name: "After", locale: "ko" });
|
|
108
|
+
expect(await harness.service.getAll()).toEqual({ name: "After", locale: "ko" });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("writes every key in one transaction", async () => {
|
|
112
|
+
harness.failNextWrite();
|
|
113
|
+
|
|
114
|
+
await expect(harness.service.setMany({ name: "After", locale: "ko" })).rejects.toThrow("write failed");
|
|
115
|
+
|
|
116
|
+
expect(await harness.service.getAll()).toEqual({ name: "Before" });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("does not keep a read that raced a write", async () => {
|
|
120
|
+
const release = harness.holdNextRead();
|
|
121
|
+
const stale = harness.service.getAll();
|
|
122
|
+
|
|
123
|
+
await harness.service.setMany({ name: "After" });
|
|
124
|
+
release();
|
|
125
|
+
|
|
126
|
+
expect(await stale).toEqual({ name: "Before" });
|
|
127
|
+
expect(await harness.service.getAll()).toEqual({ name: "After" });
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("derives the favicon fields from the same snapshot", async () => {
|
|
131
|
+
await harness.service.setFavicon(new Uint8Array([1]), "image/png");
|
|
132
|
+
const reads = harness.reads();
|
|
133
|
+
|
|
134
|
+
const all = await harness.service.getAll();
|
|
135
|
+
|
|
136
|
+
expect(all.faviconContentType).toBe("image/png");
|
|
137
|
+
expect(await harness.service.faviconVersion()).toBe(all.faviconUpdatedAt ?? "missing");
|
|
138
|
+
expect(harness.reads()).toBe(reads);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("returns the favicon unread with its type and version from the snapshot", async () => {
|
|
142
|
+
expect(await harness.service.getFavicon()).toBeNull();
|
|
143
|
+
await harness.service.setFavicon(new Uint8Array([1]), "image/png");
|
|
144
|
+
const reads = harness.reads();
|
|
145
|
+
const version = await harness.service.faviconVersion();
|
|
146
|
+
|
|
147
|
+
expect(await harness.service.getFavicon()).toEqual({ object: favicon, contentType: "image/png", version });
|
|
148
|
+
expect(version).toMatch(/^\d+$/);
|
|
149
|
+
expect(harness.reads()).toBe(reads);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("copies the snapshot for each caller", async () => {
|
|
153
|
+
const first = await harness.service.getAll();
|
|
154
|
+
first.name = "Changed";
|
|
155
|
+
|
|
156
|
+
expect(await harness.service.getAll()).toEqual({ name: "Before" });
|
|
157
|
+
});
|
|
158
|
+
});
|