diff --git a/frontend/app/[locale]/blog/[slug]/PostDetails.tsx b/frontend/app/[locale]/blog/[slug]/PostDetails.tsx index f16b593a..733b2723 100644 --- a/frontend/app/[locale]/blog/[slug]/PostDetails.tsx +++ b/frontend/app/[locale]/blog/[slug]/PostDetails.tsx @@ -84,7 +84,6 @@ function renderPortableTextSpans( const text = child?.text || ''; if (!text) return null; const marks = child?.marks || []; - const linkKey = marks.find(mark => linkMap.has(mark)); let node: React.ReactNode = marks.length === 0 ? linkifyText(text) : text; @@ -280,11 +279,14 @@ function renderPortableText( if (block?._type === 'image' && block?.url) { nodes.push( - {postTitle ); i += 1; diff --git a/frontend/components/blog/BlogFilters.tsx b/frontend/components/blog/BlogFilters.tsx index ed127b2f..7daa3399 100644 --- a/frontend/components/blog/BlogFilters.tsx +++ b/frontend/components/blog/BlogFilters.tsx @@ -192,7 +192,6 @@ export default function BlogFilters({ name: string; norm: string; } | null>(null); - const [currentPage, setCurrentPage] = useState(1); const lastFiltersRef = useRef<{ author: string; category: string; @@ -327,11 +326,6 @@ export default function BlogFilters({ didClearSearchRef.current = true; }, [pathname, router, searchParams, searchQuery]); - useEffect(() => { - if (currentPage === parsedPage) return; - setCurrentPage(parsedPage); - }, [currentPage, parsedPage]); - const resolvedAuthor = useMemo(() => { const normParam = normalizeAuthor(authorParam); if (!normParam) return selectedAuthor; @@ -363,12 +357,11 @@ export default function BlogFilters({ prevFilters.query !== nextFilters.query; if (!hasChanged) return; lastFiltersRef.current = nextFilters; - if (currentPage !== 1) { - setCurrentPage(1); + if (parsedPage !== 1) { updatePageParam(1); } }, [ - currentPage, + parsedPage, resolvedAuthor?.norm, resolvedCategory?.norm, searchQueryNormalized, @@ -402,7 +395,7 @@ export default function BlogFilters({ }, [locale, resolvedAuthor?.name]); useEffect(() => { - if (!resolvedAuthor) return; + if (!resolvedAuthor?.norm) return; const frame = window.requestAnimationFrame(() => { authorHeadingRef.current?.scrollIntoView({ behavior: 'smooth', @@ -446,6 +439,7 @@ export default function BlogFilters({ 1, Math.ceil(filteredPosts.length / POSTS_PER_PAGE) ); + const currentPage = Math.min(parsedPage, totalPages); const paginatedPosts = useMemo(() => { const start = (currentPage - 1) * POSTS_PER_PAGE; return filteredPosts.slice(start, start + POSTS_PER_PAGE); @@ -462,10 +456,9 @@ export default function BlogFilters({ }, [selectedAuthorData]); useEffect(() => { - if (currentPage <= totalPages) return; - setCurrentPage(totalPages); + if (parsedPage <= totalPages) return; updatePageParam(totalPages); - }, [currentPage, totalPages, updatePageParam]); + }, [parsedPage, totalPages, updatePageParam]); const scrollToCategoryFilters = useCallback(() => { const target = categoryFiltersRef.current || postsGridRef.current; @@ -494,11 +487,14 @@ export default function BlogFilters({ href={`/blog/${featuredPost.slug.current}`} className="group block" > -
- + {featuredPost.title}
@@ -689,7 +685,6 @@ export default function BlogFilters({ totalPages={totalPages} onPageChange={page => { pendingScrollRef.current = true; - setCurrentPage(page); updatePageParam(page); }} accentColor="var(--accent-primary)" diff --git a/frontend/components/blog/BlogHeaderSearch.tsx b/frontend/components/blog/BlogHeaderSearch.tsx index c73d34f7..f21f635d 100644 --- a/frontend/components/blog/BlogHeaderSearch.tsx +++ b/frontend/components/blog/BlogHeaderSearch.tsx @@ -95,7 +95,7 @@ export function BlogHeaderSearch() { return () => { active = false; }; - }, [open, items.length, isLoading]); + }, [open, items.length, isLoading, locale]); useEffect(() => { if (!open) return; diff --git a/frontend/components/tests/blog/blog-card.test.tsx b/frontend/components/tests/blog/blog-card.test.tsx index 826bee0e..6d1c308c 100644 --- a/frontend/components/tests/blog/blog-card.test.tsx +++ b/frontend/components/tests/blog/blog-card.test.tsx @@ -7,7 +7,8 @@ import type { Author, Post } from '@/components/blog/BlogFilters'; vi.mock('next/image', () => ({ __esModule: true, - default: (props: any) => , + // eslint-disable-next-line @next/next/no-img-element + default: (props: any) => {props.alt, })); vi.mock('next/link', () => ({ diff --git a/frontend/components/tests/blog/blog-filters.test.tsx b/frontend/components/tests/blog/blog-filters.test.tsx index c4b8bcd6..a3ed3cff 100644 --- a/frontend/components/tests/blog/blog-filters.test.tsx +++ b/frontend/components/tests/blog/blog-filters.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { render, screen, waitFor } from '@testing-library/react'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import type { Author, Post } from '@/components/blog/BlogFilters'; import BlogFilters from '@/components/blog/BlogFilters'; diff --git a/frontend/components/tests/blog/blog-header-search.test.tsx b/frontend/components/tests/blog/blog-header-search.test.tsx index 53f06a99..f2c3081e 100644 --- a/frontend/components/tests/blog/blog-header-search.test.tsx +++ b/frontend/components/tests/blog/blog-header-search.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { BlogHeaderSearch } from '@/components/blog/BlogHeaderSearch'; diff --git a/frontend/lib/tests/quiz/quiz-crypto.test.ts b/frontend/lib/tests/quiz/quiz-crypto.test.ts new file mode 100644 index 00000000..6a2a332f --- /dev/null +++ b/frontend/lib/tests/quiz/quiz-crypto.test.ts @@ -0,0 +1,162 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + createEncryptedAnswersBlob, + decryptAnswers, + encryptAnswers, +} from '@/lib/quiz/quiz-crypto'; + +import { + createCorrectAnswersMap, + createMockQuestions, + resetFactoryCounters, +} from '../factories/quiz/quiz'; +import { cleanupQuizTestEnv, setupQuizTestEnv } from './setup'; + +describe('quiz-crypto', () => { + // Setup: set encryption key before each test + beforeEach(() => { + setupQuizTestEnv(); + resetFactoryCounters(); + }); + + // Cleanup: remove encryption key after each test + afterEach(() => { + cleanupQuizTestEnv(); + }); + + describe('encryptAnswers', () => { + it('returns a base64 string', () => { + const answers = { 'q-1': 'a-1' }; + + const result = encryptAnswers(answers); + + // Base64 pattern: letters, numbers, +, /, ends with optional = + expect(result).toMatch(/^[A-Za-z0-9+/]+=*$/); + }); + + it('returns different output for same input (random IV)', () => { + const answers = { 'q-1': 'a-1' }; + + const result1 = encryptAnswers(answers); + const result2 = encryptAnswers(answers); + + // Each encryption uses random IV, so outputs differ + expect(result1).not.toBe(result2); + }); + + it('handles empty object', () => { + const result = encryptAnswers({}); + + expect(result).toBeDefined(); + expect(typeof result).toBe('string'); + }); + + it('handles multiple questions', () => { + const answers = { + 'q-1': 'a-1', + 'q-2': 'a-2', + 'q-3': 'a-3', + }; + + const result = encryptAnswers(answers); + + expect(result).toBeDefined(); + expect(result.length).toBeGreaterThan(0); + }); + }); + + describe('decryptAnswers', () => { + it('decrypts back to original data', () => { + const original = { 'q-1': 'a-1', 'q-2': 'a-2' }; + + const encrypted = encryptAnswers(original); + const decrypted = decryptAnswers(encrypted); + + expect(decrypted).toEqual(original); + }); + + it('returns null for tampered data', () => { + const original = { 'q-1': 'a-1' }; + const encrypted = encryptAnswers(original); + + // Tamper: change last 5 characters + const tampered = encrypted.slice(0, -5) + 'XXXXX'; + + const result = decryptAnswers(tampered); + + expect(result).toBeNull(); + }); + + it('returns null for invalid base64', () => { + const result = decryptAnswers('not-valid-base64!!!'); + + expect(result).toBeNull(); + }); + + it('returns null for empty string', () => { + const result = decryptAnswers(''); + + expect(result).toBeNull(); + }); + + it('returns null for truncated data', () => { + const original = { 'q-1': 'a-1' }; + const encrypted = encryptAnswers(original); + + // Truncate: remove half of the data + const truncated = encrypted.slice(0, encrypted.length / 2); + + const result = decryptAnswers(truncated); + + expect(result).toBeNull(); + }); + }); + + describe('createEncryptedAnswersBlob', () => { + it('creates encrypted blob from questions', () => { + const questions = createMockQuestions(3); + + const blob = createEncryptedAnswersBlob(questions); + + expect(blob).toBeDefined(); + expect(typeof blob).toBe('string'); + expect(blob.length).toBeGreaterThan(0); + }); + + it('encrypted blob decrypts to correct answers map', () => { + const questions = createMockQuestions(3); + const expectedMap = createCorrectAnswersMap(questions); + + const blob = createEncryptedAnswersBlob(questions); + const decrypted = decryptAnswers(blob); + + expect(decrypted).toEqual(expectedMap); + }); + + it('handles questions with no correct answer', () => { + const questions = [ + { + id: 'q-no-correct', + answers: [ + { id: 'a-1', isCorrect: false }, + { id: 'a-2', isCorrect: false }, + ], + }, + ]; + + const blob = createEncryptedAnswersBlob(questions); + const decrypted = decryptAnswers(blob); + + // Question with no correct answer is not included in map + expect(decrypted).toEqual({}); + }); + + it('handles empty questions array', () => { + const blob = createEncryptedAnswersBlob([]); + const decrypted = decryptAnswers(blob); + + expect(decrypted).toEqual({}); + }); + }); +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a5da8064..faff97b4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -186,6 +186,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -503,6 +504,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -543,6 +545,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -3271,6 +3274,7 @@ "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-8.7.0.tgz", "integrity": "sha512-tNUerSstwNC1KuHgX4CASGO0Md3CB26IJzSXmVlSuFvhsBP4ZaEPpY4jxWOn9tfdDscuVT4Kqb8cZ2o9nLCgRQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=12.16" } @@ -3736,6 +3740,7 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -3967,6 +3972,7 @@ "integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3977,6 +3983,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4026,6 +4033,7 @@ "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", @@ -4514,6 +4522,7 @@ "resolved": "https://registry.npmjs.org/@upstash/redis/-/redis-1.36.2.tgz", "integrity": "sha512-C0Yt8hc12vLaQYRG1fMci8iPrLtnTdbJG0HR5T8vKnvEP/1RdMMblsOJs5/jp0JXZJ1oSzMnQz4J9EVezNpI6A==", "license": "MIT", + "peer": true, "dependencies": { "uncrypto": "^0.1.3" } @@ -4750,6 +4759,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5189,6 +5199,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6243,6 +6254,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -6656,6 +6668,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -8717,6 +8730,7 @@ "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@acemir/cssom": "^0.9.28", "@asamuzakjp/dom-selector": "^6.7.6", @@ -9597,6 +9611,7 @@ "resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz", "integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==", "license": "MIT", + "peer": true, "dependencies": { "@next/env": "16.1.6", "@swc/helpers": "0.5.15", @@ -10148,6 +10163,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz", "integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.11.0", "pg-pool": "^3.11.0", @@ -10311,6 +10327,7 @@ "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.8.tgz", "integrity": "sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg==", "license": "Unlicense", + "peer": true, "engines": { "node": ">=12" }, @@ -10560,6 +10577,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -10569,6 +10587,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -11570,6 +11589,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -12017,6 +12037,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12383,6 +12404,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -12396,6 +12418,7 @@ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", @@ -12769,6 +12792,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" }