Compare commits

...

6 Commits

Author SHA1 Message Date
mutoe 60fd361870
docs: archive ref-sugar 2023-04-03 13:34:51 +08:00
mutoe 23d80cd7e5
fix: replace $raw() -> $$() and $computed let declare 2021-12-10 20:13:15 +08:00
mutoe a69f162b89
chore: fix dependencies breaking change 2021-12-10 20:09:49 +08:00
mutoe c56d020525
chore: upgrade dependencies 2021-12-10 19:55:30 +08:00
mutoe fbc1985de2
refactor: migrate to cypress component test 2021-08-17 21:21:46 +08:00
mutoe 7c14a84dea
Merge branch 'master' into script-setup 2021-08-15 19:17:06 +08:00
60 changed files with 1894 additions and 4419 deletions

View File

@ -16,8 +16,10 @@
"rules": {
"no-undef": "off",
"no-unused-vars": "off",
"no-void": "off",
"comma-dangle": ["warn", "always-multiline"],
"func-call-spacing": "off",
"prefer-const": "off",
"@typescript-eslint/promise-function-async": "off",
"@typescript-eslint/strict-boolean-expressions": "off",
"@typescript-eslint/no-unused-vars": "off"

View File

@ -1,7 +1,12 @@
# ![RealWorld Example App](logo.png)
[![GitHub Workflow Status (branch)](https://img.shields.io/github/workflow/status/mutoe/vue3-realworld-example-app/Test/master?label=master&logo=github&style=for-the-badge)](https://github.com/mutoe/vue3-realworld-example-app/actions?query=branch%3Amaster)
[![GitHub Workflow Status (branch)](https://img.shields.io/github/workflow/status/mutoe/vue3-realworld-example-app/Test/script-setup?label=script-setup&logo=github&style=for-the-badge)](https://github.com/mutoe/vue3-realworld-example-app/actions?query=branch%3Ascript-setup)
----
## ARCHIVED
The [ref-sugar](https://github.com/vuejs/rfcs/discussions/369) already dropped so the branch has been deprecated and is no longer in use.
----
[![Codecov branch](https://img.shields.io/codecov/c/github/mutoe/vue3-realworld-example-app/master?label=master&logo=codecov&style=flat-square)](https://app.codecov.io/gh/mutoe/vue3-realworld-example-app/branch/master)
[![Codecov branch](https://img.shields.io/codecov/c/github/mutoe/vue3-realworld-example-app/script-setup?label=script-setup&logo=codecov&style=flat-square)](https://app.codecov.io/gh/mutoe/vue3-realworld-example-app/branch/script-setup)

View File

@ -1,4 +1,6 @@
{
"projectId": "j7s91r",
"baseUrl": "http://localhost:3000"
"baseUrl": "http://localhost:3000",
"componentFolder": "src",
"testFiles": "**/*.spec.ts?(x)"
}

View File

@ -12,10 +12,21 @@
// This function is called when a project is opened or re-opened (e.g. due to
// the project's config changing)
const path = require('path')
const { startDevServer } = require('@cypress/vite-dev-server')
/**
* @type {Cypress.PluginConfig}
*/
module.exports = (on, config) => {
// `on` is used to hook into various events Cypress emits
// `config` is the resolved Cypress config
on('dev-server:start', (options) => {
return startDevServer({
options,
viteConfig: {
configFile: path.resolve(__dirname, '..', '..', 'vite.config.js'),
},
})
})
return config
}

View File

@ -13,8 +13,5 @@
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import './commands'
// Alternatively you can use CommonJS syntax:
// require('./commands')
import '@testing-library/cypress/add-commands'

View File

@ -9,47 +9,43 @@
"lint:script": "eslint \"{src/**/*.{ts,vue},cypress/**/*.js}\"",
"lint:tsc": "vue-tsc --noEmit",
"lint": "concurrently 'yarn build' 'yarn lint:tsc' 'yarn lint:script'",
"test:unit": "jest",
"test:e2e": "yarn build && concurrently -k \"yarn serve\" \"cypress run -c baseUrl=http://localhost:5000\"",
"test:unit": "cypress run-ct",
"test:e2e": "yarn build && concurrently -rk -s first \"yarn serve\" \"cypress run -c baseUrl=http://localhost:5000\"",
"test:e2e:ci": "cypress run -C cypress.prod.json",
"test": "yarn test:unit && yarn test:e2e"
},
"dependencies": {
"@harlem/core": "^1.3.2",
"@harlem/core": "^2.2.0",
"insane": "^2.6.2",
"marked": "^2.1.3",
"vue": "^3.2.2",
"vue-router": "^4.0.11"
"marked": "^4.0.7",
"vue": "^3.2.24",
"vue-router": "^4.0.12"
},
"devDependencies": {
"@babel/core": "^7.15.0",
"@testing-library/jest-dom": "^5.14.1",
"@testing-library/vue": "^6.4.2",
"@types/jest": "^27.0.1",
"@typescript-eslint/eslint-plugin": "^4.29.1",
"@typescript-eslint/parser": "^4.29.1",
"@vitejs/plugin-vue": "^1.4.0",
"@vue/compiler-sfc": "^3.2.2",
"babel-jest": "^27.0.6",
"concurrently": "^6.2.1",
"cypress": "^8.2.0",
"eslint": "^7.32.0",
"eslint-config-standard-with-typescript": "^20.0.0",
"eslint-plugin-cypress": "^2.11.3",
"eslint-plugin-import": "^2.24.0",
"@babel/core": "^7.16.0",
"@cypress/vite-dev-server": "^2.2.1",
"@cypress/vue": "^3.0.5",
"@testing-library/cypress": "^8.0.2",
"@types/marked": "^4.0.1",
"@typescript-eslint/eslint-plugin": "^5.6.0",
"@typescript-eslint/parser": "^5.6.0",
"@vitejs/plugin-vue": "^1.10.2",
"@vue/compiler-sfc": "^3.2.24",
"concurrently": "^6.4.0",
"cypress": "^9.1.1",
"eslint": "^8.4.1",
"eslint-config-standard-with-typescript": "^21.0.1",
"eslint-plugin-cypress": "^2.12.1",
"eslint-plugin-import": "^2.25.3",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^4.3.1",
"eslint-plugin-vue": "^7.16.0",
"eslint-plugin-promise": "^5.2.0",
"eslint-plugin-vue": "^8.2.0",
"husky": "^4.3.8",
"jest": "^27.0.6",
"jsdom": "^17.0.0",
"lint-staged": "^11.1.2",
"lint-staged": "^12.1.2",
"rollup-plugin-analyzer": "^4.0.0",
"ts-jest": "^27.0.4",
"typescript": "^4.3.5",
"vite": "^2.4.4",
"vue-tsc": "^0.2.2",
"vue3-jest": "^27.0.0-alpha.2"
"typescript": "^4.5.3",
"vite": "^2.7.1",
"vue-tsc": "^0.29.8"
},
"husky": {
"hooks": {
@ -60,36 +56,5 @@
"lint-staged": {
"src/**/*.{ts,vue}": "eslint --fix",
"cypress/**/*.js": "eslint --fix"
},
"jest": {
"preset": "ts-jest",
"globals": {
"ts-jest": {}
},
"testEnvironment": "jsdom",
"transform": {
"^.+\\.vue$": "vue3-jest",
"^.+\\js$": "babel-jest"
},
"collectCoverageFrom": [
"<rootDir>/src/**/*.{ts,vue}",
"!<rootDir>/src/config.ts"
],
"moduleFileExtensions": [
"vue",
"ts",
"js",
"json",
"node"
],
"testMatch": [
"<rootDir>/src/**/*.spec.ts"
],
"modulePaths": [
"<rootDir>"
],
"setupFilesAfterEnv": [
"<rootDir>/src/setup-test.ts"
]
}
}

View File

@ -4,16 +4,7 @@
<AppFooter />
</template>
<script lang="ts">
import { defineComponent } from 'vue'
<script lang="ts" setup>
import AppFooter from './components/AppFooter.vue'
import AppNavigation from './components/AppNavigation.vue'
export default defineComponent({
name: 'App',
components: {
AppNavigation,
AppFooter,
},
})
</script>

View File

@ -1,18 +1,10 @@
import { render } from '@testing-library/vue'
import registerGlobalComponents from 'src/plugins/global-components'
import { router } from 'src/router'
import { mount } from '@cypress/vue'
import AppFooter from './AppFooter.vue'
describe('# AppFooter', () => {
beforeEach(async () => {
await router.push('/')
})
it('should render correctly', () => {
const { container } = render(AppFooter, {
global: { plugins: [registerGlobalComponents, router] },
})
mount(AppFooter)
expect(container).toBeInTheDocument()
cy.contains('Real world app')
})
})

View File

@ -21,11 +21,3 @@
</div>
</footer>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
name: 'AppFooter',
})
</script>

View File

@ -1,4 +1,4 @@
import { fireEvent, render, waitFor } from '@testing-library/vue'
import { mount } from '@cypress/vue'
import { router } from 'src/router'
import AppLink from './AppLink.vue'
@ -8,20 +8,16 @@ describe('# AppLink', () => {
})
it('should redirect to another page when click the link', async () => {
// given
const { container, getByRole } = render(AppLink, {
mount(AppLink, {
global: { plugins: [router] },
props: { name: 'tag', params: { tag: 'foo' } },
slots: { default: 'Go to Foo tag' },
})
expect(container).toHaveTextContent('Go to Foo tag')
cy.contains('Go to Foo tag')
// when
const linkElement = getByRole('link', { name: 'tag' })
await fireEvent.click(linkElement)
// then
await waitFor(() => expect(linkElement).toHaveClass('router-link-active'))
cy.findByRole('link', { name: 'tag' })
.click()
.should('have.class', 'router-link-active')
})
})

View File

@ -8,27 +8,16 @@
</router-link>
</template>
<script lang="ts">
<script lang="ts" setup>
import type { AppRouteNames } from 'src/router'
import { defineComponent, PropType } from 'vue'
import type { RouteParams } from 'vue-router'
import { RouterLink } from 'vue-router'
import type { RouteParams } from 'vue-router'
import { useAttrs } from 'vue'
export default defineComponent({
name: 'AppLink',
components: {
RouterLink,
},
props: {
name: { type: String as PropType<AppRouteNames>, required: true },
params: { type: Object as PropType<RouteParams>, default: () => ({}) },
},
setup (props, { attrs }) {
return {
props,
attrs,
}
},
})
const props = defineProps<{
name: AppRouteNames
params?: RouteParams
}>()
const attrs = useAttrs()
</script>

View File

@ -1,4 +1,4 @@
import { render } from '@testing-library/vue'
import { mount } from '@cypress/vue'
import registerGlobalComponents from 'src/plugins/global-components'
import { router } from 'src/router'
import { updateUser, user } from 'src/store/user'
@ -10,30 +10,39 @@ describe('# AppNavigation', () => {
await router.push('/')
})
afterEach(() => {
Cypress.vueWrapper.unmount()
})
it('should render Sign in and Sign up when user not logged', () => {
const { container } = render(AppNavigation, {
global: { plugins: [registerGlobalComponents, router] },
})
expect(container.querySelectorAll('.nav-item')).toHaveLength(3)
expect(container.textContent).toContain('Home')
expect(container.textContent).toContain('Sign in')
expect(container.textContent).toContain('Sign up')
})
it('should render xxx when user logged', () => {
updateUser({ id: 1, username: 'foo', email: '', token: '', bio: undefined, image: undefined })
const { container } = render(AppNavigation, {
mount(AppNavigation, {
global: {
plugins: [registerGlobalComponents, router],
mocks: { $store: user },
},
})
expect(container.querySelectorAll('.nav-item')).toHaveLength(4)
expect(container.textContent).toContain('Home')
expect(container.textContent).toContain('New Post')
expect(container.textContent).toContain('Settings')
expect(container.textContent).toContain('foo')
cy.get('.nav-item')
.should('have.length', 3)
.should('contain.text', 'Home')
.should('contain.text', 'Sign in')
.should('contain.text', 'Sign up')
})
it('should render xxx when user logged', () => {
updateUser({ id: 1, username: 'foo', email: '', token: '', bio: undefined, image: undefined })
mount(AppNavigation, {
global: {
plugins: [registerGlobalComponents, router],
mocks: { $store: user },
},
})
cy.get('.nav-item')
.should('have.length', 4)
.should('contain.text', 'Home')
.should('contain.text', 'New Post')
.should('contain.text', 'Settings')
.should('contain.text', 'foo')
})
})

View File

@ -31,10 +31,9 @@
</nav>
</template>
<script lang="ts">
<script lang="ts" setup>
import type { AppRouteNames } from 'src/router'
import { user } from 'src/store/user'
import { computed, defineComponent } from 'vue'
import type { RouteParams } from 'vue-router'
interface NavLink {
@ -45,13 +44,10 @@ interface NavLink {
display: 'all' | 'anonym' | 'authorized'
}
export default defineComponent({
name: 'AppNavigation',
setup () {
const username = computed(() => user.value?.username)
const displayStatus = computed(() => username.value ? 'authorized' : 'anonym')
let username = $computed(() => user.value?.username)
let displayStatus = $computed(() => (username ? 'authorized' : 'anonym'))
const allNavLinks = computed<NavLink[]>(() => [
let allNavLinks = $computed<NavLink[]>(() => [
{
name: 'global-feed',
title: 'Home',
@ -81,19 +77,13 @@ export default defineComponent({
},
{
name: 'profile',
params: { username: username.value },
title: username.value || '',
params: { username },
title: username || '',
display: 'authorized',
},
])
])
const navLinks = computed(() => allNavLinks.value.filter(
l => l.display === displayStatus.value || l.display === 'all',
))
return {
navLinks,
}
},
})
let navLinks = $computed(() =>
allNavLinks.filter((l) => l.display === displayStatus || l.display === 'all'),
)
</script>

View File

@ -1,26 +1,26 @@
import { fireEvent, render } from '@testing-library/vue'
import { mount } from '@cypress/vue'
import AppPagination from './AppPagination.vue'
describe('# AppPagination', () => {
it('should highlight current active page', () => {
const { container } = render(AppPagination, {
mount(AppPagination, {
props: { page: 1, count: 15 },
})
const pageItems = container.querySelectorAll('.page-item')
expect(pageItems).toHaveLength(2)
expect(pageItems[0]).toHaveClass('active')
cy.get('.page-item')
.should('have.length', 2)
.its(0)
.should('have.class', 'active')
})
it('should call onPageChange when click a page item', async () => {
const { getByRole, emitted } = render(AppPagination, {
mount(AppPagination, {
props: { page: 1, count: 15 },
})
await fireEvent.click(getByRole('link', { name: 'Go to page 2' }))
cy.findByRole('link', { name: 'Go to page 2' }).click()
const events = emitted()
expect(events['page-change']).toHaveLength(1)
expect(events['page-change']?.[0]).toEqual([2])
const events = Cypress.vueWrapper.emitted()
cy.wrap(events).should('have.property', 'page-change', [2])
})
})

View File

@ -6,6 +6,7 @@
:class="['page-item', { active: isActive(pageNumber) }]"
>
<a
role="link"
:aria-label="`Go to page ${pageNumber}`"
class="page-link"
href="javascript:"
@ -15,31 +16,20 @@
</ul>
</template>
<script lang="ts">
<script lang="ts" setup>
import { limit } from 'src/services'
import { computed, defineComponent, toRefs } from 'vue'
export default defineComponent({
name: 'AppPagination',
props: {
page: { type: Number, required: true },
count: { type: Number, required: true },
},
emits: {
'page-change': (index: number) => typeof index === 'number',
},
setup (props, { emit }) {
const { count, page } = toRefs(props)
const pagesCount = computed(() => Math.ceil(count.value / limit))
const isActive = (index: number) => page.value === index
const onPageChange = (index: number) => emit('page-change', index)
const props = defineProps<{
page: number
count: number
}>()
return {
pagesCount,
isActive,
onPageChange,
}
},
})
const emit = defineEmits<{
(e: 'page-change', index: number): void
}>()
let pagesCount = $computed(() => Math.ceil(props.count / limit))
const isActive = (index: number) => props.page === index
const onPageChange = (index: number) => emit('page-change', index)
</script>

View File

@ -1,16 +1,10 @@
import { render } from '@testing-library/vue'
import { mount } from '@cypress/vue'
import registerGlobalComponents from 'src/plugins/global-components'
import { router } from 'src/router'
import { getArticle } from 'src/services/article/getArticle'
import asyncComponentWrapper from 'src/utils/test/async-component-wrapper'
import fixtures from 'src/utils/test/fixtures'
import ArticleDetail from './ArticleDetail.vue'
jest.mock('src/services/article/getArticle')
describe.skip('# ArticleDetail', () => {
const mockGetArticle = getArticle as jest.MockedFunction<typeof getArticle>
beforeEach(async () => {
await router.push({
name: 'article',
@ -19,29 +13,27 @@ describe.skip('# ArticleDetail', () => {
})
it('should render markdown body correctly', async () => {
mockGetArticle.mockResolvedValue({ ...fixtures.article, body: fixtures.markdown })
const { container } = render(asyncComponentWrapper(ArticleDetail), {
cy.intercept('/articles/:slug', { ...fixtures.article, body: fixtures.markdown })
mount(ArticleDetail, {
global: { plugins: [registerGlobalComponents, router] },
})
expect(container.querySelector('.article-content')).toMatchSnapshot()
cy.get('.article-content').should('have.html', '')
})
it('should render markdown (zh-CN) body correctly', async () => {
mockGetArticle.mockResolvedValue({ ...fixtures.article, body: fixtures.markdownCN })
const { container } = render(asyncComponentWrapper(ArticleDetail), {
mount(ArticleDetail, {
global: { plugins: [registerGlobalComponents, router] },
})
expect(container.querySelector('.article-content')).toMatchSnapshot()
cy.get('.article-content').should('have.html', '')
})
it('should filter the xss content in Markdown body', async () => {
mockGetArticle.mockResolvedValue({ ...fixtures.article, body: fixtures.markdownXss })
const { container } = render(asyncComponentWrapper(ArticleDetail), {
mount(ArticleDetail, {
global: { plugins: [registerGlobalComponents, router] },
})
expect(container.querySelector('.article-content')?.textContent).not.toContain('alert')
cy.get('.article-content').should('have.html', '')
})
})

View File

@ -42,35 +42,17 @@
</div>
</template>
<script lang="ts">
<script lang="ts" setup>
import marked from 'src/plugins/marked'
import { getArticle } from 'src/services/article/getArticle'
import { computed, defineComponent, reactive } from 'vue'
import { useRoute } from 'vue-router'
import ArticleDetailMeta from './ArticleDetailMeta.vue'
export default defineComponent({
name: 'ArticleDetail',
components: {
ArticleDetailMeta,
},
async setup () {
const route = useRoute()
const slug = route.params.slug as string
const article = reactive<Article>(await getArticle(slug))
const articleHandledBody = computed(() => marked(article.body))
const updateArticle = (newArticle: Article) => {
const route = useRoute()
const slug = route.params.slug as string
let article = $ref<Article>(await getArticle(slug))
let articleHandledBody = $computed(() => marked(article.body))
const updateArticle = (newArticle: Article) => {
Object.assign(article, newArticle)
}
return {
article,
articleHandledBody,
slug,
updateArticle,
}
},
})
}
</script>

View File

@ -1,4 +1,4 @@
import { fireEvent, render } from '@testing-library/vue'
import { mount } from '@cypress/vue'
import registerGlobalComponents from 'src/plugins/global-components'
import { router } from 'src/router'
import fixtures from 'src/utils/test/fixtures'
@ -10,37 +10,34 @@ describe('# ArticleDetailComment', () => {
})
it('should render correctly', () => {
const { container, queryByRole } = render(ArticleDetailComment, {
mount(ArticleDetailComment, {
global: { plugins: [registerGlobalComponents, router] },
props: { comment: fixtures.comment },
})
expect(container.querySelector('.card-text')).toHaveTextContent('Comment body')
expect(container.querySelector('.date-posted')).toHaveTextContent('1/1/2020')
expect(queryByRole('button', { name: 'Delete comment' })).toBeNull()
cy.get('.card-text').should('have.text', 'Comment body')
cy.get('.date-posted').should('have.text', '01/01/2020')
cy.findByRole('button', { name: 'Delete comment' }).should('not.exist')
})
it('should delete comment button when comment author is same user', () => {
const { getByRole } = render(ArticleDetailComment, {
global: { plugins: [registerGlobalComponents, router] },
props: {
comment: fixtures.comment,
username: fixtures.author.username,
},
})
expect(getByRole('button', { name: 'Delete comment' })).toBeInTheDocument()
})
it('should emit remove comment when click remove comment button', async () => {
const { getByRole, emitted } = render(ArticleDetailComment, {
mount(ArticleDetailComment, {
global: { plugins: [registerGlobalComponents, router] },
props: { comment: fixtures.comment, username: fixtures.author.username },
})
await fireEvent.click(getByRole('button', { name: 'Delete comment' }))
cy.findByRole('button', { name: 'Delete comment' }).should('exist')
})
const events = emitted()
expect(events['remove-comment']).toHaveLength(1)
it('should emit remove comment when click remove comment button', async () => {
mount(ArticleDetailComment, {
global: { plugins: [registerGlobalComponents, router] },
props: { comment: fixtures.comment, username: fixtures.author.username },
})
cy.findByRole('button', { name: 'Delete comment' }).click()
const events = Cypress.vueWrapper.emitted()
cy.wrap(events).should('have.property', 'remove-comment')
})
})

View File

@ -36,31 +36,23 @@
role="button"
aria-label="Delete comment"
class="ion-trash-a"
@click="$emit('remove-comment')"
@click="emit('remove-comment')"
/>
</span>
</div>
</div>
</template>
<script lang="ts">
import { computed, defineComponent, PropType } from 'vue'
<script lang="ts" setup>
export default defineComponent({
name: 'ArticleDetailComment',
props: {
comment: { type: Object as PropType<ArticleComment>, required: true },
username: { type: String as PropType<string | undefined>, default: undefined },
},
emits: {
'remove-comment': () => true,
},
setup (props) {
return {
showRemove: computed(() => (
props.username !== undefined && props.username === props.comment.author.username
)),
}
},
})
const props = defineProps<{
comment: ArticleComment
username?: string
}>()
const emit = defineEmits<{
(e: 'remove-comment'): boolean
}>()
let showRemove = $computed(() => (props.username !== undefined && props.username === props.comment.author.username))
</script>

View File

@ -1,21 +1,11 @@
import { render, waitFor } from '@testing-library/vue'
import { mount } from '@cypress/vue'
import registerGlobalComponents from 'src/plugins/global-components'
import { router } from 'src/router'
import { getCommentsByArticle } from 'src/services/comment/getComments'
import { deleteComment } from 'src/services/comment/postComment'
import asyncComponentWrapper from 'src/utils/test/async-component-wrapper'
import fixtures from 'src/utils/test/fixtures'
import ArticleDetailComments from './ArticleDetailComments.vue'
jest.mock('src/services/comment/getComments')
jest.mock('src/services/comment/postComment')
describe('# ArticleDetailComments', () => {
const mockGetCommentsByArticle = getCommentsByArticle as jest.MockedFunction<typeof getCommentsByArticle>
const mockDeleteComment = deleteComment as jest.MockedFunction<typeof deleteComment>
beforeEach(async () => {
mockGetCommentsByArticle.mockResolvedValue([fixtures.comment])
await router.push({
name: 'article',
params: { slug: fixtures.article.slug },
@ -23,42 +13,48 @@ describe('# ArticleDetailComments', () => {
})
it('should render correctly', async () => {
const { container } = render(asyncComponentWrapper(ArticleDetailComments), {
global: { plugins: [registerGlobalComponents, router] },
cy.intercept('GET', '/api/articles/*/comments', { body: { comments: [fixtures.comment] } }).as('retrieveComments')
mount(ArticleDetailComments, {
global: {
plugins: [registerGlobalComponents, router],
},
})
expect(mockGetCommentsByArticle).toBeCalledWith('article-foo')
expect(container).toBeInTheDocument()
cy.wait('@retrieveComments').its('request.url').should('contain', 'article-foo')
})
// TODO: resolve the Cypress.vue is undefined
it.skip('should display new comment when post new comment', async () => {
cy.intercept('GET', '/api/articles/*/comments', { body: { comments: [fixtures.comment] } }).as('retrieveComments')
// given
const { container } = render(asyncComponentWrapper(ArticleDetailComments), {
mount(ArticleDetailComments, {
global: { plugins: [registerGlobalComponents, router] },
})
await waitFor(() => expect(mockGetCommentsByArticle).toBeCalled())
expect(container.querySelectorAll('.card')).toHaveLength(1)
cy.wait('@retrieveComments')
cy.get('.card').should('have.length', 1)
// when
// wrapper.findComponent(ArticleDetailCommentsForm).vm.$emit('add-comment', fixtures.comment2)
// await nextTick()
Cypress.vue.$emit('add-comment', fixtures.comment2)
// then
expect(container.querySelectorAll('.card')).toHaveLength(2)
cy.get('.card').should('have.length', 2)
})
it.skip('should call remove comment service when click delete button', async () => {
cy.intercept('DELETE', '/api/articles/*/comments', { statusCode: 200 }).as('deleteComment')
// given
render(asyncComponentWrapper(ArticleDetailComments), {
mount(ArticleDetailComments, {
global: { plugins: [registerGlobalComponents, router] },
})
await waitFor(() => expect(mockGetCommentsByArticle).toBeCalled())
// when
// wrapper.findComponent(ArticleDetailComment).vm.$emit('remove-comment')
Cypress.vue.$emit('remove-comment')
// then
expect(mockDeleteComment).toBeCalledWith('article-foo', 1)
cy.wait('@deleteComment')
})
})

View File

@ -13,47 +13,32 @@
/>
</template>
<script lang="ts">
<script lang="ts" setup>
import { getCommentsByArticle } from 'src/services/comment/getComments'
import { deleteComment } from 'src/services/comment/postComment'
import { user } from 'src/store/user'
import { computed, defineComponent, ref } from 'vue'
import { onMounted } from 'vue'
import { useRoute } from 'vue-router'
import ArticleDetailComment from './ArticleDetailComment.vue'
import ArticleDetailCommentsForm from './ArticleDetailCommentsForm.vue'
export default defineComponent({
name: 'ArticleDetailComments',
components: {
ArticleDetailCommentsForm,
ArticleDetailComment,
},
async setup () {
const route = useRoute()
const slug = route.params.slug as string
const route = useRoute()
const slug = route.params.slug as string
const username = computed(() => user.value?.username)
let username = $computed(() => user.value?.username)
const comments = ref<ArticleComment[]>([])
let comments = $ref<ArticleComment[]>([])
const addComment = async (comment: ArticleComment) => {
comments.value.unshift(comment)
}
const addComment = async (comment: ArticleComment) => {
comments.unshift(comment)
}
const removeComment = async (commentId: number) => {
const removeComment = async (commentId: number) => {
await deleteComment(slug, commentId)
comments.value = comments.value.filter(c => c.id !== commentId)
}
comments = comments.filter(c => c.id !== commentId)
}
comments.value = await getCommentsByArticle(slug)
return {
comments,
slug,
username,
addComment,
removeComment,
}
},
onMounted(async () => {
comments = await getCommentsByArticle(slug)
})
</script>

View File

@ -1,54 +1,43 @@
import { fireEvent, render } from '@testing-library/vue'
import { mount } from '@cypress/vue'
import ArticleDetailCommentsForm from 'src/components/ArticleDetailCommentsForm.vue'
import { useProfile } from 'src/composable/useProfile'
import registerGlobalComponents from 'src/plugins/global-components'
import { router } from 'src/router'
import { postComment } from 'src/services/comment/postComment'
import fixtures from 'src/utils/test/fixtures'
import { ref } from 'vue'
jest.mock('src/composable/useProfile')
jest.mock('src/services/comment/postComment')
describe('# ArticleDetailCommentsForm', () => {
const mockUseProfile = useProfile as jest.MockedFunction<typeof useProfile>
const mockPostComment = postComment as jest.MockedFunction<typeof postComment>
beforeEach(async () => {
await router.push({ name: 'article', params: { slug: fixtures.article.slug } })
mockPostComment.mockResolvedValue(fixtures.comment2)
mockUseProfile.mockReturnValue({
profile: ref(fixtures.author),
updateProfile: jest.fn(),
})
})
it('should display sign in button when user not logged', () => {
mockUseProfile.mockReturnValue({ profile: ref(null), updateProfile: jest.fn() })
const { container } = render(ArticleDetailCommentsForm, {
cy.intercept('POST', '/api/articles/*/comments', fixtures.comment2).as('createComment')
mount(ArticleDetailCommentsForm, {
global: { plugins: [registerGlobalComponents, router] },
props: { articleSlug: fixtures.article.slug },
})
expect(container.textContent).toContain('add comments on this article')
cy.contains('add comments on this article')
})
it('should display form when user logged', async () => {
cy.intercept('POST', '/api/articles/*/comments', { statusCode: 200 }).as('createComment')
// given
const { getByRole, emitted } = render(ArticleDetailCommentsForm, {
mount(ArticleDetailCommentsForm, {
global: { plugins: [registerGlobalComponents, router] },
props: { articleSlug: fixtures.article.slug },
})
// when
const inputElement = getByRole('textbox', { name: 'Write comment' })
await fireEvent.update(inputElement, 'some texts...')
await fireEvent.click(getByRole('button', { name: 'Submit' }))
cy.findByRole('textbox', { name: 'Write comment' })
.type('some texts...')
cy.findByRole('button', { name: 'Submit' })
// then
expect(mockPostComment).toBeCalledWith('article-foo', 'some texts...')
cy.get('@createComment').should('be.calledWith', 'article-foo', 'some texts...')
const { submit } = emitted()
expect(submit).toHaveLength(1)
const events = Cypress.vueWrapper.emitted()
cy.wrap(events).should('have.property', 'submit')
})
})

View File

@ -37,37 +37,27 @@
</form>
</template>
<script lang="ts">
<script lang="ts" setup>
import { useProfile } from 'src/composable/useProfile'
import { postComment } from 'src/services/comment/postComment'
import { checkAuthorization, user } from 'src/store/user'
import { computed, defineComponent, ref } from 'vue'
export default defineComponent({
name: 'ArticleDetailCommentsForm',
props: {
articleSlug: { type: String, required: true },
},
emits: {
'add-comment': (comment: ArticleComment) => !!comment.id,
},
setup (props, { emit }) {
const username = computed(() => checkAuthorization(user) ? user.value.username : '')
const { profile } = useProfile({ username })
const props = defineProps<{
articleSlug: string
}>()
const comment = ref('')
const emit = defineEmits<{
(e: 'add-comment', comment: ArticleComment): void
}>()
const submitComment = async () => {
const newComment = await postComment(props.articleSlug, comment.value)
let username = $computed(() => checkAuthorization(user) ? user.value.username : '')
const { profile } = useProfile({ username: $$(username) })
let comment = $ref('')
const submitComment = async () => {
const newComment = await postComment(props.articleSlug, comment)
emit('add-comment', newComment)
comment.value = ''
}
return {
profile,
comment,
submitComment,
}
},
})
comment = ''
}
</script>

View File

@ -1,23 +1,10 @@
import { fireEvent, render } from '@testing-library/vue'
import { GlobalMountOptions } from '@vue/test-utils/dist/types'
import { mount } from '@cypress/vue'
import registerGlobalComponents from 'src/plugins/global-components'
import { router } from 'src/router'
import { deleteArticle } from 'src/services/article/deleteArticle'
import { deleteFavoriteArticle, postFavoriteArticle } from 'src/services/article/favoriteArticle'
import { deleteFollowProfile, postFollowProfile } from 'src/services/profile/followProfile'
import { updateUser, user } from 'src/store/user'
import fixtures from 'src/utils/test/fixtures'
import ArticleDetailMeta from './ArticleDetailMeta.vue'
jest.mock('src/services/article/deleteArticle')
jest.mock('src/services/profile/followProfile')
jest.mock('src/services/article/favoriteArticle')
const globalMountOptions: GlobalMountOptions = {
plugins: [registerGlobalComponents, router],
mocks: { $store: user },
}
describe('# ArticleDetailMeta', () => {
const editButton = 'Edit article'
const deleteButton = 'Delete article'
@ -26,109 +13,133 @@ describe('# ArticleDetailMeta', () => {
const favoriteButton = 'Favorite article'
const unfavoriteButton = 'Unfavorite article'
const mockDeleteArticle = deleteArticle as jest.MockedFunction<typeof deleteArticle>
const mockFollowUser = postFollowProfile as jest.MockedFunction<typeof postFollowProfile>
const mockUnfollowUser = deleteFollowProfile as jest.MockedFunction<typeof deleteFollowProfile>
const mockFavoriteArticle = postFavoriteArticle as jest.MockedFunction<typeof postFavoriteArticle>
const mockUnfavoriteArticle = deleteFavoriteArticle as jest.MockedFunction<typeof deleteFavoriteArticle>
beforeEach(async () => {
mockFollowUser.mockResolvedValue({ isOk: () => true } as any)
mockUnfollowUser.mockResolvedValue({ isOk: () => true } as any)
mockFavoriteArticle.mockResolvedValue({ isOk: () => true, value: fixtures.article } as any)
mockUnfavoriteArticle.mockResolvedValue({ isOk: () => true, value: fixtures.article } as any)
updateUser(fixtures.user)
await router.push({ name: 'article', params: { slug: fixtures.article.slug } })
updateUser(fixtures.user)
})
it('should display edit button when user is author', () => {
const { queryByRole } = render(ArticleDetailMeta, {
global: globalMountOptions,
mount(ArticleDetailMeta, {
global: {
plugins: [registerGlobalComponents, router],
mocks: { $store: user },
},
props: { article: fixtures.article },
})
expect(queryByRole('link', { name: editButton })).toBeInTheDocument()
expect(queryByRole('button', { name: followButton })).not.toBeInTheDocument()
cy.findByRole('link', { name: editButton }).should('exist')
cy.findByRole('button', { name: followButton }).should('not.exist')
})
it('should display follow button when user not author', () => {
updateUser({ ...fixtures.user, username: 'user2' })
const { queryByRole } = render(ArticleDetailMeta, {
global: globalMountOptions,
mount(ArticleDetailMeta, {
global: {
plugins: [registerGlobalComponents, router],
mocks: { $store: user },
},
props: { article: fixtures.article },
})
expect(queryByRole('link', { name: editButton })).not.toBeInTheDocument()
expect(queryByRole('button', { name: followButton })).toBeInTheDocument()
cy.findByRole('link', { name: editButton }).should('not.exist')
cy.findByRole('button', { name: followButton }).should('exist')
})
it('should not display follow button and edit button when user not logged', () => {
updateUser(null)
const { queryByRole } = render(ArticleDetailMeta, {
global: globalMountOptions,
mount(ArticleDetailMeta, {
global: {
plugins: [registerGlobalComponents, router],
mocks: { $store: user },
},
props: { article: fixtures.article },
})
expect(queryByRole('button', { name: editButton })).not.toBeInTheDocument()
expect(queryByRole('button', { name: followButton })).not.toBeInTheDocument()
cy.findByRole('button', { name: editButton }).should('not.exist')
cy.findByRole('button', { name: followButton }).should('not.exist')
})
it('should call delete article service when click delete button', async () => {
const { getByRole } = render(ArticleDetailMeta, {
global: globalMountOptions,
cy.intercept('DELETE', '/api/articles/*', { statusCode: 200 }).as('deleteArticle')
mount(ArticleDetailMeta, {
global: {
plugins: [registerGlobalComponents, router],
mocks: { $store: user },
},
props: { article: fixtures.article },
})
await fireEvent.click(getByRole('button', { name: deleteButton }))
cy.findByRole('button', { name: deleteButton }).click()
expect(mockDeleteArticle).toBeCalledWith('article-foo')
cy.wait('@deleteArticle').its('request.url').should('contain', 'article-foo')
})
it('should call follow service when click follow button', async () => {
cy.intercept('POST', '/api/profiles/*/follow', { statusCode: 200 }).as('followProfile')
updateUser({ ...fixtures.user, username: 'user2' })
const { getByRole } = render(ArticleDetailMeta, {
global: globalMountOptions,
mount(ArticleDetailMeta, {
global: {
plugins: [registerGlobalComponents, router],
mocks: { $store: user },
},
props: { article: fixtures.article },
})
await fireEvent.click(getByRole('button', { name: followButton }))
cy.findByRole('button', { name: followButton }).click()
expect(mockFollowUser).toBeCalledWith('Author name')
cy.wait('@followProfile').its('request.url').should('contain', 'Author%20name')
})
it('should call unfollow service when click follow button and not followed author', async () => {
cy.intercept('DELETE', '/api/profiles/*/follow', { statusCode: 200 }).as('unfollowProfile')
updateUser({ ...fixtures.user, username: 'user2' })
const { getByRole } = render(ArticleDetailMeta, {
global: globalMountOptions,
mount(ArticleDetailMeta, {
global: {
plugins: [registerGlobalComponents, router],
mocks: { $store: user },
},
props: { article: { ...fixtures.article, author: { ...fixtures.article.author, following: true } } },
})
await fireEvent.click(getByRole('button', { name: unfollowButton }))
cy.findByRole('button', { name: unfollowButton }).click()
expect(mockUnfollowUser).toBeCalledWith('Author name')
cy.wait('@unfollowProfile').its('request.url').should('contain', 'Author')
})
it('should call favorite article service when click favorite button', async () => {
cy.intercept('POST', '/api/articles/*/favorite', { statusCode: 200 }).as('favoriteArticle')
updateUser({ ...fixtures.user, username: 'user2' })
const { getByRole } = render(ArticleDetailMeta, {
global: globalMountOptions,
mount(ArticleDetailMeta, {
global: {
plugins: [registerGlobalComponents, router],
mocks: { $store: user },
},
props: { article: { ...fixtures.article, favorited: false } },
})
await fireEvent.click(getByRole('button', { name: favoriteButton }))
cy.findByRole('button', { name: favoriteButton }).click()
expect(mockFavoriteArticle).toBeCalledWith('article-foo')
cy.wait('@favoriteArticle').its('request.url').should('contain', 'article-foo')
})
it('should call favorite article service when click unfavorite button', async () => {
cy.intercept('DELETE', '/api/articles/*/favorite', { statusCode: 200 }).as('unfavoriteArticle')
updateUser({ ...fixtures.user, username: 'user2' })
const { getByRole } = render(ArticleDetailMeta, {
global: globalMountOptions,
mount(ArticleDetailMeta, {
global: {
plugins: [registerGlobalComponents, router],
mocks: { $store: user },
},
props: { article: { ...fixtures.article, favorited: true } },
})
await fireEvent.click(getByRole('button', { name: unfavoriteButton }))
cy.findByRole('button', { name: unfavoriteButton }).click()
expect(mockUnfavoriteArticle).toBeCalledWith('article-foo')
cy.wait('@unfavoriteArticle').its('request.url').should('contain', 'article-foo')
})
})

View File

@ -63,57 +63,47 @@
</div>
</template>
<script lang="ts">
<script lang="ts" setup>
import { useFavoriteArticle } from 'src/composable/useFavoriteArticle'
import { useFollow } from 'src/composable/useFollowProfile'
import { routerPush } from 'src/router'
import { deleteArticle } from 'src/services/article/deleteArticle'
import { checkAuthorization, user } from 'src/store/user'
import { computed, defineComponent, PropType, toRefs } from 'vue'
export default defineComponent({
name: 'ArticleDetailMeta',
props: {
article: { type: Object as PropType<Article>, required: true },
},
emits: {
update: (article: Article) => !!article.slug,
},
setup (props, { emit }) {
const { article } = toRefs(props)
const displayEditButton = computed(() => checkAuthorization(user) && user.value.username === article.value.author.username)
const displayFollowButton = computed(() => checkAuthorization(user) && user.value.username !== article.value.author.username)
const props = defineProps<{
article: Article
}>()
const { favoriteProcessGoing, favoriteArticle } = useFavoriteArticle({
isFavorited: computed(() => article.value.favorited),
articleSlug: computed(() => article.value.slug),
const emit = defineEmits<{
(e: 'update', article: Article): void
}>()
const { article } = $fromRefs(props)
let displayEditButton = $computed(() => checkAuthorization(user) && user.value.username === article.author.username)
let displayFollowButton = $computed(() => checkAuthorization(user) && user.value.username !== article.author.username)
let isFavorited = $computed(() => article.favorited)
let articleSlug = $computed(() => article.slug)
const { favoriteProcessGoing, favoriteArticle } = useFavoriteArticle({
isFavorited,
articleSlug,
onUpdate: newArticle => emit('update', newArticle),
})
})
const onDelete = async () => {
await deleteArticle(article.value.slug)
const onDelete = async () => {
await deleteArticle(article.slug)
await routerPush('global-feed')
}
}
const { followProcessGoing, toggleFollow } = useFollow({
following: computed(() => article.value.author.following),
username: computed(() => article.value.author.username),
let following = $computed(() => article.author.following)
let username = $computed(() => article.author.username)
const { toggleFollow, followProcessGoing } = useFollow({
following,
username,
onUpdate: (author: Profile) => {
const newArticle = { ...article.value, author }
const newArticle = { ...article, author }
emit('update', newArticle)
},
})
return {
displayEditButton,
displayFollowButton,
onDelete,
favoriteProcessGoing,
favoriteArticle,
followProcessGoing,
toggleFollow,
}
},
})
</script>

View File

@ -1,32 +1,28 @@
import { render } from '@testing-library/vue'
import { mount } from '@cypress/vue'
import { GlobalMountOptions } from '@vue/test-utils/dist/types'
import ArticlesList from 'src/components/ArticlesList.vue'
import registerGlobalComponents from 'src/plugins/global-components'
import { router } from 'src/router'
import { getArticles } from 'src/services/article/getArticles'
import fixtures from 'src/utils/test/fixtures'
import asyncComponentWrapper from '../utils/test/async-component-wrapper'
jest.mock('src/services/article/getArticles')
describe('# ArticlesList', () => {
const globalMountOptions: GlobalMountOptions = {
plugins: [registerGlobalComponents, router],
}
const mockFetchArticles = getArticles as jest.MockedFunction<typeof getArticles>
beforeEach(async () => {
mockFetchArticles.mockResolvedValue({ articles: [fixtures.article], articlesCount: 1 })
await router.push('/')
})
it('should render correctly', async () => {
const wrapper = render(asyncComponentWrapper(ArticlesList), {
cy.intercept('GET', '/api/articles?*', { body: { articles: [fixtures.article], articlesCount: 1 } })
.as('mockRequest')
mount(ArticlesList, {
global: globalMountOptions,
})
expect(wrapper).toBeTruthy()
expect(mockFetchArticles).toBeCalledTimes(1)
cy.wait('@mockRequest')
cy.contains('Article foo')
})
})

View File

@ -33,23 +33,14 @@
</template>
</template>
<script lang="ts">
<script lang="ts" setup>
import { useArticles } from 'src/composable/useArticles'
import { defineComponent } from 'vue'
import { onMounted } from 'vue'
import AppPagination from './AppPagination.vue'
import ArticlesListArticlePreview from './ArticlesListArticlePreview.vue'
import ArticlesListNavigation from './ArticlesListNavigation.vue'
export default defineComponent({
name: 'ArticlesList',
components: {
ArticlesListArticlePreview,
AppPagination,
ArticlesListNavigation,
},
async setup () {
const {
const {
fetchArticles,
articlesDownloading,
articlesCount,
@ -59,20 +50,9 @@ export default defineComponent({
changePage,
tag,
username,
} = useArticles()
} = useArticles()
onMounted(async () => {
await fetchArticles()
return {
articlesDownloading,
articles,
articlesCount,
page,
changePage,
updateArticle,
tag,
username,
}
},
})
</script>

View File

@ -1,32 +1,27 @@
import { fireEvent, render } from '@testing-library/vue'
import { mount } from '@cypress/vue'
import ArticlesListArticlePreview from 'src/components/ArticlesListArticlePreview.vue'
import registerGlobalComponents from 'src/plugins/global-components'
import { router } from 'src/router'
import fixtures from 'src/utils/test/fixtures'
const mockFavoriteArticle = jest.fn()
jest.mock('src/composable/useFavoriteArticle', () => ({
useFavoriteArticle: () => ({
favoriteProcessGoing: false,
favoriteArticle: mockFavoriteArticle,
}),
}))
describe('# ArticlesListArticlePreview', () => {
const favoriteButton = 'Favorite article'
beforeEach(async () => {
await router.push({ name: 'article', params: { slug: fixtures.article.slug } })
})
it('should call favorite method when click favorite button', async () => {
const { getByRole } = render(ArticlesListArticlePreview, {
cy.intercept('POST', '/api/articles/*/favorite', { body: { article: fixtures.articleAfterFavorite } })
.as('mockFavorite')
mount(ArticlesListArticlePreview, {
global: { plugins: [registerGlobalComponents, router] },
props: { article: fixtures.article },
})
await fireEvent.click(getByRole('button', { name: favoriteButton }))
cy.findByRole('button', { name: favoriteButton }).click()
expect(mockFavoriteArticle).toBeCalledTimes(1)
cy.wait('@mockFavorite')
// TODO: Cypress.vueWrapper is undefined
// const events = Cypress.vueWrapper.emitted()
// cy.wrap(events).should('have.property', 'update', fixtures.articleAfterFavorite)
})
})

View File

@ -50,35 +50,23 @@
</div>
</template>
<script lang="ts">
<script lang="ts" setup>
import { useFavoriteArticle } from 'src/composable/useFavoriteArticle'
import { computed, defineComponent, PropType } from 'vue'
export default defineComponent({
name: 'ArticlesListArticlePreview',
props: {
article: {
type: Object as PropType<Article>,
required: true,
},
},
emits: {
update: (article: Article) => !!article.slug,
},
setup (props, { emit }) {
const {
favoriteProcessGoing,
favoriteArticle,
} = useFavoriteArticle({
isFavorited: computed(() => props.article.favorited),
articleSlug: computed(() => props.article.slug),
const props = defineProps<{
article: Article;
}>()
const emit = defineEmits<{
(e: 'update', article: Article): void
}>()
let isFavorited = $computed(() => props.article.favorited)
let articleSlug = $computed(() => props.article.slug)
const { favoriteProcessGoing, favoriteArticle } = useFavoriteArticle({
isFavorited,
articleSlug,
onUpdate: (newArticle: Article): void => emit('update', newArticle),
})
return {
favoriteProcessGoing,
favoriteArticle,
}
},
})
</script>

View File

@ -1,4 +1,4 @@
import { render } from '@testing-library/vue'
import { mount } from '@cypress/vue'
import { GlobalMountOptions } from '@vue/test-utils/dist/types'
import ArticlesListNavigation from 'src/components/ArticlesListNavigation.vue'
import registerGlobalComponents from 'src/plugins/global-components'
@ -18,26 +18,26 @@ describe('# ArticlesListNavigation', () => {
})
it('should render global feed item when passed global feed prop', () => {
const { container } = render(ArticlesListNavigation, {
mount(ArticlesListNavigation, {
global: globalMountOptions,
props: { tag: '', username: '', useGlobalFeed: true },
})
const items = container.querySelectorAll('.nav-item')
expect(items).toHaveLength(1)
expect(items[0].textContent).toContain('Global Feed')
cy.get('.nav-item')
.should('have.length', 1)
.should('contain.text', 'Global Feed')
})
it('should render full item', () => {
const { container } = render(ArticlesListNavigation, {
mount(ArticlesListNavigation, {
global: globalMountOptions,
props: { tag: 'foo', username: '', useGlobalFeed: true, useMyFeed: true, useTagFeed: true },
})
const items = container.querySelectorAll('.nav-item')
expect(items).toHaveLength(3)
expect(items[0].textContent).toContain('Global Feed')
expect(items[1].textContent).toContain('Your Feed')
expect(items[2].textContent).toContain('foo')
cy.get('.nav-item')
.should('have.length', 3)
.should('contain.text', 'Global Feed')
.should('contain.text', 'Your Feed')
.should('contain.text', 'foo')
})
})

View File

@ -22,13 +22,22 @@
</div>
</template>
<script lang="ts">
<script lang="ts" setup>
import type { ArticlesType } from 'src/composable/useArticles'
import type { AppRouteNames } from 'src/router'
import { isAuthorized } from 'src/store/user'
import { computed, defineComponent } from 'vue'
import type { RouteParams } from 'vue-router'
const props = defineProps<{
tag: string;
username: string;
useGlobalFeed?: boolean;
useMyFeed?: boolean;
useTagFeed?: boolean;
useUserFeed?: boolean;
useUserFavorited?: boolean;
}>()
interface ArticlesListNavLink {
name: ArticlesType
routeName: AppRouteNames
@ -37,19 +46,7 @@ interface ArticlesListNavLink {
icon?: string
}
export default defineComponent({
name: 'ArticlesListNavigation',
props: {
useGlobalFeed: { type: Boolean, default: false },
useMyFeed: { type: Boolean, default: false },
useTagFeed: { type: Boolean, default: false },
useUserFeed: { type: Boolean, default: false },
useUserFavorited: { type: Boolean, default: false },
tag: { type: String, required: true },
username: { type: String, required: true },
},
setup (props) {
const allLinks = computed<ArticlesListNavLink[]>(() => [
let allLinks = $computed<ArticlesListNavLink[]>(() => [
{
name: 'global-feed',
routeName: 'global-feed',
@ -79,21 +76,15 @@ export default defineComponent({
routeParams: { username: props.username },
title: 'Favorited Articles',
},
])
])
const show = computed<Record<ArticlesType, boolean>>(() => ({
'global-feed': props.useGlobalFeed,
'my-feed': props.useMyFeed && isAuthorized.value,
'tag-feed': props.useTagFeed && props.tag !== '',
'user-feed': props.useUserFeed && props.username !== '',
'user-favorites-feed': props.useUserFavorited && props.username !== '',
}))
let show = $computed<Record<ArticlesType, boolean>>(() => ({
'global-feed': props.useGlobalFeed ?? false,
'my-feed': (props.useMyFeed && isAuthorized.value) ?? false,
'tag-feed': (props.useTagFeed && props.tag !== '') ?? false,
'user-feed': (props.useUserFeed && props.username !== '') ?? false,
'user-favorites-feed': (props.useUserFavorited && props.username !== '') ?? false,
}))
const links = computed<ArticlesListNavLink[]>(() => allLinks.value.filter(link => show.value[link.name]))
return {
links,
}
},
})
let links = $computed<ArticlesListNavLink[]>(() => allLinks.filter(link => show[link.name]))
</script>

View File

@ -1,29 +1,17 @@
import { render } from '@testing-library/vue'
import { mount } from '@cypress/vue'
import PopularTags from 'src/components/PopularTags.vue'
import { useTags } from 'src/composable/useTags'
import registerGlobalComponents from 'src/plugins/global-components'
import { router } from 'src/router'
import { ref } from 'vue'
jest.mock('src/composable/useTags')
describe('# PopularTags', () => {
const mockUseTags = useTags as jest.MockedFunction<typeof useTags>
it('should render correctly', async () => {
cy.intercept('GET', '/api/tags', { body: { tags: ['foo', 'bar'] } })
beforeEach(async () => {
const mockFetchTags = jest.fn()
mockUseTags.mockReturnValue({
tags: ref(['foo', 'bar']),
fetchTags: mockFetchTags,
})
await router.push('/')
})
it.skip('should render correctly', async () => {
const { container } = render(PopularTags, {
mount(PopularTags, {
global: { plugins: [registerGlobalComponents, router] },
})
expect(container.querySelectorAll('.tag-pill')).toHaveLength(2)
cy.get('.tag-pill')
.should('have.length', 2)
})
})

View File

@ -14,20 +14,13 @@
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
<script lang="ts" setup>
import { useTags } from 'src/composable/useTags'
import { onMounted } from 'vue'
export default defineComponent({
name: 'PopularTags',
async setup () {
const { tags, fetchTags } = useTags()
const { tags, fetchTags } = useTags()
onMounted(async () => {
await fetchTags()
return {
tags,
}
},
})
</script>

View File

@ -3,11 +3,10 @@ import { deleteFavoriteArticle, postFavoriteArticle } from 'src/services/article
import type { AuthorizationError } from 'src/types/error'
import createAsyncProcess from 'src/utils/create-async-process'
import type { Either } from 'src/utils/either'
import { ComputedRef } from 'vue'
interface useFavoriteArticleProps {
isFavorited: ComputedRef<boolean>
articleSlug: ComputedRef<string>
isFavorited: boolean
articleSlug: string
onUpdate: (newArticle: Article) => void
}
@ -15,10 +14,10 @@ interface useFavoriteArticleProps {
export const useFavoriteArticle = ({ isFavorited, articleSlug, onUpdate }: useFavoriteArticleProps) => {
const favoriteArticle = async (): Promise<void> => {
let response: Either<AuthorizationError, Article>
if (isFavorited.value) {
response = await deleteFavoriteArticle(articleSlug.value)
if (isFavorited) {
response = await deleteFavoriteArticle(articleSlug)
} else {
response = await postFavoriteArticle(articleSlug.value)
response = await postFavoriteArticle(articleSlug)
}
if (response.isOk()) onUpdate(response.value)

View File

@ -3,11 +3,10 @@ import { deleteFollowProfile, postFollowProfile } from 'src/services/profile/fol
import type { AuthorizationError } from 'src/types/error'
import createAsyncProcess from 'src/utils/create-async-process'
import type { Either } from 'src/utils/either'
import type { ComputedRef } from 'vue'
interface UseFollowProps {
username: ComputedRef<string>
following: ComputedRef<boolean>
username: string
following: boolean
onUpdate: (profile: Profile) => void
}
@ -16,10 +15,10 @@ export function useFollow ({ username, following, onUpdate }: UseFollowProps) {
async function toggleFollow (): Promise<void> {
let response: Either<AuthorizationError, Profile>
if (following.value) {
response = await deleteFollowProfile(username.value)
if (following) {
response = await deleteFollowProfile(username)
} else {
response = await postFollowProfile(username.value)
response = await postFollowProfile(username)
}
if (response.isOk()) onUpdate(response.value)

View File

@ -1,8 +1,9 @@
import { getProfile } from 'src/services/profile/getProfile'
import { ComputedRef, ref, watch } from 'vue'
import { watch, ref } from 'vue'
import type { Ref } from 'vue'
interface UseProfileProps {
username: ComputedRef<string>
username: Ref<string>
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/explicit-function-return-type
@ -11,7 +12,7 @@ export function useProfile ({ username }: UseProfileProps) {
async function fetchProfile (): Promise<void> {
updateProfile(null)
if (!username.value) return
if (!username) return
const profileData = await getProfile(username.value)
updateProfile(profileData)
}

View File

@ -3,7 +3,7 @@ import { ref } from 'vue'
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/explicit-function-return-type
export function useTags () {
const tags = ref<string[]>([])
let tags = ref<string[]>([])
async function fetchTags (): Promise<void> {
tags.value = []

View File

@ -1,4 +1,4 @@
import { render } from '@testing-library/vue'
import { mount } from '@cypress/vue'
import { router } from 'src/router'
import Article from './Article.vue'
@ -8,10 +8,10 @@ describe('# Article', () => {
})
it('should render correctly', () => {
const { container } = render(Article, {
mount(Article, {
global: { plugins: [router] },
})
expect(container.textContent).toContain('Article is downloading')
cy.contains('Article is downloading')
})
})

View File

@ -28,16 +28,7 @@
</div>
</template>
<script lang="ts">
<script lang="ts" setup>
import ArticleDetail from 'src/components/ArticleDetail.vue'
import ArticleDetailComments from 'src/components/ArticleDetailComments.vue'
import { defineComponent } from 'vue'
export default defineComponent({
name: 'ArticlePage',
components: {
ArticleDetail,
ArticleDetailComments,
},
})
</script>

View File

@ -65,10 +65,10 @@
</div>
</template>
<script lang="ts">
<script lang="ts" setup>
import { getArticle } from 'src/services/article/getArticle'
import { postArticle, putArticle } from 'src/services/article/postArticle'
import { computed, defineComponent, onMounted, reactive, ref } from 'vue'
import { onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
interface FormState {
@ -78,30 +78,27 @@ interface FormState {
tagList: string[];
}
export default defineComponent({
name: 'EditArticlePage',
setup () {
const route = useRoute()
const router = useRouter()
const slug = computed<string>(() => route.params.slug as string)
const route = useRoute()
const router = useRouter()
let slug = $computed<string>(() => route.params.slug as string)
const form = reactive<FormState>({
let form = $ref<FormState>({
title: '',
description: '',
body: '',
tagList: [],
})
})
const newTag = ref<string>('')
const addTag = () => {
form.tagList.push(newTag.value.trim())
newTag.value = ''
}
const removeTag = (tag: string) => {
let newTag = $ref('')
const addTag = () => {
form.tagList.push(newTag.trim())
newTag = ''
}
const removeTag = (tag: string) => {
form.tagList = form.tagList.filter(t => t !== tag)
}
}
async function fetchArticle (slug: string) {
async function fetchArticle (slug: string) {
const article = await getArticle(slug)
// FIXME: I always feel a little wordy here
@ -109,29 +106,19 @@ export default defineComponent({
form.description = article.description
form.body = article.body
form.tagList = article.tagList
}
}
onMounted(() => {
if (slug.value) fetchArticle(slug.value)
})
onMounted(() => {
if (slug) fetchArticle(slug)
})
const onSubmit = async () => {
const onSubmit = async () => {
let article: Article
if (slug.value) {
article = await putArticle(slug.value, form)
if (slug) {
article = await putArticle(slug, form)
} else {
article = await postArticle(form)
}
return router.push({ name: 'article', params: { slug: article.slug } })
}
return {
form,
onSubmit,
newTag,
addTag,
removeTag,
}
},
})
}
</script>

View File

@ -43,16 +43,7 @@
</div>
</template>
<script lang="ts">
<script lang="ts" setup>
import ArticlesList from 'src/components/ArticlesList.vue'
import PopularTags from 'src/components/PopularTags.vue'
import { defineComponent } from 'vue'
export default defineComponent({
name: 'HomePage',
components: {
ArticlesList,
PopularTags,
},
})
</script>

View File

@ -60,41 +60,29 @@
</div>
</template>
<script lang="ts">
<script lang="ts" setup>
import { routerPush } from 'src/router'
import { postLogin, PostLoginErrors, PostLoginForm } from 'src/services/auth/postLogin'
import { postLogin } from 'src/services/auth/postLogin'
import type { PostLoginErrors, PostLoginForm } from 'src/services/auth/postLogin'
import { updateUser } from 'src/store/user'
import { defineComponent, reactive, ref } from 'vue'
export default defineComponent({
name: 'LoginPage',
setup () {
const formRef = ref<HTMLFormElement | null>(null)
const form = reactive<PostLoginForm>({
let formRef = $ref<HTMLFormElement | null>(null)
let form = $ref<PostLoginForm>({
email: '',
password: '',
})
})
const errors = ref<PostLoginErrors>({})
let errors = $ref<PostLoginErrors>({})
const login = async () => {
if (!formRef.value?.checkValidity()) return
const login = async () => {
if (!formRef?.checkValidity()) return
const result = await postLogin(form)
if (result.isOk()) {
updateUser(result.value)
await routerPush('global-feed')
} else {
errors.value = await result.value.getErrors()
errors = await result.value.getErrors()
}
}
return {
formRef,
form,
login,
errors,
}
},
})
}
</script>

View File

@ -66,44 +66,27 @@
</div>
</template>
<script lang="ts">
<script lang="ts" setup>
import ArticlesList from 'src/components/ArticlesList.vue'
import { useFollow } from 'src/composable/useFollowProfile'
import { useProfile } from 'src/composable/useProfile'
import { checkAuthorization, user } from 'src/store/user'
import { computed, defineComponent } from 'vue'
import { useRoute } from 'vue-router'
export default defineComponent({
name: 'ProfilePage',
components: {
ArticlesList,
},
setup () {
const route = useRoute()
const username = computed<string>(() => route.params.username as string)
const route = useRoute()
let username = $computed<string>(() => route.params.username as string)
const { profile, updateProfile } = useProfile({ username })
const { profile, updateProfile } = useProfile({ username: $$(username) })
const { followProcessGoing, toggleFollow } = useFollow({
following: computed<boolean>(() => profile.value?.following ?? false),
let following = $computed<boolean>(() => profile.value?.following ?? false)
const { followProcessGoing, toggleFollow } = useFollow({
following,
username,
onUpdate: (newProfileData: Profile) => updateProfile(newProfileData),
})
const showEdit = computed<boolean>(() => checkAuthorization(user) && user.value.username === username.value)
const showFollow = computed<boolean>(() => user.value?.username !== username.value)
return {
profile,
showEdit,
showFollow,
followProcessGoing,
toggleFollow,
}
},
})
let showEdit = $computed<boolean>(() => checkAuthorization(user) && user.value.username === username)
let showFollow = $computed<boolean>(() => user.value?.username !== username)
</script>
<style scoped>

View File

@ -67,42 +67,30 @@
</div>
</template>
<script lang="ts">
<script lang="ts" setup>
import { routerPush } from 'src/router'
import { postRegister, PostRegisterErrors, PostRegisterForm } from 'src/services/auth/postRegister'
import { postRegister } from 'src/services/auth/postRegister'
import type { PostRegisterErrors, PostRegisterForm } from 'src/services/auth/postRegister'
import { updateUser } from 'src/store/user'
import { defineComponent, reactive, ref } from 'vue'
export default defineComponent({
name: 'RegisterPage',
setup () {
const formRef = ref<HTMLFormElement | null>(null)
const form = reactive<PostRegisterForm>({
let formRef = $ref<HTMLFormElement | null>(null)
let form = $ref<PostRegisterForm>({
username: '',
email: '',
password: '',
})
})
const errors = ref<PostRegisterErrors>({})
let errors = $ref<PostRegisterErrors>({})
const register = async () => {
if (!formRef.value?.checkValidity()) return
const register = async () => {
if (!formRef?.checkValidity()) return
const result = await postRegister(form)
if (result.isOk()) {
updateUser(result.value)
await routerPush('global-feed')
} else {
errors.value = await result.value.getErrors()
errors = await result.value.getErrors()
}
}
return {
formRef,
form,
register,
errors,
}
},
})
}
</script>

View File

@ -73,52 +73,41 @@
</div>
</template>
<script lang="ts">
<script lang="ts" setup>
import { routerPush } from 'src/router'
import { putProfile, PutProfileForm } from 'src/services/profile/putProfile'
import { putProfile } from 'src/services/profile/putProfile'
import type { PutProfileForm } from 'src/services/profile/putProfile'
import { checkAuthorization, updateUser, user } from 'src/store/user'
import { computed, defineComponent, onMounted, reactive } from 'vue'
import { onMounted } from 'vue'
export default defineComponent({
name: 'SettingsPage',
setup () {
const form = reactive<PutProfileForm>({})
let form = $ref<PutProfileForm>({})
const onSubmit = async () => {
const onSubmit = async () => {
const filteredForm = Object.entries(form).reduce((a, [k, v]) => (v === null ? a : { ...a, [k]: v }), {})
const userData = await putProfile(filteredForm)
updateUser(userData)
await routerPush('profile', { username: userData.username })
}
}
const onLogout = async () => {
const onLogout = async () => {
updateUser(null)
await routerPush('global-feed')
}
}
onMounted(async () => {
onMounted(async () => {
if (!checkAuthorization(user)) return await routerPush('login')
form.image = user.value.image
form.username = user.value.username
form.bio = user.value.bio
form.email = user.value.email
})
})
const isButtonDisabled = computed(() => (
let isButtonDisabled = $computed(() => (
form.image === user.value?.image &&
form.username === user.value?.username &&
form.bio === user.value?.bio &&
form.email === user.value?.email &&
!form.password
))
return {
form,
onSubmit,
isButtonDisabled,
onLogout,
}
},
})
))
</script>

View File

@ -1,4 +1,4 @@
import marked from 'marked'
import { marked } from 'marked'
import insane from 'insane'
export default (markdown: string): string => {

View File

@ -1,17 +0,0 @@
import 'jest'
import '@testing-library/jest-dom'
jest.spyOn(window.Storage.prototype, 'getItem').mockReturnValue('')
jest.spyOn(window.Storage.prototype, 'setItem').mockImplementation()
jest.mock('src/config', () => ({
CONFIG: {
API_HOST: '',
},
}))
// eslint-disable-next-line @typescript-eslint/no-empty-function
global.fetch = jest.fn().mockImplementation(() => new Promise(() => {}))
afterEach(() => {
jest.clearAllMocks()
})

View File

@ -1,2 +1 @@
declare module 'insane';
declare module 'marked';

View File

@ -7,36 +7,34 @@ describe('# Create async process', function () {
it('should expect active as Vue Ref type', function () {
const { active } = createAsyncProcess(someProcess)
expect(isRef(active)).toBe(true)
cy.wrap(isRef(active)).should('be.true')
})
it('should correctly test active functionality', async function () {
const { active, run } = createAsyncProcess(someProcess)
expect(active.value).toBe(false)
cy.wrap(active.value).should('be.false')
const promise = run()
void run()
expect(active.value).toBe(true)
cy.wrap(active.value).should('be.true')
await promise
expect(active.value).toBe(false)
cy.wrap(active.value).should('be.false')
})
it('should expect run as a function', function () {
const { run } = createAsyncProcess(someProcess)
expect(run).toBeInstanceOf(Function)
cy.wrap(run).should('be.instanceOf', Function)
})
it('should expect original function called with correct params and return correct data', async function () {
const someProcess = jest.fn().mockImplementation(a => Promise.resolve({ a, b: null }))
const someProcess = cy.stub().callsFake(a => Promise.resolve({ a, b: null }))
const { run } = createAsyncProcess(someProcess)
const result = await run(null)
expect(someProcess).toBeCalledWith(null)
expect(result).toEqual({ a: null, b: null })
cy.wrap(someProcess).should('be.calledWith', null)
cy.wrap(result).should('eq', { a: null, b: null })
})
})

View File

@ -5,6 +5,6 @@ describe('# Date filters', function () {
const dateString = '2019-01-01 00:00:00'
const result = dateFilter(dateString)
expect(result).toMatchInlineSnapshot('"January 1"')
cy.wrap(result).should('equal', 'January 1')
})
})

View File

@ -13,9 +13,9 @@ describe('# mapAuthorizationResponse', function () {
const result = mapAuthorizationResponse<Partial<Response>>(response)
expect(isEither(result)).toBe(true)
expect(result.isOk()).toBe(true)
expect(result.value).toEqual(RESPONSE)
cy.wrap(isEither(result)).should('be.true')
cy.wrap(result.isOk()).should('be.true')
cy.wrap(result.value).should('equal', RESPONSE)
})
it('should return Either with AuthorizationError and failed Response', function () {
@ -24,9 +24,9 @@ describe('# mapAuthorizationResponse', function () {
const result = mapAuthorizationResponse<Partial<Response>>(response)
expect(isEither(result)).toBe(true)
expect(result.isFail()).toBe(true)
expect(result.value).toBeInstanceOf(AuthorizationError)
cy.wrap(isEither(result)).should('be.true')
cy.wrap(result.isFail()).should('be.true')
cy.wrap(result.value).should('be.instanceOf', AuthorizationError)
})
it('should throw NetworkError when Response is failed with status != 401', function () {
@ -35,7 +35,7 @@ describe('# mapAuthorizationResponse', function () {
expect(() => {
mapAuthorizationResponse<Partial<Response>>(response)
}).toThrowError(NetworkError)
}).to.throw()
})
})
@ -48,9 +48,9 @@ describe('# mapValidationResponse', function () {
const result = mapValidationResponse<ValidationErrors, Partial<Response>>(response)
expect(isEither(result)).toBe(true)
expect(result.isOk()).toBe(true)
expect(result.value).toEqual(RESPONSE)
cy.wrap(isEither(result)).should('be.true')
cy.wrap(result.isOk()).should('be.true')
cy.wrap(result.value).should('equal', RESPONSE)
})
it('should return Either with ValidationError and failed Response', async function () {
@ -59,10 +59,12 @@ describe('# mapValidationResponse', function () {
const result = mapValidationResponse<ValidationErrors, Partial<Response>>(response)
expect(isEither(result)).toBe(true)
expect(result.isFail()).toBe(true)
expect(result.value).toBeInstanceOf(ValidationError)
expect(result.isFail() && await result.value.getErrors()).toEqual((await RESPONSE.json()).errors)
cy.wrap(isEither(result)).should('be.true')
cy.wrap(result.isFail()).should('be.true')
cy.wrap(result.value).should('be.instanceOf', ValidationError)
const errors = result.isFail() && result.value.getErrors()
const expectErrors = (await RESPONSE.json()).errors
cy.wrap(errors).should('equal', expectErrors)
})
it('should throw NetworkError when Response is failed with status != 422', function () {
@ -71,6 +73,6 @@ describe('# mapValidationResponse', function () {
expect(() => {
mapValidationResponse<ValidationErrors, Partial<Response>>(response)
}).toThrowError(NetworkError)
}).to.throw()
})
})

View File

@ -9,6 +9,6 @@ describe('# params2query', () => {
const result = params2query(params)
expect(result).toEqual('foo=bar&foo2=bar2')
expect(result).to.equal('foo=bar&foo2=bar2')
})
})

View File

@ -1,257 +0,0 @@
import FetchRequest, { FetchRequestOptions } from 'src/utils/request'
import { Either, fail, isEither, success } from 'src/utils/either'
import params2query from 'src/utils/params-to-query'
import mockFetch from 'src/utils/test/mock-fetch'
import wrapTests from 'src/utils/test/wrap-tests'
import { NetworkError } from 'src/types/error'
beforeEach(() => {
mockFetch({ type: 'body' })
})
afterEach(() => {
jest.clearAllMocks()
})
const PREFIX = '/prefix'
const SUB_PREFIX = '/sub-prefix'
const PATH = '/path'
const PARAMS = { q1: 'q1', q2: 'q2' }
type SafeMethod = 'get' | 'delete' | 'checkableGet' | 'checkableDelete'
type UnsafeMethod = 'post' | 'put' | 'patch' | 'checkablePost' | 'checkablePut' | 'checkablePatch'
type Method = SafeMethod | UnsafeMethod
type CheckableSafeMethod = 'checkableGet' | 'checkableDelete'
type CheckableUnsafeMethod = 'checkablePost' | 'checkablePut' | 'checkablePatch'
type CheckableMethod = CheckableSafeMethod | CheckableUnsafeMethod
function isSafe (method: Method): method is SafeMethod {
return ['get', 'delete'].includes(method)
}
function isCheckableSafe (method: CheckableMethod): method is CheckableSafeMethod {
return ['checkableGet', 'checkableDelete'].includes(method)
}
function isCheckable (method: CheckableMethod | Method): method is CheckableMethod {
return ['checkableGet', 'checkableDelete', 'checkablePost', 'checkablePut', 'checkablePatch'].includes(method)
}
async function triggerMethod<T = unknown> (request: FetchRequest, method: Method | CheckableMethod, options?: Partial<FetchRequestOptions>): Promise<T | Either<NetworkError, T>> {
if (isCheckable(method)) {
let response: Either<NetworkError, T>
if (isCheckableSafe(method)) response = await request[method]<T>(PATH, options)
else response = await request[method]<T>(PATH, {}, options)
return response.isOk() ? success(response.value) : fail(response.value)
} else {
let body: T
if (isSafe(method)) body = await request[method]<T>(PATH, options)
else body = await request[method]<T>(PATH, {}, options)
return body
}
}
function forCorrectMethods (task: string, fn: (method: Method) => Promise<void>): void {
wrapTests<Method>({
task,
fn,
list: ['get', 'delete', 'post', 'put', 'patch'],
testName: method => `for method: ${method}`,
})
}
function forCheckableMethods (task: string, fn: (method: CheckableMethod) => Promise<void>): void {
wrapTests<CheckableMethod>({
task,
fn,
list: ['checkableGet', 'checkableDelete', 'checkablePost', 'checkablePut', 'checkablePatch'],
testName: method => `for method: ${method}`,
})
}
function forAllMethods (task: string, fn: (method: Method | CheckableMethod) => Promise<void>): void {
forCheckableMethods(task, fn)
forCorrectMethods(task, fn)
}
forAllMethods('# Should be implemented', async (method) => {
const request = new FetchRequest()
await triggerMethod(request, method)
expect(global.fetch).toBeCalledWith(PATH, expect.objectContaining({
method: method.replace('checkable', '').toUpperCase(),
}))
})
describe('# Should implement prefix', () => {
forAllMethods('should implement global prefix', async (method) => {
const request = new FetchRequest({ prefix: PREFIX })
await triggerMethod(request, method)
expect(global.fetch).toBeCalledWith(`${PREFIX}${PATH}`, expect.any(Object))
})
forAllMethods('should implement local prefix', async (method) => {
const request = new FetchRequest()
await triggerMethod(request, method, { prefix: SUB_PREFIX })
expect(global.fetch).toBeCalledWith(`${SUB_PREFIX}${PATH}`, expect.any(Object))
})
forAllMethods('should implement global + local prefix', async (method) => {
const request = new FetchRequest({ prefix: PREFIX })
await triggerMethod(request, method, { prefix: SUB_PREFIX })
expect(global.fetch).toBeCalledWith(`${SUB_PREFIX}${PATH}`, expect.any(Object))
})
})
describe('# Should convert query object to query string in request url', () => {
forAllMethods('should implement global query', async (method) => {
const request = new FetchRequest({ params: PARAMS })
await triggerMethod(request, method)
expect(global.fetch).toBeCalledWith(`${PATH}?${params2query(PARAMS)}`, expect.any(Object))
})
forAllMethods('should implement local query', async (method) => {
const request = new FetchRequest()
await triggerMethod(request, method, { params: PARAMS })
expect(global.fetch).toBeCalledWith(`${PATH}?${params2query(PARAMS)}`, expect.any(Object))
})
forAllMethods('should implement global + local query', async (method) => {
const options = { params: { q1: 'q1', q2: 'q2' } }
const localOptions = { params: { q1: 'q11', q3: 'q3' } }
const expectedOptions = { params: { q1: 'q11', q2: 'q2', q3: 'q3' } }
const request = new FetchRequest(options)
await triggerMethod(request, method, localOptions)
expect(global.fetch).toBeCalledWith(`${PATH}?${params2query(expectedOptions.params)}`, expect.any(Object))
})
})
describe('# Should work with headers', function () {
forAllMethods('should add headers', async function (method) {
const options = { headers: { h1: 'h1', h2: 'h2' } }
const request = new FetchRequest(options)
await triggerMethod(request, method)
expect(global.fetch).toBeCalledWith(PATH, expect.objectContaining(options))
})
forAllMethods('should merge headers', async function (method) {
const options = { headers: { h1: 'h1', h2: 'h2' } }
const localOptions = { headers: { h1: 'h11', h3: 'h3' } }
const expectedOptions = { headers: { h1: 'h11', h2: 'h2', h3: 'h3' } }
const request = new FetchRequest(options)
await triggerMethod(request, method, localOptions)
expect(global.fetch).toBeCalledWith(PATH, expect.objectContaining(expectedOptions))
})
})
forCorrectMethods('# Should converted correct response body to json', async function (method) {
const DATA = { foo: 'bar' }
mockFetch({ type: 'body', ...DATA })
const request = new FetchRequest()
const body = await triggerMethod(request, method)
expect(body).toMatchObject(DATA)
})
forCheckableMethods('# Should converted checkable response to Either<NetworkError, DATA_TYPE>', async function (method) {
const DATA = { foo: 'bar' }
interface DATA_TYPE { foo: 'bar' }
mockFetch({ type: 'body', ...DATA })
const request = new FetchRequest()
const result = await triggerMethod<DATA_TYPE>(request, method)
const resultIsEither = isEither<unknown, DATA_TYPE>(result)
const resultIsOk = isEither<unknown, DATA_TYPE>(result) && result.isOk()
const resultValue = isEither<unknown, DATA_TYPE>(result) && result.isOk() ? result.value : null
expect(resultIsEither).toBe(true)
expect(resultIsOk).toBe(true)
expect(resultValue).toMatchObject(DATA)
})
forCorrectMethods('# Should throw NetworkError if correct request is not OK', async function (method) {
mockFetch({
type: 'full',
ok: false,
status: 400,
statusText: 'Bad request',
json: async () => ({}),
})
const request = new FetchRequest()
const result = triggerMethod(request, method)
await expect(result).rejects.toBeInstanceOf(NetworkError)
})
forCheckableMethods('# Should return Either<NetworkError, DATA_TYPE> if checkable request is not OK', async function (method) {
mockFetch({
type: 'full',
ok: false,
status: 400,
statusText: 'Bad request',
json: async () => ({}),
})
const request = new FetchRequest()
const result = await triggerMethod(request, method)
const resultIsEither = isEither<NetworkError, unknown>(result)
const resultIsNotOk = isEither<NetworkError, unknown>(result) && result.isFail()
const resultValue = isEither<NetworkError, unknown>(result) && result.isFail() ? result.value : null
expect(resultIsEither).toBe(true)
expect(resultIsNotOk).toBe(true)
expect(resultValue).toBeInstanceOf(NetworkError)
})
describe('# Authorization header', function () {
const TOKEN = 'token'
const OPTIONS = { headers: { Authorization: `Token ${TOKEN}` } }
forAllMethods('should add authorization header', async function (method) {
const request = new FetchRequest()
request.setAuthorizationHeader(TOKEN)
await triggerMethod(request, method)
expect(global.fetch).toBeCalledWith(PATH, expect.objectContaining(OPTIONS))
})
forAllMethods('should remove authorization header', async function (method) {
const request = new FetchRequest(OPTIONS)
await triggerMethod(request, method)
expect(global.fetch).toBeCalledTimes(1)
expect(global.fetch).toBeCalledWith(PATH, expect.objectContaining(OPTIONS))
request.deleteAuthorizationHeader()
await triggerMethod(request, method)
expect(global.fetch).toBeCalledTimes(2)
expect(global.fetch).toBeCalledWith(PATH, expect.objectContaining({
headers: {},
}))
})
})

View File

@ -1,57 +0,0 @@
import mockLocalStorage from './test/mock-local-storage'
import Storage from './storage'
describe('# storage', function () {
const DATA = { foo: 'bar' }
const KEY = 'key'
const storage = new Storage<typeof DATA>(KEY)
describe('# GET', function () {
it('should be called with correct key', function () {
const fn = mockLocalStorage('getItem')
storage.get()
expect(fn).toBeCalledWith(KEY)
})
it('should get an object given valid local storage item', function () {
mockLocalStorage('getItem', DATA)
const result = storage.get()
expect(result).toMatchObject(DATA)
})
it('should get null if invalid storage item given', function () {
mockLocalStorage('getItem', '{invalid value}', false)
expect(() => {
const result = storage.get()
expect(result).toBeNull()
}).not.toThrow()
})
})
describe('# SET', () => {
it('should be called with correct key and value', function () {
const fn = mockLocalStorage('setItem')
storage.set(DATA)
expect(fn).toBeCalledWith(KEY, JSON.stringify(DATA))
})
})
describe('# REMOVE', () => {
it('should be called with correct key', function () {
const fn = mockLocalStorage('removeItem')
storage.remove()
expect(fn).toBeCalledWith(KEY)
})
})
})

View File

@ -27,6 +27,12 @@ This is **Strong** content.`,
updatedAt: '2020-01-01T00:00:00Z',
}
const articleAfterFavorite: Article = {
...article,
favorited: true,
favoritesCount: 1,
}
const comment: ArticleComment = {
id: 1,
author,
@ -51,6 +57,7 @@ export default {
author,
user,
article,
articleAfterFavorite,
comment,
comment2,
markdown,

View File

@ -1,27 +0,0 @@
interface FetchResponseBody {
type: 'body'
}
interface FetchResponseFull {
type: 'full'
ok: boolean
status: number
statusText: string
json: () => Promise<unknown>
}
export default function mockFetch (data: FetchResponseBody | FetchResponseFull): void {
let response
const { type, ...body } = data
if (type === 'body') {
response = {
ok: true,
status: 200,
json: async () => body,
}
} else {
response = body
}
global.fetch = jest.fn().mockResolvedValue(response)
}

View File

@ -1,10 +0,0 @@
type LocalStorageKey = 'getItem' | 'setItem' | 'removeItem'
export default function mockLocalStorage<T> (key: LocalStorageKey, data?: T, stringify = true): jest.Mock {
const fn = jest.fn().mockReturnValue(stringify ? JSON.stringify(data) : data)
// use __proto__ because jsdom bug: https://github.com/facebook/jest/issues/6798#issuecomment-412871616
// eslint-disable-next-line no-proto
global.localStorage.__proto__[key] = fn
return fn
}

View File

@ -13,6 +13,7 @@
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"noUnusedLocals": true,
"types": ["cypress", "@testing-library/cypress"]
},
"include": [
"src",

View File

@ -10,7 +10,11 @@ export default defineConfig({
},
},
plugins: [
vue(),
vue({
script: {
refSugar: true,
},
}),
analyzer({ summaryOnly: true }),
],
})

4298
yarn.lock

File diff suppressed because it is too large Load Diff