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(
-
);
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"
>
-
-
![]()
+
@@ -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) =>
,
}));
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"
}