test: Add unit tests for utils

This commit is contained in:
Philip 2024-10-26 19:51:08 +02:00
parent ca8ff21745
commit bb2175f813
13 changed files with 2899 additions and 26 deletions

View file

@ -1,4 +1,5 @@
{
"root": true,
"extends": [
"next/core-web-vitals",
"plugin:@typescript-eslint/strict",
@ -7,6 +8,20 @@
"plugin:import/typescript"
],
"plugins": ["@typescript-eslint", "import"],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": "./tsconfig.json",
"ecmaVersion": 2022,
"sourceType": "module"
},
"settings": {
"import/resolver": {
"typescript": {
"project": "./tsconfig.json"
},
"node": true
}
},
"rules": {
"import/order": [
"error",
@ -26,11 +41,5 @@
}
}
]
},
"settings": {
"import/resolver": {
"typescript": true,
"node": true
}
}
}

View file

@ -14,8 +14,10 @@ jobs:
cache: "pnpm"
cache-dependency-path: "**/pnpm-lock.yaml"
- run: pnpm install
- run: pnpm run build
- run: pnpm run lint
- run: pnpm run type-check
- run: pnpm run test
- run: pnpm run build
legacy_peer_deps_build:
runs-on: ubuntu-latest
needs: regular_build
@ -30,5 +32,7 @@ jobs:
node-version: "20.x"
cache: "pnpm"
- run: pnpm install --no-strict-peer-dependencies
- run: pnpm run build
- run: pnpm run lint
- run: pnpm run type-check
- run: pnpm run test
- run: pnpm run build

View file

@ -1,5 +1,7 @@
{
"recommendations": [
"inlang.vs-code-extension"
"inlang.vs-code-extension",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode"
]
}
}

23
.vscode/settings.json vendored
View file

@ -1 +1,22 @@
{}
{
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "always"
},
"editor.formatOnSave": true,
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact"
],
"eslint.workingDirectories": [
{
"mode": "location"
}
],
"eslint.options": {
"overrideConfigFile": ".eslintrc.json"
},
"typescript.tsdk": "node_modules/typescript",
"typescript.enablePromptUseWorkspaceTsdk": true
}

31
jest.config.ts Normal file
View file

@ -0,0 +1,31 @@
import type { Config } from "jest";
import nextJest from "next/jest";
const createJestConfig = nextJest({
dir: "./",
});
const customJestConfig: Config = {
setupFilesAfterEnv: ["<rootDir>/src/tests/setup.ts"],
testEnvironment: "jest-environment-jsdom",
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/src/$1",
},
testMatch: ["**/*.test.ts", "**/*.test.tsx"],
collectCoverage: true,
coverageDirectory: "coverage",
coverageReporters: ["text", "lcov", "json"],
moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json"],
testPathIgnorePatterns: [
"<rootDir>/node_modules/",
"<rootDir>/.next/",
"<rootDir>/playwright/",
],
transformIgnorePatterns: [
"/node_modules/",
"^.+\\.module\\.(css|sass|scss)$",
],
coverageProvider: "v8",
};
export default createJestConfig(customJestConfig);

View file

@ -9,9 +9,19 @@
"start": "next start -p 1030",
"stage": "next start -p 1031",
"lint": "next lint",
"test": "playwright test"
"lint:fix": "next lint --fix",
"type-check": "tsc --noEmit",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"test:e2e": "playwright test"
},
"packageManager": "pnpm@9.12.1",
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"eslint --fix"
]
},
"dependencies": {
"@ducanh2912/next-pwa": "^10.2.9",
"@ericblade/quagga2": "^1.8.4",
@ -32,15 +42,22 @@
},
"devDependencies": {
"@playwright/test": "^1.48.0",
"@testing-library/jest-dom": "^6.6.2",
"@testing-library/react": "^16.0.1",
"@testing-library/user-event": "^14.5.2",
"@types/jest": "^29.5.14",
"@types/react": "npm:types-react@19.0.0-rc.1",
"@typescript-eslint/eslint-plugin": "^8.11.0",
"@typescript-eslint/parser": "^8.11.0",
"@typescript-eslint/typescript-estree": "^8.11.0",
"eslint": "9.13.0",
"eslint-config-next": "15.0.1",
"eslint-config-sznm": "^2.0.3",
"eslint-import-resolver-typescript": "^3.6.3",
"eslint-plugin-import": "^2.31.0",
"eslint-config-sznm": "^2.0.3"
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"ts-node": "^10.9.2"
},
"volta": {
"node": "20.0.0"

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,216 @@
import Veganify from "@frontendnetwork/veganify";
import { fetchProduct } from "./product-actions";
// Mock Veganify
jest.mock("@frontendnetwork/veganify", () => ({
getProductByBarcode: jest.fn(),
}));
describe("fetchProduct", () => {
const originalError = console.error;
beforeEach(() => {
console.error = jest.fn();
jest.clearAllMocks();
(Veganify.getProductByBarcode as jest.Mock).mockResolvedValue({
product: {
name: "Test Product",
vegan: true,
},
sources: {
openFoodFacts: true,
},
status: 200,
});
});
afterEach(() => {
console.error = originalError;
});
describe("successful responses", () => {
test("returns product and sources when available", async () => {
const mockResponse = {
product: {
name: "Test Product",
vegan: true,
ingredients: ["water", "sugar"],
},
sources: {
openFoodFacts: true,
community: false,
},
status: 200,
};
(Veganify.getProductByBarcode as jest.Mock).mockResolvedValueOnce(
mockResponse
);
const result = await fetchProduct("4000417025005");
expect(result).toEqual(mockResponse);
});
test("returns only status when no product found", async () => {
const mockResponse = {
status: 404,
};
(Veganify.getProductByBarcode as jest.Mock).mockResolvedValueOnce(
mockResponse
);
const result = await fetchProduct("4000417025005");
expect(result).toEqual({ status: 404 });
});
});
describe("error handling", () => {
test("throws error when API call fails", async () => {
const mockError = new Error("API Error");
(Veganify.getProductByBarcode as jest.Mock).mockRejectedValueOnce(
mockError
);
await expect(fetchProduct("4000417025005")).rejects.toThrow("API Error");
});
test("handles invalid barcode format", async () => {
const mockResponse = {
status: 400,
};
(Veganify.getProductByBarcode as jest.Mock).mockResolvedValueOnce(
mockResponse
);
const result = await fetchProduct("invalid");
expect(result).toEqual({ status: 400 });
});
});
describe("environment handling", () => {
const mockResponse = {
product: {
name: "Test Product",
},
sources: {
openFoodFacts: true,
},
status: 200,
};
beforeEach(() => {
(Veganify.getProductByBarcode as jest.Mock).mockResolvedValue(
mockResponse
);
});
test("uses staging flag when NEXT_PUBLIC_STAGING is true", async () => {
process.env.NEXT_PUBLIC_STAGING = "true";
await fetchProduct("4000417025005");
expect(Veganify.getProductByBarcode).toHaveBeenCalledWith(
"4000417025005",
true
);
});
test("uses production when NEXT_PUBLIC_STAGING is false", async () => {
process.env.NEXT_PUBLIC_STAGING = "false";
await fetchProduct("4000417025005");
expect(Veganify.getProductByBarcode).toHaveBeenCalledWith(
"4000417025005",
false
);
});
test("defaults to production when NEXT_PUBLIC_STAGING is undefined", async () => {
process.env.NEXT_PUBLIC_STAGING = undefined;
await fetchProduct("4000417025005");
expect(Veganify.getProductByBarcode).toHaveBeenCalledWith(
"4000417025005",
false
);
});
});
describe("edge cases", () => {
test("handles empty response", async () => {
(Veganify.getProductByBarcode as jest.Mock).mockResolvedValueOnce({
status: 200,
});
const result = await fetchProduct("4000417025005");
expect(result).toEqual({ status: 200 });
});
test("handles malformed response", async () => {
(Veganify.getProductByBarcode as jest.Mock).mockResolvedValueOnce({
unexpected: "data",
status: 200,
});
const result = await fetchProduct("4000417025005");
expect(result).toEqual({ status: 200 });
});
});
describe("input validation", () => {
test("handles empty barcode", async () => {
const mockResponse = {
status: 400,
};
(Veganify.getProductByBarcode as jest.Mock).mockResolvedValueOnce(
mockResponse
);
const result = await fetchProduct("");
expect(result).toEqual({ status: 400 });
});
test("handles very long barcode", async () => {
const longBarcode = "1".repeat(100);
const mockResponse = {
product: {
name: "Test Product",
},
sources: {
openFoodFacts: true,
},
status: 200,
};
(Veganify.getProductByBarcode as jest.Mock).mockResolvedValueOnce(
mockResponse
);
const result = await fetchProduct(longBarcode);
expect(result).toEqual(mockResponse);
});
test("handles special characters in barcode", async () => {
const specialBarcode = "123!@#456";
const mockResponse = {
product: {
name: "Test Product",
},
sources: {
openFoodFacts: true,
},
status: 200,
};
(Veganify.getProductByBarcode as jest.Mock).mockResolvedValueOnce(
mockResponse
);
const result = await fetchProduct(specialBarcode);
expect(result).toEqual(mockResponse);
});
});
});

View file

@ -0,0 +1,157 @@
import { ProductResult } from "@/models/ProductResults";
import { getProductState } from "./product-helpers";
describe("getProductState", () => {
describe("getVeganState", () => {
test("returns correct classes for vegan states", () => {
const testCases: [ProductResult, string][] = [
[{ vegan: true } as ProductResult, "vegan icon-ok"],
[{ vegan: false } as ProductResult, "non-vegan icon-cancel"],
[{ vegan: "n/a" } as ProductResult, "unknown icon-help"],
[{ vegan: undefined } as ProductResult, "unknown icon-help"],
];
testCases.forEach(([input, expected]) => {
const result = getProductState(input);
expect(result.vegan).toBe(expected);
});
});
test("handles vegetarian states", () => {
const testCases: [ProductResult, string][] = [
[{ vegetarian: true } as ProductResult, "vegan icon-ok"],
[{ vegetarian: false } as ProductResult, "non-vegan icon-cancel"],
[{ vegetarian: "n/a" } as ProductResult, "unknown icon-help"],
[{ vegetarian: undefined } as ProductResult, "unknown icon-help"],
];
testCases.forEach(([input, expected]) => {
const result = getProductState(input);
expect(result.vegetarian).toBe(expected);
});
});
test("handles animal test free states", () => {
const testCases: [ProductResult, string][] = [
[{ animaltestfree: true } as ProductResult, "vegan icon-ok"],
[{ animaltestfree: false } as ProductResult, "non-vegan icon-cancel"],
[{ animaltestfree: "n/a" } as ProductResult, "unknown icon-help"],
[{ animaltestfree: undefined } as ProductResult, "unknown icon-help"],
];
testCases.forEach(([input, expected]) => {
const result = getProductState(input);
expect(result.animaltestfree).toBe(expected);
});
});
test("handles palm oil states", () => {
const testCases: [ProductResult, string][] = [
[{ palmoil: true } as ProductResult, "vegan icon-ok"],
[{ palmoil: false } as ProductResult, "non-vegan icon-cancel"],
[{ palmoil: "n/a" } as ProductResult, "unknown icon-help"],
[{ palmoil: undefined } as ProductResult, "unknown icon-help"],
];
testCases.forEach(([input, expected]) => {
const result = getProductState(input);
expect(result.palmoil).toBe(expected);
});
});
});
describe("getNutriscoreClass", () => {
test("handles valid nutriscore grades", () => {
const testCases: [string, { score: string; className: string }][] = [
["A", { score: "nutri_a icon-a", className: "nutri_a" }],
["B", { score: "nutri_b icon-b", className: "nutri_b" }],
["C", { score: "nutri_c icon-c", className: "nutri_c" }],
["D", { score: "nutri_d icon-d", className: "nutri_d" }],
["E", { score: "nutri_e icon-e", className: "nutri_e" }],
];
testCases.forEach(([grade, expected]) => {
const result = getProductState({ nutriscore: grade } as ProductResult);
expect(result.nutriscore).toEqual(expected);
});
});
test("handles case-insensitive nutriscore grades", () => {
const input = { nutriscore: "a" } as ProductResult;
const result = getProductState(input);
expect(result.nutriscore).toEqual({
score: "nutri_a icon-a",
className: "nutri_a",
});
});
test("handles invalid nutriscore grades", () => {
const testCases: (string | undefined)[] = [
"F",
"X",
"123",
"n/a",
undefined,
];
testCases.forEach((grade) => {
const result = getProductState({ nutriscore: grade } as ProductResult);
expect(result.nutriscore).toEqual({
score: "unknown icon-help",
className: "",
});
});
});
test("handles general grade scores similarly to nutriscore", () => {
const testCases: [string, { score: string; className: string }][] = [
["A", { score: "nutri_a icon-a", className: "nutri_a" }],
["B", { score: "nutri_b icon-b", className: "nutri_b" }],
["C", { score: "nutri_c icon-c", className: "nutri_c" }],
];
testCases.forEach(([grade, expected]) => {
const result = getProductState({ grade } as ProductResult);
expect(result.grade).toEqual(expected);
});
});
});
test("handles complete product data", () => {
const input: ProductResult = {
productname: "Foo Chocolate Bar",
vegan: true,
vegetarian: false,
animaltestfree: "n/a",
palmoil: undefined,
nutriscore: "A",
grade: "B",
};
const result = getProductState(input);
expect(result).toEqual({
vegan: "vegan icon-ok",
vegetarian: "non-vegan icon-cancel",
animaltestfree: "unknown icon-help",
palmoil: "unknown icon-help",
nutriscore: { score: "nutri_a icon-a", className: "nutri_a" },
grade: { score: "nutri_b icon-b", className: "nutri_b" },
});
});
test("handles empty product data", () => {
const input = {} as ProductResult;
const result = getProductState(input);
expect(result).toEqual({
vegan: "unknown icon-help",
vegetarian: "unknown icon-help",
animaltestfree: "unknown icon-help",
palmoil: "unknown icon-help",
nutriscore: { score: "unknown icon-help", className: "" },
grade: { score: "unknown icon-help", className: "" },
});
});
});

View file

@ -0,0 +1,136 @@
import Veganify from "@frontendnetwork/veganify";
jest.mock("@frontendnetwork/veganify", () => ({
getProductByBarcode: jest.fn(),
}));
async function testFetchProduct(barcode: string) {
try {
const data = await Veganify.getProductByBarcode(
barcode,
process.env.NEXT_PUBLIC_STAGING === "true"
);
if (data && "product" in data && "sources" in data) {
return {
product: data.product,
sources: data.sources,
status: data.status,
};
} else if (data && "status" in data) {
return {
status: data.status,
};
}
throw new Error("Invalid response format");
} catch (error) {
console.error("Product fetch error:", error);
throw error;
}
}
describe("fetchProduct", () => {
beforeEach(() => {
jest.clearAllMocks();
// Reset environment variables
process.env.NEXT_PUBLIC_STAGING = undefined;
});
it("returns product and sources when available", async () => {
const mockResponse = {
product: {
name: "Test Product",
vegan: true,
},
sources: {
openFoodFacts: true,
},
status: 200,
};
(Veganify.getProductByBarcode as jest.Mock).mockResolvedValueOnce(
mockResponse
);
const result = await testFetchProduct("4000417025005");
expect(result).toEqual(mockResponse);
expect(Veganify.getProductByBarcode).toHaveBeenCalledWith(
"4000417025005",
false
);
});
it("returns only status when no product found", async () => {
const mockResponse = {
status: 404,
};
(Veganify.getProductByBarcode as jest.Mock).mockResolvedValueOnce(
mockResponse
);
const result = await testFetchProduct("4000417025005");
expect(result).toEqual({ status: 404 });
});
it("throws error when API call fails", async () => {
const mockError = new Error("API Error");
(Veganify.getProductByBarcode as jest.Mock).mockRejectedValueOnce(
mockError
);
await expect(testFetchProduct("4000417025005")).rejects.toThrow(
"API Error"
);
});
it("uses staging flag correctly", async () => {
const mockResponse = {
product: {
name: "Test Product",
vegan: true,
},
sources: {
openFoodFacts: true,
},
status: 200,
};
(Veganify.getProductByBarcode as jest.Mock).mockResolvedValue(mockResponse);
// Test with staging true
process.env.NEXT_PUBLIC_STAGING = "true";
await testFetchProduct("4000417025005");
expect(Veganify.getProductByBarcode).toHaveBeenLastCalledWith(
"4000417025005",
true
);
// Test with staging false
process.env.NEXT_PUBLIC_STAGING = "false";
await testFetchProduct("4000417025005");
expect(Veganify.getProductByBarcode).toHaveBeenLastCalledWith(
"4000417025005",
false
);
// Test with staging undefined
process.env.NEXT_PUBLIC_STAGING = undefined;
await testFetchProduct("4000417025005");
expect(Veganify.getProductByBarcode).toHaveBeenLastCalledWith(
"4000417025005",
false
);
});
it("handles invalid response format", async () => {
(Veganify.getProductByBarcode as jest.Mock).mockResolvedValueOnce({
invalidField: "invalid",
});
await expect(testFetchProduct("4000417025005")).rejects.toThrow(
"Invalid response format"
);
});
});

View file

@ -0,0 +1,167 @@
import { preprocessIngredients } from "./preprocessIngredients";
describe("preprocessIngredients", () => {
describe("percentage handling", () => {
test("removes simple percentages", () => {
expect(preprocessIngredients("water 80%, salt 20%")).toEqual([
"water",
"salt",
]);
});
test("removes decimal percentages", () => {
expect(preprocessIngredients("water 80.5%, salt 19.5%")).toEqual([
"water",
"salt",
]);
});
});
describe("separator handling", () => {
test("splits by comma", () => {
expect(preprocessIngredients("water,salt,sugar")).toEqual([
"water",
"salt",
"sugar",
]);
});
test("converts colons to commas", () => {
expect(preprocessIngredients("water:salt:sugar")).toEqual([
"water",
"salt",
"sugar",
]);
});
test("handles mixed separators", () => {
expect(preprocessIngredients("water:salt,sugar:pepper")).toEqual([
"water",
"salt",
"sugar",
"pepper",
]);
});
});
describe("whitespace handling", () => {
test("trims whitespace", () => {
expect(preprocessIngredients(" water , salt , sugar ")).toEqual([
"water",
"salt",
"sugar",
]);
});
test("preserves multiple spaces within ingredients", () => {
expect(preprocessIngredients("water salt, sugar")).toEqual([
"water salt",
"sugar",
]);
});
});
describe("parentheses handling", () => {
test("combines parenthetical content", () => {
expect(preprocessIngredients("water (filtered)")).toEqual([
"water filtered",
]);
});
test("combines multiple parenthetical contents", () => {
expect(preprocessIngredients("water (filtered) (purified)")).toEqual([
"water filtered purified",
]);
});
test("handles nested parentheses", () => {
expect(preprocessIngredients("salt (sea salt (iodized))")).toEqual([
"salt sea salt iodized",
]);
});
});
describe("number handling", () => {
test("preserves vitamin numbers", () => {
expect(preprocessIngredients("vitamin b12, vitamin d3")).toEqual([
"vitamin b12",
"vitamin d3",
]);
});
test("preserves E-numbers", () => {
expect(preprocessIngredients("e150a, e627")).toEqual(["e150a", "e627"]);
});
});
describe("deduplication", () => {
test("removes exact duplicates", () => {
expect(preprocessIngredients("water, water, salt, salt")).toEqual([
"water",
"salt",
]);
});
test("removes substring duplicates", () => {
expect(preprocessIngredients("coconut milk, coconut")).toEqual([
"coconut milk",
]);
});
});
describe("complex real-world cases", () => {
test("handles complex ingredient list", () => {
const input =
"Water (75%), Coconut Milk (15.5%), Sugar (raw) 5%, Salt (sea) 4.5%: Natural Flavoring";
expect(preprocessIngredients(input)).toEqual([
"Water",
"Coconut Milk",
"Sugar raw",
"Salt sea",
"Natural Flavoring",
]);
});
test("handles ingredients with additives", () => {
expect(
preprocessIngredients(
"water, sugar (brown), salt (sea salt), vitamin b12"
)
).toEqual(["water", "sugar brown", "salt sea salt", "vitamin b12"]);
});
});
describe("edge cases", () => {
test("handles empty input", () => {
expect(preprocessIngredients("")).toEqual([]);
});
test("handles input with only separators", () => {
expect(preprocessIngredients(",,,:::,,,")).toEqual([]);
});
test("handles input with only spaces", () => {
expect(preprocessIngredients(" ")).toEqual([]);
});
test("handles special characters", () => {
expect(preprocessIngredients("()[]{}")).toEqual(["[]{}"]);
});
});
describe("error handling", () => {
test("handles null or undefined", () => {
// @ts-expect-error Testing invalid input
expect(preprocessIngredients(null)).toEqual([]);
// @ts-expect-error Testing invalid input
expect(preprocessIngredients(undefined)).toEqual([]);
});
test("handles non-string input", () => {
// @ts-expect-error Testing invalid input
expect(preprocessIngredients(123)).toEqual([]);
// @ts-expect-error Testing invalid input
expect(preprocessIngredients({})).toEqual([]);
});
});
});

View file

@ -1,4 +1,6 @@
export function preprocessIngredients(input: string): string[] {
if (!input || typeof input !== "string") return [];
const processed = input
.replace(/\d+(\.\d+)?%/g, "")
.replace(/\b\w+\s+\d+/g, (match) => match.replace(/\d+$/, ""))
@ -10,7 +12,7 @@ export function preprocessIngredients(input: string): string[] {
.filter(Boolean)
.reduce((acc, item) => {
const parts = item
.split(/\s+(?=\()/)
.split(/\s+(?=\(\*)/)
.map((part) => part.replace(/[()]/g, "").trim());
return acc.concat(parts);
}, [] as string[])

32
src/tests/setup.ts Normal file
View file

@ -0,0 +1,32 @@
import "@testing-library/jest-dom";
global.console = {
...console,
log: jest.fn(),
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
};
jest.mock("next/navigation", () => ({
useRouter() {
return {
push: jest.fn(),
replace: jest.fn(),
prefetch: jest.fn(),
back: jest.fn(),
};
},
useSearchParams() {
return new URLSearchParams();
},
usePathname() {
return "";
},
}));
jest.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
useLocale: () => "en",
}));