diff --git a/.github/workflows/format-check.yml b/.github/workflows/format-check.yml index e4c1654a..b1d74dc2 100644 --- a/.github/workflows/format-check.yml +++ b/.github/workflows/format-check.yml @@ -20,7 +20,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 22.18.0 + node-version: 22.12.0 cache: 'npm' cache-dependency-path: package-lock.json diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index 9000568b..0f12f900 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -21,7 +21,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 22.18.0 + node-version: 22.12.0 cache: 'npm' cache-dependency-path: package-lock.json diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3da08e7e..8c77ded0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,7 +12,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 22.18.0 + node-version: 22.12.0 cache: 'npm' - run: npm ci --legacy-peer-deps - run: npm test diff --git a/.gitignore b/.gitignore index fe25d8a7..bc8c5537 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ dist/ .idea/ dist-ssr/ node_modules/ +coverage/ # --- [Caddy]: mtls caddy/mtls/*.* diff --git a/.nvmrc b/.nvmrc index 91d5f6ff..1d9b7831 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -22.18.0 +22.12.0 diff --git a/aliases.ts b/aliases.ts index a8b5656b..eb95cc28 100644 --- a/aliases.ts +++ b/aliases.ts @@ -18,6 +18,7 @@ export const aliases: AliasOptions = [ { find: '@partials', replacement: path.resolve(__dirname, './src/partials') }, { find: '@stores', replacement: path.resolve(__dirname, './src/stores') }, { find: '@api', replacement: path.resolve(__dirname, './src/stores/api') }, + { find: '@support', replacement: path.resolve(__dirname, './src/support') }, ]; export default aliases; diff --git a/src/pages/PostPage.vue b/src/pages/PostPage.vue index 915ffb6f..c045e762 100644 --- a/src/pages/PostPage.vue +++ b/src/pages/PostPage.vue @@ -15,7 +15,7 @@
- - +
@@ -91,11 +91,33 @@

{{ post.title }}

+

{{ post.excerpt }}

- +
@@ -126,7 +148,7 @@ diff --git a/src/partials/ArticleItemPartial.vue b/src/partials/ArticleItemPartial.vue index 76e84fac..d1c7f4af 100644 --- a/src/partials/ArticleItemPartial.vue +++ b/src/partials/ArticleItemPartial.vue @@ -1,7 +1,7 @@ - +
{{ date().format(new Date(item.published_at)) }}

- + {{ item.title }} - +

- + {{ item.excerpt }} - - + +
@@ -63,6 +63,7 @@ diff --git a/src/partials/PostPageSkeletonPartial.vue b/src/partials/PostPageSkeletonPartial.vue index a59e8734..660b4f5a 100644 --- a/src/partials/PostPageSkeletonPartial.vue +++ b/src/partials/PostPageSkeletonPartial.vue @@ -19,6 +19,22 @@ + +
diff --git a/src/partials/RecommendationPartial.vue b/src/partials/RecommendationPartial.vue index 846dff09..10fc3dc5 100644 --- a/src/partials/RecommendationPartial.vue +++ b/src/partials/RecommendationPartial.vue @@ -6,26 +6,29 @@
diff --git a/src/partials/ResumePageSkeletonPartial.vue b/src/partials/ResumePageSkeletonPartial.vue index 4157a2b8..028fb777 100644 --- a/src/partials/ResumePageSkeletonPartial.vue +++ b/src/partials/ResumePageSkeletonPartial.vue @@ -88,7 +88,7 @@ const props = withDefaults(defineProps<{ showRefreshButton?: boolean }>(), { showRefreshButton: false, }); -const emit = defineEmits<{ (event: 'retry'): void }>(); +const emit = defineEmits<{ (_event: 'retry'): void }>(); const showRefreshButton = toRef(props, 'showRefreshButton'); diff --git a/src/partials/SideNavPartial.vue b/src/partials/SideNavPartial.vue index 308bfa8e..2dbb82ae 100644 --- a/src/partials/SideNavPartial.vue +++ b/src/partials/SideNavPartial.vue @@ -3,9 +3,9 @@
- + - +
@@ -14,7 +14,7 @@ + +
+ + + +
@@ -73,11 +94,14 @@ diff --git a/src/partials/WidgetSocialPartial.vue b/src/partials/WidgetSocialPartial.vue index 712b3b5b..770c4104 100644 --- a/src/partials/WidgetSocialPartial.vue +++ b/src/partials/WidgetSocialPartial.vue @@ -1,5 +1,5 @@ diff --git a/src/router.ts b/src/router.ts index 1dab80bb..d70aca3d 100644 --- a/src/router.ts +++ b/src/router.ts @@ -22,6 +22,7 @@ const router: Router = createRouter({ routes: [ { path: '/', + name: 'Home', component: () => import('@pages/HomePage.vue'), }, { @@ -29,6 +30,11 @@ const router: Router = createRouter({ name: 'PostDetail', component: () => import('@pages/PostPage.vue'), }, + { + path: '/tags/:tag', + name: 'TagPosts', + component: () => import('@pages/TagPostsPage.vue'), + }, { path: '/about', component: () => import('@pages/AboutPage.vue'), diff --git a/src/support/social.ts b/src/support/social.ts new file mode 100644 index 00000000..edca7dce --- /dev/null +++ b/src/support/social.ts @@ -0,0 +1,80 @@ +import { useApiStore } from '@api/store.ts'; +import { debugError } from '@api/http-error.ts'; +import type { SocialResponse } from '@api/response'; + +export interface PlatformConfig { + icon: string; + text: string; +} + +export interface SocialNavLink { + href: string; + label: string; + icon: string; +} + +type PlatformName = 'x' | 'youtube' | 'instagram' | 'linkedin' | 'github'; + +export class Social { + private readonly apiStore = useApiStore(); + + private static readonly platformConfigs: Record = { + x: { + icon: 'M13.3174 10.7749L19.1457 4H17.7646L12.7039 9.88256L8.66193 4H4L10.1122 12.8955L4 20H5.38119L10.7254 13.7878L14.994 20H19.656L13.3171 10.7749H13.3174ZM11.4257 12.9738L10.8064 12.0881L5.87886 5.03974H8.00029L11.9769 10.728L12.5962 11.6137L17.7652 19.0075H15.6438L11.4257 12.9742V12.9738Z', + text: 'Latest Updates', + }, + youtube: { + icon: 'M21.582,6.186c-0.23-0.86-0.908-1.538-1.768-1.768C18.254,4,12,4,12,4S5.746,4,4.186,4.418 c-0.86,0.23-1.538,0.908-1.768,1.768C2,7.746,2,12,2,12s0,4.254,0.418,5.814c0.23,0.86,0.908,1.538,1.768,1.768 C5.746,20,12,20,12,20s6.254,0,7.814-0.418c0.861-0.23,1.538-0.908,1.768-1.768C22,16.254,22,12,22,12S22,7.746,21.582,6.186z M10,15.464V8.536L16,12L10,15.464z', + text: 'My Videos', + }, + instagram: { + icon: 'M7.5,2h9A5.5,5.5,0,0,1,22,7.5v9A5.5,5.5,0,0,1,16.5,22h-9A5.5,5.5,0,0,1,2,16.5v-9A5.5,5.5,0,0,1,7.5,2ZM12,16A4,4,0,1,0,12,8,4,4,0,0,0,12,16Z', + text: 'Photo Stream', + }, + linkedin: { + icon: 'M19,3H5C3.89,3,3,3.89,3,5V19C3,20.1,3.89,21,5,21H19C20.1,21,21,20.1,21,19V5C21,3.89,20.1,3,19,3M8.5,18H5.5V10H8.5V18M6.94,8.5C6.16,8.5,5.5,7.83,5.5,7C5.5,6.17,6.16,5.5,6.94,5.5C7.72,5.5,8.38,6.17,8.38,7C8.38,7.83,7.72,8.5,6.94,8.5M18.5,18H15.5V14.25C15.5,13.17,14.67,12.25,13.5,12.25C12.5,12.25,11.5,13,11.5,14.25V18H8.5V10H11.5V11.25C12.06,10.25,13,9.5,14.25,9.5C16.5,9.5,18.5,11.25,18.5,14V18Z', + text: 'Professional Profile', + }, + github: { + icon: 'M12,2A10,10 0 0,0 2,12C2,16.42 4.87,20.17 8.84,21.5C9.34,21.58 9.5,21.27 9.5,21C9.5,20.77 9.5,20.14 9.5,19.31C6.73,19.91 6.14,17.97 6.14,17.97C5.68,16.81 4.97,16.5 4.97,16.5C4.05,15.82 5.06,15.82 5.06,15.82C6.06,15.89 6.63,16.83 6.63,16.83C7.5,18.31 8.95,17.88 9.5,17.61C9.58,17.03 9.84,16.6 10.12,16.34C7.89,16.1 5.5,15.27 5.5,11.5C5.5,10.39 5.89,9.53 6.5,8.84C6.38,8.58 6.08,7.7 6.63,6.5C6.63,6.5 7.43,6.26 9.5,7.7C10.27,7.5 11.14,7.39 12,7.39C12.86,7.39 13.73,7.5 14.5,7.7C16.57,6.26 17.37,6.5 17.37,6.5C17.92,7.7 17.62,8.58 17.5,8.84C18.11,9.53 18.5,10.39 18.5,11.5C18.5,15.27 16.1,16.1 13.88,16.34C14.24,16.64 14.5,17.27 14.5,18.26C14.5,19.6 14.5,20.68 14.5,21C14.5,21.27 14.66,21.59 15.17,21.5C19.14,20.16 22,16.42 22,12A10,10 0,0,0,12,2Z', + text: 'Code & Projects', + }, + }; + + private static isPlatformName(name: string): name is PlatformName { + return Object.prototype.hasOwnProperty.call(Social.platformConfigs, name); + } + + get platforms(): Record { + return Social.platformConfigs; + } + + async fetch(): Promise { + try { + const response = await this.apiStore.getSocial(); + return response.data ?? []; + } catch (error) { + debugError(error); + return []; + } + } + + buildNavLinks(social: SocialResponse[], allowedPlatforms?: PlatformName[]): SocialNavLink[] { + const allowed = allowedPlatforms ?? (Object.keys(Social.platformConfigs) as PlatformName[]); + const allowedSet = new Set(allowed); + + return social + .filter((item): item is SocialResponse & { name: PlatformName } => { + return Social.isPlatformName(item.name) && allowedSet.has(item.name); + }) + .map((item) => { + const platform = Social.platformConfigs[item.name]; + return { + href: item.url, + label: platform?.text ?? item.name, + icon: platform?.icon ?? '', + }; + }) + .filter((link) => Boolean(link.icon)); + } +} diff --git a/src/support/tags.ts b/src/support/tags.ts new file mode 100644 index 00000000..e9f85ed2 --- /dev/null +++ b/src/support/tags.ts @@ -0,0 +1,63 @@ +import type { RouteLocationRaw } from 'vue-router'; + +export type TagSummaryState = { + isLoading: boolean; + hasError: boolean; + postCount: number; +}; + +export class Tags { + private static readonly DEFAULT_LABEL = '#TAG'; + + static normalizeParam(value: unknown): string { + if (typeof value === 'string') { + return value.trim(); + } + + if (Array.isArray(value)) { + const [first] = value as Array; + return typeof first === 'string' ? first.trim() : ''; + } + + return ''; + } + + static formatLabel(tag?: string | null): string { + const normalized = (tag ?? '').trim(); + if (!normalized) { + return this.DEFAULT_LABEL; + } + + return `#${normalized.toUpperCase()}`; + } + + static routeFor(tag: string): RouteLocationRaw { + return { + name: 'TagPosts', + params: { tag }, + }; + } + + static summaryFor(tag: string, state: TagSummaryState): string { + if (!tag) { + return 'Select a tag to explore related posts.'; + } + + const label = this.formatLabel(tag); + + if (state.isLoading) { + return `Loading posts for ${label}…`; + } + + if (state.hasError) { + return `We couldn't load posts for ${label}.`; + } + + if (state.postCount === 0) { + return `No posts found for ${label}.`; + } + + const noun = state.postCount === 1 ? 'post' : 'posts'; + return `${state.postCount} ${noun} found for ${label}.`; + } +} diff --git a/tests/pages/PostPage.test.ts b/tests/pages/PostPage.test.ts index 9438f0c9..a5130c3d 100644 --- a/tests/pages/PostPage.test.ts +++ b/tests/pages/PostPage.test.ts @@ -1,8 +1,7 @@ import { mount, flushPromises } from '@vue/test-utils'; import { faker } from '@faker-js/faker'; import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { ref } from 'vue'; -import PostPage from '@pages/PostPage.vue'; +import { defineComponent, ref } from 'vue'; import type { PostResponse } from '@api/response/index.ts'; const post: PostResponse = { @@ -26,13 +25,31 @@ const post: PostResponse = { profile_picture_url: faker.image.avatar(), }, categories: [], - tags: [], + tags: [ + { + uuid: faker.string.uuid(), + name: faker.lorem.word(), + description: faker.lorem.sentence(), + }, + { + uuid: faker.string.uuid(), + name: faker.lorem.word(), + description: faker.lorem.sentence(), + }, + ], }; const getPost = vi.fn<[], Promise>(() => Promise.resolve(post)); - -vi.mock('@api/store.ts', () => ({ useApiStore: () => ({ getPost }) })); -vi.mock('vue-router', () => ({ useRoute: () => ({ params: { slug: post.slug } }) })); +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())); @@ -52,6 +69,20 @@ vi.mock('@/public.ts', () => ({ getReadingTime: () => '', })); +import PostPage from '@pages/PostPage.vue'; + +const RouterLinkStub = defineComponent({ + name: 'RouterLinkStub', + props: { + to: { + type: [String, Object], + required: true, + }, + }, + emits: ['click'], + template: "", +}); + const mountComponent = () => mount(PostPage, { global: { @@ -62,7 +93,7 @@ const mountComponent = () => WidgetSponsorPartial: true, WidgetSocialPartial: true, WidgetSkillsPartial: true, - RouterLink: { template: '' }, + RouterLink: RouterLinkStub, }, }, }); @@ -101,6 +132,35 @@ describe('PostPage', () => { expect(wrapper.html()).toContain('

'); }); + it('renders tags when available', async () => { + const wrapper = mountComponent(); + await flushPromises(); + const tagContainer = wrapper.find('[data-testid="post-tags"]'); + expect(tagContainer.exists()).toBe(true); + expect(tagContainer.element.tagName).toBe('NAV'); + const tags = wrapper.findAll('[data-testid="post-tag"]'); + expect(tags).toHaveLength(post.tags.length); + const separators = wrapper.findAll('[data-testid="post-tag-separator"]'); + expect(separators).toHaveLength(Math.max(0, post.tags.length - 1)); + tags.forEach((tagWrapper, index) => { + const expectedLabel = `#${post.tags[index]?.name.toUpperCase()}`; + expect(tagWrapper.text()).toContain(expectedLabel); + }); + const firstTag = tags[0]; + const firstTagLink = firstTag.findComponent(RouterLinkStub); + expect(firstTagLink.props('to')).toEqual({ name: 'TagPosts', params: { tag: post.tags[0]?.name } }); + }); + + 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); diff --git a/tests/pages/TagPostsPage.test.ts b/tests/pages/TagPostsPage.test.ts new file mode 100644 index 00000000..18b2bf00 --- /dev/null +++ b/tests/pages/TagPostsPage.test.ts @@ -0,0 +1,169 @@ +import { mount, flushPromises } from '@vue/test-utils'; +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'; + +const buildPost = (index: number): PostResponse => ({ + uuid: `uuid-${index}`, + slug: `post-${index}`, + title: `Post Title ${index}`, + excerpt: `Post Excerpt ${index}`, + content: `Post Content ${index}`, + cover_image_url: `/images/post-${index}.jpg`, + published_at: new Date().toISOString(), + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + author: { + uuid: `author-${index}`, + first_name: 'Author', + last_name: `${index}`, + username: `author-${index}`, + display_name: `Author ${index}`, + bio: 'Bio', + picture_file_name: `author-${index}.jpg`, + profile_picture_url: `/images/authors/${index}.jpg`, + }, + categories: [], + tags: [], +}); + +const buildCollection = (posts: PostResponse[]): PostsCollectionResponse => ({ + page: 1, + total: posts.length, + page_size: posts.length, + total_pages: 1, + data: posts, +}); + +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' }); + +vi.mock('@api/store.ts', () => ({ + useApiStore: () => ({ + getPosts, + }), +})); + +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: { + item: { + type: Object, + required: true, + }, + }, + template: '
{{ item.title }}
', +}); + +const ArticleItemSkeletonPartialStub = defineComponent({ + name: 'ArticleItemSkeletonPartialStub', + template: '
', +}); + +const mountedWrappers: VueWrapper[] = []; + +const mountComponent = () => { + const wrapper = mount(TagPostsPage, { + global: { + stubs: { + SideNavPartial: true, + HeaderPartial: true, + FooterPartial: true, + WidgetSponsorPartial: true, + WidgetSocialPartial: true, + BackToTopLink: true, + RouterLink: true, + ArticleItemPartial: ArticleItemPartialStub, + ArticleItemSkeletonPartial: ArticleItemSkeletonPartialStub, + }, + }, + }); + + mountedWrappers.push(wrapper); + return wrapper; +}; + +describe('TagPostsPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + routeParams.tag = 'design'; + getPosts.mockResolvedValue(buildCollection(posts)); + }); + + afterEach(() => { + while (mountedWrappers.length > 0) { + mountedWrappers.pop()?.unmount(); + } + }); + + it('fetches posts for the provided tag', async () => { + const wrapper = mountComponent(); + + expect(getPosts).toHaveBeenCalledWith({ tag: '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"]'); + expect(summary.text()).toContain('2 posts found for #DESIGN'); + }); + + it('shows an empty message when no posts are returned', async () => { + getPosts.mockResolvedValueOnce(buildCollection([])); + + const wrapper = 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'); + }); + + it('handles API errors gracefully', async () => { + const error = new Error('Network failure'); + getPosts.mockRejectedValueOnce(error); + + const wrapper = 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"); + }); + + it('refetches posts when the route tag parameter changes', async () => { + const wrapper = mountComponent(); + await flushPromises(); + + const newPosts = [buildPost(3)]; + getPosts.mockResolvedValueOnce(buildCollection(newPosts)); + + routeParams.tag = 'ux'; + await flushPromises(); + await flushPromises(); + + expect(getPosts).toHaveBeenLastCalledWith({ tag: 'ux' }); + const summary = wrapper.get('[data-testid="tag-posts-summary"]'); + expect(summary.text()).toContain('1 post found for #UX'); + }); +}); diff --git a/tests/partials/SideNavPartial.test.ts b/tests/partials/SideNavPartial.test.ts index 6b25ded9..df5c169f 100644 --- a/tests/partials/SideNavPartial.test.ts +++ b/tests/partials/SideNavPartial.test.ts @@ -1,9 +1,39 @@ import { flushPromises, mount, VueWrapper } from '@vue/test-utils'; -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import { createRouter, createMemoryHistory } from 'vue-router'; import type { Router } from 'vue-router'; +import { setActivePinia, createPinia } from 'pinia'; +import { faker } from '@faker-js/faker'; import AvatarPartial from '@partials/AvatarPartial.vue'; import SideNavPartial from '@partials/SideNavPartial.vue'; +import type { SocialResponse, ApiResponse } from '@api/response/index.ts'; + +const social: SocialResponse[] = [ + { + uuid: faker.string.uuid(), + name: 'github', + handle: faker.internet.userName(), + url: faker.internet.url(), + description: faker.lorem.words(2), + }, + { + uuid: faker.string.uuid(), + name: 'linkedin', + handle: faker.internet.userName(), + url: faker.internet.url(), + description: faker.lorem.words(2), + }, + { + uuid: faker.string.uuid(), + name: 'x', + handle: faker.internet.userName(), + url: faker.internet.url(), + description: faker.lorem.words(2), + }, +]; + +const getSocial = vi.fn<[], Promise>>(() => Promise.resolve({ version: '1.0.0', data: social })); +vi.mock('@api/store.ts', () => ({ useApiStore: () => ({ getSocial }) })); const routes = [ { path: '/', name: 'home', component: { template: '
' } }, @@ -24,7 +54,8 @@ function createTestRouter(initialPath: string): Router { async function mountSideNavAt(initialPath: string): Promise { const router = createTestRouter(initialPath); - const wrapper = mount(SideNavPartial, { global: { plugins: [router] } }); + const pinia = createPinia(); + const wrapper = mount(SideNavPartial, { global: { plugins: [router, pinia] } }); await router.isReady(); await flushPromises(); @@ -33,6 +64,9 @@ async function mountSideNavAt(initialPath: string): Promise { } describe('SideNavPartial', () => { + beforeEach(() => { + setActivePinia(createPinia()); + }); it('hides the avatar on the home route', async () => { const wrapper = await mountSideNavAt('/'); @@ -48,4 +82,20 @@ describe('SideNavPartial', () => { wrapper.unmount(); }); + + it('renders social links beneath the menu separated by a hyphen', 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 + + wrapper.unmount(); + }); }); diff --git a/tests/support/lazy-loading.test.ts b/tests/support/lazy-loading.test.ts index a7bd8ad1..dae03751 100644 --- a/tests/support/lazy-loading.test.ts +++ b/tests/support/lazy-loading.test.ts @@ -172,7 +172,7 @@ describe('lazyLinkDirective', () => { it('reinitialises listeners when href changes during updates', async () => { const observers = installMockIntersectionObserver(); - const idle = installIdleCallback({ immediate: true }); + const _idle = installIdleCallback({ immediate: true }); await withDirective(async (directive, router) => { const element = document.createElement('a'); diff --git a/tests/support/social.test.ts b/tests/support/social.test.ts new file mode 100644 index 00000000..791a1278 --- /dev/null +++ b/tests/support/social.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it, beforeEach, vi } from 'vitest'; +import { Social } from '@/support/social.ts'; +import type { SocialResponse } from '@api/response'; + +const { getSocialMock, debugErrorMock } = vi.hoisted(() => ({ + getSocialMock: vi.fn(), + debugErrorMock: vi.fn(), +})); + +vi.mock('@api/store.ts', () => ({ + useApiStore: () => ({ + getSocial: getSocialMock, + }), +})); + +vi.mock('@api/http-error.ts', async () => { + const actual = await vi.importActual('@api/http-error.ts'); + return { + ...actual, + debugError: debugErrorMock, + }; +}); + +const sampleSocialResponse: SocialResponse[] = [ + { uuid: '1', name: 'x', handle: '@user', url: 'https://x.com/user', description: 'micro posts' }, + { uuid: '2', name: 'github', handle: 'user', url: 'https://github.com/user', description: 'repos' }, +]; + +describe('Social.fetch', () => { + beforeEach(() => { + getSocialMock.mockReset(); + debugErrorMock.mockReset(); + }); + + it('returns API data when available', async () => { + getSocialMock.mockResolvedValueOnce({ data: sampleSocialResponse }); + + const social = new Social(); + const result = await social.fetch(); + + expect(getSocialMock).toHaveBeenCalledTimes(1); + expect(result).toEqual(sampleSocialResponse); + }); + + it('falls back to an empty list when the response lacks data', async () => { + getSocialMock.mockResolvedValueOnce({ data: undefined }); + + const social = new Social(); + const result = await social.fetch(); + + expect(result).toEqual([]); + expect(debugErrorMock).not.toHaveBeenCalled(); + }); + + it('logs errors and returns an empty list when the request fails', async () => { + const error = new Error('network failed'); + getSocialMock.mockRejectedValueOnce(error); + + const social = new Social(); + const result = await social.fetch(); + + expect(result).toEqual([]); + expect(debugErrorMock).toHaveBeenCalledWith(error); + }); +}); + +describe('Social.buildNavLinks', () => { + it('ignores unknown platforms and uses provided allowlist', () => { + const socialEntries: SocialResponse[] = [ + { uuid: '1', name: 'x', handle: '@user', url: 'https://x.com/user', description: 'micro posts' }, + { uuid: '2', name: 'github', handle: 'user', url: 'https://github.com/user', description: 'repos' }, + { uuid: '3', name: 'facebook', handle: 'user', url: 'https://fb.com/user', description: 'legacy' }, + ]; + + const social = new Social(); + const links = social.buildNavLinks(socialEntries, ['github']); + + expect(links).toHaveLength(1); + expect(links[0]).toMatchObject({ + href: 'https://github.com/user', + label: 'Code & Projects', + }); + expect(links[0].icon).toBeTruthy(); + }); + + it('defaults to all configured platforms when allowlist is omitted', () => { + const socialEntries: SocialResponse[] = [ + { uuid: '1', name: 'x', handle: '@user', url: 'https://x.com/user', description: 'micro posts' }, + { uuid: '2', name: 'linkedin', handle: 'user', url: 'https://linkedin.com/in/user', description: 'work' }, + ]; + + const social = new Social(); + const links = social.buildNavLinks(socialEntries); + + expect(links.map((link) => link.label)).toEqual(['Latest Updates', 'Professional Profile']); + }); +}); diff --git a/tests/support/tags.test.ts b/tests/support/tags.test.ts new file mode 100644 index 00000000..c789c96b --- /dev/null +++ b/tests/support/tags.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; +import { Tags, type TagSummaryState } from '@/support/tags.ts'; + +const baseSummaryState: TagSummaryState = { + isLoading: false, + hasError: false, + postCount: 0, +}; + +describe('Tags.normalizeParam', () => { + it('returns trimmed strings as-is', () => { + expect(Tags.normalizeParam(' vue ')).toBe('vue'); + }); + + it('returns the first trimmed string when provided an array parameter', () => { + expect(Tags.normalizeParam([' php ', 'extra'])).toBe('php'); + }); + + it('returns an empty string for arrays without string values', () => { + expect(Tags.normalizeParam([123])).toBe(''); + }); + + it('returns an empty string for unsupported types', () => { + expect(Tags.normalizeParam(undefined)).toBe(''); + expect(Tags.normalizeParam({})).toBe(''); + }); +}); + +describe('Tags.formatLabel', () => { + it('returns the default label when the tag is empty', () => { + expect(Tags.formatLabel('')).toBe('#TAG'); + expect(Tags.formatLabel(null)).toBe('#TAG'); + }); + + it('formats the tag as uppercase with a leading #', () => { + expect(Tags.formatLabel(' vue ')).toBe('#VUE'); + }); +}); + +describe('Tags.routeFor', () => { + it('creates a router location pointing to TagPosts', () => { + expect(Tags.routeFor('vue')).toEqual({ + name: 'TagPosts', + params: { tag: 'vue' }, + }); + }); +}); + +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.'); + }); + + it('describes loading state when awaiting posts', () => { + expect( + Tags.summaryFor('vue', { + ...baseSummaryState, + isLoading: true, + }), + ).toBe('Loading posts for #VUE…'); + }); + + it('reports failures when the API request fails', () => { + expect( + Tags.summaryFor('vue', { + ...baseSummaryState, + hasError: true, + }), + ).toBe("We couldn't load posts for #VUE."); + }); + + it('mentions when no posts were found', () => { + expect( + Tags.summaryFor('vue', { + ...baseSummaryState, + }), + ).toBe('No posts found for #VUE.'); + }); + + it('handles singular and plural post counts', () => { + expect( + Tags.summaryFor('vue', { + ...baseSummaryState, + postCount: 1, + }), + ).toBe('1 post found for #VUE.'); + + expect( + Tags.summaryFor('vue', { + ...baseSummaryState, + postCount: 3, + }), + ).toBe('3 posts found for #VUE.'); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 51a9f087..0a132500 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,7 +26,8 @@ "@public/*": ["./src/public/*"], "@fonts/*": ["./src/fonts/*"], "@stores/*": ["./src/stores/*"], - "@api/*": ["./src/stores/api/*"] + "@api/*": ["./src/stores/api/*"], + "@support/*": ["./src/support/*"] }, "lib": ["esnext", "dom", "dom.iterable", "scripthost"] },