diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 2cf5d6a7..365bbb14 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -2,10 +2,11 @@ name: Unit Tests
on:
pull_request:
- types: [opened, reopened, ready_for_review, synchronize]
+ types: [opened, reopened, ready_for_review, synchronize, labeled]
jobs:
vitest:
+ if: ${{ github.event.pull_request.draft == false || contains(github.event.pull_request.labels.*.name, 'testing') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
diff --git a/package-lock.json b/package-lock.json
index 0636fb25..2cb1f141 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -26,8 +26,11 @@
"@typescript-eslint/eslint-plugin": "^8.38.0",
"@typescript-eslint/parser": "^8.38.0",
"@vitejs/plugin-vue": "^5.2.1",
- "@vitest/coverage-v8": "^3.2.0",
- "eslint": "^9.31.0",
+ "@vitest/coverage-v8": "^3.2.0",
+ "@faker-js/faker": "^8.4.0",
+ "jsdom": "^24.0.0",
+ "@vue/test-utils": "^2.4.1",
+ "eslint": "^9.31.0",
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-vue": "^10.3.0",
"globals": "^16.3.0",
diff --git a/package.json b/package.json
index d6d8b8f2..37a39d49 100644
--- a/package.json
+++ b/package.json
@@ -29,9 +29,12 @@
"@types/node": "^22.14.1",
"@typescript-eslint/eslint-plugin": "^8.38.0",
"@typescript-eslint/parser": "^8.38.0",
- "@vitejs/plugin-vue": "^5.2.1",
- "@vitest/coverage-v8": "^3.2.0",
- "eslint": "^9.31.0",
+ "@vitejs/plugin-vue": "^5.2.1",
+ "@vitest/coverage-v8": "^3.2.0",
+ "@faker-js/faker": "^8.4.0",
+ "jsdom": "^24.0.0",
+ "@vue/test-utils": "^2.4.1",
+ "eslint": "^9.31.0",
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-vue": "^10.3.0",
"globals": "^16.3.0",
diff --git a/tests/pages/AboutPage.test.ts b/tests/pages/AboutPage.test.ts
new file mode 100644
index 00000000..03077f1c
--- /dev/null
+++ b/tests/pages/AboutPage.test.ts
@@ -0,0 +1,69 @@
+import { mount, flushPromises } from '@vue/test-utils';
+import { faker } from '@faker-js/faker';
+import { describe, it, expect, vi } from 'vitest';
+import AboutPage from '@pages/AboutPage.vue';
+import type { ProfileResponse, ProfileSkillResponse } from '@api/response/index.ts';
+
+const skills: ProfileSkillResponse[] = [
+ {
+ uuid: faker.string.uuid(),
+ percentage: faker.number.int({ min: 1, max: 100 }),
+ item: faker.lorem.word(),
+ description: faker.lorem.sentence(),
+ },
+];
+
+const profile: ProfileResponse = {
+ nickname: faker.word.words(1).toLowerCase(),
+ handle: faker.internet.userName(),
+ name: faker.person.fullName(),
+ email: faker.internet.email(),
+ profession: faker.person.jobTitle(),
+ skills,
+};
+
+const getProfile = vi.fn<[], Promise<{ data: ProfileResponse }>>(() =>
+ Promise.resolve({ data: profile })
+);
+
+vi.mock('@api/store.ts', () => ({ useApiStore: () => ({ getProfile }) }));
+vi.mock('@api/http-error.ts', () => ({ debugError: vi.fn() }));
+
+describe('AboutPage', () => {
+ it('shows formatted nickname', async () => {
+ const wrapper = mount(AboutPage, {
+ global: {
+ stubs: {
+ SideNavPartial: true,
+ HeaderPartial: true,
+ WidgetSocialPartial: true,
+ WidgetSkillsPartial: true,
+ FooterPartial: true,
+ },
+ },
+ });
+ await flushPromises();
+ const formatted = profile.nickname.charAt(0).toUpperCase() + profile.nickname.slice(1);
+ expect(getProfile).toHaveBeenCalled();
+ expect(wrapper.find('h1').text()).toContain(formatted);
+ });
+
+ it('handles profile errors gracefully', async () => {
+ const error = new Error('fail');
+ getProfile.mockRejectedValueOnce(error);
+ const wrapper = mount(AboutPage, {
+ global: {
+ stubs: {
+ SideNavPartial: true,
+ HeaderPartial: true,
+ WidgetSocialPartial: true,
+ WidgetSkillsPartial: true,
+ FooterPartial: true,
+ },
+ },
+ });
+ await flushPromises();
+ const { debugError } = await import('@api/http-error.ts');
+ expect(debugError).toHaveBeenCalledWith(error);
+ });
+});
diff --git a/tests/pages/HomePage.test.ts b/tests/pages/HomePage.test.ts
new file mode 100644
index 00000000..c659099b
--- /dev/null
+++ b/tests/pages/HomePage.test.ts
@@ -0,0 +1,76 @@
+import { mount, flushPromises } from '@vue/test-utils';
+import { faker } from '@faker-js/faker';
+import { describe, it, expect, vi } from 'vitest';
+import HomePage from '@pages/HomePage.vue';
+import type { ProfileResponse, ProfileSkillResponse } from '@api/response/index.ts';
+
+const skills: ProfileSkillResponse[] = [
+ {
+ uuid: faker.string.uuid(),
+ percentage: faker.number.int({ min: 1, max: 100 }),
+ item: faker.lorem.word(),
+ description: faker.lorem.sentence(),
+ },
+];
+
+const profile: ProfileResponse = {
+ nickname: faker.person.firstName(),
+ handle: faker.internet.userName(),
+ name: faker.person.fullName(),
+ email: faker.internet.email(),
+ profession: faker.person.jobTitle(),
+ skills,
+};
+
+const getProfile = vi.fn<[], Promise<{ data: ProfileResponse }>>(() =>
+ Promise.resolve({ data: profile })
+);
+
+vi.mock('@api/store.ts', () => ({ useApiStore: () => ({ getProfile }) }));
+vi.mock('@api/http-error.ts', () => ({ debugError: vi.fn() }));
+
+describe('HomePage', () => {
+ it('loads profile on mount', async () => {
+ const wrapper = mount(HomePage, {
+ global: {
+ stubs: {
+ SideNavPartial: true,
+ HeaderPartial: true,
+ HeroPartial: true,
+ FooterPartial: true,
+ ArticlesListPartial: true,
+ FeaturedProjectsPartial: true,
+ TalksPartial: true,
+ WidgetSponsorPartial: true,
+ WidgetSkillsPartial: { template: '
', props: ['skills'] },
+ },
+ },
+ });
+ await flushPromises();
+ expect(getProfile).toHaveBeenCalledTimes(1);
+ expect(wrapper.find('.skills').exists()).toBe(true);
+ });
+
+ it('handles profile load errors', async () => {
+ const error = new Error('oops');
+ getProfile.mockRejectedValueOnce(error);
+ const wrapper = mount(HomePage, {
+ global: {
+ stubs: {
+ SideNavPartial: true,
+ HeaderPartial: true,
+ HeroPartial: true,
+ FooterPartial: true,
+ ArticlesListPartial: true,
+ FeaturedProjectsPartial: true,
+ TalksPartial: true,
+ WidgetSponsorPartial: true,
+ WidgetSkillsPartial: true,
+ },
+ },
+ });
+ 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
new file mode 100644
index 00000000..82672e22
--- /dev/null
+++ b/tests/pages/PostPage.test.ts
@@ -0,0 +1,101 @@
+import { mount, flushPromises } from '@vue/test-utils';
+import { faker } from '@faker-js/faker';
+import { describe, it, expect, vi } from 'vitest';
+import { ref } from 'vue';
+import PostPage from '@pages/PostPage.vue';
+import type { PostResponse } from '@api/response/index.ts';
+
+const post: PostResponse = {
+ uuid: faker.string.uuid(),
+ slug: faker.lorem.slug(),
+ title: faker.lorem.words(2),
+ excerpt: faker.lorem.sentence(),
+ content: faker.lorem.paragraph(),
+ cover_image_url: faker.image.url(),
+ published_at: faker.date.past().toISOString(),
+ created_at: faker.date.past().toISOString(),
+ updated_at: faker.date.recent().toISOString(),
+ author: {
+ uuid: faker.string.uuid(),
+ first_name: faker.person.firstName(),
+ last_name: faker.person.lastName(),
+ username: faker.internet.userName(),
+ display_name: faker.person.fullName(),
+ bio: faker.lorem.sentence(),
+ picture_file_name: faker.system.fileName(),
+ profile_picture_url: faker.image.avatar(),
+ },
+ categories: [],
+ tags: [],
+};
+
+const getPost = vi.fn<[], Promise>(() => Promise.resolve(post));
+
+vi.mock('@api/store.ts', () => ({ useApiStore: () => ({ getPost }) }));
+vi.mock('vue-router', () => ({ useRoute: () => ({ params: { slug: post.slug } }) }));
+vi.mock('marked', () => ({ marked: { use: vi.fn(), parse: vi.fn(() => '') } }));
+vi.mock('dompurify', () => ({ default: { sanitize: vi.fn((html: string) => html) } }));
+vi.mock('highlight.js', () => ({ default: { highlightElement: vi.fn() } }));
+vi.mock('@/dark-mode.ts', () => ({ useDarkMode: () => ({ isDark: ref(false) }) }));
+vi.mock('@api/http-error.ts', () => ({ debugError: vi.fn() }));
+
+describe('PostPage', () => {
+ it('fetches post on mount', async () => {
+ const wrapper = mount(PostPage, {
+ global: {
+ stubs: {
+ SideNavPartial: true,
+ HeaderPartial: true,
+ FooterPartial: true,
+ WidgetSponsorPartial: true,
+ WidgetSkillsPartial: true,
+ RouterLink: { template: '' },
+ },
+ },
+ });
+ await flushPromises();
+ expect(getPost).toHaveBeenCalledWith(post.slug);
+ expect(wrapper.text()).toContain(post.title);
+ });
+
+ it('processes markdown content', async () => {
+ const { marked } = await import('marked');
+ const DOMPurify = await import('dompurify');
+ const wrapper = mount(PostPage, {
+ global: {
+ stubs: {
+ SideNavPartial: true,
+ HeaderPartial: true,
+ FooterPartial: true,
+ WidgetSponsorPartial: true,
+ WidgetSkillsPartial: true,
+ RouterLink: { template: '' },
+ },
+ },
+ });
+ await flushPromises();
+ expect(marked.parse).toHaveBeenCalledWith(post.content);
+ expect(DOMPurify.default.sanitize).toHaveBeenCalled();
+ expect(wrapper.html()).toContain('');
+ });
+
+ it('handles post errors gracefully', async () => {
+ const error = new Error('fail');
+ getPost.mockRejectedValueOnce(error);
+ const wrapper = mount(PostPage, {
+ global: {
+ stubs: {
+ SideNavPartial: true,
+ HeaderPartial: true,
+ FooterPartial: true,
+ WidgetSponsorPartial: true,
+ WidgetSkillsPartial: true,
+ RouterLink: { template: '' },
+ },
+ },
+ });
+ await flushPromises();
+ const { debugError } = await import('@api/http-error.ts');
+ expect(debugError).toHaveBeenCalledWith(error);
+ });
+});
diff --git a/tests/pages/ProjectsPage.test.ts b/tests/pages/ProjectsPage.test.ts
new file mode 100644
index 00000000..b6ace169
--- /dev/null
+++ b/tests/pages/ProjectsPage.test.ts
@@ -0,0 +1,90 @@
+import { mount, flushPromises } from '@vue/test-utils';
+import { faker } from '@faker-js/faker';
+import { describe, it, expect, vi } from 'vitest';
+import ProjectsPage from '@pages/ProjectsPage.vue';
+import type { ProfileResponse, ProfileSkillResponse, ProjectsResponse } from '@api/response/index.ts';
+
+const skills: ProfileSkillResponse[] = [
+ {
+ uuid: faker.string.uuid(),
+ percentage: faker.number.int({ min: 1, max: 100 }),
+ item: faker.lorem.word(),
+ description: faker.lorem.sentence(),
+ },
+];
+
+const profile: ProfileResponse = {
+ nickname: faker.person.firstName(),
+ handle: faker.internet.userName(),
+ name: faker.person.fullName(),
+ email: faker.internet.email(),
+ profession: faker.person.jobTitle(),
+ skills,
+};
+
+const projects: ProjectsResponse[] = [
+ {
+ uuid: faker.string.uuid(),
+ title: faker.lorem.words(2),
+ excerpt: faker.lorem.sentence(),
+ url: faker.internet.url(),
+ language: faker.lorem.word(),
+ icon: faker.image.avatarGitHub(),
+ is_open_source: true,
+ created_at: faker.date.past().toISOString(),
+ updated_at: faker.date.recent().toISOString(),
+ },
+];
+
+const getProfile = vi.fn<[], Promise<{ data: ProfileResponse }>>(() =>
+ Promise.resolve({ data: profile })
+);
+const getProjects = vi.fn<[], Promise<{ version: string; data: ProjectsResponse[] }>>(() =>
+ Promise.resolve({ version: '1.0.0', data: projects })
+);
+
+vi.mock('@api/store.ts', () => ({ useApiStore: () => ({ getProfile, getProjects }) }));
+vi.mock('@api/http-error.ts', () => ({ debugError: vi.fn() }));
+
+describe('ProjectsPage', () => {
+ it('loads profile and projects', async () => {
+ const wrapper = mount(ProjectsPage, {
+ global: {
+ stubs: {
+ SideNavPartial: true,
+ HeaderPartial: true,
+ WidgetSponsorPartial: true,
+ WidgetSkillsPartial: true,
+ FooterPartial: true,
+ ProjectCardPartial: { template: '{{ item.title }}
', props: ['item'] },
+ },
+ },
+ });
+ await flushPromises();
+ expect(getProfile).toHaveBeenCalled();
+ expect(getProjects).toHaveBeenCalled();
+ const items = wrapper.findAll('.project');
+ expect(items).toHaveLength(projects.length);
+ expect(wrapper.text()).toContain(projects[0].title);
+ });
+
+ it('handles API errors', async () => {
+ const error = new Error('oops');
+ getProfile.mockRejectedValueOnce(error);
+ const wrapper = mount(ProjectsPage, {
+ global: {
+ stubs: {
+ SideNavPartial: true,
+ HeaderPartial: true,
+ WidgetSponsorPartial: true,
+ WidgetSkillsPartial: true,
+ FooterPartial: true,
+ ProjectCardPartial: true,
+ },
+ },
+ });
+ await flushPromises();
+ const { debugError } = await import('@api/http-error.ts');
+ expect(debugError).toHaveBeenCalledWith(error);
+ });
+});
diff --git a/tests/pages/ResumePage.test.ts b/tests/pages/ResumePage.test.ts
new file mode 100644
index 00000000..dd30400c
--- /dev/null
+++ b/tests/pages/ResumePage.test.ts
@@ -0,0 +1,113 @@
+import { mount, flushPromises } from '@vue/test-utils';
+import { faker } from '@faker-js/faker';
+import { describe, it, expect, vi } from 'vitest';
+import ResumePage from '@pages/ResumePage.vue';
+import type { ProfileResponse, ProfileSkillResponse, EducationResponse, ExperienceResponse, RecommendationsResponse } from '@api/response/index.ts';
+
+const skills: ProfileSkillResponse[] = [
+ { uuid: faker.string.uuid(), percentage: 50, item: faker.lorem.word(), description: faker.lorem.sentence() },
+];
+const profile: ProfileResponse = {
+ nickname: faker.person.firstName(),
+ handle: faker.internet.userName(),
+ name: faker.person.fullName(),
+ email: faker.internet.email(),
+ profession: faker.person.jobTitle(),
+ skills,
+};
+const education: EducationResponse[] = [
+ {
+ uuid: faker.string.uuid(),
+ icon: faker.image.avatarGitHub(),
+ school: faker.company.name(),
+ degree: faker.word.words(1),
+ field: faker.lorem.word(),
+ description: faker.lorem.sentence(),
+ graduated_at: '2020',
+ issuing_country: faker.location.country(),
+ },
+];
+const experience: ExperienceResponse[] = [
+ {
+ uuid: faker.string.uuid(),
+ company: faker.company.name(),
+ employment_type: 'full-time',
+ location_type: 'remote',
+ position: faker.person.jobTitle(),
+ start_date: '2020',
+ end_date: '2021',
+ summary: faker.lorem.sentence(),
+ country: faker.location.country(),
+ city: faker.location.city(),
+ skills: faker.lorem.word(),
+ },
+];
+const recommendations: RecommendationsResponse[] = [
+ {
+ uuid: faker.string.uuid(),
+ relation: 'friend',
+ text: faker.lorem.sentence(),
+ created_at: faker.date.past().toISOString(),
+ person: {
+ avatar: faker.image.avatar(),
+ full_name: faker.person.fullName(),
+ company: faker.company.name(),
+ designation: faker.person.jobTitle(),
+ },
+ },
+];
+
+const getProfile = vi.fn<[], Promise<{ data: ProfileResponse }>>(() => Promise.resolve({ data: profile }));
+const getExperience = vi.fn<[], Promise<{ version: string; data: ExperienceResponse[] }>>(() => Promise.resolve({ version: '1.0.0', data: experience }));
+const getRecommendations = vi.fn<[], Promise<{ version: string; data: RecommendationsResponse[] }>>(() => Promise.resolve({ version: '1.0.0', data: recommendations }));
+const getEducation = vi.fn<[], Promise<{ version: string; data: EducationResponse[] }>>(() => Promise.resolve({ version: '1.0.0', data: education }));
+
+vi.mock('@api/store.ts', () => ({ useApiStore: () => ({ getProfile, getExperience, getRecommendations, getEducation }) }));
+vi.mock('@api/http-error.ts', () => ({ debugError: vi.fn() }));
+
+describe('ResumePage', () => {
+ it('fetches data on mount', async () => {
+ const wrapper = mount(ResumePage, {
+ global: {
+ stubs: {
+ SideNavPartial: true,
+ HeaderPartial: true,
+ FooterPartial: true,
+ WidgetLangPartial: true,
+ WidgetSkillsPartial: true,
+ EducationPartial: true,
+ ExperiencePartial: true,
+ RecommendationPartial: true,
+ },
+ },
+ });
+ await flushPromises();
+ expect(getProfile).toHaveBeenCalled();
+ expect(getExperience).toHaveBeenCalled();
+ expect(getRecommendations).toHaveBeenCalled();
+ expect(getEducation).toHaveBeenCalled();
+ expect(wrapper.find('h1').text()).toContain('My resume');
+ });
+
+ it('handles fetch failures', async () => {
+ const error = new Error('oops');
+ getProfile.mockRejectedValueOnce(error);
+ const wrapper = mount(ResumePage, {
+ global: {
+ stubs: {
+ SideNavPartial: true,
+ HeaderPartial: true,
+ FooterPartial: true,
+ WidgetLangPartial: true,
+ WidgetSkillsPartial: true,
+ EducationPartial: true,
+ ExperiencePartial: true,
+ RecommendationPartial: true,
+ },
+ },
+ });
+ await flushPromises();
+ const { debugError } = await import('@api/http-error.ts');
+ expect(debugError).toHaveBeenCalledWith(error);
+ });
+});
diff --git a/tests/pages/SubscribePage.test.ts b/tests/pages/SubscribePage.test.ts
new file mode 100644
index 00000000..9ddb9989
--- /dev/null
+++ b/tests/pages/SubscribePage.test.ts
@@ -0,0 +1,19 @@
+import { mount } from '@vue/test-utils';
+import { describe, it, expect } from 'vitest';
+import SubscribePage from '@pages/SubscribePage.vue';
+
+describe('SubscribePage', () => {
+ it('renders heading', () => {
+ const wrapper = mount(SubscribePage, {
+ global: {
+ stubs: {
+ SideNavPartial: true,
+ HeaderPartial: true,
+ WidgetSponsorPartial: true,
+ FooterPartial: true,
+ },
+ },
+ });
+ expect(wrapper.find('h1').text()).toContain('Never miss an update');
+ });
+});
diff --git a/tests/partials/ArticleItemPartial.test.ts b/tests/partials/ArticleItemPartial.test.ts
new file mode 100644
index 00000000..02ef5464
--- /dev/null
+++ b/tests/partials/ArticleItemPartial.test.ts
@@ -0,0 +1,42 @@
+import { mount } from '@vue/test-utils';
+import { faker } from '@faker-js/faker';
+import { describe, it, expect, vi } from 'vitest';
+import ArticleItemPartial from '@partials/ArticleItemPartial.vue';
+import type { PostResponse } from '@api/response/index.ts';
+
+vi.mock('@/public.ts', () => ({
+ date: () => ({ format: () => 'formatted' }),
+}));
+
+describe('ArticleItemPartial', () => {
+ const item: PostResponse = {
+ uuid: faker.string.uuid(),
+ slug: faker.lorem.slug(),
+ title: faker.lorem.words(2),
+ excerpt: faker.lorem.sentence(),
+ content: faker.lorem.paragraph(),
+ cover_image_url: faker.image.url(),
+ published_at: faker.date.past().toISOString(),
+ created_at: faker.date.past().toISOString(),
+ updated_at: faker.date.recent().toISOString(),
+ author: {
+ uuid: faker.string.uuid(),
+ first_name: faker.person.firstName(),
+ last_name: faker.person.lastName(),
+ username: faker.internet.userName(),
+ display_name: faker.person.fullName(),
+ bio: faker.lorem.sentence(),
+ picture_file_name: faker.system.fileName(),
+ profile_picture_url: faker.image.url(),
+ },
+ categories: [],
+ tags: [],
+ };
+
+ it('renders item information', () => {
+ const wrapper = mount(ArticleItemPartial, { props: { item } });
+ expect(wrapper.text()).toContain('formatted');
+ expect(wrapper.text()).toContain(item.title);
+ expect(wrapper.find('img').attributes('src')).toBe(item.cover_image_url);
+ });
+});
diff --git a/tests/partials/ArticlesListPartial.test.ts b/tests/partials/ArticlesListPartial.test.ts
new file mode 100644
index 00000000..5caec505
--- /dev/null
+++ b/tests/partials/ArticlesListPartial.test.ts
@@ -0,0 +1,105 @@
+import { mount, flushPromises } from '@vue/test-utils';
+import { faker } from '@faker-js/faker';
+import { describe, it, expect, vi } from 'vitest';
+import ArticlesListPartial from '@partials/ArticlesListPartial.vue';
+import type {
+ PostResponse,
+ PostsAuthorResponse,
+ PostsCategoryResponse,
+ PostsTagResponse,
+ PostsCollectionResponse,
+ CategoryResponse,
+ CategoriesCollectionResponse,
+} from '@api/response/index.ts';
+
+const author: PostsAuthorResponse = {
+ uuid: faker.string.uuid(),
+ first_name: faker.person.firstName(),
+ last_name: faker.person.lastName(),
+ username: faker.internet.userName(),
+ display_name: faker.person.fullName(),
+ bio: faker.lorem.sentence(),
+ picture_file_name: faker.system.fileName(),
+ profile_picture_url: faker.image.url(),
+};
+
+const postCategory: PostsCategoryResponse = {
+ uuid: faker.string.uuid(),
+ name: faker.lorem.word(),
+ slug: faker.lorem.slug(),
+ description: faker.lorem.sentence(),
+};
+
+const postTag: PostsTagResponse = {
+ uuid: faker.string.uuid(),
+ name: faker.lorem.word(),
+ description: faker.lorem.sentence(),
+};
+
+const posts: PostResponse[] = [
+ {
+ uuid: faker.string.uuid(),
+ slug: faker.lorem.slug(),
+ title: faker.lorem.words(2),
+ excerpt: faker.lorem.sentence(),
+ content: faker.lorem.paragraph(),
+ cover_image_url: faker.image.url(),
+ published_at: faker.date.past().toISOString(),
+ created_at: faker.date.past().toISOString(),
+ updated_at: faker.date.recent().toISOString(),
+ author,
+ categories: [postCategory],
+ tags: [postTag],
+ },
+];
+const categories: CategoryResponse[] = [
+ {
+ uuid: faker.string.uuid(),
+ slug: 'all',
+ name: 'All',
+ description: faker.lorem.sentence(),
+ },
+];
+
+const postsCollection: PostsCollectionResponse = {
+ page: 1,
+ total: posts.length,
+ page_size: 5,
+ total_pages: 1,
+ data: posts,
+};
+
+const categoriesCollection: CategoriesCollectionResponse = {
+ page: 1,
+ total: categories.length,
+ page_size: 5,
+ total_pages: 1,
+ data: categories,
+};
+
+const getPosts = vi.fn<[], Promise>(() =>
+ Promise.resolve(postsCollection),
+);
+const getCategories = vi.fn<[], Promise>(() =>
+ Promise.resolve(categoriesCollection),
+);
+
+vi.mock('@api/store.ts', () => ({
+ useApiStore: () => ({
+ getPosts,
+ getCategories,
+ searchTerm: '',
+ }),
+}));
+
+describe('ArticlesListPartial', () => {
+ it('loads posts on mount', async () => {
+ const wrapper = mount(ArticlesListPartial);
+ await flushPromises();
+ expect(getCategories).toHaveBeenCalled();
+ expect(getPosts).toHaveBeenCalled();
+ const items = wrapper.findAllComponents({ name: 'ArticleItemPartial' });
+ expect(items).toHaveLength(1);
+ expect(wrapper.text()).toContain(posts[0].title);
+ });
+});
diff --git a/tests/partials/AvatarPartial.test.ts b/tests/partials/AvatarPartial.test.ts
new file mode 100644
index 00000000..178b97e5
--- /dev/null
+++ b/tests/partials/AvatarPartial.test.ts
@@ -0,0 +1,22 @@
+import { mount } from '@vue/test-utils';
+import { faker } from '@faker-js/faker';
+import { describe, it, expect } from 'vitest';
+import AvatarPartial from '@partials/AvatarPartial.vue';
+
+describe('AvatarPartial', () => {
+ it('applies default size classes', () => {
+ const wrapper = mount(AvatarPartial);
+ const img = wrapper.find('img');
+ expect(img.classes()).toContain('w-20');
+ expect(img.classes()).toContain('h-20');
+ });
+
+ it('accepts custom size classes', () => {
+ const width: string = `w-${faker.number.int({ min: 5, max: 20 })}`;
+ const height: string = `h-${faker.number.int({ min: 5, max: 20 })}`;
+ const wrapper = mount(AvatarPartial, { props: { width, height } });
+ const img = wrapper.find('img');
+ expect(img.classes()).toContain(width);
+ expect(img.classes()).toContain(height);
+ });
+});
diff --git a/tests/partials/EducationPartial.test.ts b/tests/partials/EducationPartial.test.ts
new file mode 100644
index 00000000..f5df47fc
--- /dev/null
+++ b/tests/partials/EducationPartial.test.ts
@@ -0,0 +1,25 @@
+import { mount } from '@vue/test-utils';
+import { faker } from '@faker-js/faker';
+import { describe, it, expect } from 'vitest';
+import EducationPartial from '@partials/EducationPartial.vue';
+import type { EducationResponse } from '@api/response/index.ts';
+
+const education: EducationResponse[] = [
+ {
+ uuid: faker.string.uuid(),
+ degree: faker.word.words(1),
+ school: faker.company.name(),
+ graduated_at: '2020',
+ description: '**hi**',
+ icon: faker.image.avatarGitHub(),
+ field: faker.lorem.word(),
+ issuing_country: faker.location.country(),
+ },
+];
+
+describe('EducationPartial', () => {
+ it('renders markdown as html', () => {
+ const wrapper = mount(EducationPartial, { props: { education } });
+ expect(wrapper.html()).toContain('hi');
+ });
+});
diff --git a/tests/partials/ExperiencePartial.test.ts b/tests/partials/ExperiencePartial.test.ts
new file mode 100644
index 00000000..6e962179
--- /dev/null
+++ b/tests/partials/ExperiencePartial.test.ts
@@ -0,0 +1,30 @@
+import { mount } from '@vue/test-utils';
+import { faker } from '@faker-js/faker';
+import { describe, it, expect } from 'vitest';
+import ExperiencePartial from '@partials/ExperiencePartial.vue';
+import type { ExperienceResponse } from '@api/response/index.ts';
+
+const experience: ExperienceResponse[] = [
+ {
+ uuid: faker.string.uuid(),
+ start_date: faker.date.past().getFullYear().toString(),
+ end_date: faker.date.recent().getFullYear().toString(),
+ position: faker.person.jobTitle(),
+ company: faker.company.name(),
+ summary: faker.lorem.sentence(),
+ skills: faker.lorem.word(),
+ employment_type: faker.lorem.word(),
+ location_type: faker.lorem.word(),
+ country: faker.location.country(),
+ city: faker.location.city(),
+ },
+];
+
+describe('ExperiencePartial', () => {
+ it('renders each experience item', () => {
+ const wrapper = mount(ExperiencePartial, { props: { experience } });
+ const items = wrapper.findAll('li');
+ expect(items).toHaveLength(1);
+ expect(items[0].text()).toContain(experience[0].company);
+ });
+});
diff --git a/tests/partials/FeaturedProjectsPartial.test.ts b/tests/partials/FeaturedProjectsPartial.test.ts
new file mode 100644
index 00000000..84db143e
--- /dev/null
+++ b/tests/partials/FeaturedProjectsPartial.test.ts
@@ -0,0 +1,60 @@
+import { mount, flushPromises } from '@vue/test-utils';
+import { faker } from '@faker-js/faker';
+import { describe, it, expect, vi } from 'vitest';
+import FeaturedProjectsPartial from '@partials/FeaturedProjectsPartial.vue';
+import type { ProjectsResponse } from '@api/response/index.ts';
+
+const projects: ProjectsResponse[] = [
+ {
+ uuid: faker.string.uuid(),
+ title: faker.lorem.words(1),
+ excerpt: faker.lorem.sentence(),
+ url: faker.internet.url(),
+ language: faker.lorem.word(),
+ icon: faker.image.avatarGitHub(),
+ is_open_source: true,
+ created_at: faker.date.past().toISOString(),
+ updated_at: faker.date.recent().toISOString(),
+ },
+ {
+ uuid: faker.string.uuid(),
+ title: faker.lorem.words(1),
+ excerpt: faker.lorem.sentence(),
+ url: faker.internet.url(),
+ language: faker.lorem.word(),
+ icon: faker.image.avatarGitHub(),
+ is_open_source: true,
+ created_at: faker.date.past().toISOString(),
+ updated_at: faker.date.recent().toISOString(),
+ },
+ {
+ uuid: faker.string.uuid(),
+ title: faker.lorem.words(1),
+ excerpt: faker.lorem.sentence(),
+ url: faker.internet.url(),
+ language: faker.lorem.word(),
+ icon: faker.image.avatarGitHub(),
+ is_open_source: true,
+ created_at: faker.date.past().toISOString(),
+ updated_at: faker.date.recent().toISOString(),
+ },
+];
+const getProjects = vi.fn<[], Promise<{ version: string; data: ProjectsResponse[] }>>(
+ () => Promise.resolve({ version: '1.0.0', data: projects }),
+);
+
+vi.mock('@api/store.ts', () => ({
+ useApiStore: () => ({ getProjects }),
+}));
+
+describe('FeaturedProjectsPartial', () => {
+ it('fetches projects on mount and limits to two', async () => {
+ const wrapper = mount(FeaturedProjectsPartial);
+ await flushPromises();
+ expect(getProjects).toHaveBeenCalled();
+ const anchors = wrapper.findAll('a');
+ expect(anchors).toHaveLength(2);
+ expect(anchors[0].text()).toContain(projects[0].title);
+ expect(anchors[0].attributes('href')).toBe(projects[0].url);
+ });
+});
diff --git a/tests/partials/FooterPartial.test.ts b/tests/partials/FooterPartial.test.ts
new file mode 100644
index 00000000..b22feaa8
--- /dev/null
+++ b/tests/partials/FooterPartial.test.ts
@@ -0,0 +1,10 @@
+import { mount } from '@vue/test-utils';
+import { describe, it, expect } from 'vitest';
+import FooterPartial from '@partials/FooterPartial.vue';
+
+describe('FooterPartial', () => {
+ it('renders copyright', () => {
+ const wrapper = mount(FooterPartial);
+ expect(wrapper.text()).toContain('All rights reserved');
+ });
+});
diff --git a/tests/partials/HeaderPartial.test.ts b/tests/partials/HeaderPartial.test.ts
new file mode 100644
index 00000000..ccb0de65
--- /dev/null
+++ b/tests/partials/HeaderPartial.test.ts
@@ -0,0 +1,36 @@
+import { mount } from '@vue/test-utils';
+import { faker } from '@faker-js/faker';
+import { describe, it, expect, vi } from 'vitest';
+import HeaderPartial from '@partials/HeaderPartial.vue';
+
+const toggleDarkMode = vi.fn();
+vi.mock('@/dark-mode.ts', () => ({ useDarkMode: () => ({ toggleDarkMode }) }));
+
+const setSearchTerm = vi.fn();
+vi.mock('@api/store.ts', () => ({ useApiStore: () => ({ setSearchTerm }) }));
+
+describe('HeaderPartial', () => {
+ it('validates search length', async () => {
+ const wrapper = mount(HeaderPartial);
+ const input = wrapper.find('#search');
+ await input.setValue('abc');
+ await wrapper.find('form').trigger('submit');
+ expect(wrapper.vm.validationError).toBeDefined();
+ expect(setSearchTerm).not.toHaveBeenCalled();
+ });
+
+ it('submits valid search', async () => {
+ const wrapper = mount(HeaderPartial);
+ const query: string = faker.lorem.words(2);
+ 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');
+ expect(toggleDarkMode).toHaveBeenCalled();
+ });
+});
diff --git a/tests/partials/HeroPartial.test.ts b/tests/partials/HeroPartial.test.ts
new file mode 100644
index 00000000..c0f9c821
--- /dev/null
+++ b/tests/partials/HeroPartial.test.ts
@@ -0,0 +1,11 @@
+import { mount } from '@vue/test-utils';
+import { describe, it, expect } from 'vitest';
+import HeroPartial from '@partials/HeroPartial.vue';
+import AvatarPartial from '@partials/AvatarPartial.vue';
+
+describe('HeroPartial', () => {
+ it('renders avatar', () => {
+ const wrapper = mount(HeroPartial);
+ expect(wrapper.findComponent(AvatarPartial).exists()).toBe(true);
+ });
+});
diff --git a/tests/partials/ProjectCardPartial.test.ts b/tests/partials/ProjectCardPartial.test.ts
new file mode 100644
index 00000000..9efd3f4d
--- /dev/null
+++ b/tests/partials/ProjectCardPartial.test.ts
@@ -0,0 +1,29 @@
+import { mount } from '@vue/test-utils';
+import { faker } from '@faker-js/faker';
+import { describe, it, expect, vi } from 'vitest';
+import ProjectCardPartial from '@partials/ProjectCardPartial.vue';
+import type { ProjectsResponse } from '@api/response/index.ts';
+
+vi.mock('@/public.ts', () => ({
+ image: (p: string) => `/img/${p}`,
+ getRandomInt: () => 6,
+}));
+
+describe('ProjectCardPartial', () => {
+ const item: ProjectsResponse = {
+ uuid: faker.string.uuid(),
+ title: faker.lorem.word(),
+ excerpt: faker.lorem.sentence(),
+ url: faker.internet.url(),
+ is_open_source: false,
+ created_at: faker.date.past().toISOString(),
+ updated_at: faker.date.recent().toISOString(),
+ language: faker.lorem.word(),
+ icon: faker.image.avatarGitHub(),
+ };
+
+ it('uses random icon path', () => {
+ const wrapper = mount(ProjectCardPartial, { props: { item } });
+ expect(wrapper.find('img').attributes('src')).toBe('/img/icons/icon-06.svg');
+ });
+});
diff --git a/tests/partials/RecommendationPartial.test.ts b/tests/partials/RecommendationPartial.test.ts
new file mode 100644
index 00000000..ed8799c7
--- /dev/null
+++ b/tests/partials/RecommendationPartial.test.ts
@@ -0,0 +1,33 @@
+import { mount } from '@vue/test-utils';
+import { faker } from '@faker-js/faker';
+import { describe, it, expect, vi } from 'vitest';
+import RecommendationPartial from '@partials/RecommendationPartial.vue';
+import type { RecommendationsResponse } from '@api/response/index.ts';
+
+vi.mock('@/public.ts', () => ({
+ image: (p: string) => `/img/${p}`,
+ date: () => ({ format: () => 'now' }),
+}));
+
+describe('RecommendationPartial', () => {
+ const data: RecommendationsResponse[] = [
+ {
+ uuid: faker.string.uuid(),
+ relation: 'friend',
+ text: '**great**',
+ created_at: faker.date.past().toISOString(),
+ person: {
+ full_name: faker.person.fullName(),
+ company: faker.company.name(),
+ avatar: faker.image.avatar(),
+ designation: faker.person.jobTitle(),
+ },
+ },
+ ];
+
+ it('sanitises and formats recommendation', () => {
+ const wrapper = mount(RecommendationPartial, { props: { recommendations: data } });
+ expect(wrapper.html()).toContain('great');
+ expect(wrapper.text()).toContain('now');
+ });
+});
diff --git a/tests/partials/SideNavPartial.test.ts b/tests/partials/SideNavPartial.test.ts
new file mode 100644
index 00000000..154c1a1c
--- /dev/null
+++ b/tests/partials/SideNavPartial.test.ts
@@ -0,0 +1,21 @@
+import { mount } from '@vue/test-utils';
+import { describe, it, expect } from 'vitest';
+import { createRouter, createMemoryHistory } from 'vue-router';
+import SideNavPartial from '@partials/SideNavPartial.vue';
+
+const router = createRouter({
+ history: createMemoryHistory(),
+ routes: [
+ { path: '/', name: 'home' },
+ { path: '/about', name: 'about' },
+ ],
+});
+
+describe('SideNavPartial', () => {
+ it('detects home route', async () => {
+ router.push('/');
+ await router.isReady();
+ const wrapper = mount(SideNavPartial, { global: { plugins: [router] } });
+ expect(wrapper.html()).toContain('Home');
+ });
+});
diff --git a/tests/partials/TalksPartial.test.ts b/tests/partials/TalksPartial.test.ts
new file mode 100644
index 00000000..d7290333
--- /dev/null
+++ b/tests/partials/TalksPartial.test.ts
@@ -0,0 +1,34 @@
+import { mount, flushPromises } from '@vue/test-utils';
+import { describe, it, expect, vi } from 'vitest';
+import TalksPartial from '@partials/TalksPartial.vue';
+import type { ApiResponse, TalksResponse } from '@api/response/index.ts';
+
+const talks: TalksResponse[] = [
+ {
+ uuid: '123e4567-e89b-12d3-a456-426614174000',
+ title: 'Test Talk Title',
+ subject: 'Test Subject',
+ location: 'Test City',
+ url: '/test-talk',
+ photo: 'https://example.com/photo.jpg',
+ created_at: '2024-01-01T00:00:00Z',
+ updated_at: '2024-01-02T00:00:00Z',
+ },
+];
+const getTalks = vi.fn<[], Promise>>(() =>
+ Promise.resolve({ version: '1.0.0', data: talks }),
+);
+vi.mock('@api/store.ts', () => ({ useApiStore: () => ({ getTalks }) }));
+
+describe('TalksPartial', () => {
+ it('loads talks on mount', async () => {
+ const wrapper = mount(TalksPartial);
+ await flushPromises();
+
+ expect(getTalks).toHaveBeenCalledTimes(1);
+ const anchor = wrapper.find('a');
+ expect(anchor.exists()).toBe(true);
+ expect(anchor.attributes('href')).toBe(talks[0].url);
+ expect(anchor.text()).toContain(talks[0].title);
+ });
+});
diff --git a/tests/partials/WidgetLangPartial.test.ts b/tests/partials/WidgetLangPartial.test.ts
new file mode 100644
index 00000000..1d82201f
--- /dev/null
+++ b/tests/partials/WidgetLangPartial.test.ts
@@ -0,0 +1,12 @@
+import { mount } from '@vue/test-utils';
+import { describe, it, expect } from 'vitest';
+import WidgetLangPartial from '@partials/WidgetLangPartial.vue';
+
+describe('WidgetLangPartial', () => {
+ it('renders language list', () => {
+ const wrapper = mount(WidgetLangPartial);
+ const items = wrapper.findAll('li');
+ expect(items).toHaveLength(2);
+ expect(items[0].text()).toContain('English');
+ });
+});
diff --git a/tests/partials/WidgetSkillsPartial.test.ts b/tests/partials/WidgetSkillsPartial.test.ts
new file mode 100644
index 00000000..8eebccde
--- /dev/null
+++ b/tests/partials/WidgetSkillsPartial.test.ts
@@ -0,0 +1,29 @@
+import { mount } from '@vue/test-utils';
+import { faker } from '@faker-js/faker';
+import { nextTick } from 'vue';
+import { describe, it, expect } from 'vitest';
+import WidgetSkillsPartial from '@partials/WidgetSkillsPartial.vue';
+import type { ProfileSkillResponse } from '@api/response/index.ts';
+
+const skills: ProfileSkillResponse[] = [
+ {
+ uuid: faker.string.uuid(),
+ item: faker.lorem.word(),
+ percentage: 80,
+ description: faker.lorem.sentence(),
+ },
+];
+
+describe('WidgetSkillsPartial', () => {
+ it('shows tooltip on hover', async () => {
+ const wrapper = mount(WidgetSkillsPartial, { props: { skills } });
+ const div = wrapper.find('li div');
+ await div.trigger('mouseenter');
+ await nextTick();
+ expect(document.body.textContent).toContain(skills[0].item);
+
+ await div.trigger('mouseleave');
+ await nextTick();
+ expect(document.body.textContent).not.toContain(skills[0].item);
+ });
+});
diff --git a/tests/partials/WidgetSocialPartial.test.ts b/tests/partials/WidgetSocialPartial.test.ts
new file mode 100644
index 00000000..f7001f53
--- /dev/null
+++ b/tests/partials/WidgetSocialPartial.test.ts
@@ -0,0 +1,33 @@
+import { mount, flushPromises } from '@vue/test-utils';
+import { faker } from '@faker-js/faker';
+import { describe, it, expect, vi } from 'vitest';
+import WidgetSocialPartial from '@partials/WidgetSocialPartial.vue';
+import type { SocialResponse } 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),
+ },
+];
+import type { ApiResponse } from '@api/response/index.ts';
+
+const getSocial = vi.fn<[], Promise>>(() =>
+ Promise.resolve({ version: '1.0.0', data: social }),
+);
+vi.mock('@api/store.ts', () => ({ useApiStore: () => ({ getSocial }) }));
+
+describe('WidgetSocialPartial', () => {
+ it('fetches social links', async () => {
+ const wrapper = mount(WidgetSocialPartial);
+ await flushPromises();
+ expect(getSocial).toHaveBeenCalled();
+ const anchors = wrapper.findAll('a');
+ expect(anchors).toHaveLength(1);
+ expect(anchors[0].attributes('href')).toBe(social[0].url);
+ expect(anchors[0].text()).toContain('Follow me on GitHub');
+ });
+});
diff --git a/tests/partials/WidgetSponsorPartial.test.ts b/tests/partials/WidgetSponsorPartial.test.ts
new file mode 100644
index 00000000..ac70de51
--- /dev/null
+++ b/tests/partials/WidgetSponsorPartial.test.ts
@@ -0,0 +1,10 @@
+import { mount } from '@vue/test-utils';
+import { describe, it, expect } from 'vitest';
+import WidgetSponsorPartial from '@partials/WidgetSponsorPartial.vue';
+
+describe('WidgetSponsorPartial', () => {
+ it('renders sponsor info', () => {
+ const wrapper = mount(WidgetSponsorPartial);
+ expect(wrapper.text()).toContain('Build The Site/App You Want!');
+ });
+});
diff --git a/tests/setup.ts b/tests/setup.ts
index 31eb0f87..b295633d 100644
--- a/tests/setup.ts
+++ b/tests/setup.ts
@@ -1,3 +1,7 @@
+import { faker } from '@faker-js/faker';
+
+faker.seed(123);
+
class LocalStorageMock {
private store: Record = {};
clear() {
diff --git a/tests/stores/api/store.test.ts b/tests/stores/api/store.test.ts
index 3ff3a03a..6f0e9d91 100644
--- a/tests/stores/api/store.test.ts
+++ b/tests/stores/api/store.test.ts
@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { setActivePinia, createPinia } from 'pinia';
import { useApiStore } from '@api/store.ts';
+import type { ApiClient } from '@api/client.ts';
vi.mock('@api/http-error.ts', async () => {
const mod = await vi.importActual('@api/http-error.ts');
@@ -24,7 +25,7 @@ describe('useApiStore', () => {
setActivePinia(createPinia());
store = useApiStore();
client = new FakeClient();
- store.client = client as any;
+ store.client = client as unknown as ApiClient;
});
it('sets search term', () => {
diff --git a/vitest.config.ts b/vitest.config.ts
index 9b1fa1bd..313ab716 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -1,22 +1,28 @@
import { defineConfig } from 'vitest/config';
import aliases from './aliases';
+import vue from '@vitejs/plugin-vue';
export default defineConfig({
- resolve: {
- alias: aliases,
- },
- test: {
- environment: 'node',
+ plugins: [vue()],
+ resolve: {
+ alias: aliases,
+ },
+ test: {
+ environment: 'jsdom',
setupFiles: ['./tests/setup.ts'],
- coverage: {
- provider: 'v8',
- reporter: ['text', 'html'],
- lines: 90,
- functions: 90,
- branches: 85,
- statements: 90,
- all: true,
- include: ['src/stores/**/*.ts'],
- },
+ coverage: {
+ provider: 'v8',
+ reporter: ['text', 'html'],
+ lines: 90,
+ functions: 90,
+ branches: 85,
+ statements: 90,
+ all: true,
+ include: [
+ 'src/stores/**/*.ts',
+ 'src/partials/**/*.vue',
+ 'src/pages/**/*.vue',
+ ],
+ },
},
});