x
This commit is contained in:
1 parent
78b722e120
commit
75770ca122
18 files changed
+1119
-318
No files matched your search
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { settingsRoute } from './settings'
|
||||
|
||||
function jsonRequest(path: string, method: string, body: unknown) {
|
||||
return new Request(`http://localhost${path}`, {
|
||||
method,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
describe('footer layout route boundaries', () => {
|
||||
for (const method of ['PUT', 'POST']) {
|
||||
test(`${method} /settings rejects footerLayout writes`, async () => {
|
||||
const response = await settingsRoute.handle(jsonRequest('/settings', method, {
|
||||
footerLayout: { columns: ['brand'] },
|
||||
}))
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(await response.json()).toEqual({
|
||||
error: 'Use PATCH /settings/footer-layout to update footer layout',
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
test('PATCH /settings/footer-layout requires admin authentication', async () => {
|
||||
const response = await settingsRoute.handle(jsonRequest('/settings/footer-layout', 'PATCH', {
|
||||
dryRun: true,
|
||||
}))
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(await response.json()).toEqual({
|
||||
error: 'Unauthorized: Missing or invalid token format',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,25 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import { FooterLayoutSettingsPatchSchema } from "@homaroma/types";
|
||||
import { z } from "zod";
|
||||
import prisma from "../lib/prisma";
|
||||
import { adminOnly } from "../middleware/auth";
|
||||
import { adminOnly, requireAdmin } from "../middleware/auth";
|
||||
import {
|
||||
mergeFooterLayoutSettings,
|
||||
normalizeFooterLayoutSettings,
|
||||
} from "../lib/footerLayoutSettings";
|
||||
|
||||
const FooterLayoutUpdateRequestSchema = FooterLayoutSettingsPatchSchema.extend({
|
||||
expectedVersion: z.string().optional(),
|
||||
dryRun: z.boolean().optional(),
|
||||
}).strict().superRefine((value, context) => {
|
||||
if (!value.dryRun && !value.expectedVersion) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["expectedVersion"],
|
||||
message: "expectedVersion is required when updating footer layout",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export async function saveStoreSettingsTranslations(settingsId: number, payload: any) {
|
||||
const results: any[] = [];
|
||||
@@ -171,6 +190,7 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
|
||||
const mobileHeaderStyle = (settings.themeTokens as any)?.mobileHeaderStyle ?? 'logo_center';
|
||||
return {
|
||||
...settingsData,
|
||||
footerLayout: normalizeFooterLayoutSettings((settingsData as any).footerLayout),
|
||||
translations,
|
||||
headerBehavior,
|
||||
mobileHeaderBehavior,
|
||||
@@ -220,6 +240,21 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
|
||||
return { error: "Failed to fetch footer brand" };
|
||||
}
|
||||
})
|
||||
.get("/footer-layout", async ({ set }) => {
|
||||
try {
|
||||
const settings = await prisma.storeSettings.findFirst();
|
||||
const current = settings ?? await prisma.storeSettings.create({ data: {} });
|
||||
|
||||
return {
|
||||
version: current.updatedAt.toISOString(),
|
||||
layout: normalizeFooterLayoutSettings((current as any).footerLayout),
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("[Settings] Footer layout fetch error:", err);
|
||||
set.status = 500;
|
||||
return { error: "Failed to fetch footer layout" };
|
||||
}
|
||||
})
|
||||
.use(adminOnly)
|
||||
.patch("/footer-brand", async ({ body, set }: { body: any; set: any }) => {
|
||||
try {
|
||||
@@ -271,10 +306,74 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
|
||||
set.status = 400;
|
||||
return { error: err?.message || "Failed to update footer brand" };
|
||||
}
|
||||
}, { body: t.Any() })
|
||||
}, { beforeHandle: requireAdmin, body: t.Any() })
|
||||
.patch("/footer-layout", async ({ body, set }: { body: unknown; set: any }) => {
|
||||
const request = FooterLayoutUpdateRequestSchema.safeParse(body);
|
||||
if (!request.success) {
|
||||
set.status = 400;
|
||||
return { error: "Invalid footer layout", issues: request.error.issues };
|
||||
}
|
||||
|
||||
try {
|
||||
const { expectedVersion, dryRun, ...patch } = request.data;
|
||||
const settings = await prisma.storeSettings.findFirst();
|
||||
const current = settings ?? await prisma.storeSettings.create({ data: {} });
|
||||
const version = current.updatedAt.toISOString();
|
||||
|
||||
if (expectedVersion && expectedVersion !== version) {
|
||||
set.status = 409;
|
||||
return {
|
||||
error: "Footer layout has changed since it was read",
|
||||
code: "VERSION_CONFLICT",
|
||||
expectedVersion,
|
||||
currentVersion: version,
|
||||
};
|
||||
}
|
||||
|
||||
const currentLayout = normalizeFooterLayoutSettings((current as any).footerLayout);
|
||||
const layout = mergeFooterLayoutSettings(currentLayout, patch);
|
||||
if (dryRun) {
|
||||
return { dryRun: true, version, wouldUpdate: layout };
|
||||
}
|
||||
|
||||
const updated = await prisma.$transaction(async (transaction) => {
|
||||
const result = await transaction.storeSettings.updateMany({
|
||||
where: { id: current.id, updatedAt: current.updatedAt },
|
||||
data: { footerLayout: layout as any },
|
||||
});
|
||||
if (result.count === 0) return null;
|
||||
return transaction.storeSettings.findUniqueOrThrow({ where: { id: current.id } });
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
const latest = await prisma.storeSettings.findUnique({ where: { id: current.id } });
|
||||
set.status = 409;
|
||||
return {
|
||||
error: "Footer layout has changed since it was read",
|
||||
code: "VERSION_CONFLICT",
|
||||
expectedVersion,
|
||||
currentVersion: latest?.updatedAt.toISOString() ?? version,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
version: updated.updatedAt.toISOString(),
|
||||
layout,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("[Settings] Footer layout update error:", err);
|
||||
set.status = 500;
|
||||
return { error: "Failed to update footer layout" };
|
||||
}
|
||||
}, { beforeHandle: requireAdmin, body: t.Any() })
|
||||
.put("/", async ({ body, set }: { body: any; set: any }) => {
|
||||
try {
|
||||
const { headerBehavior, mobileHeaderBehavior, mobileHeaderStyle, translations, ...data } = body as any;
|
||||
const { footerLayout, headerBehavior, mobileHeaderBehavior, mobileHeaderStyle, translations, ...data } = body as any;
|
||||
if (footerLayout !== undefined) {
|
||||
set.status = 400;
|
||||
return { error: "Use PATCH /settings/footer-layout to update footer layout" };
|
||||
}
|
||||
const currentTokens = (typeof data.themeTokens === 'object' && data.themeTokens !== null)
|
||||
? data.themeTokens
|
||||
: {};
|
||||
@@ -354,12 +453,17 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
|
||||
defaultSeoDescription: t.Optional(t.Nullable(t.String())),
|
||||
defaultOgImage: t.Optional(t.Nullable(t.String())),
|
||||
brandPrompt: t.Optional(t.Nullable(t.String())),
|
||||
footerLayout: t.Optional(t.Any()),
|
||||
translations: t.Optional(t.Any()),
|
||||
})
|
||||
})
|
||||
.post("/", async ({ body, set }: { body: any; set: any }) => {
|
||||
try {
|
||||
const { headerBehavior, mobileHeaderBehavior, mobileHeaderStyle, translations, ...data } = body as any;
|
||||
const { footerLayout, headerBehavior, mobileHeaderBehavior, mobileHeaderStyle, translations, ...data } = body as any;
|
||||
if (footerLayout !== undefined) {
|
||||
set.status = 400;
|
||||
return { error: "Use PATCH /settings/footer-layout to update footer layout" };
|
||||
}
|
||||
const currentTokens = (typeof data.themeTokens === 'object' && data.themeTokens !== null)
|
||||
? data.themeTokens
|
||||
: {};
|
||||
@@ -439,6 +543,7 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
|
||||
defaultSeoDescription: t.Optional(t.Nullable(t.String())),
|
||||
defaultOgImage: t.Optional(t.Nullable(t.String())),
|
||||
brandPrompt: t.Optional(t.Nullable(t.String())),
|
||||
footerLayout: t.Optional(t.Any()),
|
||||
translations: t.Optional(t.Any()),
|
||||
})
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user