refcator tests for apollo calls

This commit is contained in:
Moriz Wahl 2023-01-19 18:21:00 +01:00
parent 7bd643b98b
commit be65595bee
3 changed files with 325 additions and 271 deletions

View File

@ -1,8 +1,8 @@
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import Community from './Community' import Community from './Community'
import { toastErrorSpy, toastSuccessSpy } from '@test/testSetup' import { toastErrorSpy, toastSuccessSpy, toastInfoSpy } from '@test/testSetup'
import { createContribution, updateContribution, deleteContribution } from '@/graphql/mutations' import { createContribution, updateContribution, deleteContribution } from '@/graphql/mutations'
import { listContributions, listAllContributions } from '@/graphql/queries' import { listContributions, listAllContributions, openCreations } from '@/graphql/queries'
import { createMockClient } from 'mock-apollo-client' import { createMockClient } from 'mock-apollo-client'
import VueApollo from 'vue-apollo' import VueApollo from 'vue-apollo'
@ -12,54 +12,48 @@ const apolloProvider = new VueApollo({
}) })
const localVue = global.localVue const localVue = global.localVue
localVue.use(VueApollo)
const mockStoreDispach = jest.fn() const mockStoreDispach = jest.fn()
const apolloQueryMock = jest.fn() const routerPushMock = jest.fn()
const apolloMutationMock = jest.fn()
const apolloRefetchMock = jest.fn()
describe('Community', () => { describe('Community', () => {
let wrapper let wrapper
const mocks = { mockClient.setRequestHandler(
$t: jest.fn((t) => t), openCreations,
$d: jest.fn((d) => d), jest
$apollo: { .fn()
query: apolloQueryMock, .mockRejectedValueOnce({ message: 'Open Creations failed!' })
mutate: apolloMutationMock, .mockResolvedValue({
queries: { data: {
OpenCreations: { openCreations: [
refetch: apolloRefetchMock, {
month: 0,
year: 2023,
amount: '1000',
}, },
{
month: 1,
year: 2023,
amount: '1000',
}, },
{
month: 2,
year: 2023,
amount: '1000',
}, },
$store: { ],
dispatch: mockStoreDispach,
state: {
creation: ['1000', '1000', '1000'],
}, },
}, }),
$i18n: { )
locale: 'en',
},
$router: {
push: jest.fn(),
},
$route: {
hash: 'my',
},
}
const Wrapper = () => { mockClient.setRequestHandler(
return mount(Community, { listContributions,
localVue, jest
mocks, .fn()
}) .mockRejectedValueOnce({ message: 'List Contributions failed!' })
} .mockResolvedValue({
describe('mount', () => {
beforeEach(() => {
apolloQueryMock.mockResolvedValue({
data: { data: {
listContributions: { listContributions: {
contributionList: [ contributionList: [
@ -71,10 +65,40 @@ describe('Community', () => {
deletedAt: null, deletedAt: null,
confirmedBy: null, confirmedBy: null,
confirmedAt: null, confirmedAt: null,
firstName: 'Bibi',
contributionDate: '2022-07-15T08:47:06.000Z',
lastName: 'Bloxberg',
state: 'IN_PROGRESS',
messagesCount: 0,
},
{
id: 1550,
amount: '200',
memo: 'Fleisig, fleisig am Arbeiten gewesen',
createdAt: '2022-07-15T08:47:06.000Z',
deletedAt: null,
confirmedBy: null,
confirmedAt: null,
firstName: 'Bibi',
contributionDate: '2022-06-15T08:47:06.000Z',
lastName: 'Bloxberg',
state: 'CONFIRMED',
messagesCount: 0,
}, },
], ],
contributionCount: 1, contributionCount: 1,
}, },
},
}),
)
mockClient.setRequestHandler(
listAllContributions,
jest
.fn()
.mockRejectedValueOnce({ message: 'List All Contributions failed!' })
.mockResolvedValue({
data: {
listAllContributions: { listAllContributions: {
contributionList: [ contributionList: [
{ {
@ -82,29 +106,137 @@ describe('Community', () => {
amount: '200', amount: '200',
memo: 'Fleisig, fleisig am Arbeiten mein lieber Freund, 50 Zeichen sind viel', memo: 'Fleisig, fleisig am Arbeiten mein lieber Freund, 50 Zeichen sind viel',
createdAt: '2022-07-15T08:47:06.000Z', createdAt: '2022-07-15T08:47:06.000Z',
contributionDate: '2022-07-15T08:47:06.000Z',
deletedAt: null, deletedAt: null,
confirmedBy: null, confirmedBy: null,
confirmedAt: null, confirmedAt: null,
firstName: 'Bibi',
lastName: 'Bloxberg',
},
{
id: 1550,
amount: '200',
memo: 'Fleisig, fleisig am Arbeiten gewesen',
createdAt: '2022-07-15T08:47:06.000Z',
deletedAt: null,
confirmedBy: null,
confirmedAt: null,
firstName: 'Bibi',
contributionDate: '2022-06-15T08:47:06.000Z',
lastName: 'Bloxberg',
messagesCount: 0,
}, },
{ {
id: 1556, id: 1556,
amount: '400', amount: '400',
memo: 'Ein anderer lieber Freund ist auch sehr felißig am Arbeiten!!!!', memo: 'Ein anderer lieber Freund ist auch sehr felißig am Arbeiten!!!!',
createdAt: '2022-07-16T08:47:06.000Z', createdAt: '2022-07-16T08:47:06.000Z',
contributionDate: '2022-07-16T08:47:06.000Z',
deletedAt: null, deletedAt: null,
confirmedBy: null, confirmedBy: null,
confirmedAt: null, confirmedAt: null,
firstName: 'Bob',
lastName: 'der Baumeister',
}, },
], ],
contributionCount: 2, contributionCount: 3,
}, },
}, },
}),
)
mockClient.setRequestHandler(
createContribution,
jest
.fn()
.mockRejectedValueOnce({ message: 'Create Contribution failed!' })
.mockResolvedValue({
data: {
createContribution: true,
},
}),
)
mockClient.setRequestHandler(
updateContribution,
jest
.fn()
.mockRejectedValueOnce({ message: 'Update Contribution failed!' })
.mockResolvedValue({
data: {
updateContribution: true,
},
}),
)
mockClient.setRequestHandler(
deleteContribution,
jest
.fn()
.mockRejectedValueOnce({ message: 'Delete Contribution failed!' })
.mockResolvedValue({
data: {
deleteContribution: true,
},
}),
)
const mocks = {
$t: jest.fn((t) => t),
$d: jest.fn((d) => d),
$store: {
dispatch: mockStoreDispach,
state: {
user: {
firstName: 'Bibi',
lastName: 'Bloxberg',
},
},
},
$i18n: {
locale: 'en',
},
$router: {
push: routerPushMock,
},
$route: {
hash: '#edit',
},
}
const Wrapper = () => {
return mount(Community, {
localVue,
mocks,
apolloProvider,
}) })
}
let apolloMutateSpy
let refetchContributionsSpy
let refetchAllContributionsSpy
let refetchOpenCreationsSpy
describe('mount', () => {
beforeEach(() => {
jest.clearAllMocks()
wrapper = Wrapper() wrapper = Wrapper()
apolloMutateSpy = jest.spyOn(wrapper.vm.$apollo, 'mutate')
refetchContributionsSpy = jest.spyOn(wrapper.vm.$apollo.queries.ListContributions, 'refetch')
refetchAllContributionsSpy = jest.spyOn(
wrapper.vm.$apollo.queries.ListAllContributions,
'refetch',
)
refetchOpenCreationsSpy = jest.spyOn(wrapper.vm.$apollo.queries.OpenCreations, 'refetch')
}) })
it('has a DIV .community-page', () => { describe('server response for queries is error', () => {
expect(wrapper.find('div.community-page').exists()).toBe(true) it('toasts three errors', () => {
expect(toastErrorSpy).toBeCalledTimes(3)
expect(toastErrorSpy).toBeCalledWith('Open Creations failed!')
expect(toastErrorSpy).toBeCalledWith('List Contributions failed!')
expect(toastErrorSpy).toBeCalledWith('List All Contributions failed!')
})
}) })
describe('tabs', () => { describe('tabs', () => {
@ -112,60 +244,51 @@ describe('Community', () => {
expect(wrapper.findAll('div[role="tabpanel"]')).toHaveLength(3) expect(wrapper.findAll('div[role="tabpanel"]')).toHaveLength(3)
}) })
it.todo('check for correct tabIndex if state is "IN_PROGRESS" or not') it('check for correct tabIndex if state is "IN_PROGRESS" or not', () => {
expect(routerPushMock).toBeCalledWith({ path: '/community#my' })
})
it('toasts an info', () => {
expect(toastInfoSpy).toBeCalledWith(
'Du hast eine Rückfrage auf eine Contribution. Bitte beantworte diese!',
)
})
}) })
describe('API calls after creation', () => { describe('API calls after creation', () => {
it('has a DIV .community-page', () => {
expect(wrapper.find('div.community-page').exists()).toBe(true)
})
it('emits update transactions', () => { it('emits update transactions', () => {
expect(wrapper.emitted('update-transactions')).toEqual([[0]]) expect(wrapper.emitted('update-transactions')).toEqual([[0]])
}) })
})
it('queries list of own contributions', () => { describe('save contrubtion', () => {
expect(apolloQueryMock).toBeCalledWith({ describe('with error', () => {
fetchPolicy: 'no-cache', const now = new Date().toISOString()
query: listContributions, beforeEach(async () => {
variables: { await wrapper.setData({
currentPage: 1, form: {
pageSize: 25, id: null,
date: now,
memo: 'Mein Beitrag zur Gemeinschaft für diesen Monat ...',
amount: '200',
}, },
}) })
await wrapper.find('form').trigger('submit')
}) })
it('queries list of all contributions', () => { it('toasts the error message', () => {
expect(apolloQueryMock).toBeCalledWith({ expect(toastErrorSpy).toBeCalledWith('Create Contribution failed!')
fetchPolicy: 'no-cache',
query: listAllContributions,
variables: {
currentPage: 1,
pageSize: 25,
},
}) })
}) })
describe('server response is error', () => {
beforeEach(() => {
jest.clearAllMocks()
apolloQueryMock.mockRejectedValue({ message: 'Ups' })
wrapper = Wrapper()
})
it('toasts two errors', () => {
expect(toastErrorSpy).toBeCalledTimes(2)
expect(toastErrorSpy).toBeCalledWith('Ups')
})
})
})
describe('set contrubtion', () => {
describe('with success', () => { describe('with success', () => {
const now = new Date().toISOString() const now = new Date().toISOString()
beforeEach(async () => { beforeEach(async () => {
jest.clearAllMocks() jest.clearAllMocks()
apolloMutationMock.mockResolvedValue({
data: {
createContribution: true,
},
})
await wrapper.setData({ await wrapper.setData({
form: { form: {
id: null, id: null,
@ -178,7 +301,7 @@ describe('Community', () => {
}) })
it('calls the create contribution mutation', () => { it('calls the create contribution mutation', () => {
expect(apolloMutationMock).toBeCalledWith({ expect(apolloMutateSpy).toBeCalledWith({
fetchPolicy: 'no-cache', fetchPolicy: 'no-cache',
mutation: createContribution, mutation: createContribution,
variables: { variables: {
@ -194,62 +317,49 @@ describe('Community', () => {
}) })
it('updates the contribution list', () => { it('updates the contribution list', () => {
expect(apolloQueryMock).toBeCalledWith({ expect(refetchContributionsSpy).toBeCalled()
fetchPolicy: 'no-cache',
query: listContributions,
variables: {
currentPage: 1,
pageSize: 25,
},
})
}) })
it('verifies the login (to get the new creations available)', () => { it('updates the all contribution list', () => {
expect(apolloRefetchMock).toBeCalled() expect(refetchAllContributionsSpy).toBeCalled()
}) })
it('set all data to the default values)', () => { it('updates the open creations', () => {
expect(refetchOpenCreationsSpy).toBeCalled()
})
it('sets all data to the default values)', () => {
expect(wrapper.vm.form.id).toBe(null) expect(wrapper.vm.form.id).toBe(null)
expect(wrapper.vm.form.date).toBe('') expect(wrapper.vm.form.date).toBe('')
expect(wrapper.vm.form.memo).toBe('') expect(wrapper.vm.form.memo).toBe('')
expect(wrapper.vm.form.amount).toBe('') expect(wrapper.vm.form.amount).toBe('')
}) })
}) })
describe('with error', () => {
const now = new Date().toISOString()
beforeEach(async () => {
jest.clearAllMocks()
apolloMutationMock.mockRejectedValue({
message: 'Ouch!',
})
await wrapper.setData({
form: {
id: null,
date: now,
memo: 'Mein Beitrag zur Gemeinschaft für diesen Monat ...',
amount: '200',
},
})
await wrapper.find('form').trigger('submit')
})
it('toasts the error message', () => {
expect(toastErrorSpy).toBeCalledWith('Ouch!')
})
})
}) })
describe('update contrubtion', () => { describe('update contrubtion', () => {
describe('with error', () => {
const now = new Date().toISOString()
beforeEach(async () => {
await wrapper
.findComponent({ name: 'ContributionForm' })
.vm.$emit('update-contribution', {
id: 2,
date: now,
memo: 'Mein Beitrag zur Gemeinschaft für diesen Monat ...',
amount: '400',
})
})
it('toasts the error message', () => {
expect(toastErrorSpy).toBeCalledWith('Update Contribution failed!')
})
})
describe('with success', () => { describe('with success', () => {
const now = new Date().toISOString() const now = new Date().toISOString()
beforeEach(async () => { beforeEach(async () => {
jest.clearAllMocks() jest.clearAllMocks()
apolloMutationMock.mockResolvedValue({
data: {
updateContribution: true,
},
})
await wrapper await wrapper
.findComponent({ name: 'ContributionForm' }) .findComponent({ name: 'ContributionForm' })
.vm.$emit('update-contribution', { .vm.$emit('update-contribution', {
@ -261,7 +371,7 @@ describe('Community', () => {
}) })
it('calls the update contribution mutation', () => { it('calls the update contribution mutation', () => {
expect(apolloMutationMock).toBeCalledWith({ expect(apolloMutateSpy).toBeCalledWith({
fetchPolicy: 'no-cache', fetchPolicy: 'no-cache',
mutation: updateContribution, mutation: updateContribution,
variables: { variables: {
@ -278,40 +388,15 @@ describe('Community', () => {
}) })
it('updates the contribution list', () => { it('updates the contribution list', () => {
expect(apolloQueryMock).toBeCalledWith({ expect(refetchContributionsSpy).toBeCalled()
fetchPolicy: 'no-cache',
query: listContributions,
variables: {
currentPage: 1,
pageSize: 25,
},
})
}) })
it('verifies the login (to get the new creations available)', () => { it('updates the all contribution list', () => {
expect(apolloRefetchMock).toBeCalled() expect(refetchAllContributionsSpy).toBeCalled()
})
}) })
describe('with error', () => { it('updates the open creations', () => {
const now = new Date().toISOString() expect(refetchOpenCreationsSpy).toBeCalled()
beforeEach(async () => {
jest.clearAllMocks()
apolloMutationMock.mockRejectedValue({
message: 'Oh No!',
})
await wrapper
.findComponent({ name: 'ContributionForm' })
.vm.$emit('update-contribution', {
id: 2,
date: now,
memo: 'Mein Beitrag zur Gemeinschaft für diesen Monat ...',
amount: '400',
})
})
it('toasts the error message', () => {
expect(toastErrorSpy).toBeCalledWith('Oh No!')
}) })
}) })
}) })
@ -324,19 +409,25 @@ describe('Community', () => {
contributionListComponent = await wrapper.findComponent({ name: 'ContributionList' }) contributionListComponent = await wrapper.findComponent({ name: 'ContributionList' })
}) })
describe('with success', () => { describe('with error', () => {
beforeEach(async () => { beforeEach(async () => {
jest.clearAllMocks() jest.clearAllMocks()
apolloMutationMock.mockResolvedValue({
data: {
deleteContribution: true,
},
})
contributionListComponent.vm.$emit('delete-contribution', { id: 2 }) contributionListComponent.vm.$emit('delete-contribution', { id: 2 })
}) })
it('toasts the error message', () => {
expect(toastErrorSpy).toBeCalledWith('Delete Contribution failed!')
})
})
describe('with success', () => {
beforeEach(async () => {
jest.clearAllMocks()
await contributionListComponent.vm.$emit('delete-contribution', { id: 2 })
})
it('calls the API', () => { it('calls the API', () => {
expect(apolloMutationMock).toBeCalledWith({ expect(apolloMutateSpy).toBeCalledWith({
fetchPolicy: 'no-cache', fetchPolicy: 'no-cache',
mutation: deleteContribution, mutation: deleteContribution,
variables: { variables: {
@ -350,32 +441,15 @@ describe('Community', () => {
}) })
it('updates the contribution list', () => { it('updates the contribution list', () => {
expect(apolloQueryMock).toBeCalledWith({ expect(refetchContributionsSpy).toBeCalled()
fetchPolicy: 'no-cache',
query: listContributions,
variables: {
currentPage: 1,
pageSize: 25,
},
})
}) })
it('verifies the login (to get the new creations available)', () => { it('updates the all contribution list', () => {
expect(apolloRefetchMock).toBeCalled() expect(refetchAllContributionsSpy).toBeCalled()
})
}) })
describe('with error', () => { it('updates the open creations', () => {
beforeEach(async () => { expect(refetchOpenCreationsSpy).toBeCalled()
jest.clearAllMocks()
apolloMutationMock.mockRejectedValue({
message: 'Oh my god!',
})
contributionListComponent.vm.$emit('delete-contribution', { id: 2 })
})
it('toasts the error message', () => {
expect(toastErrorSpy).toBeCalledWith('Oh my god!')
}) })
}) })
}) })

View File

@ -10,7 +10,7 @@
/> />
<div class="mb-3"></div> <div class="mb-3"></div>
<contribution-form <contribution-form
@set-contribution="setContribution" @set-contribution="saveContribution"
@update-contribution="updateContribution" @update-contribution="updateContribution"
v-model="form" v-model="form"
:isThisMonth="isThisMonth" :isThisMonth="isThisMonth"
@ -70,6 +70,7 @@ export default {
itemsAll: [], itemsAll: [],
currentPage: 1, currentPage: 1,
pageSize: 25, pageSize: 25,
currentPageAll: 1,
pageSizeAll: 25, pageSizeAll: 25,
contributionCount: 0, contributionCount: 0,
contributionCountAll: 0, contributionCountAll: 0,
@ -107,6 +108,51 @@ export default {
this.toastError(message) this.toastError(message)
}, },
}, },
ListAllContributions: {
query() {
return listAllContributions
},
fetchPolicy: 'network-only',
variables() {
return {
currentPage: this.currentPageAll,
pageSize: this.pageSizeAll,
}
},
update({ listAllContributions }) {
this.contributionCountAll = listAllContributions.contributionCount
this.itemsAll = listAllContributions.contributionList
},
error({ message }) {
this.toastError(message)
},
},
ListContributions: {
query() {
return listContributions
},
fetchPolicy: 'network-only',
variables() {
return {
currentPage: this.currentPage,
pageSize: this.pageSize,
}
},
update({ listContributions }) {
this.contributionCount = listContributions.contributionCount
this.items = listContributions.contributionList
if (this.items.find((item) => item.state === 'IN_PROGRESS')) {
this.tabIndex = 1
if (this.$route.hash !== '#my') {
this.$router.push({ path: '/community#my' })
}
this.toastInfo('Du hast eine Rückfrage auf eine Contribution. Bitte beantworte diese!')
}
},
error({ message }) {
this.toastError(message)
},
},
}, },
watch: { watch: {
$route(to, from) { $route(to, from) {
@ -160,7 +206,12 @@ export default {
this.$root.$emit('bv::toggle::collapse', value.id) this.$root.$emit('bv::toggle::collapse', value.id)
}) })
}, },
setContribution(data) { refetchData() {
this.$apollo.queries.ListAllContributions.refetch()
this.$apollo.queries.ListContributions.refetch()
this.$apollo.queries.OpenCreations.refetch()
},
saveContribution(data) {
this.$apollo this.$apollo
.mutate({ .mutate({
fetchPolicy: 'no-cache', fetchPolicy: 'no-cache',
@ -173,15 +224,7 @@ export default {
}) })
.then((result) => { .then((result) => {
this.toastSuccess(this.$t('contribution.submitted')) this.toastSuccess(this.$t('contribution.submitted'))
this.updateListContributions({ this.refetchData()
currentPage: this.currentPage,
pageSize: this.pageSize,
})
this.updateListAllContributions({
currentPage: this.currentPage,
pageSize: this.pageSize,
})
this.$apollo.queries.OpenCreations.refetch()
}) })
.catch((err) => { .catch((err) => {
this.toastError(err.message) this.toastError(err.message)
@ -201,15 +244,7 @@ export default {
}) })
.then((result) => { .then((result) => {
this.toastSuccess(this.$t('contribution.updated')) this.toastSuccess(this.$t('contribution.updated'))
this.updateListContributions({ this.refetchData()
currentPage: this.currentPage,
pageSize: this.pageSize,
})
this.updateListAllContributions({
currentPage: this.currentPage,
pageSize: this.pageSize,
})
this.$apollo.queries.OpenCreations.refetch()
}) })
.catch((err) => { .catch((err) => {
this.toastError(err.message) this.toastError(err.message)
@ -226,68 +261,21 @@ export default {
}) })
.then((result) => { .then((result) => {
this.toastSuccess(this.$t('contribution.deleted')) this.toastSuccess(this.$t('contribution.deleted'))
this.updateListContributions({ this.refetchData()
currentPage: this.currentPage,
pageSize: this.pageSize,
})
this.updateListAllContributions({
currentPage: this.currentPage,
pageSize: this.pageSize,
})
this.$apollo.queries.OpenCreations.refetch()
}) })
.catch((err) => { .catch((err) => {
this.toastError(err.message) this.toastError(err.message)
}) })
}, },
updateListAllContributions(pagination) { updateListAllContributions(pagination) {
this.$apollo this.currentPageAll = pagination.currentPage
.query({ this.pageSizeAll = pagination.pageSize
fetchPolicy: 'no-cache', this.$apollo.queries.ListAllContributions.refetch()
query: listAllContributions,
variables: {
currentPage: pagination.currentPage,
pageSize: pagination.pageSize,
},
})
.then((result) => {
const {
data: { listAllContributions },
} = result
this.contributionCountAll = listAllContributions.contributionCount
this.itemsAll = listAllContributions.contributionList
})
.catch((err) => {
this.toastError(err.message)
})
}, },
updateListContributions(pagination) { updateListContributions(pagination) {
this.$apollo this.currentPage = pagination.currentPage
.query({ this.pageSize = pagination.pageSize
fetchPolicy: 'no-cache', this.$apollo.queries.ListContributions.refetch()
query: listContributions,
variables: {
currentPage: pagination.currentPage,
pageSize: pagination.pageSize,
},
})
.then((result) => {
const {
data: { listContributions },
} = result
this.contributionCount = listContributions.contributionCount
this.items = listContributions.contributionList
if (this.items.find((item) => item.state === 'IN_PROGRESS')) {
this.tabIndex = 1
if (this.$route.hash !== '#my') {
this.$router.push({ path: '/community#my' })
}
this.toastInfo('Du hast eine Rückfrage auf eine Contribution. Bitte beantworte diese!')
}
})
.catch((err) => {
this.toastError(err.message)
})
}, },
updateContributionForm(item) { updateContributionForm(item) {
this.form.id = item.id this.form.id = item.id
@ -306,16 +294,7 @@ export default {
this.items.find((item) => item.id === id).state = 'PENDING' this.items.find((item) => item.id === id).state = 'PENDING'
}, },
}, },
created() { created() {
this.updateListContributions({
currentPage: this.currentPage,
pageSize: this.pageSize,
})
this.updateListAllContributions({
currentPage: this.currentPage,
pageSize: this.pageSize,
})
this.updateTransactions(0) this.updateTransactions(0)
this.tabIndex = 1 this.tabIndex = 1
this.$router.push({ path: '/community#my' }) this.$router.push({ path: '/community#my' })

View File

@ -22,6 +22,7 @@ import { loadFilters } from '@/filters/amount'
import { toasters } from '@/mixins/toaster' import { toasters } from '@/mixins/toaster'
export const toastErrorSpy = jest.spyOn(toasters.methods, 'toastError') export const toastErrorSpy = jest.spyOn(toasters.methods, 'toastError')
export const toastSuccessSpy = jest.spyOn(toasters.methods, 'toastSuccess') export const toastSuccessSpy = jest.spyOn(toasters.methods, 'toastSuccess')
export const toastInfoSpy = jest.spyOn(toasters.methods, 'toastInfo')
Object.keys(rules).forEach((rule) => { Object.keys(rules).forEach((rule) => {
extend(rule, { extend(rule, {