diff --git a/.dockerignore b/.dockerignore
index b793c6fe..dccfa97f 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,7 +1,51 @@
-.github
-.idea
-tests
+# Git
+.git
+.gitignore
+.gitattributes
+
+# Docker
+.dockerignore
+docker-compose.yml
+docker/
+
+# Node
node_modules
+npm-debug.log
+.npmrc
+.nvmrc
+
+# Editor/IDE
+.idea
+.vscode
.editorconfig
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Project specific
+.github
+tests
.env
.env.example
+.env.local
+.env.development.local
+.env.test.local
+.env.production.local
+dist
+coverage
+build
+caddy
+
+# Other
+LICENSE
+README.md
+Makefile
+*.md
+
+# Config files not needed for build
+.prettierrc.json
+.prettierignore
+eslint.config.js
+vitest.config.ts
+
diff --git a/caddy/WebCaddyfile.local b/caddy/WebCaddyfile.local
index 2d175085..9eee98c8 100644
--- a/caddy/WebCaddyfile.local
+++ b/caddy/WebCaddyfile.local
@@ -11,11 +11,7 @@
respond 204
}
- # CORS on normal requests
- @localOrigin header Origin http://localhost:5173
-
- header @localOrigin Access-Control-Allow-Origin "http://localhost:5173"
-
+ # CORS headers for all requests
header {
Vary "Origin"
X-Frame-Options "SAMEORIGIN"
@@ -28,11 +24,16 @@
# --- Local relay: strip /relay and call host API (no TLS) ---
handle_path /relay/* {
- uri replace ^/relay /api
-
- reverse_proxy {$RELAY_UPSTREAM:http://host.docker.internal:8080} {
- header_up Host {upstream_hostport}
- }
+ # In local mode, proxy to API Caddy which expects /api prefix
+ # handle_path strips /relay, so /relay/generate-signature becomes /generate-signature
+ # We prepend /api to the path before forwarding
+ rewrite * /api{uri}
+
+ reverse_proxy {$RELAY_UPSTREAM:http://host.docker.internal:18080} {
+ header_up Host {upstream_hostport}
+ # explicitly forward Origin so the Go CORS middleware can emit headers
+ header_up Origin {http.request.header.Origin}
+ }
}
# --- Serve SPA (simple) ---
diff --git a/docker-compose.yml b/docker-compose.yml
index 9b888427..7cee14ef 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -59,7 +59,7 @@ services:
ports:
- "5173:80"
environment:
- - RELAY_UPSTREAM=http://host.docker.internal:8080
+ - RELAY_UPSTREAM=http://host.docker.internal:18080
volumes:
- ./dist:/usr/share/caddy:ro
- ./caddy/mtls:/etc/caddy/mtls:ro
@@ -77,6 +77,7 @@ services:
user: "${UID:-1000}:${GID:-1000}"
volumes:
- .:/app
+ - oullin_web_node_modules:/app/node_modules
profiles:
- local
depends_on:
@@ -97,3 +98,4 @@ networks:
volumes:
oullin_web_data:
oullin_web_config:
+ oullin_web_node_modules:
diff --git a/eslint.config.js b/eslint.config.js
index ca576243..6837209b 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -183,7 +183,7 @@ export default [
},
{
- files: ['src/partials/EducationPartial.vue', 'src/partials/RecommendationPartial.vue', 'src/pages/PostPage.vue'],
+ files: ['src/partials/ArticleItemPartial.vue', 'src/partials/EducationPartial.vue', 'src/partials/RecommendationPartial.vue', 'src/pages/PostPage.vue'],
rules: {
'vue/no-v-html': 'off',
},
diff --git a/src/components/WidgetSkillsTransitionWrapper.vue b/src/components/WidgetSkillsTransitionWrapper.vue
index be306ff2..862715fd 100644
--- a/src/components/WidgetSkillsTransitionWrapper.vue
+++ b/src/components/WidgetSkillsTransitionWrapper.vue
@@ -1,9 +1,7 @@
-
-
-
-
+
+
diff --git a/src/components/WidgetSocialTransitionWrapper.vue b/src/components/WidgetSocialTransitionWrapper.vue
index cb0bc192..80cb2cb4 100644
--- a/src/components/WidgetSocialTransitionWrapper.vue
+++ b/src/components/WidgetSocialTransitionWrapper.vue
@@ -1,9 +1,7 @@
-
-
-
-
+
+
diff --git a/src/pages/AboutPage.vue b/src/pages/AboutPage.vue
index b9744c4b..9cdd7483 100644
--- a/src/pages/AboutPage.vue
+++ b/src/pages/AboutPage.vue
@@ -53,14 +53,12 @@
Let's Connect
-
-
- I'm happy to connect by
- email
- to discuss projects and ideas. While I'm not always available for freelance or long-term work, please don't hesitate to reach out anytime.
-
-
-
+
+ I'm happy to connect by
+ email
+ to discuss projects and ideas. While I'm not always available for freelance or long-term work, please don't hesitate to reach out anytime.
+
+
diff --git a/src/pages/PostPage.vue b/src/pages/PostPage.vue
index 8173511c..f4d2710d 100644
--- a/src/pages/PostPage.vue
+++ b/src/pages/PostPage.vue
@@ -28,112 +28,100 @@
@@ -199,22 +187,6 @@ const htmlContent = computed(() => {
return '';
});
-const searchInput = ref(null);
-
-const handleTagClick = (tagName: string) => {
- const label = Tags.formatLabel(tagName);
- apiStore.setSearchTerm(label);
-
- const input = searchInput.value;
- if (!input) {
- return;
- }
-
- input.value = label;
- input.dispatchEvent(new Event('input', { bubbles: true }));
- input.focus();
-};
-
const xURLFor = (post: PostResponse) => {
return `https://x.com/intent/tweet?url=${fullURLFor(post)}&text=${post.title}`;
};
@@ -271,10 +243,6 @@ watch(htmlContent, async (newContent) => {
});
onMounted(async () => {
- if (typeof document !== 'undefined') {
- searchInput.value = document.getElementById('search') as HTMLInputElement | null;
- }
-
await initializeHighlighter(highlight);
try {
diff --git a/src/pages/ProjectsPage.vue b/src/pages/ProjectsPage.vue
index 65992b31..739b77bf 100644
--- a/src/pages/ProjectsPage.vue
+++ b/src/pages/ProjectsPage.vue
@@ -24,26 +24,24 @@
Over the years, I've built and shared command-line tools and frameworks to tackle real engineering challenges—complete with clear docs and automated
tests—and partnered with banks, insurers, and fintech to deliver custom software that balances performance, security, and scalability.
-
+
Feel free to dive into my open-source repos and client case studies to see how I turn complex requirements into reliable, maintainable systems.
Open Source / Client Projects
-
-
-
- Projects will be added soon. Check back later!
-
+
+
+
Projects will be added soon. Check back later!
diff --git a/src/pages/ResumePage.vue b/src/pages/ResumePage.vue
index 66f79f8b..53787471 100644
--- a/src/pages/ResumePage.vue
+++ b/src/pages/ResumePage.vue
@@ -30,25 +30,23 @@
-
-
-
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
-
+
+
+
+
+
diff --git a/src/pages/TagPostsPage.vue b/src/pages/TagPostsPage.vue
index fde3940a..8fe36548 100644
--- a/src/pages/TagPostsPage.vue
+++ b/src/pages/TagPostsPage.vue
@@ -15,51 +15,51 @@
- Topics & Tags Explorer
+
+
Topics & Tags Explorer
+
+ ←
+ Go back
+
+
-
-
-
-
- Post tags help you quickly find the themes, tools or ideas you care about most across the blog. Browse through the tags below to group related posts
- together and dive deeper into specific topics, from high-level concepts to hands-on guides.
-
-
- {{ summaryMessage }}
-
-
+
+
+ Post tags help you quickly find the themes, tools or ideas you care about most across the blog. Browse through the tags below to group related posts
+ together and dive deeper into specific topics, from high-level concepts to hands-on guides.
+
+
+
+ {{ summaryContent.text
+ }}
+ {{ summaryContent.label }}
+
+ {{ summaryContent.suffix }}
+
+
+ {{ summaryContent.text }}
+
+
-
Articles
-
-
-
- {{ summaryMessage }}
-
-
- {{ summaryMessage }}
-
-
-
+
+
@@ -89,7 +89,7 @@
diff --git a/src/partials/ArticleItemPartial.vue b/src/partials/ArticleItemPartial.vue
index d1c7f4af..088825d1 100644
--- a/src/partials/ArticleItemPartial.vue
+++ b/src/partials/ArticleItemPartial.vue
@@ -39,12 +39,12 @@
- {{ item.title }}
+
- {{ item.excerpt }}
+
diff --git a/src/partials/SideNavPartial.vue b/src/partials/SideNavPartial.vue
index 19f51ff8..82921203 100644
--- a/src/partials/SideNavPartial.vue
+++ b/src/partials/SideNavPartial.vue
@@ -66,25 +66,6 @@
-
-
@@ -101,7 +82,6 @@ const currentRoute: RouteLocationNormalizedLoaded = useRoute();
const socialService = new Social();
const socialNavLinks = ref
([]);
const isLoadingSocialLinks = ref(true);
-const socialSkeletonCount = 2;
const isHome = computed(() => {
// `path` excludes query strings, ensuring the avatar is hidden on the homepage
@@ -109,10 +89,6 @@ const isHome = computed(() => {
return currentRoute.path === '/';
});
-const shouldDisplaySocialSection = computed(() => {
- return isLoadingSocialLinks.value || socialNavLinks.value.length > 0;
-});
-
function bindIconClassFor(isActive: boolean): string {
return isActive ? 'blog-side-nav-router-link-a-active' : 'blog-side-nav-router-link-a-resting';
}
diff --git a/src/partials/TalksPartial.vue b/src/partials/TalksPartial.vue
index 09e16f8c..c1029b04 100644
--- a/src/partials/TalksPartial.vue
+++ b/src/partials/TalksPartial.vue
@@ -4,41 +4,39 @@
-
-
-
-
-
diff --git a/src/public.ts b/src/public.ts
index f530900a..55071a30 100644
--- a/src/public.ts
+++ b/src/public.ts
@@ -1,3 +1,5 @@
+import type { Router as VueRouter } from 'vue-router';
+
const IMAGES_DIR = 'images';
export function image(filename: string): string {
@@ -50,3 +52,17 @@ export function getRandomInt(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
+
+export function goBack(router: VueRouter): void {
+ if (typeof window === 'undefined') {
+ router.push({ name: 'Home' });
+ return;
+ }
+
+ if (window.history.length > 1) {
+ router.back();
+ return;
+ }
+
+ router.push({ name: 'Home' });
+}
diff --git a/src/support/tags.ts b/src/support/tags.ts
index 694b66fa..1c3229da 100644
--- a/src/support/tags.ts
+++ b/src/support/tags.ts
@@ -6,6 +6,13 @@ export type TagSummaryState = {
postCount: number;
};
+export type TagSummaryDescription = {
+ text: string;
+ label?: string;
+ suffix?: string;
+ onLabelClick?: () => void;
+};
+
export class Tags {
private static readonly DEFAULT_LABEL = '#TAG';
@@ -44,26 +51,27 @@ export class Tags {
};
}
- static summaryFor(tag: string, state: TagSummaryState): string {
+ static summaryFor(tag: string, state: TagSummaryState, onLabelClick?: (label: string) => void): TagSummaryDescription {
if (!tag) {
- return 'Select a tag to explore related posts.';
+ return { text: 'Select a tag to explore related posts.' };
}
const label = this.formatLabel(tag);
+ const handleLabelClick = onLabelClick ? () => onLabelClick(label) : undefined;
if (state.isLoading) {
- return `Loading posts for ${label}…`;
+ return { text: 'Loading posts for', label, suffix: '…', onLabelClick: handleLabelClick };
}
if (state.hasError) {
- return `We couldn't load posts for ${label}.`;
+ return { text: "We couldn't load posts for", label, suffix: '.', onLabelClick: handleLabelClick };
}
if (state.postCount === 0) {
- return `No posts found for ${label}.`;
+ return { text: 'No posts found for', label, suffix: '.', onLabelClick: handleLabelClick };
}
const noun = state.postCount === 1 ? 'post' : 'posts';
- return `${state.postCount} ${noun} found for ${label}.`;
+ return { text: `${state.postCount} ${noun} found for `, label, suffix: '', onLabelClick: handleLabelClick };
}
}
diff --git a/src/support/useTextHighlight.ts b/src/support/useTextHighlight.ts
new file mode 100644
index 00000000..8ad36a72
--- /dev/null
+++ b/src/support/useTextHighlight.ts
@@ -0,0 +1,30 @@
+import { computed } from 'vue';
+import DOMPurify from 'dompurify';
+import { useApiStore } from '@api/store.ts';
+
+export function useTextHighlight() {
+ const apiStore = useApiStore();
+ const searchTerm = computed(() => apiStore.searchTerm.trim());
+
+ const highlight = (text: string | null | undefined): string => {
+ if (!text) {
+ return '';
+ }
+
+ const term = searchTerm.value;
+
+ if (!term) {
+ return DOMPurify.sanitize(text);
+ }
+
+ // Escape special regex characters in the search term
+ const escapedTerm = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ const regex = new RegExp(`(${escapedTerm})`, 'gi');
+
+ const highlighted = text.replace(regex, '$1 ');
+
+ return DOMPurify.sanitize(highlighted);
+ };
+
+ return { highlight };
+}
diff --git a/tests/pages/AboutPage.test.ts b/tests/pages/AboutPage.test.ts
index 072542a6..4abd66f2 100644
--- a/tests/pages/AboutPage.test.ts
+++ b/tests/pages/AboutPage.test.ts
@@ -3,6 +3,8 @@ import { faker } from '@faker-js/faker';
import { describe, it, expect, vi, afterEach } from 'vitest';
import AboutPage from '@pages/AboutPage.vue';
import type { ProfileResponse, ProfileSkillResponse } from '@api/response/index.ts';
+import { createRouter, createMemoryHistory, RouterView, type Router } from 'vue-router';
+import { defineComponent } from 'vue';
const skills: ProfileSkillResponse[] = [
{
@@ -27,43 +29,52 @@ const getProfile = vi.fn<[], Promise<{ data: ProfileResponse }>>(() => Promise.r
vi.mock('@api/store.ts', () => ({ useApiStore: () => ({ getProfile }) }));
vi.mock('@api/http-error.ts', () => ({ debugError: vi.fn() }));
+const App = defineComponent({
+ template: ' ',
+ components: { RouterView },
+});
+
+let router: Router;
+
+const mountComponent = async () => {
+ router = createRouter({
+ history: createMemoryHistory(),
+ routes: [{ path: '/', name: 'About', component: AboutPage }],
+ });
+ await router.push('/');
+ await router.isReady();
+
+ return mount(App, {
+ global: {
+ plugins: [router],
+ stubs: {
+ SideNavPartial: true,
+ HeaderPartial: true,
+ WidgetSocialTransitionWrapper: true,
+ WidgetSkillsPartial: true,
+ FooterPartial: true,
+ },
+ },
+ });
+};
+
describe('AboutPage', () => {
afterEach(() => {
vi.clearAllMocks();
});
it('shows formatted nickname', async () => {
- const wrapper = mount(AboutPage, {
- global: {
- stubs: {
- SideNavPartial: true,
- HeaderPartial: true,
- WidgetSocialTransitionWrapper: true,
- WidgetSkillsPartial: true,
- FooterPartial: true,
- },
- },
- });
+ const wrapper = await mountComponent();
await flushPromises();
const formatted = profile.nickname.charAt(0).toUpperCase() + profile.nickname.slice(1);
expect(getProfile).toHaveBeenCalled();
expect(wrapper.find('h1').text()).toContain(formatted);
});
- it('renders skeleton while loading the profile', () => {
+ it('renders skeleton while loading the profile', async () => {
getProfile.mockReturnValueOnce(new Promise(() => {}));
- const wrapper = mount(AboutPage, {
- global: {
- stubs: {
- SideNavPartial: true,
- HeaderPartial: true,
- WidgetSocialTransitionWrapper: true,
- WidgetSkillsPartial: true,
- FooterPartial: true,
- },
- },
- });
+ const wrapper = await mountComponent();
const skeleton = wrapper.find('[data-testid="about-connect-skeleton"]');
expect(skeleton.exists()).toBe(true);
@@ -73,17 +84,7 @@ describe('AboutPage', () => {
it('handles profile errors gracefully', async () => {
const error = new Error('fail');
getProfile.mockRejectedValueOnce(error);
- const _wrapper = mount(AboutPage, {
- global: {
- stubs: {
- SideNavPartial: true,
- HeaderPartial: true,
- WidgetSocialTransitionWrapper: true,
- WidgetSkillsPartial: true,
- FooterPartial: true,
- },
- },
- });
+ await mountComponent();
await flushPromises();
const { debugError } = await import('@api/http-error.ts');
expect(debugError).toHaveBeenCalledWith(error);
diff --git a/tests/pages/PostPage.test.ts b/tests/pages/PostPage.test.ts
index 4b1c1138..48b96b1f 100644
--- a/tests/pages/PostPage.test.ts
+++ b/tests/pages/PostPage.test.ts
@@ -3,6 +3,7 @@ import { faker } from '@faker-js/faker';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { defineComponent, ref } from 'vue';
import type { PostResponse } from '@api/response/index.ts';
+import { createRouter, createMemoryHistory, RouterView, type Router } from 'vue-router';
const createTag = () => {
const tag = faker.lorem.word();
@@ -43,13 +44,6 @@ const getPost = vi.fn<[], Promise>(() => Promise.resolve(post));
const setSearchTerm = vi.fn();
vi.mock('@api/store.ts', () => ({ useApiStore: () => ({ getPost, setSearchTerm }) }));
-vi.mock('vue-router', () => ({
- useRoute: () => ({ params: { slug: post.slug } }),
- RouterLink: {
- name: 'RouterLink',
- template: ' ',
- },
-}));
const renderMarkdown = vi.hoisted(() => vi.fn(() => '
'));
const initializeHighlighter = vi.hoisted(() => vi.fn(() => Promise.resolve()));
@@ -83,9 +77,24 @@ const RouterLinkStub = defineComponent({
template: " ",
});
-const mountComponent = () =>
- mount(PostPage, {
+const App = defineComponent({
+ template: ' ',
+ components: { RouterView },
+});
+
+let router: Router;
+
+const mountComponent = async () => {
+ router = createRouter({
+ history: createMemoryHistory(),
+ routes: [{ path: '/posts/:slug', name: 'PostDetail', component: PostPage }],
+ });
+ await router.push({ name: 'PostDetail', params: { slug: post.slug } });
+ await router.isReady();
+
+ return mount(App, {
global: {
+ plugins: [router],
stubs: {
SideNavPartial: true,
HeaderPartial: true,
@@ -97,6 +106,7 @@ const mountComponent = () =>
},
},
});
+};
describe('PostPage', () => {
beforeEach(() => {
@@ -104,7 +114,7 @@ describe('PostPage', () => {
});
it('fetches post on mount', async () => {
- const wrapper = mountComponent();
+ const wrapper = await mountComponent();
const skeleton = wrapper.find('[data-testid="post-page-skeleton"]');
expect(skeleton.exists()).toBe(true);
expect(skeleton.classes()).toContain('min-h-[25rem]');
@@ -115,7 +125,7 @@ describe('PostPage', () => {
});
it('initializes highlight.js on mount', async () => {
- const wrapper = mountComponent();
+ const wrapper = await mountComponent();
await flushPromises();
const highlightCore = await import('highlight.js/lib/core');
expect(initializeHighlighter).toHaveBeenCalledWith(highlightCore.default);
@@ -125,7 +135,7 @@ describe('PostPage', () => {
it('processes markdown content', async () => {
const DOMPurify = await import('dompurify');
- const wrapper = mountComponent();
+ const wrapper = await mountComponent();
await flushPromises();
expect(renderMarkdown).toHaveBeenCalledWith(post.content);
expect(DOMPurify.default.sanitize).toHaveBeenCalled();
@@ -133,7 +143,7 @@ describe('PostPage', () => {
});
it('renders tags when available', async () => {
- const wrapper = mountComponent();
+ const wrapper = await mountComponent();
await flushPromises();
const tagContainer = wrapper.find('[data-testid="post-tags"]');
expect(tagContainer.exists()).toBe(true);
@@ -151,20 +161,10 @@ describe('PostPage', () => {
expect(firstTagLink.props('to')).toEqual({ name: 'TagPosts', params: { tag: post.tags[0]!.name.toLowerCase() } });
});
- it('populates the search term when a tag is clicked', async () => {
- const wrapper = mountComponent();
- await flushPromises();
- const firstTag = wrapper.find('[data-testid="post-tag"]');
- expect(firstTag.exists()).toBe(true);
- await firstTag.trigger('click');
- const expectedLabel = `#${post.tags[0]?.name.toUpperCase()}`;
- expect(setSearchTerm).toHaveBeenCalledWith(expectedLabel);
- });
-
it('handles post errors gracefully', async () => {
const error = new Error('fail');
getPost.mockRejectedValueOnce(error);
- const wrapper = mountComponent();
+ const wrapper = await mountComponent();
await flushPromises();
const { debugError } = await import('@api/http-error.ts');
expect(debugError).toHaveBeenCalledWith(error);
@@ -173,7 +173,7 @@ describe('PostPage', () => {
});
it('renders the follow widget above the sponsor widget', async () => {
- const wrapper = mountComponent();
+ const wrapper = await mountComponent();
await flushPromises();
@@ -190,7 +190,7 @@ describe('PostPage', () => {
});
it('renders a back to top link targeting the post header', async () => {
- const wrapper = mountComponent();
+ const wrapper = await mountComponent();
await flushPromises();
diff --git a/tests/pages/TagPostsPage.test.ts b/tests/pages/TagPostsPage.test.ts
index 3deef249..0ba8e69b 100644
--- a/tests/pages/TagPostsPage.test.ts
+++ b/tests/pages/TagPostsPage.test.ts
@@ -3,6 +3,8 @@ import type { VueWrapper } from '@vue/test-utils';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { defineComponent, reactive } from 'vue';
import type { PostResponse, PostsCollectionResponse } from '@api/response/index.ts';
+import { createRouter, createMemoryHistory, type Router, RouterView } from 'vue-router';
+import TagPostsPage from '@pages/TagPostsPage.vue';
const buildPost = (index: number): PostResponse => ({
uuid: `uuid-${index}`,
@@ -40,11 +42,17 @@ const posts = [buildPost(1), buildPost(2)];
const getPosts = vi.hoisted(() => vi.fn());
const debugError = vi.hoisted(() => vi.fn());
-const routeParams = reactive<{ tag: string }>({ tag: 'design' });
+const storeState = reactive({ searchTerm: '' });
vi.mock('@api/store.ts', () => ({
useApiStore: () => ({
getPosts,
+ get searchTerm() {
+ return storeState.searchTerm;
+ },
+ setSearchTerm: (term: string) => {
+ storeState.searchTerm = term;
+ },
}),
}));
@@ -52,17 +60,6 @@ vi.mock('@api/http-error.ts', () => ({
debugError,
}));
-vi.mock('vue-router', async () => {
- const actual = await vi.importActual('vue-router');
-
- return {
- ...actual,
- useRoute: () => reactive({ params: routeParams }),
- };
-});
-
-import TagPostsPage from '@pages/TagPostsPage.vue';
-
const ArticleItemPartialStub = defineComponent({
name: 'ArticleItemPartialStub',
props: {
@@ -79,11 +76,25 @@ const ArticleItemSkeletonPartialStub = defineComponent({
template: '
',
});
+const App = defineComponent({
+ template: ' ',
+ components: { RouterView },
+});
+
const mountedWrappers: VueWrapper[] = [];
+let router: Router;
+
+const mountComponent = async () => {
+ router = createRouter({
+ history: createMemoryHistory(),
+ routes: [{ path: '/tags/:tag', name: 'TagPosts', component: TagPostsPage }],
+ });
+ router.push('/tags/design');
+ await router.isReady();
-const mountComponent = () => {
- const wrapper = mount(TagPostsPage, {
+ const wrapper = mount(App, {
global: {
+ plugins: [router],
stubs: {
SideNavPartial: true,
HeaderPartial: true,
@@ -105,7 +116,7 @@ const mountComponent = () => {
describe('TagPostsPage', () => {
beforeEach(() => {
vi.clearAllMocks();
- routeParams.tag = 'design';
+ storeState.searchTerm = '';
getPosts.mockResolvedValue(buildCollection(posts));
});
@@ -116,12 +127,9 @@ describe('TagPostsPage', () => {
});
it('fetches posts for the provided tag', async () => {
- const wrapper = mountComponent();
-
- expect(getPosts).toHaveBeenCalledWith({ tag: 'design' });
-
+ const wrapper = await mountComponent();
+ expect(getPosts).toHaveBeenCalledWith({ tag: 'design', text: '#DESIGN' });
await flushPromises();
-
const renderedPosts = wrapper.findAll('[data-testid="article-item-stub"]');
expect(renderedPosts).toHaveLength(posts.length);
const summary = wrapper.get('[data-testid="tag-posts-summary"]');
@@ -130,40 +138,53 @@ describe('TagPostsPage', () => {
it('shows an empty message when no posts are returned', async () => {
getPosts.mockResolvedValueOnce(buildCollection([]));
-
- const wrapper = mountComponent();
+ const wrapper = await mountComponent();
await flushPromises();
-
- expect(wrapper.find('[data-testid="tag-posts-list"]').exists()).toBe(false);
- const emptyState = wrapper.get('[data-testid="tag-posts-empty"]');
- expect(emptyState.text()).toContain('No posts found for #DESIGN');
+ const renderedPosts = wrapper.findAll('[data-testid="article-item-stub"]');
+ expect(renderedPosts).toHaveLength(0);
+ const summary = wrapper.get('[data-testid="tag-posts-summary"]');
+ expect(summary.text()).toContain('No posts found for#DESIGN');
});
it('handles API errors gracefully', async () => {
const error = new Error('Network failure');
getPosts.mockRejectedValueOnce(error);
-
- const wrapper = mountComponent();
+ const wrapper = await mountComponent();
await flushPromises();
-
expect(debugError).toHaveBeenCalledWith(error);
- const errorState = wrapper.get('[data-testid="tag-posts-error"]');
- expect(errorState.text()).toContain("We couldn't load posts for #DESIGN");
+ const summary = wrapper.get('[data-testid="tag-posts-summary"]');
+ expect(summary.text()).toContain("We couldn't load posts for#DESIGN");
});
it('refetches posts when the route tag parameter changes', async () => {
- const wrapper = mountComponent();
+ const wrapper = await mountComponent();
await flushPromises();
const newPosts = [buildPost(3)];
getPosts.mockResolvedValueOnce(buildCollection(newPosts));
- routeParams.tag = 'ux';
- await flushPromises();
+ await router.push('/tags/ux');
await flushPromises();
- expect(getPosts).toHaveBeenLastCalledWith({ tag: 'ux' });
+ expect(getPosts).toHaveBeenLastCalledWith({ tag: 'ux', text: '#UX' });
const summary = wrapper.get('[data-testid="tag-posts-summary"]');
expect(summary.text()).toContain('1 post found for #UX');
});
+
+ it('refetches posts when the search term changes', async () => {
+ const wrapper = await mountComponent();
+ await flushPromises();
+
+ expect(getPosts).toHaveBeenCalledWith({ tag: 'design', text: '#DESIGN' });
+
+ const newPosts = [buildPost(3)];
+ getPosts.mockResolvedValueOnce(buildCollection(newPosts));
+
+ storeState.searchTerm = 'new search';
+ await flushPromises();
+
+ expect(getPosts).toHaveBeenLastCalledWith({ tag: 'design', text: 'new search' });
+ const summary = wrapper.get('[data-testid="tag-posts-summary"]');
+ expect(summary.text()).toContain('1 post found for #DESIGN');
+ });
});
diff --git a/tests/partials/ArticleItemPartial.test.ts b/tests/partials/ArticleItemPartial.test.ts
index d6bd7e25..5cd20aa9 100644
--- a/tests/partials/ArticleItemPartial.test.ts
+++ b/tests/partials/ArticleItemPartial.test.ts
@@ -1,6 +1,7 @@
import { mount } from '@vue/test-utils';
import { faker } from '@faker-js/faker';
-import { describe, it, expect, vi } from 'vitest';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { setActivePinia, createPinia } from 'pinia';
import ArticleItemPartial from '@partials/ArticleItemPartial.vue';
import CoverImageLoader from '@components/CoverImageLoader.vue';
import type { PostResponse } from '@api/response/index.ts';
@@ -9,7 +10,17 @@ vi.mock('@/public.ts', () => ({
date: () => ({ format: () => 'formatted' }),
}));
+vi.mock('@api/store.ts', () => ({
+ useApiStore: () => ({
+ searchTerm: '',
+ }),
+}));
+
describe('ArticleItemPartial', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia());
+ });
+
const item: PostResponse = {
uuid: faker.string.uuid(),
slug: faker.lorem.slug(),
diff --git a/tests/partials/HeaderPartial.test.ts b/tests/partials/HeaderPartial.test.ts
index ccb0de65..46b42561 100644
--- a/tests/partials/HeaderPartial.test.ts
+++ b/tests/partials/HeaderPartial.test.ts
@@ -1,6 +1,6 @@
import { mount } from '@vue/test-utils';
-import { faker } from '@faker-js/faker';
-import { describe, it, expect, vi } from 'vitest';
+import { beforeEach, describe, it, expect, vi } from 'vitest';
+import { createRouter, createMemoryHistory } from 'vue-router';
import HeaderPartial from '@partials/HeaderPartial.vue';
const toggleDarkMode = vi.fn();
@@ -9,28 +9,45 @@ vi.mock('@/dark-mode.ts', () => ({ useDarkMode: () => ({ toggleDarkMode }) }));
const setSearchTerm = vi.fn();
vi.mock('@api/store.ts', () => ({ useApiStore: () => ({ setSearchTerm }) }));
+const routes = [
+ { path: '/', name: 'home', component: { template: '
' } },
+ { path: '/tags/:tag', name: 'TagPosts', component: { template: '
' } },
+];
+
+const mountWithRouter = async () => {
+ const router = createRouter({ history: createMemoryHistory(), routes });
+ router.push('/');
+ await router.isReady();
+
+ return mount(HeaderPartial, { global: { plugins: [router] } });
+};
+
describe('HeaderPartial', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
it('validates search length', async () => {
- const wrapper = mount(HeaderPartial);
+ const wrapper = await mountWithRouter();
const input = wrapper.find('#search');
await input.setValue('abc');
await wrapper.find('form').trigger('submit');
- expect(wrapper.vm.validationError).toBeDefined();
+ expect(wrapper.vm.validationError).toBeTruthy();
expect(setSearchTerm).not.toHaveBeenCalled();
});
it('submits valid search', async () => {
- const wrapper = mount(HeaderPartial);
- const query: string = faker.lorem.words(2);
+ const wrapper = await mountWithRouter();
+ const query = 'valid search';
const input = wrapper.find('#search');
await input.setValue(query);
await wrapper.find('form').trigger('submit');
expect(setSearchTerm).toHaveBeenCalledWith(query);
});
- it('toggles dark mode', () => {
- const wrapper = mount(HeaderPartial);
- wrapper.find('label[for="light-switch"]').trigger('click');
+ it('toggles dark mode', async () => {
+ const wrapper = await mountWithRouter();
+ await wrapper.find('label[for="light-switch"]').trigger('click');
expect(toggleDarkMode).toHaveBeenCalled();
});
});
diff --git a/tests/partials/SideNavPartial.test.ts b/tests/partials/SideNavPartial.test.ts
index df5c169f..b495e8d0 100644
--- a/tests/partials/SideNavPartial.test.ts
+++ b/tests/partials/SideNavPartial.test.ts
@@ -55,7 +55,14 @@ function createTestRouter(initialPath: string): Router {
async function mountSideNavAt(initialPath: string): Promise {
const router = createTestRouter(initialPath);
const pinia = createPinia();
- const wrapper = mount(SideNavPartial, { global: { plugins: [router, pinia] } });
+ const wrapper = mount(SideNavPartial, {
+ global: {
+ plugins: [router, pinia],
+ stubs: {
+ RouterView: true,
+ },
+ },
+ });
await router.isReady();
await flushPromises();
@@ -83,18 +90,11 @@ describe('SideNavPartial', () => {
wrapper.unmount();
});
- it('renders social links beneath the menu separated by a hyphen', async () => {
+ it('does not render social links section', async () => {
const wrapper = await mountSideNavAt('/');
const socialSection = wrapper.find('[data-testid="side-nav-social-links"]');
- expect(socialSection.exists()).toBe(true);
-
- const separator = wrapper.find('[data-testid="side-nav-social-separator"]');
- expect(separator.exists()).toBe(true);
- expect(separator.text().trim()).toBe('-');
-
- const socialLinks = socialSection.findAll('a[aria-label]');
- expect(socialLinks).toHaveLength(2); // Component filters for github and linkedin only
+ expect(socialSection.exists()).toBe(false);
wrapper.unmount();
});
diff --git a/tests/setup.ts b/tests/setup.ts
index ec82c327..f8270755 100644
--- a/tests/setup.ts
+++ b/tests/setup.ts
@@ -1,8 +1,16 @@
+import { config } from '@vue/test-utils';
import { faker } from '@faker-js/faker';
import { webcrypto } from 'node:crypto';
faker.seed(123);
+// Mock the v-lazy-link directive
+config.global.directives['lazy-link'] = {
+ mounted: () => {},
+ updated: () => {},
+ unmounted: () => {},
+};
+
// Ensure Web Crypto API is available in the test environment (jsdom)
if (!globalThis.crypto || !('subtle' in globalThis.crypto)) {
// Vitest in jsdom may not expose crypto.subtle by default
@@ -12,6 +20,9 @@ if (!globalThis.crypto || !('subtle' in globalThis.crypto)) {
class LocalStorageMock {
private store: Record = {};
+ get length() {
+ return Object.keys(this.store).length;
+ }
clear() {
this.store = {};
}
@@ -24,6 +35,10 @@ class LocalStorageMock {
removeItem(key: string) {
delete this.store[key];
}
+ key(index: number) {
+ const keys = Object.keys(this.store);
+ return keys[index] ?? null;
+ }
}
declare global {
diff --git a/tests/support/router.ts b/tests/support/router.ts
new file mode 100644
index 00000000..f16fa1ed
--- /dev/null
+++ b/tests/support/router.ts
@@ -0,0 +1,9 @@
+import { createRouter, createMemoryHistory } from 'vue-router';
+import type { RouteRecordRaw } from 'vue-router';
+
+export const createTestRouter = (routes: RouteRecordRaw[] = []) => {
+ return createRouter({
+ history: createMemoryHistory(),
+ routes: [{ path: '/', name: 'Home', component: { template: 'Home
' } }, ...routes],
+ });
+};
diff --git a/tests/support/tags.test.ts b/tests/support/tags.test.ts
index 17394f2c..61f53b9c 100644
--- a/tests/support/tags.test.ts
+++ b/tests/support/tags.test.ts
@@ -1,4 +1,4 @@
-import { describe, expect, it } from 'vitest';
+import { describe, expect, it, vi } from 'vitest';
import { Tags, type TagSummaryState } from '@/support/tags.ts';
const baseSummaryState: TagSummaryState = {
@@ -52,7 +52,7 @@ describe('Tags.routeFor', () => {
describe('Tags.summaryFor', () => {
it('prompts the user to select a tag when one is missing', () => {
- expect(Tags.summaryFor('', baseSummaryState)).toBe('Select a tag to explore related posts.');
+ expect(Tags.summaryFor('', baseSummaryState)).toEqual({ text: 'Select a tag to explore related posts.' });
});
it('describes loading state when awaiting posts', () => {
@@ -61,7 +61,7 @@ describe('Tags.summaryFor', () => {
...baseSummaryState,
isLoading: true,
}),
- ).toBe('Loading posts for #VUE…');
+ ).toEqual({ text: 'Loading posts for', label: '#VUE', suffix: '…', onLabelClick: undefined });
});
it('reports failures when the API request fails', () => {
@@ -70,7 +70,7 @@ describe('Tags.summaryFor', () => {
...baseSummaryState,
hasError: true,
}),
- ).toBe("We couldn't load posts for #VUE.");
+ ).toEqual({ text: "We couldn't load posts for", label: '#VUE', suffix: '.', onLabelClick: undefined });
});
it('mentions when no posts were found', () => {
@@ -78,7 +78,7 @@ describe('Tags.summaryFor', () => {
Tags.summaryFor('vue', {
...baseSummaryState,
}),
- ).toBe('No posts found for #VUE.');
+ ).toEqual({ text: 'No posts found for', label: '#VUE', suffix: '.', onLabelClick: undefined });
});
it('handles singular and plural post counts', () => {
@@ -87,13 +87,29 @@ describe('Tags.summaryFor', () => {
...baseSummaryState,
postCount: 1,
}),
- ).toBe('1 post found for #VUE.');
+ ).toEqual({ text: '1 post found for ', label: '#VUE', suffix: '', onLabelClick: undefined });
expect(
Tags.summaryFor('vue', {
...baseSummaryState,
postCount: 3,
}),
- ).toBe('3 posts found for #VUE.');
+ ).toEqual({ text: '3 posts found for ', label: '#VUE', suffix: '', onLabelClick: undefined });
+ });
+
+ it('uses the callback for label clicks', () => {
+ const handler = vi.fn();
+ const summary = Tags.summaryFor(
+ 'vue',
+ {
+ ...baseSummaryState,
+ postCount: 2,
+ },
+ handler,
+ );
+
+ expect(summary.label).toBe('#VUE');
+ summary.onLabelClick?.();
+ expect(handler).toHaveBeenCalledWith('#VUE');
});
});