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"
>
@@ -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"
>
@@ -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"
>
@@ -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
@@ -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"
>
@@ -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
@@ -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 @@