feat: Initialize API v1 for Ingredients

This commit is contained in:
Philip 2024-10-27 13:47:30 +01:00
parent 7aa9a50207
commit d15cac07eb
11 changed files with 199 additions and 128 deletions

View file

@ -4,7 +4,7 @@
"private": true,
"scripts": {
"preinstall": "npx only-allow pnpm",
"dev": "next dev --turbopack",
"dev": "next dev",
"build": "next build",
"start": "next start -p 1030",
"stage": "next start -p 1031",
@ -25,7 +25,7 @@
"dependencies": {
"@ducanh2912/next-pwa": "^10.2.9",
"@ericblade/quagga2": "^1.8.4",
"@frontendnetwork/veganify": "^1.2.9",
"@frontendnetwork/veganify": "^1.3.0",
"@types/node": "22.8.1",
"@types/react-dom": "npm:types-react-dom@19.0.0-rc.1",
"jest-worker": "^29.7.0",

View file

@ -30,8 +30,8 @@ importers:
specifier: ^1.8.4
version: 1.8.4
'@frontendnetwork/veganify':
specifier: ^1.2.9
version: 1.2.9
specifier: ^1.3.0
version: 1.3.0
'@types/node':
specifier: 22.8.1
version: 22.8.1
@ -819,8 +819,8 @@ packages:
'@formatjs/intl-localematcher@0.5.6':
resolution: {integrity: sha512-roz1+Ba5e23AHX6KUAWmLEyTRZegM5YDuxuvkHCyK3RJddf/UXB2f+s7pOMm9ktfPGla0g+mQXOn5vsuYirnaA==}
'@frontendnetwork/veganify@1.2.9':
resolution: {integrity: sha512-npn7Y51JW9c2IakO4X9BAByZoSm9NhJXDNE3W0+kJR7G7KBmOr1R+8xs7QB4wQ/cTeuS0SHY90QbWubnCgoawg==}
'@frontendnetwork/veganify@1.3.0':
resolution: {integrity: sha512-0vikV+EnScvrI7/Gi1cpJynomiSGz2t7HAWcR0tLUhhdlBmDimAvIxgWZYxPl2hEYjI0J5PSHNxltV/nePndAQ==}
'@humanfs/core@0.19.0':
resolution: {integrity: sha512-2cbWIHbZVEweE853g8jymffCA+NCMiuqeECeBBLm8dg2oFdjuGJhgN4UAbI+6v0CKbbhvtXA4qV8YR5Ji86nmw==}
@ -5068,7 +5068,7 @@ snapshots:
dependencies:
tslib: 2.6.2
'@frontendnetwork/veganify@1.2.9': {}
'@frontendnetwork/veganify@1.3.0': {}
'@humanfs/core@0.19.0': {}

View file

@ -14,14 +14,21 @@ export function IngredientsForm() {
vegan: null,
surelyVegan: [],
notVegan: [],
maybeVegan: [],
maybeNotVegan: [],
unknown: [],
});
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setResult({ vegan: null, surelyVegan: [], notVegan: [], maybeVegan: [] });
setResult({
vegan: null,
surelyVegan: [],
notVegan: [],
maybeNotVegan: [],
unknown: [],
});
setError(null);
const formData = new FormData(event.currentTarget);

View file

@ -1,23 +1,41 @@
import React from "react";
import { IconClassType } from "./models/Tooltip";
import { TranslationFunction } from "./models/TranslateFunction";
import { TooltipClient } from "./shared/Tooltip";
export function IngredientList({
items,
iconClass,
}: {
interface IngredientListProps {
items: string[];
iconClass: string;
}) {
iconClass: IconClassType;
t: TranslationFunction;
}
export function IngredientList({ items, iconClass, t }: IngredientListProps) {
const tooltipMessages = {
"maybe-vegan": t("maybe_vegan"),
"unknown-vegan": t("unknown_vegan"),
} as const;
const shouldShowTooltip =
iconClass.includes("maybe-vegan") || iconClass.includes("unknown-vegan");
const tooltipBaseClass = iconClass.split(
" "
)[0] as keyof typeof tooltipMessages;
const tooltipMessage = shouldShowTooltip
? tooltipMessages[tooltipBaseClass]
: "";
return (
<>
{items.map((item) => (
<div className="Grid" key={item}>
<div className="Grid-cell description">
{item.charAt(0).toUpperCase() + item.slice(1)}
<TooltipClient message={tooltipMessage as string} key={item}>
<div className="Grid">
<div className="Grid-cell description">
{item.charAt(0).toUpperCase() + item.slice(1)}
</div>
<div className="Grid-cell icons">
<span className={iconClass}></span>
</div>
</div>
<div className="Grid-cell icons">
<span className={iconClass}></span>
</div>
</div>
</TooltipClient>
))}
</>
);

View file

@ -1,14 +1,14 @@
import { IngredientList } from "./IngredientsList";
import { IngredientResult } from "./models/IngredientResult";
import { TranslationFunction } from "./models/TranslateFunction";
import { SourceInfo } from "./SourceInfo";
export function ResultDisplay({
result,
t,
}: {
interface ResultDisplayProps {
result: IngredientResult;
t: (key: string, values?: Record<string, string>) => string;
}) {
t: TranslationFunction;
}
export function ResultDisplay({ result, t }: ResultDisplayProps) {
return (
<div id="result">
<div className="">
@ -28,14 +28,22 @@ export function ResultDisplay({
<IngredientList
items={result.notVegan}
iconClass="non-vegan icon-cancel"
t={t}
/>
<IngredientList
items={result.maybeVegan}
iconClass="maybe-vegan icon-help"
items={result.unknown}
iconClass="unknown-vegan icon-help"
t={t}
/>
<IngredientList
items={result.maybeNotVegan}
iconClass="maybe-vegan icon-attention-alt"
t={t}
/>
<IngredientList
items={result.surelyVegan}
iconClass="vegan icon-ok"
t={t}
/>
<SourceInfo t={t} />
</div>

View file

@ -2,5 +2,6 @@ export interface IngredientResult {
vegan: boolean | null;
surelyVegan: string[];
notVegan: string[];
maybeVegan: string[];
maybeNotVegan: string[];
unknown: string[];
}

View file

@ -0,0 +1,10 @@
export type IconClassType =
| "vegan icon-ok"
| "non-vegan icon-cancel"
| "unknown-vegan icon-help"
| "maybe-vegan icon-attention-alt";
export interface TooltipMessages {
"maybe-vegan": string;
"unkown-vegan": string;
}

View file

@ -0,0 +1,4 @@
export type TranslationFunction = (
key: string,
values?: Record<string, string>
) => string;

View file

@ -0,0 +1,32 @@
"use client";
import { useState } from "react";
interface TooltipProps {
message: string;
children: React.ReactNode;
}
export function TooltipClient({ message, children }: TooltipProps) {
const [isVisible, setIsVisible] = useState(false);
if (!message) {
return children;
}
return (
<div
className="tooltip-wrapper"
onMouseEnter={() => setIsVisible(true)}
onMouseLeave={() => setIsVisible(false)}
>
{children}
{isVisible && (
<div className="tooltip">
{message}
<div className="tooltip-arrow"></div>
</div>
)}
</div>
);
}

View file

@ -1,136 +1,126 @@
import Veganify from "@frontendnetwork/veganify";
import { checkIngredients } from "./actions";
jest.mock("@frontendnetwork/veganify", () => ({
getProductByBarcode: jest.fn(),
__esModule: true,
default: {
checkIngredientsListV1: 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", () => {
describe("checkIngredients", () => {
// Reset all mocks before each test
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",
it("should successfully check ingredients and return formatted data", async () => {
// Mock successful API response
const mockApiResponse = {
data: {
vegan: true,
surely_vegan: ["apple", "banana"],
not_vegan: [],
maybe_not_vegan: [],
unknown: ["artificial-flavor"],
},
sources: {
openFoodFacts: true,
},
status: 200,
};
(Veganify.getProductByBarcode as jest.Mock).mockResolvedValueOnce(
mockResponse
(Veganify.checkIngredientsListV1 as jest.Mock).mockResolvedValue(
mockApiResponse
);
const result = await testFetchProduct("4000417025005");
const result = await checkIngredients("apple, banana, artificial-flavor");
expect(result).toEqual(mockResponse);
expect(Veganify.getProductByBarcode).toHaveBeenCalledWith(
"4000417025005",
false
// Check if the function returns the expected formatted data
expect(result).toEqual({
vegan: true,
surelyVegan: ["apple", "banana"],
notVegan: [],
maybeNotVegan: [],
unknown: ["artificial-flavor"],
});
// Verify Veganify was called with correct parameters
expect(Veganify.checkIngredientsListV1).toHaveBeenCalledWith(
"apple, banana, artificial-flavor",
process.env.NEXT_PUBLIC_STAGING === "true"
);
});
it("returns only status when no product found", async () => {
const mockResponse = {
status: 404,
};
(Veganify.getProductByBarcode as jest.Mock).mockResolvedValueOnce(
mockResponse
it("should throw an error when ingredients string is empty", async () => {
await expect(checkIngredients("")).rejects.toThrow(
"Ingredients cannot be empty"
);
await expect(checkIngredients(" ")).rejects.toThrow(
"Ingredients cannot be empty"
);
const result = await testFetchProduct("4000417025005");
expect(result).toEqual({ status: 404 });
// Verify Veganify was not called
expect(Veganify.checkIngredientsListV1).not.toHaveBeenCalled();
});
it("throws error when API call fails", async () => {
const mockError = new Error("API Error");
(Veganify.getProductByBarcode as jest.Mock).mockRejectedValueOnce(
mockError
it("should throw an error when API call fails", async () => {
// Mock API failure
(Veganify.checkIngredientsListV1 as jest.Mock).mockRejectedValue(
new Error("API Error")
);
await expect(testFetchProduct("4000417025005")).rejects.toThrow(
"API Error"
await expect(checkIngredients("apple")).rejects.toThrow(
"Failed to check ingredients"
);
// Verify Veganify was called
expect(Veganify.checkIngredientsListV1).toHaveBeenCalledWith(
"apple",
process.env.NEXT_PUBLIC_STAGING === "true"
);
});
it("uses staging flag correctly", async () => {
const mockResponse = {
product: {
name: "Test Product",
vegan: true,
it("should handle non-vegan ingredients correctly", async () => {
// Mock API response with non-vegan ingredients
const mockApiResponse = {
data: {
vegan: false,
surely_vegan: ["apple"],
not_vegan: ["gelatin"],
maybe_not_vegan: ["sugar"],
unknown: [],
},
sources: {
openFoodFacts: true,
},
status: 200,
};
(Veganify.getProductByBarcode as jest.Mock).mockResolvedValue(mockResponse);
(Veganify.checkIngredientsListV1 as jest.Mock).mockResolvedValue(
mockApiResponse
);
const result = await checkIngredients("apple, gelatin, sugar");
expect(result).toEqual({
vegan: false,
surelyVegan: ["apple"],
notVegan: ["gelatin"],
maybeNotVegan: ["sugar"],
unknown: [],
});
});
it("should handle staging environment flag correctly", async () => {
const originalEnv = process.env.NEXT_PUBLIC_STAGING;
// Test with staging true
process.env.NEXT_PUBLIC_STAGING = "true";
await testFetchProduct("4000417025005");
expect(Veganify.getProductByBarcode).toHaveBeenLastCalledWith(
"4000417025005",
true
);
await checkIngredients("apple");
expect(Veganify.checkIngredientsListV1).toHaveBeenCalledWith("apple", true);
// Test with staging false
process.env.NEXT_PUBLIC_STAGING = "false";
await testFetchProduct("4000417025005");
expect(Veganify.getProductByBarcode).toHaveBeenLastCalledWith(
"4000417025005",
await checkIngredients("apple");
expect(Veganify.checkIngredientsListV1).toHaveBeenCalledWith(
"apple",
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"
);
process.env.NEXT_PUBLIC_STAGING = originalEnv;
});
});

View file

@ -9,7 +9,7 @@ export async function checkIngredients(ingredients: string) {
}
try {
const data = await Veganify.checkIngredientsList(
const data = await Veganify.checkIngredientsListV1(
ingredients,
process.env.NEXT_PUBLIC_STAGING === "true"
);
@ -18,7 +18,8 @@ export async function checkIngredients(ingredients: string) {
vegan: data.data.vegan,
surelyVegan: data.data.surely_vegan,
notVegan: data.data.not_vegan,
maybeVegan: data.data.maybe_vegan,
maybeNotVegan: data.data.maybe_not_vegan,
unknown: data.data.unknown,
};
} catch (error) {
console.error(error);