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', + ], + }, }, });