Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54b0984348 | ||
|
|
8cdc8705bf | ||
|
|
f6fc9779db | ||
|
|
3b6035c349 | ||
|
|
ef62a67534 | ||
|
|
30d2b76056 | ||
|
|
aaaa60f5db | ||
|
|
3377744111 | ||
|
|
f0490db544 |
@@ -0,0 +1,227 @@
|
|||||||
|
{
|
||||||
|
"base_commands": [
|
||||||
|
".",
|
||||||
|
"[",
|
||||||
|
"[[",
|
||||||
|
"ag",
|
||||||
|
"awk",
|
||||||
|
"basename",
|
||||||
|
"bash",
|
||||||
|
"bc",
|
||||||
|
"break",
|
||||||
|
"cat",
|
||||||
|
"cd",
|
||||||
|
"chmod",
|
||||||
|
"clear",
|
||||||
|
"cmp",
|
||||||
|
"column",
|
||||||
|
"comm",
|
||||||
|
"command",
|
||||||
|
"continue",
|
||||||
|
"cp",
|
||||||
|
"curl",
|
||||||
|
"cut",
|
||||||
|
"date",
|
||||||
|
"df",
|
||||||
|
"diff",
|
||||||
|
"dig",
|
||||||
|
"dirname",
|
||||||
|
"du",
|
||||||
|
"echo",
|
||||||
|
"egrep",
|
||||||
|
"env",
|
||||||
|
"eval",
|
||||||
|
"exec",
|
||||||
|
"exit",
|
||||||
|
"expand",
|
||||||
|
"export",
|
||||||
|
"expr",
|
||||||
|
"false",
|
||||||
|
"fd",
|
||||||
|
"fgrep",
|
||||||
|
"file",
|
||||||
|
"find",
|
||||||
|
"fmt",
|
||||||
|
"fold",
|
||||||
|
"gawk",
|
||||||
|
"gh",
|
||||||
|
"git",
|
||||||
|
"grep",
|
||||||
|
"gunzip",
|
||||||
|
"gzip",
|
||||||
|
"head",
|
||||||
|
"help",
|
||||||
|
"host",
|
||||||
|
"iconv",
|
||||||
|
"id",
|
||||||
|
"jobs",
|
||||||
|
"join",
|
||||||
|
"jq",
|
||||||
|
"kill",
|
||||||
|
"killall",
|
||||||
|
"less",
|
||||||
|
"let",
|
||||||
|
"ln",
|
||||||
|
"ls",
|
||||||
|
"lsof",
|
||||||
|
"man",
|
||||||
|
"mkdir",
|
||||||
|
"mktemp",
|
||||||
|
"more",
|
||||||
|
"mv",
|
||||||
|
"nl",
|
||||||
|
"paste",
|
||||||
|
"pgrep",
|
||||||
|
"ping",
|
||||||
|
"pkill",
|
||||||
|
"popd",
|
||||||
|
"printenv",
|
||||||
|
"printf",
|
||||||
|
"ps",
|
||||||
|
"pushd",
|
||||||
|
"pwd",
|
||||||
|
"read",
|
||||||
|
"readlink",
|
||||||
|
"realpath",
|
||||||
|
"reset",
|
||||||
|
"return",
|
||||||
|
"rev",
|
||||||
|
"rg",
|
||||||
|
"rm",
|
||||||
|
"rmdir",
|
||||||
|
"sed",
|
||||||
|
"seq",
|
||||||
|
"set",
|
||||||
|
"sh",
|
||||||
|
"shuf",
|
||||||
|
"sleep",
|
||||||
|
"sort",
|
||||||
|
"source",
|
||||||
|
"split",
|
||||||
|
"stat",
|
||||||
|
"tail",
|
||||||
|
"tar",
|
||||||
|
"tee",
|
||||||
|
"test",
|
||||||
|
"time",
|
||||||
|
"timeout",
|
||||||
|
"touch",
|
||||||
|
"tr",
|
||||||
|
"tree",
|
||||||
|
"true",
|
||||||
|
"type",
|
||||||
|
"uname",
|
||||||
|
"unexpand",
|
||||||
|
"uniq",
|
||||||
|
"unset",
|
||||||
|
"unzip",
|
||||||
|
"watch",
|
||||||
|
"wc",
|
||||||
|
"wget",
|
||||||
|
"whereis",
|
||||||
|
"which",
|
||||||
|
"whoami",
|
||||||
|
"xargs",
|
||||||
|
"yes",
|
||||||
|
"yq",
|
||||||
|
"zip",
|
||||||
|
"zsh"
|
||||||
|
],
|
||||||
|
"stack_commands": [
|
||||||
|
"ar",
|
||||||
|
"clang",
|
||||||
|
"clang++",
|
||||||
|
"cmake",
|
||||||
|
"composer",
|
||||||
|
"dive",
|
||||||
|
"docker",
|
||||||
|
"docker-buildx",
|
||||||
|
"docker-compose",
|
||||||
|
"dockerfile",
|
||||||
|
"eslint",
|
||||||
|
"g++",
|
||||||
|
"gcc",
|
||||||
|
"ipython",
|
||||||
|
"jupyter",
|
||||||
|
"ld",
|
||||||
|
"make",
|
||||||
|
"meson",
|
||||||
|
"next",
|
||||||
|
"ninja",
|
||||||
|
"nm",
|
||||||
|
"node",
|
||||||
|
"notebook",
|
||||||
|
"npm",
|
||||||
|
"npx",
|
||||||
|
"objdump",
|
||||||
|
"pdb",
|
||||||
|
"php",
|
||||||
|
"pip",
|
||||||
|
"pip3",
|
||||||
|
"pipx",
|
||||||
|
"pnpm",
|
||||||
|
"pnpx",
|
||||||
|
"pudb",
|
||||||
|
"python",
|
||||||
|
"python3",
|
||||||
|
"react-scripts",
|
||||||
|
"strip",
|
||||||
|
"ts-node",
|
||||||
|
"tsc",
|
||||||
|
"tsx",
|
||||||
|
"vitest"
|
||||||
|
],
|
||||||
|
"script_commands": [
|
||||||
|
"bun",
|
||||||
|
"npm",
|
||||||
|
"pnpm",
|
||||||
|
"yarn"
|
||||||
|
],
|
||||||
|
"custom_commands": [],
|
||||||
|
"detected_stack": {
|
||||||
|
"languages": [
|
||||||
|
"python",
|
||||||
|
"javascript",
|
||||||
|
"typescript",
|
||||||
|
"php",
|
||||||
|
"c"
|
||||||
|
],
|
||||||
|
"package_managers": [
|
||||||
|
"pnpm"
|
||||||
|
],
|
||||||
|
"frameworks": [
|
||||||
|
"nextjs",
|
||||||
|
"react",
|
||||||
|
"vitest",
|
||||||
|
"eslint"
|
||||||
|
],
|
||||||
|
"databases": [],
|
||||||
|
"infrastructure": [
|
||||||
|
"docker"
|
||||||
|
],
|
||||||
|
"cloud_providers": [],
|
||||||
|
"code_quality_tools": [],
|
||||||
|
"version_managers": []
|
||||||
|
},
|
||||||
|
"custom_scripts": {
|
||||||
|
"npm_scripts": [
|
||||||
|
"dev",
|
||||||
|
"build",
|
||||||
|
"start",
|
||||||
|
"lint",
|
||||||
|
"build:images",
|
||||||
|
"generate:images",
|
||||||
|
"generate:images:dry",
|
||||||
|
"test",
|
||||||
|
"test:coverage"
|
||||||
|
],
|
||||||
|
"make_targets": [],
|
||||||
|
"poetry_scripts": [],
|
||||||
|
"cargo_aliases": [],
|
||||||
|
"shell_scripts": []
|
||||||
|
},
|
||||||
|
"project_dir": "C:\\Users\\damja\\WebstormProjects\\Portfolio",
|
||||||
|
"created_at": "2026-01-22T15:28:38.237190",
|
||||||
|
"project_hash": "c4ad399e16be367eb4e6b076fe1d9ee3",
|
||||||
|
"inherited_from": "C:\\Users\\damja\\WebstormProjects\\Portfolio"
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"active": true,
|
||||||
|
"spec": "030-add-unit-tests-vitest-configured-but-no-tests-exis",
|
||||||
|
"state": "building",
|
||||||
|
"subtasks": {
|
||||||
|
"completed": 2,
|
||||||
|
"total": 8,
|
||||||
|
"in_progress": 1,
|
||||||
|
"failed": 0
|
||||||
|
},
|
||||||
|
"phase": {
|
||||||
|
"current": "Utility Functions Tests",
|
||||||
|
"id": null,
|
||||||
|
"total": 2
|
||||||
|
},
|
||||||
|
"workers": {
|
||||||
|
"active": 0,
|
||||||
|
"max": 1
|
||||||
|
},
|
||||||
|
"session": {
|
||||||
|
"number": 4,
|
||||||
|
"started_at": "2026-01-25T06:18:19.433683"
|
||||||
|
},
|
||||||
|
"last_update": "2026-01-25T06:32:23.146357"
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"sandbox": {
|
||||||
|
"enabled": true,
|
||||||
|
"autoAllowBashIfSandboxed": true
|
||||||
|
},
|
||||||
|
"permissions": {
|
||||||
|
"defaultMode": "acceptEdits",
|
||||||
|
"allow": [
|
||||||
|
"Read(./**)",
|
||||||
|
"Write(./**)",
|
||||||
|
"Edit(./**)",
|
||||||
|
"Glob(./**)",
|
||||||
|
"Grep(./**)",
|
||||||
|
"Read(C:\\Users\\damja\\WebstormProjects\\Portfolio\\.auto-claude\\worktrees\\tasks\\030-add-unit-tests-vitest-configured-but-no-tests-exis/**)",
|
||||||
|
"Write(C:\\Users\\damja\\WebstormProjects\\Portfolio\\.auto-claude\\worktrees\\tasks\\030-add-unit-tests-vitest-configured-but-no-tests-exis/**)",
|
||||||
|
"Edit(C:\\Users\\damja\\WebstormProjects\\Portfolio\\.auto-claude\\worktrees\\tasks\\030-add-unit-tests-vitest-configured-but-no-tests-exis/**)",
|
||||||
|
"Glob(C:\\Users\\damja\\WebstormProjects\\Portfolio\\.auto-claude\\worktrees\\tasks\\030-add-unit-tests-vitest-configured-but-no-tests-exis/**)",
|
||||||
|
"Grep(C:\\Users\\damja\\WebstormProjects\\Portfolio\\.auto-claude\\worktrees\\tasks\\030-add-unit-tests-vitest-configured-but-no-tests-exis/**)",
|
||||||
|
"Read(C:\\Users\\damja\\WebstormProjects\\Portfolio\\.auto-claude\\worktrees\\tasks\\030-add-unit-tests-vitest-configured-but-no-tests-exis\\.auto-claude\\specs\\030-add-unit-tests-vitest-configured-but-no-tests-exis/**)",
|
||||||
|
"Write(C:\\Users\\damja\\WebstormProjects\\Portfolio\\.auto-claude\\worktrees\\tasks\\030-add-unit-tests-vitest-configured-but-no-tests-exis\\.auto-claude\\specs\\030-add-unit-tests-vitest-configured-but-no-tests-exis/**)",
|
||||||
|
"Edit(C:\\Users\\damja\\WebstormProjects\\Portfolio\\.auto-claude\\worktrees\\tasks\\030-add-unit-tests-vitest-configured-but-no-tests-exis\\.auto-claude\\specs\\030-add-unit-tests-vitest-configured-but-no-tests-exis/**)",
|
||||||
|
"Read(C:\\Users\\damja\\WebstormProjects\\Portfolio\\.auto-claude/**)",
|
||||||
|
"Write(C:\\Users\\damja\\WebstormProjects\\Portfolio\\.auto-claude/**)",
|
||||||
|
"Edit(C:\\Users\\damja\\WebstormProjects\\Portfolio\\.auto-claude/**)",
|
||||||
|
"Glob(C:\\Users\\damja\\WebstormProjects\\Portfolio\\.auto-claude/**)",
|
||||||
|
"Grep(C:\\Users\\damja\\WebstormProjects\\Portfolio\\.auto-claude/**)",
|
||||||
|
"Bash(*)",
|
||||||
|
"WebFetch(*)",
|
||||||
|
"WebSearch(*)",
|
||||||
|
"mcp__context7__resolve-library-id(*)",
|
||||||
|
"mcp__context7__get-library-docs(*)",
|
||||||
|
"mcp__graphiti-memory__search_nodes(*)",
|
||||||
|
"mcp__graphiti-memory__search_facts(*)",
|
||||||
|
"mcp__graphiti-memory__add_episode(*)",
|
||||||
|
"mcp__graphiti-memory__get_episodes(*)",
|
||||||
|
"mcp__graphiti-memory__get_entity_edge(*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,3 +82,6 @@ supabase/.temp/
|
|||||||
|
|
||||||
# Source images (originals before optimization)
|
# Source images (originals before optimization)
|
||||||
source-images/
|
source-images/
|
||||||
|
|
||||||
|
# Auto Claude data directory
|
||||||
|
.auto-claude/
|
||||||
|
|||||||
@@ -38,13 +38,17 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/forms": "^0.5.7",
|
"@tailwindcss/forms": "^0.5.7",
|
||||||
|
"@testing-library/jest-dom": "^6.0.0",
|
||||||
|
"@testing-library/react": "^14.0.0",
|
||||||
"@types/mdx": "^2.0.13",
|
"@types/mdx": "^2.0.13",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"@vitest/coverage-v8": "1.6.1",
|
||||||
"autoprefixer": "^10.4.18",
|
"autoprefixer": "^10.4.18",
|
||||||
"eslint": "^9.9.1",
|
"eslint": "^9.9.1",
|
||||||
"eslint-config-next": "^15.1.0",
|
"eslint-config-next": "^15.1.0",
|
||||||
|
"jsdom": "^23.2.0",
|
||||||
"postcss": "^8.4.35",
|
"postcss": "^8.4.35",
|
||||||
"sharp": "^0.34.3",
|
"sharp": "^0.34.3",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
|
|||||||
@@ -1,186 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
interface UserSettings {
|
|
||||||
theme: 'light' | 'dark';
|
|
||||||
notifications: boolean;
|
|
||||||
language: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TestLocalStoragePage() {
|
|
||||||
// Test 1: Simple string value
|
|
||||||
const [name, setName, removeName] = useLocalStorage('test-name', 'Guest');
|
|
||||||
|
|
||||||
// Test 2: Complex object
|
|
||||||
const [settings, setSettings, removeSettings] = useLocalStorage<UserSettings>(
|
|
||||||
'test-settings',
|
|
||||||
{
|
|
||||||
theme: 'light',
|
|
||||||
notifications: true,
|
|
||||||
language: 'en',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Test 3: Array
|
|
||||||
const [items, setItems, removeItems] = useLocalStorage<string[]>('test-items', []);
|
|
||||||
|
|
||||||
// Test 4: Number
|
|
||||||
const [count, setCount, removeCount] = useLocalStorage('test-count', 0);
|
|
||||||
|
|
||||||
const [newItem, setNewItem] = useState('');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ padding: '2rem', maxWidth: '800px', margin: '0 auto' }}>
|
|
||||||
<h1>useLocalStorage Hook Test Page</h1>
|
|
||||||
<p style={{ color: '#666', marginBottom: '2rem' }}>
|
|
||||||
Open DevTools (F12) → Application → Local Storage to see values update in real-time.
|
|
||||||
Refresh the page to verify persistence.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Test 1: String */}
|
|
||||||
<div style={{ marginBottom: '2rem', padding: '1rem', border: '1px solid #ddd', borderRadius: '8px' }}>
|
|
||||||
<h2>Test 1: String Value</h2>
|
|
||||||
<p>Current Name: <strong>{name}</strong></p>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
placeholder="Enter your name"
|
|
||||||
style={{ padding: '0.5rem', marginRight: '0.5rem' }}
|
|
||||||
/>
|
|
||||||
<button onClick={() => removeName()} style={{ padding: '0.5rem' }}>
|
|
||||||
Reset
|
|
||||||
</button>
|
|
||||||
<p style={{ fontSize: '0.875rem', color: '#666', marginTop: '0.5rem' }}>
|
|
||||||
localStorage key: <code>test-name</code>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Test 2: Complex Object */}
|
|
||||||
<div style={{ marginBottom: '2rem', padding: '1rem', border: '1px solid #ddd', borderRadius: '8px' }}>
|
|
||||||
<h2>Test 2: Complex Object</h2>
|
|
||||||
<div style={{ marginBottom: '1rem' }}>
|
|
||||||
<p>Theme: <strong>{settings.theme}</strong></p>
|
|
||||||
<button
|
|
||||||
onClick={() => setSettings((prev) => ({ ...prev, theme: prev.theme === 'light' ? 'dark' : 'light' }))}
|
|
||||||
style={{ padding: '0.5rem', marginRight: '0.5rem' }}
|
|
||||||
>
|
|
||||||
Toggle Theme
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div style={{ marginBottom: '1rem' }}>
|
|
||||||
<label>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={settings.notifications}
|
|
||||||
onChange={(e) => setSettings((prev) => ({ ...prev, notifications: e.target.checked }))}
|
|
||||||
/>
|
|
||||||
{' '}Enable Notifications
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div style={{ marginBottom: '1rem' }}>
|
|
||||||
<label>Language: </label>
|
|
||||||
<select
|
|
||||||
value={settings.language}
|
|
||||||
onChange={(e) => setSettings((prev) => ({ ...prev, language: e.target.value }))}
|
|
||||||
style={{ padding: '0.5rem' }}
|
|
||||||
>
|
|
||||||
<option value="en">English</option>
|
|
||||||
<option value="de">German</option>
|
|
||||||
<option value="es">Spanish</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<button onClick={() => removeSettings()} style={{ padding: '0.5rem' }}>
|
|
||||||
Reset Settings
|
|
||||||
</button>
|
|
||||||
<p style={{ fontSize: '0.875rem', color: '#666', marginTop: '0.5rem' }}>
|
|
||||||
localStorage key: <code>test-settings</code>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Test 3: Array */}
|
|
||||||
<div style={{ marginBottom: '2rem', padding: '1rem', border: '1px solid #ddd', borderRadius: '8px' }}>
|
|
||||||
<h2>Test 3: Array Value</h2>
|
|
||||||
<p>Items ({items.length}):</p>
|
|
||||||
<ul style={{ minHeight: '60px' }}>
|
|
||||||
{items.map((item, index) => (
|
|
||||||
<li key={index}>
|
|
||||||
{item}{' '}
|
|
||||||
<button
|
|
||||||
onClick={() => setItems((prev) => prev.filter((_, i) => i !== index))}
|
|
||||||
style={{ padding: '0.25rem 0.5rem', fontSize: '0.875rem' }}
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
<div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newItem}
|
|
||||||
onChange={(e) => setNewItem(e.target.value)}
|
|
||||||
onKeyPress={(e) => {
|
|
||||||
if (e.key === 'Enter' && newItem.trim()) {
|
|
||||||
setItems((prev) => [...prev, newItem.trim()]);
|
|
||||||
setNewItem('');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder="Add new item"
|
|
||||||
style={{ padding: '0.5rem', marginRight: '0.5rem' }}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
if (newItem.trim()) {
|
|
||||||
setItems((prev) => [...prev, newItem.trim()]);
|
|
||||||
setNewItem('');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
style={{ padding: '0.5rem', marginRight: '0.5rem' }}
|
|
||||||
>
|
|
||||||
Add Item
|
|
||||||
</button>
|
|
||||||
<button onClick={() => removeItems()} style={{ padding: '0.5rem' }}>
|
|
||||||
Clear All
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p style={{ fontSize: '0.875rem', color: '#666', marginTop: '0.5rem' }}>
|
|
||||||
localStorage key: <code>test-items</code>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Test 4: Number */}
|
|
||||||
<div style={{ marginBottom: '2rem', padding: '1rem', border: '1px solid #ddd', borderRadius: '8px' }}>
|
|
||||||
<h2>Test 4: Number Value</h2>
|
|
||||||
<p>Count: <strong>{count}</strong></p>
|
|
||||||
<button onClick={() => setCount((prev) => prev + 1)} style={{ padding: '0.5rem', marginRight: '0.5rem' }}>
|
|
||||||
Increment
|
|
||||||
</button>
|
|
||||||
<button onClick={() => setCount((prev) => prev - 1)} style={{ padding: '0.5rem', marginRight: '0.5rem' }}>
|
|
||||||
Decrement
|
|
||||||
</button>
|
|
||||||
<button onClick={() => removeCount()} style={{ padding: '0.5rem' }}>
|
|
||||||
Reset
|
|
||||||
</button>
|
|
||||||
<p style={{ fontSize: '0.875rem', color: '#666', marginTop: '0.5rem' }}>
|
|
||||||
localStorage key: <code>test-count</code>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Instructions */}
|
|
||||||
<div style={{ marginTop: '2rem', padding: '1rem', backgroundColor: '#f0f9ff', borderRadius: '8px' }}>
|
|
||||||
<h3>Verification Checklist:</h3>
|
|
||||||
<ul>
|
|
||||||
<li>✓ Open DevTools → Application → Local Storage → http://localhost:3000</li>
|
|
||||||
<li>✓ Interact with the controls above and watch localStorage update in real-time</li>
|
|
||||||
<li>✓ Refresh the page (F5) - all values should persist</li>
|
|
||||||
<li>✓ Check Console for hydration errors (there should be none)</li>
|
|
||||||
<li>✓ Check Console for any errors (there should be none)</li>
|
|
||||||
<li>✓ Verify TypeScript has no errors in your editor</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { renderHook, waitFor } from '@testing-library/react';
|
||||||
|
import { useAsync } from './useAsync';
|
||||||
|
import * as errorHandling from '../utils/errorHandling';
|
||||||
|
|
||||||
|
// Mock the errorHandling module
|
||||||
|
vi.mock('../utils/errorHandling', async () => {
|
||||||
|
const actual = await vi.importActual('../utils/errorHandling');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
handleError: vi.fn((error: unknown) => {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
return new Error('Handled error');
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useAsync', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('initial state', () => {
|
||||||
|
it('should initialize with null data, null error, and loading false', () => {
|
||||||
|
const { result } = renderHook(() => useAsync<string>());
|
||||||
|
|
||||||
|
expect(result.current.data).toBeNull();
|
||||||
|
expect(result.current.error).toBeNull();
|
||||||
|
expect(result.current.loading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should provide an execute function', () => {
|
||||||
|
const { result } = renderHook(() => useAsync<string>());
|
||||||
|
|
||||||
|
expect(result.current.execute).toBeInstanceOf(Function);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loading states', () => {
|
||||||
|
it('should set loading to true when executing', async () => {
|
||||||
|
const { result } = renderHook(() => useAsync<string>());
|
||||||
|
|
||||||
|
const asyncFn = vi.fn(() => new Promise<string>((resolve) => {
|
||||||
|
setTimeout(() => resolve('test data'), 100);
|
||||||
|
}));
|
||||||
|
|
||||||
|
result.current.execute(asyncFn);
|
||||||
|
|
||||||
|
// Loading should be true immediately after execute is called
|
||||||
|
expect(result.current.loading).toBe(true);
|
||||||
|
expect(result.current.data).toBeNull();
|
||||||
|
expect(result.current.error).toBeNull();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.loading).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set loading to false after successful execution', async () => {
|
||||||
|
const { result } = renderHook(() => useAsync<string>());
|
||||||
|
|
||||||
|
const asyncFn = vi.fn(async () => 'test data');
|
||||||
|
|
||||||
|
await result.current.execute(asyncFn);
|
||||||
|
|
||||||
|
expect(result.current.loading).toBe(false);
|
||||||
|
expect(result.current.data).toBe('test data');
|
||||||
|
expect(result.current.error).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set loading to false after failed execution', async () => {
|
||||||
|
const { result } = renderHook(() => useAsync<string>());
|
||||||
|
|
||||||
|
const error = new Error('Test error');
|
||||||
|
const asyncFn = vi.fn(async () => {
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await result.current.execute(asyncFn);
|
||||||
|
} catch (e) {
|
||||||
|
// Expected to throw
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(result.current.loading).toBe(false);
|
||||||
|
expect(result.current.data).toBeNull();
|
||||||
|
expect(result.current.error).toBe(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reset data and error when starting new execution', async () => {
|
||||||
|
const { result } = renderHook(() => useAsync<string>());
|
||||||
|
|
||||||
|
// First execution with data
|
||||||
|
await result.current.execute(async () => 'first data');
|
||||||
|
|
||||||
|
expect(result.current.data).toBe('first data');
|
||||||
|
expect(result.current.error).toBeNull();
|
||||||
|
|
||||||
|
// Second execution that will take some time
|
||||||
|
const slowAsyncFn = vi.fn(() => new Promise<string>((resolve) => {
|
||||||
|
setTimeout(() => resolve('second data'), 100);
|
||||||
|
}));
|
||||||
|
|
||||||
|
result.current.execute(slowAsyncFn);
|
||||||
|
|
||||||
|
// Immediately after calling execute, state should be reset
|
||||||
|
expect(result.current.data).toBeNull();
|
||||||
|
expect(result.current.error).toBeNull();
|
||||||
|
expect(result.current.loading).toBe(true);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.loading).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('data flow', () => {
|
||||||
|
it('should set data on successful execution', async () => {
|
||||||
|
const { result } = renderHook(() => useAsync<string>());
|
||||||
|
|
||||||
|
const testData = 'success data';
|
||||||
|
const asyncFn = vi.fn(async () => testData);
|
||||||
|
|
||||||
|
await result.current.execute(asyncFn);
|
||||||
|
|
||||||
|
expect(result.current.data).toBe(testData);
|
||||||
|
expect(result.current.error).toBeNull();
|
||||||
|
expect(result.current.loading).toBe(false);
|
||||||
|
expect(asyncFn).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle different data types', async () => {
|
||||||
|
// Test with number
|
||||||
|
const { result: numberResult } = renderHook(() => useAsync<number>());
|
||||||
|
await numberResult.current.execute(async () => 42);
|
||||||
|
expect(numberResult.current.data).toBe(42);
|
||||||
|
|
||||||
|
// Test with object
|
||||||
|
const { result: objectResult } = renderHook(() => useAsync<{ id: number; name: string }>());
|
||||||
|
const testObject = { id: 1, name: 'test' };
|
||||||
|
await objectResult.current.execute(async () => testObject);
|
||||||
|
expect(objectResult.current.data).toEqual(testObject);
|
||||||
|
|
||||||
|
// Test with array
|
||||||
|
const { result: arrayResult } = renderHook(() => useAsync<string[]>());
|
||||||
|
const testArray = ['a', 'b', 'c'];
|
||||||
|
await arrayResult.current.execute(async () => testArray);
|
||||||
|
expect(arrayResult.current.data).toEqual(testArray);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return data from execute function', async () => {
|
||||||
|
const { result } = renderHook(() => useAsync<string>());
|
||||||
|
|
||||||
|
const testData = 'returned data';
|
||||||
|
const asyncFn = vi.fn(async () => testData);
|
||||||
|
|
||||||
|
const returnedData = await result.current.execute(asyncFn);
|
||||||
|
|
||||||
|
expect(returnedData).toBe(testData);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle null data', async () => {
|
||||||
|
const { result } = renderHook(() => useAsync<string | null>());
|
||||||
|
|
||||||
|
const asyncFn = vi.fn(async () => null);
|
||||||
|
|
||||||
|
await result.current.execute(asyncFn);
|
||||||
|
|
||||||
|
expect(result.current.data).toBeNull();
|
||||||
|
expect(result.current.error).toBeNull();
|
||||||
|
expect(result.current.loading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should replace previous data with new data', async () => {
|
||||||
|
const { result } = renderHook(() => useAsync<string>());
|
||||||
|
|
||||||
|
await result.current.execute(async () => 'first');
|
||||||
|
expect(result.current.data).toBe('first');
|
||||||
|
|
||||||
|
await result.current.execute(async () => 'second');
|
||||||
|
expect(result.current.data).toBe('second');
|
||||||
|
|
||||||
|
await result.current.execute(async () => 'third');
|
||||||
|
expect(result.current.data).toBe('third');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('error handling', () => {
|
||||||
|
it('should set error on failed execution', async () => {
|
||||||
|
const { result } = renderHook(() => useAsync<string>());
|
||||||
|
|
||||||
|
const error = new Error('Test error');
|
||||||
|
const asyncFn = vi.fn(async () => {
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await result.current.execute(asyncFn);
|
||||||
|
} catch (e) {
|
||||||
|
// Expected to throw
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(result.current.data).toBeNull();
|
||||||
|
expect(result.current.error).toBe(error);
|
||||||
|
expect(result.current.loading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call handleError with error and context', async () => {
|
||||||
|
const { result } = renderHook(() => useAsync<string>());
|
||||||
|
|
||||||
|
const error = new Error('Test error');
|
||||||
|
const asyncFn = vi.fn(async () => {
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await result.current.execute(asyncFn);
|
||||||
|
} catch (e) {
|
||||||
|
// Expected to throw
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(errorHandling.handleError).toHaveBeenCalledWith(error, 'Async Operation');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should re-throw the handled error', async () => {
|
||||||
|
const { result } = renderHook(() => useAsync<string>());
|
||||||
|
|
||||||
|
const originalError = new Error('Test error');
|
||||||
|
const asyncFn = vi.fn(async () => {
|
||||||
|
throw originalError;
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(result.current.execute(asyncFn)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle errors of different types', async () => {
|
||||||
|
const { result } = renderHook(() => useAsync<string>());
|
||||||
|
|
||||||
|
// Test with Error object
|
||||||
|
const error1 = new Error('Error object');
|
||||||
|
await expect(result.current.execute(async () => {
|
||||||
|
throw error1;
|
||||||
|
})).rejects.toThrow();
|
||||||
|
|
||||||
|
// Test with string error
|
||||||
|
await expect(result.current.execute(async () => {
|
||||||
|
throw 'String error';
|
||||||
|
})).rejects.toThrow();
|
||||||
|
|
||||||
|
// Test with number error
|
||||||
|
await expect(result.current.execute(async () => {
|
||||||
|
throw 404;
|
||||||
|
})).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear error on successful subsequent execution', async () => {
|
||||||
|
const { result } = renderHook(() => useAsync<string>());
|
||||||
|
|
||||||
|
// First execution fails
|
||||||
|
try {
|
||||||
|
await result.current.execute(async () => {
|
||||||
|
throw new Error('First error');
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// Expected to throw
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(result.current.error).not.toBeNull();
|
||||||
|
|
||||||
|
// Second execution succeeds
|
||||||
|
await result.current.execute(async () => 'success');
|
||||||
|
|
||||||
|
expect(result.current.error).toBeNull();
|
||||||
|
expect(result.current.data).toBe('success');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should replace previous error with new error', async () => {
|
||||||
|
const { result } = renderHook(() => useAsync<string>());
|
||||||
|
|
||||||
|
const error1 = new Error('First error');
|
||||||
|
const error2 = new Error('Second error');
|
||||||
|
|
||||||
|
// First execution fails
|
||||||
|
try {
|
||||||
|
await result.current.execute(async () => {
|
||||||
|
throw error1;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// Expected to throw
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(result.current.error).toBe(error1);
|
||||||
|
|
||||||
|
// Second execution also fails
|
||||||
|
try {
|
||||||
|
await result.current.execute(async () => {
|
||||||
|
throw error2;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// Expected to throw
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(result.current.error).toBe(error2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('execute function stability', () => {
|
||||||
|
it('should maintain the same execute function reference', () => {
|
||||||
|
const { result, rerender } = renderHook(() => useAsync<string>());
|
||||||
|
|
||||||
|
const firstExecute = result.current.execute;
|
||||||
|
rerender();
|
||||||
|
const secondExecute = result.current.execute;
|
||||||
|
|
||||||
|
expect(firstExecute).toBe(secondExecute);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('concurrent executions', () => {
|
||||||
|
it('should handle multiple concurrent executions', async () => {
|
||||||
|
const { result } = renderHook(() => useAsync<string>());
|
||||||
|
|
||||||
|
const slowAsyncFn = vi.fn(() => new Promise<string>((resolve) => {
|
||||||
|
setTimeout(() => resolve('slow result'), 100);
|
||||||
|
}));
|
||||||
|
|
||||||
|
const fastAsyncFn = vi.fn(() => new Promise<string>((resolve) => {
|
||||||
|
setTimeout(() => resolve('fast result'), 10);
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Start slow execution
|
||||||
|
const slowPromise = result.current.execute(slowAsyncFn);
|
||||||
|
|
||||||
|
// Start fast execution (will reset state)
|
||||||
|
const fastPromise = result.current.execute(fastAsyncFn);
|
||||||
|
|
||||||
|
// Fast one should complete
|
||||||
|
await fastPromise;
|
||||||
|
expect(result.current.data).toBe('fast result');
|
||||||
|
|
||||||
|
// Slow one should also complete, but will override the fast result
|
||||||
|
await slowPromise;
|
||||||
|
// Note: In the current implementation, the last executed function will set the final state
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,235 +0,0 @@
|
|||||||
import { renderHook, act } from '@testing-library/react';
|
|
||||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
||||||
import { useLocalStorage } from './useLocalStorage';
|
|
||||||
|
|
||||||
// Mock the error handling utility
|
|
||||||
vi.mock('../utils/errorHandling', () => ({
|
|
||||||
handleError: vi.fn()
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock localStorage
|
|
||||||
const localStorageMock = (() => {
|
|
||||||
let store: Record<string, string> = {};
|
|
||||||
|
|
||||||
return {
|
|
||||||
getItem: (key: string) => store[key] || null,
|
|
||||||
setItem: (key: string, value: string) => {
|
|
||||||
store[key] = value.toString();
|
|
||||||
},
|
|
||||||
removeItem: (key: string) => {
|
|
||||||
delete store[key];
|
|
||||||
},
|
|
||||||
clear: () => {
|
|
||||||
store = {};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
|
|
||||||
describe('useLocalStorage', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
// Setup localStorage mock
|
|
||||||
Object.defineProperty(window, 'localStorage', {
|
|
||||||
value: localStorageMock,
|
|
||||||
writable: true
|
|
||||||
});
|
|
||||||
localStorageMock.clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return initial value when localStorage is empty', () => {
|
|
||||||
const { result } = renderHook(() => useLocalStorage('test-key', 'initial-value'));
|
|
||||||
const [value] = result.current;
|
|
||||||
|
|
||||||
expect(value).toBe('initial-value');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return initial value from function when localStorage is empty', () => {
|
|
||||||
const initializer = vi.fn(() => 'computed-value');
|
|
||||||
const { result } = renderHook(() => useLocalStorage('test-key', initializer));
|
|
||||||
const [value] = result.current;
|
|
||||||
|
|
||||||
expect(value).toBe('computed-value');
|
|
||||||
expect(initializer).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should set value in localStorage', () => {
|
|
||||||
const { result } = renderHook(() => useLocalStorage('test-key', 'initial'));
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
const [, setValue] = result.current;
|
|
||||||
setValue('new-value');
|
|
||||||
});
|
|
||||||
|
|
||||||
const [value] = result.current;
|
|
||||||
expect(value).toBe('new-value');
|
|
||||||
expect(JSON.parse(localStorage.getItem('test-key')!)).toBe('new-value');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle updater function in setValue', () => {
|
|
||||||
const { result } = renderHook(() => useLocalStorage('test-key', 10));
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
const [, setValue] = result.current;
|
|
||||||
setValue((prev) => prev + 5);
|
|
||||||
});
|
|
||||||
|
|
||||||
const [value] = result.current;
|
|
||||||
expect(value).toBe(15);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should read existing value from localStorage', () => {
|
|
||||||
localStorage.setItem('test-key', JSON.stringify('existing-value'));
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useLocalStorage('test-key', 'initial-value'));
|
|
||||||
const [value] = result.current;
|
|
||||||
|
|
||||||
expect(value).toBe('existing-value');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle complex objects', () => {
|
|
||||||
const complexObject = { name: 'John', age: 30, hobbies: ['reading', 'coding'] };
|
|
||||||
const { result } = renderHook(() => useLocalStorage('test-key', complexObject));
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
const [, setValue] = result.current;
|
|
||||||
setValue({ ...complexObject, age: 31 });
|
|
||||||
});
|
|
||||||
|
|
||||||
const [value] = result.current;
|
|
||||||
expect(value).toEqual({ name: 'John', age: 31, hobbies: ['reading', 'coding'] });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should remove value from localStorage', () => {
|
|
||||||
localStorage.setItem('test-key', JSON.stringify('existing-value'));
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useLocalStorage('test-key', 'initial-value'));
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
const [, , removeValue] = result.current;
|
|
||||||
removeValue();
|
|
||||||
});
|
|
||||||
|
|
||||||
const [value] = result.current;
|
|
||||||
expect(value).toBe('initial-value');
|
|
||||||
expect(localStorage.getItem('test-key')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle invalid JSON in localStorage', () => {
|
|
||||||
localStorage.setItem('test-key', 'invalid-json{');
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useLocalStorage('test-key', 'fallback-value'));
|
|
||||||
const [value] = result.current;
|
|
||||||
|
|
||||||
expect(value).toBe('fallback-value');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be SSR-safe (no window)', () => {
|
|
||||||
const originalWindow = global.window;
|
|
||||||
// @ts-ignore - Temporarily remove window for SSR test
|
|
||||||
delete global.window;
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useLocalStorage('test-key', 'ssr-value'));
|
|
||||||
const [value] = result.current;
|
|
||||||
|
|
||||||
expect(value).toBe('ssr-value');
|
|
||||||
|
|
||||||
// Restore window
|
|
||||||
global.window = originalWindow;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle localStorage quota exceeded', () => {
|
|
||||||
const { handleError } = require('../utils/errorHandling');
|
|
||||||
|
|
||||||
// Mock setItem to throw quota exceeded error
|
|
||||||
const originalSetItem = localStorage.setItem;
|
|
||||||
localStorage.setItem = vi.fn(() => {
|
|
||||||
throw new DOMException('QuotaExceededError');
|
|
||||||
});
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useLocalStorage('test-key', 'initial'));
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
const [, setValue] = result.current;
|
|
||||||
setValue('new-value');
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(handleError).toHaveBeenCalled();
|
|
||||||
|
|
||||||
// Restore original setItem
|
|
||||||
localStorage.setItem = originalSetItem;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should serialize and deserialize arrays', () => {
|
|
||||||
const initialArray = [1, 2, 3, 4, 5];
|
|
||||||
const { result } = renderHook(() => useLocalStorage('test-key', initialArray));
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
const [, setValue] = result.current;
|
|
||||||
setValue([...initialArray, 6]);
|
|
||||||
});
|
|
||||||
|
|
||||||
const [value] = result.current;
|
|
||||||
expect(value).toEqual([1, 2, 3, 4, 5, 6]);
|
|
||||||
expect(JSON.parse(localStorage.getItem('test-key')!)).toEqual([1, 2, 3, 4, 5, 6]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle boolean values', () => {
|
|
||||||
const { result } = renderHook(() => useLocalStorage('test-key', false));
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
const [, setValue] = result.current;
|
|
||||||
setValue(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
const [value] = result.current;
|
|
||||||
expect(value).toBe(true);
|
|
||||||
expect(JSON.parse(localStorage.getItem('test-key')!)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle number values', () => {
|
|
||||||
const { result } = renderHook(() => useLocalStorage('test-key', 0));
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
const [, setValue] = result.current;
|
|
||||||
setValue(42);
|
|
||||||
});
|
|
||||||
|
|
||||||
const [value] = result.current;
|
|
||||||
expect(value).toBe(42);
|
|
||||||
expect(JSON.parse(localStorage.getItem('test-key')!)).toBe(42);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle null values', () => {
|
|
||||||
const { result } = renderHook(() => useLocalStorage<string | null>('test-key', null));
|
|
||||||
|
|
||||||
const [value] = result.current;
|
|
||||||
expect(value).toBe(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should update localStorage when key changes', () => {
|
|
||||||
const { result, rerender } = renderHook(
|
|
||||||
({ key, value }) => useLocalStorage(key, value),
|
|
||||||
{ initialProps: { key: 'key1', value: 'value1' } }
|
|
||||||
);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
const [, setValue] = result.current;
|
|
||||||
setValue('updated-value1');
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(localStorage.getItem('key1')).toBe(JSON.stringify('updated-value1'));
|
|
||||||
|
|
||||||
// Change the key
|
|
||||||
rerender({ key: 'key2', value: 'value2' });
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
const [, setValue] = result.current;
|
|
||||||
setValue('updated-value2');
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(localStorage.getItem('key2')).toBe(JSON.stringify('updated-value2'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import { handleError } from '../utils/errorHandling';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Function type for updating the stored value
|
|
||||||
* @template T - The type of the stored value
|
|
||||||
* @param value - Either a new value of type T or an updater function that takes the current value and returns a new value
|
|
||||||
*/
|
|
||||||
type SetValue<T> = (value: T | ((val: T) => T)) => void;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom hook for managing state in localStorage with automatic serialization
|
|
||||||
*
|
|
||||||
* @template T - The type of the stored value
|
|
||||||
* @param {string} key - The localStorage key to use
|
|
||||||
* @param {T | (() => T)} initialValue - The initial value or a function that returns the initial value
|
|
||||||
* @returns {[T, SetValue<T>, () => void]} A tuple containing:
|
|
||||||
* - The current stored value
|
|
||||||
* - A function to update the stored value
|
|
||||||
* - A function to remove the value from storage
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* const [theme, setTheme, removeTheme] = useLocalStorage('theme', 'light');
|
|
||||||
* setTheme('dark'); // Updates both state and localStorage
|
|
||||||
* removeTheme(); // Removes from localStorage and resets to initial value
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* @remarks
|
|
||||||
* - SSR-safe: Returns initial value during server-side rendering
|
|
||||||
* - Automatically serializes/deserializes JSON
|
|
||||||
* - Handles localStorage quota exceeded errors
|
|
||||||
* - Handles invalid JSON gracefully
|
|
||||||
*/
|
|
||||||
export function useLocalStorage<T>(
|
|
||||||
key: string,
|
|
||||||
initialValue: T | (() => T)
|
|
||||||
): [T, SetValue<T>, () => void] {
|
|
||||||
// Get initial value - SSR safe
|
|
||||||
const getInitialValue = useCallback((): T => {
|
|
||||||
// Check if we're in a browser environment
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return initialValue instanceof Function ? initialValue() : initialValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const item = window.localStorage.getItem(key);
|
|
||||||
if (item) {
|
|
||||||
return JSON.parse(item) as T;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
handleError(error, `LocalStorage Read (key: ${key})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return initialValue instanceof Function ? initialValue() : initialValue;
|
|
||||||
}, [key, initialValue]);
|
|
||||||
|
|
||||||
const [storedValue, setStoredValue] = useState<T>(getInitialValue);
|
|
||||||
|
|
||||||
// Update localStorage whenever storedValue changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
window.localStorage.setItem(key, JSON.stringify(storedValue));
|
|
||||||
} catch (error) {
|
|
||||||
handleError(error, `LocalStorage Write (key: ${key})`);
|
|
||||||
}
|
|
||||||
}, [key, storedValue]);
|
|
||||||
|
|
||||||
// Set value function that supports both direct values and updater functions
|
|
||||||
const setValue: SetValue<T> = useCallback((value) => {
|
|
||||||
try {
|
|
||||||
setStoredValue((prevValue) => {
|
|
||||||
const newValue = value instanceof Function ? value(prevValue) : value;
|
|
||||||
return newValue;
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
handleError(error, `LocalStorage Update (key: ${key})`);
|
|
||||||
}
|
|
||||||
}, [key]);
|
|
||||||
|
|
||||||
// Remove value from localStorage and reset to initial value
|
|
||||||
const removeValue = useCallback(() => {
|
|
||||||
try {
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
window.localStorage.removeItem(key);
|
|
||||||
}
|
|
||||||
const resetValue = initialValue instanceof Function ? initialValue() : initialValue;
|
|
||||||
setStoredValue(resetValue);
|
|
||||||
} catch (error) {
|
|
||||||
handleError(error, `LocalStorage Remove (key: ${key})`);
|
|
||||||
}
|
|
||||||
}, [key, initialValue]);
|
|
||||||
|
|
||||||
return [storedValue, setValue, removeValue];
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,463 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { getAllBlogPosts, getBlogPostBySlug, getBlogPostSlugs, type BlogPost } from './blog';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
// Mock fs and path modules
|
||||||
|
vi.mock('fs');
|
||||||
|
vi.mock('path');
|
||||||
|
|
||||||
|
describe('blog', () => {
|
||||||
|
const mockBlogPostsDir = '/mock/blog-posts';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
// Mock path.join to return predictable paths
|
||||||
|
vi.mocked(path.join).mockImplementation((...args) => args.join('/'));
|
||||||
|
|
||||||
|
// Mock process.cwd
|
||||||
|
vi.stubGlobal('process', {
|
||||||
|
...process,
|
||||||
|
cwd: () => '/mock'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAllBlogPosts', () => {
|
||||||
|
it('should return empty array when directory does not exist', () => {
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||||
|
|
||||||
|
const posts = getAllBlogPosts();
|
||||||
|
|
||||||
|
expect(posts).toEqual([]);
|
||||||
|
expect(fs.existsSync).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array when directory is empty', () => {
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.readdirSync).mockReturnValue([]);
|
||||||
|
|
||||||
|
const posts = getAllBlogPosts();
|
||||||
|
|
||||||
|
expect(posts).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse and return blog posts from markdown files', () => {
|
||||||
|
const mockContent = `# Test Blog Post
|
||||||
|
|
||||||
|
**Meta-Description:** This is a test blog post about testing.
|
||||||
|
|
||||||
|
**Keywords:** testing, vitest, typescript
|
||||||
|
|
||||||
|
## Einführung
|
||||||
|
|
||||||
|
This is the introduction to the blog post. It provides an overview of what will be covered.
|
||||||
|
|
||||||
|
## Main Content
|
||||||
|
|
||||||
|
Here is the main content of the blog post.`;
|
||||||
|
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.readdirSync).mockReturnValue(['001-test-blog-post.md'] as any);
|
||||||
|
vi.mocked(fs.readFileSync).mockReturnValue(mockContent);
|
||||||
|
|
||||||
|
const posts = getAllBlogPosts();
|
||||||
|
|
||||||
|
expect(posts).toHaveLength(1);
|
||||||
|
expect(posts[0]).toMatchObject({
|
||||||
|
slug: 'test-blog-post',
|
||||||
|
title: 'Test Blog Post',
|
||||||
|
excerpt: 'This is a test blog post about testing.',
|
||||||
|
tags: ['testing', 'vitest', 'typescript'],
|
||||||
|
category: 'Web Development'
|
||||||
|
});
|
||||||
|
expect(posts[0].date).toBe('2026-01-19');
|
||||||
|
expect(posts[0].coverImage).toBe('/images/posts/test-blog-post/cover.jpg');
|
||||||
|
expect(posts[0].content).toBe(mockContent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should sort posts by date descending (newest first)', () => {
|
||||||
|
const mockContent1 = '# Post 1\n\n**Meta-Description:** First post';
|
||||||
|
const mockContent2 = '# Post 2\n\n**Meta-Description:** Second post';
|
||||||
|
const mockContent3 = '# Post 3\n\n**Meta-Description:** Third post';
|
||||||
|
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.readdirSync).mockReturnValue([
|
||||||
|
'001-first-post.md',
|
||||||
|
'002-second-post.md',
|
||||||
|
'003-third-post.md'
|
||||||
|
] as any);
|
||||||
|
|
||||||
|
vi.mocked(fs.readFileSync)
|
||||||
|
.mockReturnValueOnce(mockContent1)
|
||||||
|
.mockReturnValueOnce(mockContent2)
|
||||||
|
.mockReturnValueOnce(mockContent3);
|
||||||
|
|
||||||
|
const posts = getAllBlogPosts();
|
||||||
|
|
||||||
|
expect(posts).toHaveLength(3);
|
||||||
|
// Posts should be sorted newest first (higher numbers = older posts)
|
||||||
|
expect(posts[0].slug).toBe('first-post');
|
||||||
|
expect(posts[1].slug).toBe('second-post');
|
||||||
|
expect(posts[2].slug).toBe('third-post');
|
||||||
|
expect(new Date(posts[0].date).getTime()).toBeGreaterThanOrEqual(new Date(posts[1].date).getTime());
|
||||||
|
expect(new Date(posts[1].date).getTime()).toBeGreaterThanOrEqual(new Date(posts[2].date).getTime());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter out non-markdown files', () => {
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.readdirSync).mockReturnValue([
|
||||||
|
'001-test-post.md',
|
||||||
|
'README.txt',
|
||||||
|
'image.png',
|
||||||
|
'002-another-post.md',
|
||||||
|
'config.json'
|
||||||
|
] as any);
|
||||||
|
|
||||||
|
const mockContent = '# Test\n\n**Meta-Description:** Test';
|
||||||
|
vi.mocked(fs.readFileSync).mockReturnValue(mockContent);
|
||||||
|
|
||||||
|
const posts = getAllBlogPosts();
|
||||||
|
|
||||||
|
expect(posts).toHaveLength(2);
|
||||||
|
expect(fs.readFileSync).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect category from filename - AI Agents', () => {
|
||||||
|
const mockContent = '# AI Agent Post\n\n**Meta-Description:** About AI agents';
|
||||||
|
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.readdirSync).mockReturnValue(['001-agentic-workflow.md'] as any);
|
||||||
|
vi.mocked(fs.readFileSync).mockReturnValue(mockContent);
|
||||||
|
|
||||||
|
const posts = getAllBlogPosts();
|
||||||
|
|
||||||
|
expect(posts[0].category).toBe('KI-Agenten');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect category from filename - Voice AI', () => {
|
||||||
|
const mockContent = '# Voice AI Post\n\n**Meta-Description:** About voice technology';
|
||||||
|
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.readdirSync).mockReturnValue(['001-vapi-integration.md'] as any);
|
||||||
|
vi.mocked(fs.readFileSync).mockReturnValue(mockContent);
|
||||||
|
|
||||||
|
const posts = getAllBlogPosts();
|
||||||
|
|
||||||
|
expect(posts[0].category).toBe('Voice AI');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect category from content when not in filename', () => {
|
||||||
|
const mockContent = `# My Post
|
||||||
|
|
||||||
|
**Meta-Description:** A post about automation
|
||||||
|
|
||||||
|
I love using n8n for automation workflows.`;
|
||||||
|
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.readdirSync).mockReturnValue(['001-my-post.md'] as any);
|
||||||
|
vi.mocked(fs.readFileSync).mockReturnValue(mockContent);
|
||||||
|
|
||||||
|
const posts = getAllBlogPosts();
|
||||||
|
|
||||||
|
expect(posts[0].category).toBe('Automatisierung');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use default category when no keywords match', () => {
|
||||||
|
const mockContent = '# Generic Post\n\n**Meta-Description:** Generic content';
|
||||||
|
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.readdirSync).mockReturnValue(['001-generic-post.md'] as any);
|
||||||
|
vi.mocked(fs.readFileSync).mockReturnValue(mockContent);
|
||||||
|
|
||||||
|
const posts = getAllBlogPosts();
|
||||||
|
|
||||||
|
expect(posts[0].category).toBe('Technologie');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should extract tags from keywords field', () => {
|
||||||
|
const mockContent = `# Test Post
|
||||||
|
|
||||||
|
**Meta-Description:** Test description
|
||||||
|
|
||||||
|
**Keywords:** javascript, typescript, testing, vitest, unit-tests, integration-tests, extra-tag`;
|
||||||
|
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.readdirSync).mockReturnValue(['001-test.md'] as any);
|
||||||
|
vi.mocked(fs.readFileSync).mockReturnValue(mockContent);
|
||||||
|
|
||||||
|
const posts = getAllBlogPosts();
|
||||||
|
|
||||||
|
// Should limit to 6 tags
|
||||||
|
expect(posts[0].tags).toHaveLength(6);
|
||||||
|
expect(posts[0].tags).toEqual([
|
||||||
|
'javascript',
|
||||||
|
'typescript',
|
||||||
|
'testing',
|
||||||
|
'vitest',
|
||||||
|
'unit-tests',
|
||||||
|
'integration-tests'
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle posts without keywords', () => {
|
||||||
|
const mockContent = '# Test Post\n\n**Meta-Description:** Test';
|
||||||
|
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.readdirSync).mockReturnValue(['001-test.md'] as any);
|
||||||
|
vi.mocked(fs.readFileSync).mockReturnValue(mockContent);
|
||||||
|
|
||||||
|
const posts = getAllBlogPosts();
|
||||||
|
|
||||||
|
expect(posts[0].tags).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use introduction as excerpt when meta description is missing', () => {
|
||||||
|
const mockContent = `# Test Post
|
||||||
|
|
||||||
|
## Einführung
|
||||||
|
|
||||||
|
This is a very long introduction that should be truncated to 200 characters. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.
|
||||||
|
|
||||||
|
## Main Content`;
|
||||||
|
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.readdirSync).mockReturnValue(['001-test.md'] as any);
|
||||||
|
vi.mocked(fs.readFileSync).mockReturnValue(mockContent);
|
||||||
|
|
||||||
|
const posts = getAllBlogPosts();
|
||||||
|
|
||||||
|
expect(posts[0].excerpt).toHaveLength(203); // 200 chars + '...'
|
||||||
|
expect(posts[0].excerpt).toContain('This is a very long introduction');
|
||||||
|
expect(posts[0].excerpt).toMatch(/\.\.\.$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use title as excerpt when both meta description and introduction are missing', () => {
|
||||||
|
const mockContent = '# Test Post Title\n\nSome content';
|
||||||
|
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.readdirSync).mockReturnValue(['001-test.md'] as any);
|
||||||
|
vi.mocked(fs.readFileSync).mockReturnValue(mockContent);
|
||||||
|
|
||||||
|
const posts = getAllBlogPosts();
|
||||||
|
|
||||||
|
expect(posts[0].excerpt).toBe('Test Post Title');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle posts without H1 title', () => {
|
||||||
|
const mockContent = 'No title here\n\n**Meta-Description:** Test';
|
||||||
|
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.readdirSync).mockReturnValue(['001-fallback-title.md'] as any);
|
||||||
|
vi.mocked(fs.readFileSync).mockReturnValue(mockContent);
|
||||||
|
|
||||||
|
const posts = getAllBlogPosts();
|
||||||
|
|
||||||
|
expect(posts[0].title).toBe('001-fallback-title');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip posts that fail to parse', () => {
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.readdirSync).mockReturnValue([
|
||||||
|
'001-good-post.md',
|
||||||
|
'002-bad-post.md'
|
||||||
|
] as any);
|
||||||
|
|
||||||
|
vi.mocked(fs.readFileSync)
|
||||||
|
.mockReturnValueOnce('# Good Post\n\n**Meta-Description:** Good')
|
||||||
|
.mockImplementationOnce(() => {
|
||||||
|
throw new Error('File read error');
|
||||||
|
});
|
||||||
|
|
||||||
|
const posts = getAllBlogPosts();
|
||||||
|
|
||||||
|
expect(posts).toHaveLength(1);
|
||||||
|
expect(posts[0].title).toBe('Good Post');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate correct dates based on post numbers', () => {
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.readdirSync).mockReturnValue([
|
||||||
|
'001-first.md',
|
||||||
|
'005-fifth.md',
|
||||||
|
'010-tenth.md'
|
||||||
|
] as any);
|
||||||
|
|
||||||
|
const mockContent = '# Post\n\n**Meta-Description:** Test';
|
||||||
|
vi.mocked(fs.readFileSync).mockReturnValue(mockContent);
|
||||||
|
|
||||||
|
const posts = getAllBlogPosts();
|
||||||
|
|
||||||
|
// Post 001 should be 2026-01-19
|
||||||
|
expect(posts.find(p => p.slug === 'first')?.date).toBe('2026-01-19');
|
||||||
|
|
||||||
|
// Post 005 should be 4 days earlier (2026-01-15)
|
||||||
|
expect(posts.find(p => p.slug === 'fifth')?.date).toBe('2026-01-15');
|
||||||
|
|
||||||
|
// Post 010 should be 9 days earlier (2026-01-10)
|
||||||
|
expect(posts.find(p => p.slug === 'tenth')?.date).toBe('2026-01-10');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trim whitespace from extracted fields', () => {
|
||||||
|
const mockContent = `# Test Post With Spaces
|
||||||
|
|
||||||
|
**Meta-Description:** Description with spaces
|
||||||
|
|
||||||
|
**Keywords:** tag1 , tag2 , tag3 `;
|
||||||
|
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.readdirSync).mockReturnValue(['001-test.md'] as any);
|
||||||
|
vi.mocked(fs.readFileSync).mockReturnValue(mockContent);
|
||||||
|
|
||||||
|
const posts = getAllBlogPosts();
|
||||||
|
|
||||||
|
expect(posts[0].title).toBe('Test Post With Spaces');
|
||||||
|
expect(posts[0].excerpt).toBe('Description with spaces');
|
||||||
|
expect(posts[0].tags).toEqual(['tag1', 'tag2', 'tag3']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getBlogPostBySlug', () => {
|
||||||
|
it('should return post when slug matches', () => {
|
||||||
|
const mockContent = '# Test Post\n\n**Meta-Description:** Test';
|
||||||
|
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.readdirSync).mockReturnValue(['001-test-post.md'] as any);
|
||||||
|
vi.mocked(fs.readFileSync).mockReturnValue(mockContent);
|
||||||
|
|
||||||
|
const post = getBlogPostBySlug('test-post');
|
||||||
|
|
||||||
|
expect(post).not.toBeNull();
|
||||||
|
expect(post?.slug).toBe('test-post');
|
||||||
|
expect(post?.title).toBe('Test Post');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null when slug does not match', () => {
|
||||||
|
const mockContent = '# Test Post\n\n**Meta-Description:** Test';
|
||||||
|
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.readdirSync).mockReturnValue(['001-test-post.md'] as any);
|
||||||
|
vi.mocked(fs.readFileSync).mockReturnValue(mockContent);
|
||||||
|
|
||||||
|
const post = getBlogPostBySlug('non-existent-slug');
|
||||||
|
|
||||||
|
expect(post).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null when no posts exist', () => {
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.readdirSync).mockReturnValue([]);
|
||||||
|
|
||||||
|
const post = getBlogPostBySlug('any-slug');
|
||||||
|
|
||||||
|
expect(post).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null when directory does not exist', () => {
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||||
|
|
||||||
|
const post = getBlogPostBySlug('any-slug');
|
||||||
|
|
||||||
|
expect(post).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return correct post when multiple posts exist', () => {
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.readdirSync).mockReturnValue([
|
||||||
|
'001-first-post.md',
|
||||||
|
'002-second-post.md',
|
||||||
|
'003-third-post.md'
|
||||||
|
] as any);
|
||||||
|
|
||||||
|
vi.mocked(fs.readFileSync)
|
||||||
|
.mockReturnValueOnce('# First Post\n\n**Meta-Description:** First')
|
||||||
|
.mockReturnValueOnce('# Second Post\n\n**Meta-Description:** Second')
|
||||||
|
.mockReturnValueOnce('# Third Post\n\n**Meta-Description:** Third');
|
||||||
|
|
||||||
|
const post = getBlogPostBySlug('second-post');
|
||||||
|
|
||||||
|
expect(post).not.toBeNull();
|
||||||
|
expect(post?.slug).toBe('second-post');
|
||||||
|
expect(post?.title).toBe('Second Post');
|
||||||
|
expect(post?.excerpt).toBe('Second');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return post with all content included', () => {
|
||||||
|
const mockContent = `# Full Post
|
||||||
|
|
||||||
|
**Meta-Description:** Description
|
||||||
|
|
||||||
|
**Keywords:** tag1, tag2
|
||||||
|
|
||||||
|
## Einführung
|
||||||
|
|
||||||
|
Introduction text
|
||||||
|
|
||||||
|
## Main Content
|
||||||
|
|
||||||
|
This is the main content of the post with lots of details.`;
|
||||||
|
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.readdirSync).mockReturnValue(['001-full-post.md'] as any);
|
||||||
|
vi.mocked(fs.readFileSync).mockReturnValue(mockContent);
|
||||||
|
|
||||||
|
const post = getBlogPostBySlug('full-post');
|
||||||
|
|
||||||
|
expect(post?.content).toBe(mockContent);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getBlogPostSlugs', () => {
|
||||||
|
it('should return all post slugs', () => {
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.readdirSync).mockReturnValue([
|
||||||
|
'001-first-post.md',
|
||||||
|
'002-second-post.md',
|
||||||
|
'003-third-post.md'
|
||||||
|
] as any);
|
||||||
|
|
||||||
|
const mockContent = '# Post\n\n**Meta-Description:** Test';
|
||||||
|
vi.mocked(fs.readFileSync).mockReturnValue(mockContent);
|
||||||
|
|
||||||
|
const slugs = getBlogPostSlugs();
|
||||||
|
|
||||||
|
expect(slugs).toHaveLength(3);
|
||||||
|
expect(slugs).toContain('first-post');
|
||||||
|
expect(slugs).toContain('second-post');
|
||||||
|
expect(slugs).toContain('third-post');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array when no posts exist', () => {
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.readdirSync).mockReturnValue([]);
|
||||||
|
|
||||||
|
const slugs = getBlogPostSlugs();
|
||||||
|
|
||||||
|
expect(slugs).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array when directory does not exist', () => {
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||||
|
|
||||||
|
const slugs = getBlogPostSlugs();
|
||||||
|
|
||||||
|
expect(slugs).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return slugs in date order (newest first)', () => {
|
||||||
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(fs.readdirSync).mockReturnValue([
|
||||||
|
'003-third.md',
|
||||||
|
'001-first.md',
|
||||||
|
'002-second.md'
|
||||||
|
] as any);
|
||||||
|
|
||||||
|
const mockContent = '# Post\n\n**Meta-Description:** Test';
|
||||||
|
vi.mocked(fs.readFileSync).mockReturnValue(mockContent);
|
||||||
|
|
||||||
|
const slugs = getBlogPostSlugs();
|
||||||
|
|
||||||
|
expect(slugs).toEqual(['first', 'second', 'third']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -142,12 +142,18 @@ export function getAllBlogPosts(): BlogPost[] {
|
|||||||
const posts: BlogPost[] = [];
|
const posts: BlogPost[] = [];
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
|
try {
|
||||||
const filePath = path.join(BLOG_POSTS_DIR, file);
|
const filePath = path.join(BLOG_POSTS_DIR, file);
|
||||||
const content = fs.readFileSync(filePath, 'utf-8');
|
const content = fs.readFileSync(filePath, 'utf-8');
|
||||||
const post = parseMarkdownPost(file, content);
|
const post = parseMarkdownPost(file, content);
|
||||||
if (post) {
|
if (post) {
|
||||||
posts.push(post);
|
posts.push(post);
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Skip files that fail to parse
|
||||||
|
console.warn(`Failed to parse blog post: ${file}`, error);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by date descending (newest first)
|
// Sort by date descending (newest first)
|
||||||
|
|||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||||
|
import { cache } from './cache';
|
||||||
|
|
||||||
|
describe('Cache', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
cache.clear(); // Clear cache before each test
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
cache.clear(); // Clean up after each test
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('set and get', () => {
|
||||||
|
it('should set and retrieve a value', () => {
|
||||||
|
cache.set('key', 'value');
|
||||||
|
expect(cache.get('key')).toBe('value');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set multiple values with different keys', () => {
|
||||||
|
cache.set('key1', 'value1');
|
||||||
|
cache.set('key2', 'value2');
|
||||||
|
cache.set('key3', 'value3');
|
||||||
|
|
||||||
|
expect(cache.get('key1')).toBe('value1');
|
||||||
|
expect(cache.get('key2')).toBe('value2');
|
||||||
|
expect(cache.get('key3')).toBe('value3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should overwrite existing value for same key', () => {
|
||||||
|
cache.set('key', 'value1');
|
||||||
|
expect(cache.get('key')).toBe('value1');
|
||||||
|
|
||||||
|
cache.set('key', 'value2');
|
||||||
|
expect(cache.get('key')).toBe('value2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for non-existing key', () => {
|
||||||
|
expect(cache.get('nonexistent')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should store different types of values', () => {
|
||||||
|
cache.set('string', 'text');
|
||||||
|
cache.set('number', 42);
|
||||||
|
cache.set('boolean', true);
|
||||||
|
cache.set('object', { name: 'test' });
|
||||||
|
cache.set('array', [1, 2, 3]);
|
||||||
|
cache.set('null', null);
|
||||||
|
|
||||||
|
expect(cache.get('string')).toBe('text');
|
||||||
|
expect(cache.get('number')).toBe(42);
|
||||||
|
expect(cache.get('boolean')).toBe(true);
|
||||||
|
expect(cache.get('object')).toEqual({ name: 'test' });
|
||||||
|
expect(cache.get('array')).toEqual([1, 2, 3]);
|
||||||
|
expect(cache.get('null')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TTL expiration', () => {
|
||||||
|
it('should use default TTL of 5 minutes', () => {
|
||||||
|
cache.set('test', 'value');
|
||||||
|
|
||||||
|
// Should be available before TTL expires
|
||||||
|
expect(cache.get('test')).toBe('value');
|
||||||
|
|
||||||
|
// Advance time by 4 minutes 59 seconds
|
||||||
|
vi.advanceTimersByTime(4 * 60 * 1000 + 59 * 1000);
|
||||||
|
expect(cache.get('test')).toBe('value');
|
||||||
|
|
||||||
|
// Advance time by 2 more seconds (total 5 minutes 1 second)
|
||||||
|
vi.advanceTimersByTime(2 * 1000);
|
||||||
|
expect(cache.get('test')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept custom TTL per item', () => {
|
||||||
|
cache.set('short', 'value', 1000); // 1 second TTL
|
||||||
|
cache.set('long', 'value', 10000); // 10 seconds TTL
|
||||||
|
|
||||||
|
// Both should be available initially
|
||||||
|
expect(cache.get('short')).toBe('value');
|
||||||
|
expect(cache.get('long')).toBe('value');
|
||||||
|
|
||||||
|
// After 1.5 seconds, short should expire but long should remain
|
||||||
|
vi.advanceTimersByTime(1500);
|
||||||
|
expect(cache.get('short')).toBeNull();
|
||||||
|
expect(cache.get('long')).toBe('value');
|
||||||
|
|
||||||
|
// After 9 more seconds (total 10.5), long should expire
|
||||||
|
vi.advanceTimersByTime(9000);
|
||||||
|
expect(cache.get('long')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for expired item', () => {
|
||||||
|
cache.set('key', 'value', 1000);
|
||||||
|
|
||||||
|
// Should be available initially
|
||||||
|
expect(cache.get('key')).toBe('value');
|
||||||
|
|
||||||
|
// Should be null after expiration
|
||||||
|
vi.advanceTimersByTime(1001);
|
||||||
|
expect(cache.get('key')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should delete expired item from storage', () => {
|
||||||
|
cache.set('key', 'value', 1000);
|
||||||
|
|
||||||
|
// Item should exist
|
||||||
|
expect(cache.get('key')).toBe('value');
|
||||||
|
|
||||||
|
// After expiration, get should delete it
|
||||||
|
vi.advanceTimersByTime(1001);
|
||||||
|
expect(cache.get('key')).toBeNull();
|
||||||
|
|
||||||
|
// Subsequent get should also return null (item was deleted)
|
||||||
|
expect(cache.get('key')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return value at exact TTL boundary', () => {
|
||||||
|
cache.set('key', 'value', 1000);
|
||||||
|
|
||||||
|
// At exactly TTL time, should still be valid
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
expect(cache.get('key')).toBe('value');
|
||||||
|
|
||||||
|
// One millisecond later, should expire
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
expect(cache.get('key')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should respect different TTLs for different items', () => {
|
||||||
|
cache.set('item1', 'value1', 1000);
|
||||||
|
cache.set('item2', 'value2', 2000);
|
||||||
|
cache.set('item3', 'value3', 3000);
|
||||||
|
|
||||||
|
// All items should be available initially
|
||||||
|
expect(cache.get('item1')).toBe('value1');
|
||||||
|
expect(cache.get('item2')).toBe('value2');
|
||||||
|
expect(cache.get('item3')).toBe('value3');
|
||||||
|
|
||||||
|
// After 1.5 seconds, item1 should expire
|
||||||
|
vi.advanceTimersByTime(1500);
|
||||||
|
expect(cache.get('item1')).toBeNull();
|
||||||
|
expect(cache.get('item2')).toBe('value2');
|
||||||
|
expect(cache.get('item3')).toBe('value3');
|
||||||
|
|
||||||
|
// After 1 more second (total 2.5), item2 should expire
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
expect(cache.get('item1')).toBeNull();
|
||||||
|
expect(cache.get('item2')).toBeNull();
|
||||||
|
expect(cache.get('item3')).toBe('value3');
|
||||||
|
|
||||||
|
// After 1 more second (total 3.5), item3 should expire
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
expect(cache.get('item1')).toBeNull();
|
||||||
|
expect(cache.get('item2')).toBeNull();
|
||||||
|
expect(cache.get('item3')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reset TTL when item is overwritten', () => {
|
||||||
|
cache.set('key', 'value1', 1000);
|
||||||
|
|
||||||
|
// Advance time by 500ms
|
||||||
|
vi.advanceTimersByTime(500);
|
||||||
|
|
||||||
|
// Overwrite with new value and new TTL
|
||||||
|
cache.set('key', 'value2', 2000);
|
||||||
|
|
||||||
|
// After 1500ms more (total 2000ms from first set), should still be available
|
||||||
|
// because TTL was reset
|
||||||
|
vi.advanceTimersByTime(1500);
|
||||||
|
expect(cache.get('key')).toBe('value2');
|
||||||
|
|
||||||
|
// After 600ms more (2100ms from second set), should expire
|
||||||
|
vi.advanceTimersByTime(600);
|
||||||
|
expect(cache.get('key')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle zero TTL', () => {
|
||||||
|
cache.set('key', 'value', 0);
|
||||||
|
|
||||||
|
// Should expire immediately
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
expect(cache.get('key')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle very long TTL', () => {
|
||||||
|
const oneDayInMs = 24 * 60 * 60 * 1000;
|
||||||
|
cache.set('key', 'value', oneDayInMs);
|
||||||
|
|
||||||
|
// Should be available after 23 hours
|
||||||
|
vi.advanceTimersByTime(23 * 60 * 60 * 1000);
|
||||||
|
expect(cache.get('key')).toBe('value');
|
||||||
|
|
||||||
|
// Should still be available at exactly 24 hours
|
||||||
|
vi.advanceTimersByTime(60 * 60 * 1000);
|
||||||
|
expect(cache.get('key')).toBe('value');
|
||||||
|
|
||||||
|
// Should expire after 24 hours + 1ms
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
expect(cache.get('key')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('has', () => {
|
||||||
|
it('should return true for existing key', () => {
|
||||||
|
cache.set('key', 'value');
|
||||||
|
expect(cache.has('key')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for non-existing key', () => {
|
||||||
|
expect(cache.has('nonexistent')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for expired key', () => {
|
||||||
|
cache.set('key', 'value', 1000);
|
||||||
|
|
||||||
|
expect(cache.has('key')).toBe(true);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(1001);
|
||||||
|
expect(cache.has('key')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for null values', () => {
|
||||||
|
cache.set('key', null);
|
||||||
|
expect(cache.has('key')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('delete', () => {
|
||||||
|
it('should delete existing key', () => {
|
||||||
|
cache.set('key', 'value');
|
||||||
|
expect(cache.get('key')).toBe('value');
|
||||||
|
|
||||||
|
cache.delete('key');
|
||||||
|
expect(cache.get('key')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle deleting non-existing key', () => {
|
||||||
|
expect(() => cache.delete('nonexistent')).not.toThrow();
|
||||||
|
expect(cache.get('nonexistent')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should only delete specified key', () => {
|
||||||
|
cache.set('key1', 'value1');
|
||||||
|
cache.set('key2', 'value2');
|
||||||
|
cache.set('key3', 'value3');
|
||||||
|
|
||||||
|
cache.delete('key2');
|
||||||
|
|
||||||
|
expect(cache.get('key1')).toBe('value1');
|
||||||
|
expect(cache.get('key2')).toBeNull();
|
||||||
|
expect(cache.get('key3')).toBe('value3');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('clear', () => {
|
||||||
|
it('should clear all items from cache', () => {
|
||||||
|
cache.set('key1', 'value1');
|
||||||
|
cache.set('key2', 'value2');
|
||||||
|
cache.set('key3', 'value3');
|
||||||
|
|
||||||
|
cache.clear();
|
||||||
|
|
||||||
|
expect(cache.get('key1')).toBeNull();
|
||||||
|
expect(cache.get('key2')).toBeNull();
|
||||||
|
expect(cache.get('key3')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle clearing empty cache', () => {
|
||||||
|
expect(() => cache.clear()).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow setting items after clear', () => {
|
||||||
|
cache.set('key', 'value1');
|
||||||
|
cache.clear();
|
||||||
|
cache.set('key', 'value2');
|
||||||
|
|
||||||
|
expect(cache.get('key')).toBe('value2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { AppError, errorCodes, handleError, createAPIError } from './errorHandling';
|
||||||
|
import * as analytics from './analytics';
|
||||||
|
|
||||||
|
// Mock the analytics module
|
||||||
|
vi.mock('./analytics', () => ({
|
||||||
|
trackEvent: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('errorHandling', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AppError', () => {
|
||||||
|
it('should create an AppError with all properties', () => {
|
||||||
|
const error = new AppError('Test error', 'TEST_CODE', 'error', { key: 'value' });
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(Error);
|
||||||
|
expect(error).toBeInstanceOf(AppError);
|
||||||
|
expect(error.message).toBe('Test error');
|
||||||
|
expect(error.code).toBe('TEST_CODE');
|
||||||
|
expect(error.severity).toBe('error');
|
||||||
|
expect(error.metadata).toEqual({ key: 'value' });
|
||||||
|
expect(error.name).toBe('AppError');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create an AppError with default severity', () => {
|
||||||
|
const error = new AppError('Test error', 'TEST_CODE');
|
||||||
|
|
||||||
|
expect(error.severity).toBe('error');
|
||||||
|
expect(error.metadata).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create an AppError with warning severity', () => {
|
||||||
|
const error = new AppError('Test warning', 'TEST_CODE', 'warning');
|
||||||
|
|
||||||
|
expect(error.severity).toBe('warning');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create an AppError with info severity', () => {
|
||||||
|
const error = new AppError('Test info', 'TEST_CODE', 'info');
|
||||||
|
|
||||||
|
expect(error.severity).toBe('info');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('errorCodes', () => {
|
||||||
|
it('should have all error codes defined', () => {
|
||||||
|
expect(errorCodes.NETWORK_ERROR).toBe('ERR_NETWORK');
|
||||||
|
expect(errorCodes.API_ERROR).toBe('ERR_API');
|
||||||
|
expect(errorCodes.AUTH_ERROR).toBe('ERR_AUTH');
|
||||||
|
expect(errorCodes.VALIDATION_ERROR).toBe('ERR_VALIDATION');
|
||||||
|
expect(errorCodes.UNKNOWN_ERROR).toBe('ERR_UNKNOWN');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleError', () => {
|
||||||
|
it('should handle AppError and return it unchanged', () => {
|
||||||
|
const originalError = new AppError('Test error', 'TEST_CODE', 'error', { foo: 'bar' });
|
||||||
|
const result = handleError(originalError, 'test-context');
|
||||||
|
|
||||||
|
expect(result).toBe(originalError);
|
||||||
|
expect(result.message).toBe('Test error');
|
||||||
|
expect(result.code).toBe('TEST_CODE');
|
||||||
|
expect(result.severity).toBe('error');
|
||||||
|
expect(result.metadata).toEqual({ foo: 'bar' });
|
||||||
|
expect(analytics.trackEvent).toHaveBeenCalledWith(
|
||||||
|
'Error',
|
||||||
|
'TEST_CODE',
|
||||||
|
'test-context: Test error'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert regular Error to AppError', () => {
|
||||||
|
const originalError = new Error('Regular error');
|
||||||
|
const result = handleError(originalError, 'test-context');
|
||||||
|
|
||||||
|
expect(result).toBeInstanceOf(AppError);
|
||||||
|
expect(result.message).toBe('Regular error');
|
||||||
|
expect(result.code).toBe(errorCodes.UNKNOWN_ERROR);
|
||||||
|
expect(result.severity).toBe('error');
|
||||||
|
expect(result.metadata).toEqual({ originalError });
|
||||||
|
expect(analytics.trackEvent).toHaveBeenCalledWith(
|
||||||
|
'Error',
|
||||||
|
errorCodes.UNKNOWN_ERROR,
|
||||||
|
'test-context: Regular error'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle unknown error types', () => {
|
||||||
|
const result = handleError('string error', 'test-context');
|
||||||
|
|
||||||
|
expect(result).toBeInstanceOf(AppError);
|
||||||
|
expect(result.message).toBe('An unexpected error occurred');
|
||||||
|
expect(result.code).toBe(errorCodes.UNKNOWN_ERROR);
|
||||||
|
expect(result.severity).toBe('error');
|
||||||
|
expect(result.metadata).toEqual({ originalError: 'string error' });
|
||||||
|
expect(analytics.trackEvent).toHaveBeenCalledWith(
|
||||||
|
'Error',
|
||||||
|
errorCodes.UNKNOWN_ERROR,
|
||||||
|
'test-context: An unexpected error occurred'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle null error', () => {
|
||||||
|
const result = handleError(null, 'test-context');
|
||||||
|
|
||||||
|
expect(result).toBeInstanceOf(AppError);
|
||||||
|
expect(result.message).toBe('An unexpected error occurred');
|
||||||
|
expect(result.code).toBe(errorCodes.UNKNOWN_ERROR);
|
||||||
|
expect(result.metadata).toEqual({ originalError: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle undefined error', () => {
|
||||||
|
const result = handleError(undefined, 'test-context');
|
||||||
|
|
||||||
|
expect(result).toBeInstanceOf(AppError);
|
||||||
|
expect(result.message).toBe('An unexpected error occurred');
|
||||||
|
expect(result.code).toBe(errorCodes.UNKNOWN_ERROR);
|
||||||
|
expect(result.metadata).toEqual({ originalError: undefined });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should track error in analytics', () => {
|
||||||
|
const error = new AppError('Analytics test', 'ANALYTICS_CODE');
|
||||||
|
handleError(error, 'analytics-context');
|
||||||
|
|
||||||
|
expect(analytics.trackEvent).toHaveBeenCalledTimes(1);
|
||||||
|
expect(analytics.trackEvent).toHaveBeenCalledWith(
|
||||||
|
'Error',
|
||||||
|
'ANALYTICS_CODE',
|
||||||
|
'analytics-context: Analytics test'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createAPIError', () => {
|
||||||
|
it('should create an API error with status code', () => {
|
||||||
|
const error = createAPIError(404, 'Not found');
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(AppError);
|
||||||
|
expect(error.message).toBe('Not found');
|
||||||
|
expect(error.code).toBe(errorCodes.API_ERROR);
|
||||||
|
expect(error.severity).toBe('error');
|
||||||
|
expect(error.metadata).toEqual({ status: 404 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create an API error for 500 status', () => {
|
||||||
|
const error = createAPIError(500, 'Internal server error');
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(AppError);
|
||||||
|
expect(error.message).toBe('Internal server error');
|
||||||
|
expect(error.code).toBe(errorCodes.API_ERROR);
|
||||||
|
expect(error.metadata).toEqual({ status: 500 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create an API error for 401 status', () => {
|
||||||
|
const error = createAPIError(401, 'Unauthorized');
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(AppError);
|
||||||
|
expect(error.message).toBe('Unauthorized');
|
||||||
|
expect(error.metadata).toEqual({ status: 401 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -34,7 +34,6 @@
|
|||||||
"src/pages-vite",
|
"src/pages-vite",
|
||||||
"src/hooks",
|
"src/hooks",
|
||||||
"src/services",
|
"src/services",
|
||||||
"src/utils",
|
|
||||||
"**/*.bak"
|
"**/*.bak"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [],
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: [],
|
||||||
|
include: ['**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
||||||
|
exclude: [
|
||||||
|
'**/node_modules/**',
|
||||||
|
'**/dist/**',
|
||||||
|
'**/.next/**',
|
||||||
|
'**/coverage/**',
|
||||||
|
'**/*.bak/**',
|
||||||
|
'**/src/components-vite/**',
|
||||||
|
'**/src/pages-vite/**'
|
||||||
|
],
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
reporter: ['text', 'json', 'html'],
|
||||||
|
exclude: [
|
||||||
|
'node_modules/',
|
||||||
|
'.next/',
|
||||||
|
'coverage/',
|
||||||
|
'**/*.config.{js,ts}',
|
||||||
|
'**/*.bak',
|
||||||
|
'src/components-vite/**',
|
||||||
|
'src/pages-vite/**'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': resolve(__dirname, './src')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user