diff --git a/hooks/useTranslate.ts b/hooks/useTranslate.ts index e72a6b9..cf9380d 100644 --- a/hooks/useTranslate.ts +++ b/hooks/useTranslate.ts @@ -1,7 +1,12 @@ import translations from "./translations.json"; import { Language, useLanguage } from "../lib/LanguageContext"; +import { z } from "zod"; -export type TranslationKey = keyof typeof translations; +export const translationKeySchema = z.enum( + Object.keys(translations) as [keyof typeof translations], +); + +export type TranslationKey = z.infer; const translate = (key: TranslationKey, language: Language): string => { const translation = translations[key]; diff --git a/lib/cmsClient.test.ts b/lib/cmsClient.test.ts new file mode 100644 index 0000000..f2b38aa --- /dev/null +++ b/lib/cmsClient.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createClient, + getNavigationLinks, + NavigationLink, + navigationLinkSchema, +} from "./cmsClient"; +import { generateMock } from "@anatine/zod-mock"; +import { z } from "zod"; +import { TranslationKey } from "../hooks/useTranslate"; + +describe("cmsClient", async () => { + it("returns valid data from the CMS", async () => { + const mockData = generateMock(z.array(navigationLinkSchema)); + const client = createClient("http://mockUrl"); + const mockRequest = vi.spyOn(client, "request").mockResolvedValue(mockData); + mockRequest.mockResolvedValue(mockData); + + const data = await getNavigationLinks(client); + + expect(mockRequest).toHaveBeenCalled(); + expect(data).toEqual(mockData); + }); + + it("fails on invalid label key from the CMS", async () => { + const mockData: NavigationLink[] = [ + { + label_key: "invalid" as TranslationKey, + url: "page", + category: "GENERAL", + }, + ]; + const client = createClient("http://mockUrl"); + const mockRequest = vi.spyOn(client, "request").mockResolvedValue(mockData); + mockRequest.mockResolvedValue(mockData); + + expect(getNavigationLinks(client)).rejects.toThrow(); + }); + + it("fails on invalid category from the CMS", async () => { + const mockData: NavigationLink[] = [ + { + label_key: "general:nation", + url: "page", + category: "invalid" as "GENERAL", + }, + ]; + const client = createClient("http://mockUrl"); + const mockRequest = vi.spyOn(client, "request").mockResolvedValue(mockData); + mockRequest.mockResolvedValue(mockData); + + expect(getNavigationLinks(client)).rejects.toThrow(); + }); +}); diff --git a/lib/cmsClient.ts b/lib/cmsClient.ts index 160155c..80e6ffc 100644 --- a/lib/cmsClient.ts +++ b/lib/cmsClient.ts @@ -1,32 +1,27 @@ -import { TranslationKey } from "@/hooks/useTranslate"; -import { createDirectus, rest } from "@directus/sdk"; +import { translationKeySchema } from "../hooks/useTranslate"; +import { createDirectus, readItems, rest } from "@directus/sdk"; +import { z } from "zod"; type Schema = { NavigationLink: NavigationLink[]; - Translation: Translation[]; }; -export type NavigationLink = { - label_key: TranslationKey; - url: string; - category: "GENERAL" | "FOR_MEMBERS"; -}; +export const navigationLinkSchema = z.object({ + label_key: translationKeySchema, + url: z.string(), + category: z.enum(["GENERAL", "FOR_MEMBERS"]), +}); -/* - * You shouldnt use this type, use the better typed one in useTranslate.tsx instead - */ -export type Translation = { - key: string; - fi: string; - en: string; - sv: string; -}; +export type NavigationLink = z.infer; + +type CmsClient = ReturnType; -const createClient = () => { - if (process.env.DIRECTUS_URL === undefined) { - throw Error("Environment variable DIRECTUS_URL not defined"); - } - return createDirectus(process.env.DIRECTUS_URL).with(rest()); +export const createClient = (url: string) => { + return createDirectus(url).with(rest()); }; -export default createClient; +export const getNavigationLinks = async (client: CmsClient) => { + const links = await client.request(readItems("NavigationLink")); + + return links.map((link) => navigationLinkSchema.parse(link)); +}; diff --git a/package-lock.json b/package-lock.json index baba4fe..f349e29 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,11 @@ "name": "new-sato-website", "version": "0.1.0", "dependencies": { + "@anatine/zod-mock": "^3.13.4", "@directus/sdk": "^17.0.0", "@emotion/react": "^11.11.4", "@emotion/styled": "^11.11.5", + "@faker-js/faker": "^8.4.1", "@googleapis/forms": "^2.0.7", "@mui/material": "^5.15.20", "dotenv": "^16.4.5", @@ -51,6 +53,18 @@ "node": ">=6.0.0" } }, + "node_modules/@anatine/zod-mock": { + "version": "3.13.4", + "resolved": "https://registry.npmjs.org/@anatine/zod-mock/-/zod-mock-3.13.4.tgz", + "integrity": "sha512-yO/KeuyYsEDCTcQ+7CiRuY3dnafMHIZUMok6Ci7aERRCTQ+/XmsiPk/RnMx5wlLmWBTmX9kw+PavbMsjM+sAJA==", + "dependencies": { + "randexp": "^0.5.3" + }, + "peerDependencies": { + "@faker-js/faker": "^7.0.0 || ^8.0.0", + "zod": "^3.21.4" + } + }, "node_modules/@babel/code-frame": { "version": "7.24.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.7.tgz", @@ -1115,6 +1129,21 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@faker-js/faker": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-8.4.1.tgz", + "integrity": "sha512-XQ3cU+Q8Uqmrbf2e0cIC/QN43sTBSC8KF12u29Mb47tWrt2hAgBXSgpZMj4Ao8Uk0iJcU99QsOCaIL8934obCg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/fakerjs" + } + ], + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0", + "npm": ">=6.14.13" + } + }, "node_modules/@floating-ui/core": { "version": "1.6.2", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.2.tgz", @@ -3319,6 +3348,14 @@ "url": "https://dotenvx.com" } }, + "node_modules/drange": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/drange/-/drange-1.1.1.tgz", + "integrity": "sha512-pYxfDYpued//QpnLIm4Avk7rsNtAtQkUES2cwAYSvD/wd2pKD71gN2Ebj3e7klzXwjocvE8c5vx/1fxwpqmSxA==", + "engines": { + "node": ">=4" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -6480,6 +6517,18 @@ ], "license": "MIT" }, + "node_modules/randexp": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/randexp/-/randexp-0.5.3.tgz", + "integrity": "sha512-U+5l2KrcMNOUPYvazA3h5ekF80FHTUG+87SEAmHZmolh1M+i/WyTCxVzmi+tidIa1tM4BSe8g2Y/D3loWDjj+w==", + "dependencies": { + "drange": "^1.0.2", + "ret": "^0.2.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -6624,6 +6673,14 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/ret": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.2.2.tgz", + "integrity": "sha512-M0b3YWQs7R3Z917WRQy1HHA7Ba7D8hvZg6UE5mLykJxQVE2ju0IXbGlaHPPlkY+WN7wFP+wUMXmBFA0aV6vYGQ==", + "engines": { + "node": ">=4" + } + }, "node_modules/reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", diff --git a/package.json b/package.json index f8d025b..4fccd67 100644 --- a/package.json +++ b/package.json @@ -15,9 +15,11 @@ "prepare": "husky && npm run fetchTranslations" }, "dependencies": { + "@anatine/zod-mock": "^3.13.4", "@directus/sdk": "^17.0.0", "@emotion/react": "^11.11.4", "@emotion/styled": "^11.11.5", + "@faker-js/faker": "^8.4.1", "@googleapis/forms": "^2.0.7", "@mui/material": "^5.15.20", "dotenv": "^16.4.5",