Compare commits
6 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
60fd361870 | |
|
|
23d80cd7e5 | |
|
|
a69f162b89 | |
|
|
c56d020525 | |
|
|
fbc1985de2 | |
|
|
7c14a84dea |
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
# 
|
||||
|
||||
[](https://github.com/mutoe/vue3-realworld-example-app/actions?query=branch%3Amaster)
|
||||
[](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.
|
||||
|
||||
----
|
||||
|
||||
[](https://app.codecov.io/gh/mutoe/vue3-realworld-example-app/branch/master)
|
||||
[](https://app.codecov.io/gh/mutoe/vue3-realworld-example-app/branch/script-setup)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
{
|
||||
"projectId": "j7s91r",
|
||||
"baseUrl": "http://localhost:3000"
|
||||
"baseUrl": "http://localhost:3000",
|
||||
"componentFolder": "src",
|
||||
"testFiles": "**/*.spec.ts?(x)"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
89
package.json
89
package.json
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
11
src/App.vue
11
src/App.vue
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -21,11 +21,3 @@
|
|||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'AppFooter',
|
||||
})
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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('/')
|
||||
})
|
||||
|
||||
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')
|
||||
afterEach(() => {
|
||||
Cypress.vueWrapper.unmount()
|
||||
})
|
||||
|
||||
it('should render xxx when user logged', () => {
|
||||
updateUser({ id: 1, username: 'foo', email: '', token: '', bio: undefined, image: undefined })
|
||||
const { container } = render(AppNavigation, {
|
||||
it('should render Sign in and Sign up when user not logged', () => {
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,55 +44,46 @@ 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[]>(() => [
|
||||
{
|
||||
name: 'global-feed',
|
||||
title: 'Home',
|
||||
display: 'all',
|
||||
},
|
||||
{
|
||||
name: 'login',
|
||||
title: 'Sign in',
|
||||
display: 'anonym',
|
||||
},
|
||||
{
|
||||
name: 'register',
|
||||
title: 'Sign up',
|
||||
display: 'anonym',
|
||||
},
|
||||
{
|
||||
name: 'create-article',
|
||||
title: 'New Post',
|
||||
display: 'authorized',
|
||||
icon: 'ion-compose',
|
||||
},
|
||||
{
|
||||
name: 'settings',
|
||||
title: 'Settings',
|
||||
display: 'authorized',
|
||||
icon: 'ion-gear-a',
|
||||
},
|
||||
{
|
||||
name: 'profile',
|
||||
params: { username: username.value },
|
||||
title: username.value || '',
|
||||
display: 'authorized',
|
||||
},
|
||||
])
|
||||
|
||||
const navLinks = computed(() => allNavLinks.value.filter(
|
||||
l => l.display === displayStatus.value || l.display === 'all',
|
||||
))
|
||||
|
||||
return {
|
||||
navLinks,
|
||||
}
|
||||
let allNavLinks = $computed<NavLink[]>(() => [
|
||||
{
|
||||
name: 'global-feed',
|
||||
title: 'Home',
|
||||
display: 'all',
|
||||
},
|
||||
})
|
||||
{
|
||||
name: 'login',
|
||||
title: 'Sign in',
|
||||
display: 'anonym',
|
||||
},
|
||||
{
|
||||
name: 'register',
|
||||
title: 'Sign up',
|
||||
display: 'anonym',
|
||||
},
|
||||
{
|
||||
name: 'create-article',
|
||||
title: 'New Post',
|
||||
display: 'authorized',
|
||||
icon: 'ion-compose',
|
||||
},
|
||||
{
|
||||
name: 'settings',
|
||||
title: 'Settings',
|
||||
display: 'authorized',
|
||||
icon: 'ion-gear-a',
|
||||
},
|
||||
{
|
||||
name: 'profile',
|
||||
params: { username },
|
||||
title: username || '',
|
||||
display: 'authorized',
|
||||
},
|
||||
])
|
||||
|
||||
let navLinks = $computed(() =>
|
||||
allNavLinks.filter((l) => l.display === displayStatus || l.display === 'all'),
|
||||
)
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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', '')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
Object.assign(article, newArticle)
|
||||
}
|
||||
|
||||
return {
|
||||
article,
|
||||
articleHandledBody,
|
||||
slug,
|
||||
updateArticle,
|
||||
}
|
||||
},
|
||||
})
|
||||
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)
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
await deleteComment(slug, commentId)
|
||||
comments.value = comments.value.filter(c => c.id !== commentId)
|
||||
}
|
||||
const removeComment = async (commentId: number) => {
|
||||
await deleteComment(slug, 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>
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
emit('add-comment', newComment)
|
||||
comment.value = ''
|
||||
}
|
||||
let username = $computed(() => checkAuthorization(user) ? user.value.username : '')
|
||||
const { profile } = useProfile({ username: $$(username) })
|
||||
|
||||
return {
|
||||
profile,
|
||||
comment,
|
||||
submitComment,
|
||||
}
|
||||
},
|
||||
})
|
||||
let comment = $ref('')
|
||||
|
||||
const submitComment = async () => {
|
||||
const newComment = await postComment(props.articleSlug, comment)
|
||||
emit('add-comment', newComment)
|
||||
comment = ''
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -63,56 +63,46 @@
|
|||
</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),
|
||||
onUpdate: newArticle => emit('update', newArticle),
|
||||
})
|
||||
const emit = defineEmits<{
|
||||
(e: 'update', article: Article): void
|
||||
}>()
|
||||
|
||||
const onDelete = async () => {
|
||||
await deleteArticle(article.value.slug)
|
||||
await routerPush('global-feed')
|
||||
}
|
||||
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)
|
||||
|
||||
const { followProcessGoing, toggleFollow } = useFollow({
|
||||
following: computed(() => article.value.author.following),
|
||||
username: computed(() => article.value.author.username),
|
||||
onUpdate: (author: Profile) => {
|
||||
const newArticle = { ...article.value, author }
|
||||
emit('update', newArticle)
|
||||
},
|
||||
})
|
||||
let isFavorited = $computed(() => article.favorited)
|
||||
let articleSlug = $computed(() => article.slug)
|
||||
const { favoriteProcessGoing, favoriteArticle } = useFavoriteArticle({
|
||||
isFavorited,
|
||||
articleSlug,
|
||||
onUpdate: newArticle => emit('update', newArticle),
|
||||
})
|
||||
|
||||
return {
|
||||
displayEditButton,
|
||||
displayFollowButton,
|
||||
onDelete,
|
||||
favoriteProcessGoing,
|
||||
favoriteArticle,
|
||||
followProcessGoing,
|
||||
toggleFollow,
|
||||
}
|
||||
const onDelete = async () => {
|
||||
await deleteArticle(article.slug)
|
||||
await routerPush('global-feed')
|
||||
}
|
||||
|
||||
let following = $computed(() => article.author.following)
|
||||
let username = $computed(() => article.author.username)
|
||||
const { toggleFollow, followProcessGoing } = useFollow({
|
||||
following,
|
||||
username,
|
||||
onUpdate: (author: Profile) => {
|
||||
const newArticle = { ...article, author }
|
||||
emit('update', newArticle)
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -33,46 +33,26 @@
|
|||
</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,
|
||||
},
|
||||
const {
|
||||
fetchArticles,
|
||||
articlesDownloading,
|
||||
articlesCount,
|
||||
articles,
|
||||
updateArticle,
|
||||
page,
|
||||
changePage,
|
||||
tag,
|
||||
username,
|
||||
} = useArticles()
|
||||
|
||||
async setup () {
|
||||
const {
|
||||
fetchArticles,
|
||||
articlesDownloading,
|
||||
articlesCount,
|
||||
articles,
|
||||
updateArticle,
|
||||
page,
|
||||
changePage,
|
||||
tag,
|
||||
username,
|
||||
} = useArticles()
|
||||
|
||||
await fetchArticles()
|
||||
|
||||
return {
|
||||
articlesDownloading,
|
||||
articles,
|
||||
articlesCount,
|
||||
page,
|
||||
changePage,
|
||||
updateArticle,
|
||||
tag,
|
||||
username,
|
||||
}
|
||||
},
|
||||
onMounted(async () => {
|
||||
await fetchArticles()
|
||||
})
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
onUpdate: (newArticle: Article): void => emit('update', newArticle),
|
||||
})
|
||||
const props = defineProps<{
|
||||
article: Article;
|
||||
}>()
|
||||
|
||||
return {
|
||||
favoriteProcessGoing,
|
||||
favoriteArticle,
|
||||
}
|
||||
},
|
||||
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),
|
||||
})
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,63 +46,45 @@ 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 },
|
||||
let allLinks = $computed<ArticlesListNavLink[]>(() => [
|
||||
{
|
||||
name: 'global-feed',
|
||||
routeName: 'global-feed',
|
||||
title: 'Global Feed',
|
||||
},
|
||||
setup (props) {
|
||||
const allLinks = computed<ArticlesListNavLink[]>(() => [
|
||||
{
|
||||
name: 'global-feed',
|
||||
routeName: 'global-feed',
|
||||
title: 'Global Feed',
|
||||
},
|
||||
{
|
||||
name: 'my-feed',
|
||||
routeName: 'my-feed',
|
||||
title: 'Your Feed',
|
||||
},
|
||||
{
|
||||
name: 'tag-feed',
|
||||
routeName: 'tag',
|
||||
routeParams: { tag: props.tag },
|
||||
title: props.tag,
|
||||
icon: 'ion-pound',
|
||||
},
|
||||
{
|
||||
name: 'user-feed',
|
||||
routeName: 'profile',
|
||||
routeParams: { username: props.username },
|
||||
title: 'My articles',
|
||||
},
|
||||
{
|
||||
name: 'user-favorites-feed',
|
||||
routeName: 'profile-favorites',
|
||||
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 !== '',
|
||||
}))
|
||||
|
||||
const links = computed<ArticlesListNavLink[]>(() => allLinks.value.filter(link => show.value[link.name]))
|
||||
|
||||
return {
|
||||
links,
|
||||
}
|
||||
{
|
||||
name: 'my-feed',
|
||||
routeName: 'my-feed',
|
||||
title: 'Your Feed',
|
||||
},
|
||||
})
|
||||
{
|
||||
name: 'tag-feed',
|
||||
routeName: 'tag',
|
||||
routeParams: { tag: props.tag },
|
||||
title: props.tag,
|
||||
icon: 'ion-pound',
|
||||
},
|
||||
{
|
||||
name: 'user-feed',
|
||||
routeName: 'profile',
|
||||
routeParams: { username: props.username },
|
||||
title: 'My articles',
|
||||
},
|
||||
{
|
||||
name: 'user-favorites-feed',
|
||||
routeName: 'profile-favorites',
|
||||
routeParams: { username: props.username },
|
||||
title: 'Favorited Articles',
|
||||
},
|
||||
])
|
||||
|
||||
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,
|
||||
}))
|
||||
|
||||
let links = $computed<ArticlesListNavLink[]>(() => allLinks.filter(link => show[link.name]))
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
await fetchTags()
|
||||
|
||||
return {
|
||||
tags,
|
||||
}
|
||||
},
|
||||
onMounted(async () => {
|
||||
await fetchTags()
|
||||
})
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,60 +78,47 @@ 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>({
|
||||
title: '',
|
||||
description: '',
|
||||
body: '',
|
||||
tagList: [],
|
||||
})
|
||||
|
||||
const newTag = ref<string>('')
|
||||
const addTag = () => {
|
||||
form.tagList.push(newTag.value.trim())
|
||||
newTag.value = ''
|
||||
}
|
||||
const removeTag = (tag: string) => {
|
||||
form.tagList = form.tagList.filter(t => t !== tag)
|
||||
}
|
||||
|
||||
async function fetchArticle (slug: string) {
|
||||
const article = await getArticle(slug)
|
||||
|
||||
// FIXME: I always feel a little wordy here
|
||||
form.title = article.title
|
||||
form.description = article.description
|
||||
form.body = article.body
|
||||
form.tagList = article.tagList
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (slug.value) fetchArticle(slug.value)
|
||||
})
|
||||
|
||||
const onSubmit = async () => {
|
||||
let article: Article
|
||||
if (slug.value) {
|
||||
article = await putArticle(slug.value, form)
|
||||
} else {
|
||||
article = await postArticle(form)
|
||||
}
|
||||
return router.push({ name: 'article', params: { slug: article.slug } })
|
||||
}
|
||||
|
||||
return {
|
||||
form,
|
||||
onSubmit,
|
||||
newTag,
|
||||
addTag,
|
||||
removeTag,
|
||||
}
|
||||
},
|
||||
let form = $ref<FormState>({
|
||||
title: '',
|
||||
description: '',
|
||||
body: '',
|
||||
tagList: [],
|
||||
})
|
||||
|
||||
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) {
|
||||
const article = await getArticle(slug)
|
||||
|
||||
// FIXME: I always feel a little wordy here
|
||||
form.title = article.title
|
||||
form.description = article.description
|
||||
form.body = article.body
|
||||
form.tagList = article.tagList
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (slug) fetchArticle(slug)
|
||||
})
|
||||
|
||||
const onSubmit = async () => {
|
||||
let article: Article
|
||||
if (slug) {
|
||||
article = await putArticle(slug, form)
|
||||
} else {
|
||||
article = await postArticle(form)
|
||||
}
|
||||
return router.push({ name: 'article', params: { slug: article.slug } })
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>({
|
||||
email: '',
|
||||
password: '',
|
||||
})
|
||||
|
||||
const errors = ref<PostLoginErrors>({})
|
||||
|
||||
const login = async () => {
|
||||
if (!formRef.value?.checkValidity()) return
|
||||
|
||||
const result = await postLogin(form)
|
||||
if (result.isOk()) {
|
||||
updateUser(result.value)
|
||||
await routerPush('global-feed')
|
||||
} else {
|
||||
errors.value = await result.value.getErrors()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
formRef,
|
||||
form,
|
||||
login,
|
||||
errors,
|
||||
}
|
||||
},
|
||||
let formRef = $ref<HTMLFormElement | null>(null)
|
||||
let form = $ref<PostLoginForm>({
|
||||
email: '',
|
||||
password: '',
|
||||
})
|
||||
|
||||
let errors = $ref<PostLoginErrors>({})
|
||||
|
||||
const login = async () => {
|
||||
if (!formRef?.checkValidity()) return
|
||||
|
||||
const result = await postLogin(form)
|
||||
if (result.isOk()) {
|
||||
updateUser(result.value)
|
||||
await routerPush('global-feed')
|
||||
} else {
|
||||
errors = await result.value.getErrors()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
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 following = $computed<boolean>(() => profile.value?.following ?? false)
|
||||
const { followProcessGoing, toggleFollow } = useFollow({
|
||||
following,
|
||||
username,
|
||||
onUpdate: (newProfileData: Profile) => updateProfile(newProfileData),
|
||||
})
|
||||
|
||||
let showEdit = $computed<boolean>(() => checkAuthorization(user) && user.value.username === username)
|
||||
let showFollow = $computed<boolean>(() => user.value?.username !== username)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
|||
|
|
@ -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>({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
})
|
||||
|
||||
const errors = ref<PostRegisterErrors>({})
|
||||
|
||||
const register = async () => {
|
||||
if (!formRef.value?.checkValidity()) return
|
||||
|
||||
const result = await postRegister(form)
|
||||
if (result.isOk()) {
|
||||
updateUser(result.value)
|
||||
await routerPush('global-feed')
|
||||
} else {
|
||||
errors.value = await result.value.getErrors()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
formRef,
|
||||
form,
|
||||
register,
|
||||
errors,
|
||||
}
|
||||
},
|
||||
let formRef = $ref<HTMLFormElement | null>(null)
|
||||
let form = $ref<PostRegisterForm>({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
})
|
||||
|
||||
let errors = $ref<PostRegisterErrors>({})
|
||||
|
||||
const register = async () => {
|
||||
if (!formRef?.checkValidity()) return
|
||||
|
||||
const result = await postRegister(form)
|
||||
if (result.isOk()) {
|
||||
updateUser(result.value)
|
||||
await routerPush('global-feed')
|
||||
} else {
|
||||
errors = await result.value.getErrors()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -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 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 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 () => {
|
||||
updateUser(null)
|
||||
await routerPush('global-feed')
|
||||
}
|
||||
const onLogout = async () => {
|
||||
updateUser(null)
|
||||
await routerPush('global-feed')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!checkAuthorization(user)) return await routerPush('login')
|
||||
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(() => (
|
||||
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,
|
||||
}
|
||||
},
|
||||
form.image = user.value.image
|
||||
form.username = user.value.username
|
||||
form.bio = user.value.bio
|
||||
form.email = user.value.email
|
||||
})
|
||||
|
||||
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
|
||||
))
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import marked from 'marked'
|
||||
import { marked } from 'marked'
|
||||
import insane from 'insane'
|
||||
|
||||
export default (markdown: string): string => {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
@ -1,2 +1 @@
|
|||
declare module 'insane';
|
||||
declare module 'marked';
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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: {},
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@
|
|||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noUnusedLocals": true,
|
||||
"types": ["cypress", "@testing-library/cypress"]
|
||||
},
|
||||
"include": [
|
||||
"src",
|
||||
|
|
|
|||
|
|
@ -10,7 +10,11 @@ export default defineConfig({
|
|||
},
|
||||
},
|
||||
plugins: [
|
||||
vue(),
|
||||
vue({
|
||||
script: {
|
||||
refSugar: true,
|
||||
},
|
||||
}),
|
||||
analyzer({ summaryOnly: true }),
|
||||
],
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue