diff --git a/package.json b/package.json index ab8e6d3..a6e708e 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "lint:script": "eslint \"{src/**/*.{ts,vue},cypress/**/*.ts}\"", "lint:tsc": "vue-tsc --noEmit", "lint": "concurrently \"npm run lint:tsc\" \"npm run lint:script\"", - "test:unit": "vitest run --coverage", + "test:unit": "vitest run", "test:e2e": "npm run build && concurrently -rk -s first \"npm run serve\" \"cypress open --e2e -c baseUrl=http://localhost:4137\"", "test:e2e:local": "cypress open --e2e -c baseUrl=http://localhost:5173", "test:e2e:ci": "npm run build && concurrently -rk -s first \"npm run serve\" \"cypress run --e2e -c baseUrl=http://localhost:4137\"", @@ -29,6 +29,7 @@ "@mutoe/eslint-config-preset-vue": "~2.1.2", "@pinia/testing": "^0.1.3", "@testing-library/cypress": "^8.0.7", + "@testing-library/user-event": "^14.4.3", "@testing-library/vue": "^7.0.0", "@types/marked": "^4.0.8", "@vitejs/plugin-vue": "^4.3.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ea746c..f26871d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,6 +31,9 @@ devDependencies: '@testing-library/cypress': specifier: ^8.0.7 version: 8.0.7(cypress@11.2.0) + '@testing-library/user-event': + specifier: ^14.4.3 + version: 14.4.3(@testing-library/dom@9.3.1) '@testing-library/vue': specifier: ^7.0.0 version: 7.0.0(@vue/compiler-sfc@3.3.4)(vue@3.3.4) @@ -658,6 +661,15 @@ packages: pretty-format: 27.5.1 dev: true + /@testing-library/user-event@14.4.3(@testing-library/dom@9.3.1): + resolution: {integrity: sha512-kCUc5MEwaEMakkO5x7aoD+DLi02ehmEM2QCGWvNqAS1dV/fAvORWEjnjsEIvml59M7Y5kCkWN6fCCyPOe8OL6Q==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + dependencies: + '@testing-library/dom': 9.3.1 + dev: true + /@testing-library/vue@7.0.0(@vue/compiler-sfc@3.3.4)(vue@3.3.4): resolution: {integrity: sha512-JU/q93HGo2qdm1dCgWymkeQlfpC0/0/DBZ2nAHgEAsVZxX11xVIxT7gbXdI7HACQpUbsUWt1zABGU075Fzt9XQ==} engines: {node: '>=14'} diff --git a/src/components/ArticlesList.spec.ts b/src/components/ArticlesList.spec.ts index 89feca4..2266c57 100644 --- a/src/components/ArticlesList.spec.ts +++ b/src/components/ArticlesList.spec.ts @@ -1,8 +1,8 @@ import { render } from '@testing-library/vue' -import ArticlesList from 'src/components/ArticlesList.vue' import fixtures from 'src/utils/test/fixtures' import { asyncWrapper, renderOptions, setupMockServer } from 'src/utils/test/test.utils' import { describe, it, expect } from 'vitest' +import ArticlesList from './ArticlesList.vue' describe('# ArticlesList', () => { const server = setupMockServer( diff --git a/src/components/__snapshots__/ArticleDetail.spec.ts.snap b/src/components/__snapshots__/ArticleDetail.spec.ts.snap index c9be4e0..d44eb61 100644 --- a/src/components/__snapshots__/ArticleDetail.spec.ts.snap +++ b/src/components/__snapshots__/ArticleDetail.spec.ts.snap @@ -23,11 +23,11 @@ exports[`# ArticleDetail > should filter the xss content in Markdown body 1`] = aria-label="profile" class="" data-v-b9ba5d49="" - href="/profile/Author%20name" + href="/profile/mutoe" > Author name @@ -41,10 +41,10 @@ exports[`# ArticleDetail > should filter the xss content in Markdown body 1`] = aria-label="profile" class="author" data-v-b9ba5d49="" - href="/profile/Author%20name" + href="/profile/mutoe" > - Author name + mutoe should filter the xss content in Markdown body 1`] = aria-label="profile" class="" data-v-b9ba5d49="" - href="/profile/Author%20name" + href="/profile/mutoe" > Author name @@ -138,10 +138,10 @@ exports[`# ArticleDetail > should filter the xss content in Markdown body 1`] = aria-label="profile" class="author" data-v-b9ba5d49="" - href="/profile/Author%20name" + href="/profile/mutoe" > - Author name + mutoe should render markdown (zh-CN) body correctly 1`] = ` aria-label="profile" class="" data-v-b9ba5d49="" - href="/profile/Author%20name" + href="/profile/mutoe" > Author name @@ -220,10 +220,10 @@ exports[`# ArticleDetail > should render markdown (zh-CN) body correctly 1`] = ` aria-label="profile" class="author" data-v-b9ba5d49="" - href="/profile/Author%20name" + href="/profile/mutoe" > - Author name + mutoe Author name @@ -1557,10 +1557,10 @@ D-->>A: Dashed open arrow aria-label="profile" class="author" data-v-b9ba5d49="" - href="/profile/Author%20name" + href="/profile/mutoe" > - Author name + mutoe should render markdown body correctly 1`] = ` aria-label="profile" class="" data-v-b9ba5d49="" - href="/profile/Author%20name" + href="/profile/mutoe" > Author name @@ -1639,10 +1639,10 @@ exports[`# ArticleDetail > should render markdown body correctly 1`] = ` aria-label="profile" class="author" data-v-b9ba5d49="" - href="/profile/Author%20name" + href="/profile/mutoe" > - Author name + mutoe Author name @@ -2692,10 +2692,10 @@ Third paragraph of definition 2. aria-label="profile" class="author" data-v-b9ba5d49="" - href="/profile/Author%20name" + href="/profile/mutoe" > - Author name + mutoe { + const server = setupMockServer() + + it('should call create api when fill form and click submit button', async () => { + server.use(['POST', '/api/articles', { article: { ...fixtures.article, slug: 'article-title' } }]) + vi.spyOn(router, 'push') + const { getByRole, getByPlaceholderText } = render(EditArticle, await renderOptions({ + router, + initialRoute: '/articles', + })) + + await fireEvent.update(getByPlaceholderText('Article Title'), 'Article Title') + await fireEvent.update(getByPlaceholderText("What's this article about?"), 'Article descriptions') + await fireEvent.update(getByPlaceholderText('Write your article (in markdown)'), 'this is **article body**.') + await userEvent.type(getByPlaceholderText('Enter tags'), 'tag1{Enter}tag2{Enter}') + + await fireEvent.click(getByRole('button', { name: 'Publish Article' })) + + const mockedRequest = await server.waitForRequest('POST', '/api/articles') + + expect(router.push).toHaveBeenCalledWith({ name: 'article', params: { slug: 'article-title' } }) + expect(await mockedRequest.json()).toMatchInlineSnapshot(` + { + "article": { + "body": "this is **article body**.", + "description": "Article descriptions", + "tagList": [ + "tag1", + "tag2", + ], + "title": "Article Title", + }, + } + `) + }) + + it('should call update api when click submit button and in editing', async () => { + server.use( + ['GET', '/api/articles/*', { article: fixtures.article }], + ['PUT', '/api/articles/*', { article: fixtures.article }], + ) + vi.spyOn(router, 'push') + const { getByRole, getByPlaceholderText } = render(EditArticle, await renderOptions({ + router, + initialRoute: { name: 'article', params: { slug: 'article-foo' } }, + })) + await server.waitForRequest('GET', '/api/articles/*') + + await userEvent.type(getByPlaceholderText('Enter tags'), 'tag1{Enter}tag2{Enter}') + + await fireEvent.click(getByRole('button', { name: 'Publish Article' })) + + const mockedRequest = await server.waitForRequest('PUT', '/api/articles/article-foo') + + expect(router.push).toHaveBeenCalledWith({ name: 'article', params: { slug: 'article-foo' } }) + expect(await mockedRequest.json()).toMatchInlineSnapshot(` + { + "article": { + "body": "# Article body + + This is **Strong** content.", + "description": "Article description", + "tagList": [ + "foo", + "tag1", + "tag2", + ], + "title": "Article foo", + }, + } + `) + }) + + it('should can remove tag when lick remove tag button', async () => { + server.use( + ['GET', '/api/articles/*', { article: fixtures.article }], + ['PUT', '/api/articles/*', { article: fixtures.article }], + ) + const { getByRole, getByPlaceholderText } = render(EditArticle, await renderOptions({ + router, + initialRoute: { name: 'article', params: { slug: 'article-foo' } }, + })) + await server.waitForRequest('GET', '/api/articles/*') + + await userEvent.type(getByPlaceholderText('Enter tags'), 'tag1{Enter}tag2{Enter}') + await userEvent.click(getByRole('button', { name: 'Remove tag: tag1' })) + + await fireEvent.click(getByRole('button', { name: 'Publish Article' })) + + const mockedRequest = await server.waitForRequest('PUT', '/api/articles/article-foo') + + expect(await mockedRequest.json()).toMatchInlineSnapshot(` + { + "article": { + "body": "# Article body + + This is **Strong** content.", + "description": "Article description", + "tagList": [ + "foo", + "tag2", + ], + "title": "Article foo", + }, + } + `) + }) +}) diff --git a/src/pages/EditArticle.vue b/src/pages/EditArticle.vue index f6fd47f..39047d1 100644 --- a/src/pages/EditArticle.vue +++ b/src/pages/EditArticle.vue @@ -45,6 +45,8 @@ > {{ tag }} diff --git a/src/pages/Login.spec.ts b/src/pages/Login.spec.ts new file mode 100644 index 0000000..44e2413 --- /dev/null +++ b/src/pages/Login.spec.ts @@ -0,0 +1,66 @@ +import { fireEvent, render } from '@testing-library/vue' +import { useUserStore } from 'src/store/user.ts' +import fixtures from 'src/utils/test/fixtures.ts' +import { createTestRouter, renderOptions, setupMockServer } from 'src/utils/test/test.utils.ts' +import { describe, expect, it, vi } from 'vitest' +import Login from './Login.vue' + +describe('# Login page', () => { + const server = setupMockServer() + + it('should call login api when fill form and click submit button', async () => { + const router = createTestRouter() + server.use(['POST', '/api/users/login', { user: fixtures.user }]) + const { getByRole, getByPlaceholderText } = render(Login, renderOptions({ + router, + })) + const store = useUserStore() + + await fireEvent.update(getByPlaceholderText('Email'), 'email@email.com') + await fireEvent.update(getByPlaceholderText('Password'), 'password') + + await fireEvent.click(getByRole('button', { name: 'Sign in' })) + + const mockedRequest = await server.waitForRequest('POST', '/api/users/login') + + expect(router.currentRoute.value.path).toBe('/') + expect(store.updateUser).toHaveBeenCalledWith(fixtures.user) + expect(await mockedRequest.json()).toMatchInlineSnapshot(` + { + "user": { + "email": "email@email.com", + "password": "password", + }, + } + `) + }) + + it('should display error message when api returned some errors', async () => { + server.use(['POST', '/api/users/login', 400, { errors: { password: ['is invalid'] } }]) + const { container, getByRole, getByPlaceholderText } = render(Login, renderOptions()) + + await fireEvent.update(getByPlaceholderText('Email'), 'email@email.com') + await fireEvent.update(getByPlaceholderText('Password'), 'password') + + await fireEvent.click(getByRole('button', { name: 'Sign in' })) + + await server.waitForRequest('POST', '/api/users/login') + + expect(container).toHaveTextContent('password is invalid') + }) + + it('should not trigger api call when user submit a invalid form', async () => { + const { getByRole, getByPlaceholderText } = render(Login, renderOptions()) + const formElement = getByRole('form', { name: 'Login form' }) as HTMLFormElement + vi.spyOn(formElement, 'checkValidity') + + expect(getByRole('button', { name: 'Sign in' })).toHaveProperty('disabled', true) + + await fireEvent.update(getByPlaceholderText('Email'), 'email') + await fireEvent.update(getByPlaceholderText('Password'), 'password') + + await fireEvent.click(getByRole('button', { name: 'Sign in' })) + + expect(formElement.checkValidity).toHaveBeenCalled() + }) +}) diff --git a/src/pages/Login.vue b/src/pages/Login.vue index f87fc30..6d04f30 100644 --- a/src/pages/Login.vue +++ b/src/pages/Login.vue @@ -23,6 +23,7 @@
{ + const server = setupMockServer( + ['GET', '/api/profiles/*', { profile: fixtures.user }], + ['GET', '/api/articles', { articles: [fixtures.article], articlesCount: 1 }], + ) + + it('should display user info', async () => { + const router = createTestRouter() + const { container } = render(asyncWrapper(Profile), await renderOptions({ + router, + initialState: { user: { user: null } }, + initialRoute: '/profile/mutoe', + })) + + await flushPromises() + + expect(container).toHaveTextContent('mutoe') + }) + + it('should display edit button when author logged', async () => { + vi.spyOn(router, 'push') + const { getByRole } = render(asyncWrapper(Profile), await renderOptions({ + router, + initialState: { user: { user: fixtures.user } }, + initialRoute: '/profile/mutoe', + })) + + await flushPromises() + + await fireEvent.click(getByRole('link', { name: 'Edit profile settings' })) + + expect(router.push).toHaveBeenCalledWith({ name: 'settings', params: {} }) + }) + + it('should jump to login page when click follow user', async () => { + server.use(['POST', '/api/profiles/*/follow', { profile: fixtures.user }]) + vi.spyOn(router, 'push') + const { getByRole } = render(asyncWrapper(Profile), await renderOptions({ + router, + initialState: { user: { user: null } }, + initialRoute: '/profile/mutoe', + })) + + await flushPromises() + + await fireEvent.click(getByRole('button', { name: 'Follow mutoe' })) + + await server.waitForRequest('POST', '/api/profiles/*/follow') + }) +}) diff --git a/src/pages/Profile.vue b/src/pages/Profile.vue index a05ed1b..b982edb 100644 --- a/src/pages/Profile.vue +++ b/src/pages/Profile.vue @@ -27,6 +27,7 @@ v-if="showEdit" class="btn btn-sm btn-outline-secondary action-btn" name="settings" + aria-label="Edit profile settings" > Edit profile settings @@ -71,7 +72,7 @@ import ArticlesList from 'src/components/ArticlesList.vue' import { useFollow } from 'src/composable/useFollowProfile' import { useProfile } from 'src/composable/useProfile' import type { Profile } from 'src/services/api' -import { isAuthorized, useUserStore } from 'src/store/user' +import { useUserStore } from 'src/store/user' import { computed } from 'vue' import { useRoute } from 'vue-router' @@ -86,9 +87,9 @@ const { followProcessGoing, toggleFollow } = useFollow({ onUpdate: (newProfileData: Profile) => updateProfile(newProfileData), }) -const { user } = storeToRefs(useUserStore()) +const { user, isAuthorized } = storeToRefs(useUserStore()) -const showEdit = computed(() => isAuthorized() && user.value?.username === username.value) +const showEdit = computed(() => isAuthorized && user.value?.username === username.value) const showFollow = computed(() => user.value?.username !== username.value) diff --git a/src/pages/Register.spec.ts b/src/pages/Register.spec.ts new file mode 100644 index 0000000..df78735 --- /dev/null +++ b/src/pages/Register.spec.ts @@ -0,0 +1,72 @@ +import { fireEvent, render } from '@testing-library/vue' +import { createTestRouter, renderOptions, setupMockServer } from 'src/utils/test/test.utils.ts' +import { describe, expect, it, vi } from 'vitest' +import Register from './Register.vue' + +describe('# Register form', () => { + const server = setupMockServer() + + it('should call register api when fill form and click submit button', async () => { + const router = createTestRouter() + server.use(['POST', '/api/users']) + const { getByRole, getByPlaceholderText } = render(Register, renderOptions({ + router, + })) + + await fireEvent.update(getByPlaceholderText('Your Name'), 'username') + await fireEvent.update(getByPlaceholderText('Email'), 'email@email.com') + await fireEvent.update(getByPlaceholderText('Password'), 'password') + + await fireEvent.click(getByRole('button', { name: 'Sign up' })) + + const mockedRequest = await server.waitForRequest('POST', '/api/users') + + expect(router.currentRoute.value.path).toBe('/') + expect(await mockedRequest.json()).toMatchInlineSnapshot(` + { + "user": { + "email": "email@email.com", + "password": "password", + "username": "username", + }, + } + `) + }) + + it('should display error message when api returned some errors', async () => { + server.use(['POST', '/api/users', 400, { + errors: { + email: ['is invalid'], + username: ['is already taken'], + }, + }]) + const { container, getByRole, getByPlaceholderText } = render(Register, renderOptions()) + + await fireEvent.update(getByPlaceholderText('Your Name'), 'username') + await fireEvent.update(getByPlaceholderText('Email'), 'email@email.com') + await fireEvent.update(getByPlaceholderText('Password'), 'password') + + await fireEvent.click(getByRole('button', { name: 'Sign up' })) + + await server.waitForRequest('POST', '/api/users') + + expect(container).toHaveTextContent('email is invalid') + expect(container).toHaveTextContent('username is already taken') + }) + + it('should not trigger api call when user submit a invalid form', async () => { + const { getByRole, getByPlaceholderText } = render(Register, renderOptions()) + const formElement = getByRole('form', { name: 'Registration form' }) as HTMLFormElement + vi.spyOn(formElement, 'checkValidity') + + expect(getByRole('button', { name: 'Sign up' })).toHaveProperty('disabled', true) + + await fireEvent.update(getByPlaceholderText('Your Name'), 'username') + await fireEvent.update(getByPlaceholderText('Email'), 'email') + await fireEvent.update(getByPlaceholderText('Password'), 'password') + + await fireEvent.click(getByRole('button', { name: 'Sign up' })) + + expect(formElement.checkValidity).toHaveBeenCalled() + }) +}) diff --git a/src/pages/Register.vue b/src/pages/Register.vue index 63fd8d0..61b9a04 100644 --- a/src/pages/Register.vue +++ b/src/pages/Register.vue @@ -23,6 +23,7 @@
diff --git a/src/pages/Settings.spec.ts b/src/pages/Settings.spec.ts new file mode 100644 index 0000000..f6d7a69 --- /dev/null +++ b/src/pages/Settings.spec.ts @@ -0,0 +1,83 @@ +import { fireEvent, render, waitFor } from '@testing-library/vue' +import { router } from 'src/router.ts' +import { useUserStore } from 'src/store/user.ts' +import fixtures from 'src/utils/test/fixtures.ts' +import { renderOptions, setupMockServer } from 'src/utils/test/test.utils.ts' +import { describe, expect, it, vi } from 'vitest' +import Settings from './Settings.vue' + +describe('# Settings Page', () => { + const server = setupMockServer() + + it('should render correctly', async () => { + const { container } = render(Settings, renderOptions({ + initialState: { user: { user: fixtures.user } }, + })) + + expect(container).toHaveTextContent('Your Settings') + }) + + it('should jump to login page when user not logged', async () => { + vi.spyOn(router, 'push') + render(Settings, await renderOptions({ + router, + initialState: { user: { user: null } }, + initialRoute: '/settings', + })) + + await waitFor(() => expect(router.push).toBeCalled()) + }) + + it('should jump to home page and clear logged state when click logout button', async () => { + vi.spyOn(router, 'push') + const { getByRole } = render(Settings, await renderOptions({ + router, + initialState: { user: { user: fixtures.user } }, + initialRoute: '/settings', + })) + const store = useUserStore() + + await fireEvent.click(getByRole('button', { name: 'Logout' })) + + expect(store.isAuthorized).toBe(false) + expect(router.push).toHaveBeenCalledWith({ name: 'global-feed' }) + }) + + it('should not trigger update api when user click submit directly', async () => { + const { getByRole } = render(Settings, await renderOptions({ + router, + initialState: { user: { user: fixtures.user } }, + initialRoute: '/settings', + })) + + expect(getByRole('button', { name: 'Update Settings' })).toHaveProperty('disabled') + }) + + it('should submit new settings when submit form', async () => { + vi.spyOn(router, 'push') + server.use(['PUT', '/api/user', { user: { ...fixtures.user, username: 'new username' } }]) + const { getByRole, getByPlaceholderText } = render(Settings, await renderOptions({ + router, + initialState: { user: { user: fixtures.user } }, + initialRoute: '/settings', + })) + + await fireEvent.update(getByPlaceholderText('Your name'), 'new username') + await fireEvent.update(getByPlaceholderText('New password'), 'new password') + await fireEvent.click(getByRole('button', { name: 'Update Settings' })) + + const mockedRequest = await server.waitForRequest('PUT', '/api/user') + expect(router.push).toHaveBeenCalledWith({ name: 'profile', params: { username: 'new username' } }) + expect(await mockedRequest.json()).toMatchInlineSnapshot(` + { + "user": { + "bio": "Author bio", + "email": "foo@example.com", + "image": "", + "password": "new password", + "username": "new username", + }, + } + `) + }) +}) diff --git a/src/pages/Settings.vue b/src/pages/Settings.vue index 3069691..b093250 100644 --- a/src/pages/Settings.vue +++ b/src/pages/Settings.vue @@ -46,7 +46,7 @@ v-model="form.password" type="password" class="form-control form-control-lg" - placeholder="New Password" + placeholder="New password" >