@antelopejs/dms-mailing 0.4.1 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -339
- package/dist/data/templates-table.js +2 -10
- package/dist/data/templates-table.js.map +1 -1
- package/dist/db/models/index.d.ts +1 -0
- package/dist/db/models/index.js +1 -0
- package/dist/db/models/index.js.map +1 -1
- package/dist/db/models/send-events.model.d.ts +1 -0
- package/dist/db/models/sends.model.d.ts +1 -0
- package/dist/db/models/settings.model.d.ts +1 -0
- package/dist/db/models/suppressions.model.d.ts +1 -0
- package/dist/db/models/template-slugs.model.d.ts +27 -0
- package/dist/db/models/template-slugs.model.js +43 -0
- package/dist/db/models/template-slugs.model.js.map +1 -0
- package/dist/db/models/template-versions.model.d.ts +1 -0
- package/dist/db/models/templates.model.d.ts +1 -0
- package/dist/db/models/webhook-status.model.d.ts +1 -0
- package/dist/db/tables/index.d.ts +1 -0
- package/dist/db/tables/index.js +1 -0
- package/dist/db/tables/index.js.map +1 -1
- package/dist/db/tables/template-slugs.table.d.ts +7 -0
- package/dist/db/tables/template-slugs.table.js +35 -0
- package/dist/db/tables/template-slugs.table.js.map +1 -0
- package/dist/routes/templates.js +1 -0
- package/dist/routes/templates.js.map +1 -1
- package/dist/services/templates.d.ts +2 -0
- package/dist/services/templates.js +26 -0
- package/dist/services/templates.js.map +1 -1
- package/frontend-vue/app/components/CategoriesInput.vue +18 -24
- package/frontend-vue/app/components/DuplicateModal.vue +9 -3
- package/frontend-vue/app/components/Editor.vue +3 -3
- package/frontend-vue/app/components/NewTemplateModal.vue +9 -3
- package/frontend-vue/app/components/OverviewGate.vue +20 -29
- package/frontend-vue/app/components/PageHeader.vue +8 -12
- package/frontend-vue/app/components/RealSendModal.vue +3 -1
- package/frontend-vue/app/components/RenderingModal.vue +5 -7
- package/frontend-vue/app/components/RetentionInput.vue +13 -23
- package/frontend-vue/app/components/SendDrawer.vue +5 -2
- package/frontend-vue/app/components/StarterGallery.vue +11 -9
- package/frontend-vue/app/components/TemplateCard.vue +19 -4
- package/frontend-vue/app/components/TemplateDrawer.vue +8 -2
- package/frontend-vue/app/components/TemplateGallery.vue +25 -20
- package/frontend-vue/app/components/TemplatePreviewFrame.vue +8 -6
- package/frontend-vue/app/components/TestSendModal.vue +3 -1
- package/frontend-vue/app/components/VersionHistory.vue +3 -3
- package/frontend-vue/app/components/WebhookStatus.vue +20 -17
- package/frontend-vue/app/components/editor/EditorBlockList.vue +33 -30
- package/frontend-vue/app/components/editor/EditorPaletteGroup.vue +14 -14
- package/frontend-vue/app/components/editor/EditorWorkspace.vue +5 -0
- package/frontend-vue/app/composables/useEditorActions.ts +3 -1
- package/frontend-vue/app/composables/useEditorSession.ts +6 -4
- package/frontend-vue/app/composables/useMailingApi.ts +5 -58
- package/frontend-vue/app/composables/useMailingReads.ts +229 -0
- package/frontend-vue/app/composables/useOverviewSetup.ts +15 -7
- package/frontend-vue/app/composables/useSortableList.ts +159 -0
- package/frontend-vue/app/composables/useTemplateCategories.ts +8 -27
- package/frontend-vue/app/utils/blocks/dnd.ts +2 -0
- package/frontend-vue/app/utils/gallery.ts +19 -7
- package/frontend-vue/app/utils/period.ts +0 -5
- package/frontend-vue/i18n/locales/mailing-en-GB.json +1 -1
- package/frontend-vue/i18n/locales/mailing-fr-FR.json +1 -1
- package/frontend-vue/package.json +4 -3
- package/frontend-vue/pnpm-lock.yaml +6 -13
- package/frontend-vue/tests/categories-input.test.ts +43 -13
- package/frontend-vue/tests/editor-block-list.test.ts +46 -6
- package/frontend-vue/tests/editor-session.test.ts +4 -0
- package/frontend-vue/tests/gallery.test.ts +16 -0
- package/frontend-vue/tests/helpers/api-data.ts +173 -0
- package/frontend-vue/tests/new-template-modal.test.ts +17 -5
- package/frontend-vue/tests/on-demand-reads.test.ts +81 -0
- package/frontend-vue/tests/overview-gate.test.ts +59 -21
- package/frontend-vue/tests/page-reads.test.ts +157 -0
- package/frontend-vue/tests/period.test.ts +0 -9
- package/frontend-vue/tests/retention-input.test.ts +30 -9
- package/frontend-vue/tests/send-modals.test.ts +8 -1
- package/frontend-vue/tests/sortable-list.test.ts +253 -0
- package/frontend-vue/tests/template-drawer.test.ts +30 -14
- package/frontend-vue/tests/template-gallery.test.ts +45 -24
- package/package.json +31 -14
- package/skills/dms-mailing/SKILL.md +63 -0
- package/src/data/templates-table.ts +7 -27
- package/src/db/models/index.ts +1 -0
- package/src/db/models/template-slugs.model.ts +61 -0
- package/src/db/tables/index.ts +1 -0
- package/src/db/tables/template-slugs.table.ts +29 -0
- package/src/routes/templates.ts +2 -0
- package/src/services/templates.ts +49 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { vi } from 'vitest'
|
|
2
|
+
import { computed, ref, toValue, watch, type MaybeRefOrGetter } from 'vue'
|
|
3
|
+
|
|
4
|
+
/** A backend read a component made through `useApiData`. */
|
|
5
|
+
export interface ApiRead {
|
|
6
|
+
url: string
|
|
7
|
+
query: Record<string, unknown>
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type ApiBackend = (read: ApiRead) => unknown
|
|
11
|
+
|
|
12
|
+
interface ApiDataOptions {
|
|
13
|
+
query?: MaybeRefOrGetter<Record<string, unknown> | undefined>
|
|
14
|
+
pick?: string[]
|
|
15
|
+
immediate?: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ApiDataStub {
|
|
19
|
+
/** Every read made, in order. */
|
|
20
|
+
reads: ApiRead[]
|
|
21
|
+
/** Answers the next reads; a thrown error or a rejection fails them. */
|
|
22
|
+
respond: (backend: ApiBackend) => void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function picked(value: unknown, keys: string[] | undefined): unknown {
|
|
26
|
+
if (!keys || !value || typeof value !== 'object') return value
|
|
27
|
+
const source = value as Record<string, unknown>
|
|
28
|
+
return Object.fromEntries(keys.map((key) => [key, source[key]]))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Stands in for the DMS's `useApiData` (an auto-import the layer's tests do
|
|
33
|
+
* not have): reads the URL, and again whenever it or its query changes, from
|
|
34
|
+
* `respond`'s backend, with the result's `data`, `error`, `status` and
|
|
35
|
+
* `refresh()`; with `immediate: false`, only once `execute()` asks. Whether the server renders it and hands it over is the DMS's
|
|
36
|
+
* and the renderer's, proven on the playground.
|
|
37
|
+
*/
|
|
38
|
+
export function stubApiData(): ApiDataStub {
|
|
39
|
+
const stub: ApiDataStub = {
|
|
40
|
+
reads: [],
|
|
41
|
+
respond: (next) => {
|
|
42
|
+
backend = next
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
let backend: ApiBackend = () => ({})
|
|
46
|
+
vi.stubGlobal(
|
|
47
|
+
'useApiData',
|
|
48
|
+
(
|
|
49
|
+
url: MaybeRefOrGetter<string | null | undefined>,
|
|
50
|
+
options: ApiDataOptions = {},
|
|
51
|
+
) => {
|
|
52
|
+
const data = ref<unknown>(null)
|
|
53
|
+
const error = ref<unknown>(null)
|
|
54
|
+
const status = ref<'idle' | 'pending' | 'success' | 'error'>('idle')
|
|
55
|
+
const request = () => ({
|
|
56
|
+
url: toValue(url),
|
|
57
|
+
query: toValue(options.query) ?? {},
|
|
58
|
+
})
|
|
59
|
+
async function refresh(): Promise<void> {
|
|
60
|
+
const { url: path, query } = request()
|
|
61
|
+
if (!path) return
|
|
62
|
+
const read = { url: path, query }
|
|
63
|
+
stub.reads.push(read)
|
|
64
|
+
status.value = 'pending'
|
|
65
|
+
try {
|
|
66
|
+
data.value = picked(await backend(read), options.pick)
|
|
67
|
+
error.value = null
|
|
68
|
+
status.value = 'success'
|
|
69
|
+
} catch (failure) {
|
|
70
|
+
error.value = failure
|
|
71
|
+
status.value = 'error'
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// A read made on demand (`immediate: false`) waits for `execute()`.
|
|
75
|
+
if (options.immediate !== false)
|
|
76
|
+
watch(
|
|
77
|
+
() => JSON.stringify(request()),
|
|
78
|
+
() => void refresh(),
|
|
79
|
+
{
|
|
80
|
+
immediate: true,
|
|
81
|
+
},
|
|
82
|
+
)
|
|
83
|
+
return {
|
|
84
|
+
data,
|
|
85
|
+
error,
|
|
86
|
+
status,
|
|
87
|
+
pending: computed(() => status.value === 'pending'),
|
|
88
|
+
refresh,
|
|
89
|
+
execute: refresh,
|
|
90
|
+
clear: () => {
|
|
91
|
+
data.value = null
|
|
92
|
+
},
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
)
|
|
96
|
+
return stub
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The DMS's regional formatter, in the zone the tests run in. */
|
|
100
|
+
export function stubRegionalFormat(): void {
|
|
101
|
+
vi.stubGlobal('useRegionalFormat', () => ({
|
|
102
|
+
dateTimeFormat: (options: Intl.DateTimeFormatOptions) =>
|
|
103
|
+
new Intl.DateTimeFormat('en', options),
|
|
104
|
+
}))
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The read routes of the mailing API, as a component's tests mock them. */
|
|
108
|
+
export interface MailingReadMocks {
|
|
109
|
+
templateContent?: (id: string) => unknown
|
|
110
|
+
performance?: (id: string) => unknown
|
|
111
|
+
changes?: (id: string) => unknown
|
|
112
|
+
versions?: (id: string) => unknown
|
|
113
|
+
listTemplates?: () => unknown
|
|
114
|
+
starters?: () => unknown
|
|
115
|
+
starterPreview?: (id: string, locale?: string) => unknown
|
|
116
|
+
send?: (id: string) => unknown
|
|
117
|
+
sendHtml?: (id: string) => unknown
|
|
118
|
+
provider?: () => unknown
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
type ReadRoute = (
|
|
122
|
+
mocks: MailingReadMocks,
|
|
123
|
+
match: RegExpExecArray,
|
|
124
|
+
read: ApiRead,
|
|
125
|
+
) => unknown
|
|
126
|
+
|
|
127
|
+
const READ_ROUTES: Array<[RegExp, ReadRoute]> = [
|
|
128
|
+
[/^\/api\/mailing\/templates\/starters$/, (m) => m.starters?.()],
|
|
129
|
+
[
|
|
130
|
+
/^\/api\/mailing\/templates\/starters\/([^/]+)\/preview$/,
|
|
131
|
+
(m, [, id], { query }) =>
|
|
132
|
+
m.starterPreview?.(id!, query.locale as string | undefined),
|
|
133
|
+
],
|
|
134
|
+
[
|
|
135
|
+
/^\/api\/mailing\/templates\/([^/]+)\/content$/,
|
|
136
|
+
(m, [, id]) => m.templateContent?.(id!),
|
|
137
|
+
],
|
|
138
|
+
[
|
|
139
|
+
/^\/api\/mailing\/templates\/([^/]+)\/performance$/,
|
|
140
|
+
(m, [, id]) => m.performance?.(id!),
|
|
141
|
+
],
|
|
142
|
+
[
|
|
143
|
+
/^\/api\/mailing\/templates\/([^/]+)\/changes$/,
|
|
144
|
+
(m, [, id]) => m.changes?.(id!),
|
|
145
|
+
],
|
|
146
|
+
[
|
|
147
|
+
/^\/api\/mailing\/templates\/([^/]+)\/versions$/,
|
|
148
|
+
(m, [, id]) => m.versions?.(id!),
|
|
149
|
+
],
|
|
150
|
+
[/^\/api\/mailing\/tables\/templates\/list$/, (m) => m.listTemplates?.()],
|
|
151
|
+
[/^\/api\/mailing\/sends\/([^/]+)\/html$/, (m, [, id]) => m.sendHtml?.(id!)],
|
|
152
|
+
[/^\/api\/mailing\/sends\/([^/]+)$/, (m, [, id]) => m.send?.(id!)],
|
|
153
|
+
[/^\/api\/mailing\/provider$/, (m) => m.provider?.()],
|
|
154
|
+
]
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* A backend answering the reads a component makes on demand with the
|
|
158
|
+
* test's mocks of them, and the others (or a read with no mock) with
|
|
159
|
+
* `fallback`.
|
|
160
|
+
*/
|
|
161
|
+
export function mailingReads(
|
|
162
|
+
mocks: MailingReadMocks,
|
|
163
|
+
fallback: ApiBackend = () => ({}),
|
|
164
|
+
): ApiBackend {
|
|
165
|
+
return (read) => {
|
|
166
|
+
for (const [pattern, route] of READ_ROUTES) {
|
|
167
|
+
const match = pattern.exec(read.url)
|
|
168
|
+
const answer = match ? route(mocks, match, read) : undefined
|
|
169
|
+
if (answer !== undefined) return answer
|
|
170
|
+
}
|
|
171
|
+
return fallback(read)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -10,15 +10,16 @@ import {
|
|
|
10
10
|
typeInto,
|
|
11
11
|
type NuxtStubs,
|
|
12
12
|
} from './helpers/nuxt-globals'
|
|
13
|
+
import { mailingReads, stubApiData } from './helpers/api-data'
|
|
13
14
|
|
|
14
15
|
const api = {
|
|
15
16
|
listTemplates: vi.fn(),
|
|
16
17
|
starters: vi.fn(),
|
|
17
|
-
listCategories: vi.fn(),
|
|
18
18
|
createTemplate: vi.fn(),
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
vi.mock('../app/composables/useMailingApi', () => ({
|
|
21
|
+
vi.mock('../app/composables/useMailingApi', async (importOriginal) => ({
|
|
22
|
+
...(await importOriginal<object>()),
|
|
22
23
|
useMailingApi: () => api,
|
|
23
24
|
}))
|
|
24
25
|
|
|
@@ -54,9 +55,20 @@ beforeEach(() => {
|
|
|
54
55
|
},
|
|
55
56
|
],
|
|
56
57
|
})
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
stubApiData().respond(
|
|
59
|
+
mailingReads(
|
|
60
|
+
{ listTemplates: api.listTemplates, starters: api.starters },
|
|
61
|
+
({ url }) => {
|
|
62
|
+
if (url !== '/api/mailing/templates/categories')
|
|
63
|
+
throw new Error(`unexpected read ${url}`)
|
|
64
|
+
return {
|
|
65
|
+
categories: [
|
|
66
|
+
{ id: 'billing', label: 'Billing', icon: 'i-ph-file-text' },
|
|
67
|
+
],
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
),
|
|
71
|
+
)
|
|
60
72
|
api.createTemplate.mockResolvedValue(['new-id'])
|
|
61
73
|
})
|
|
62
74
|
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { effectScope, ref } from 'vue'
|
|
3
|
+
import {
|
|
4
|
+
useStarterPreviewRead,
|
|
5
|
+
useTemplateContentRead,
|
|
6
|
+
useTemplateListRead,
|
|
7
|
+
} from '../app/composables/useMailingReads'
|
|
8
|
+
import { stubApiData, type ApiDataStub } from './helpers/api-data'
|
|
9
|
+
|
|
10
|
+
let reads: ApiDataStub
|
|
11
|
+
const options: Record<string, unknown>[] = []
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
reads = stubApiData()
|
|
15
|
+
options.length = 0
|
|
16
|
+
const stubbed = globalThis.useApiData
|
|
17
|
+
vi.stubGlobal(
|
|
18
|
+
'useApiData',
|
|
19
|
+
(url: unknown, readOptions: Record<string, unknown>) => {
|
|
20
|
+
options.push(readOptions)
|
|
21
|
+
return stubbed(url as string, readOptions)
|
|
22
|
+
},
|
|
23
|
+
)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
vi.unstubAllGlobals()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
const inScope = <T>(setup: () => T): T => effectScope().run(setup) as T
|
|
31
|
+
|
|
32
|
+
describe('reads made on demand', () => {
|
|
33
|
+
it('read nothing until asked, then the current URL each time', async () => {
|
|
34
|
+
reads.respond(({ url }) => ({ url }))
|
|
35
|
+
const id = ref('t1')
|
|
36
|
+
const readContent = inScope(() => useTemplateContentRead(id))
|
|
37
|
+
await Promise.resolve()
|
|
38
|
+
expect(reads.reads).toEqual([])
|
|
39
|
+
expect(options[0]).toMatchObject({
|
|
40
|
+
immediate: false,
|
|
41
|
+
refreshOnPageRefresh: false,
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
await expect(readContent()).resolves.toEqual({
|
|
45
|
+
url: '/api/mailing/templates/t1/content',
|
|
46
|
+
})
|
|
47
|
+
id.value = 't2'
|
|
48
|
+
await readContent()
|
|
49
|
+
expect(reads.reads.map((read) => read.url)).toEqual([
|
|
50
|
+
'/api/mailing/templates/t1/content',
|
|
51
|
+
'/api/mailing/templates/t2/content',
|
|
52
|
+
])
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('reject with what the read failed with', async () => {
|
|
56
|
+
const failure = Object.assign(new Error('gone'), { statusCode: 404 })
|
|
57
|
+
reads.respond(() => Promise.reject(failure))
|
|
58
|
+
const readContent = inScope(() => useTemplateContentRead('t1'))
|
|
59
|
+
await expect(readContent()).rejects.toBe(failure)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('send their query with the read', async () => {
|
|
63
|
+
reads.respond(() => ({}))
|
|
64
|
+
const readList = inScope(() => useTemplateListRead())
|
|
65
|
+
const readPreview = inScope(() =>
|
|
66
|
+
useStarterPreviewRead('invoice-ready', 'fr'),
|
|
67
|
+
)
|
|
68
|
+
await readList()
|
|
69
|
+
await readPreview()
|
|
70
|
+
expect(reads.reads).toEqual([
|
|
71
|
+
{
|
|
72
|
+
url: '/api/mailing/tables/templates/list',
|
|
73
|
+
query: { limit: '100', sortKey: 'name' },
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
url: '/api/mailing/templates/starters/invoice-ready/preview',
|
|
77
|
+
query: { locale: 'fr' },
|
|
78
|
+
},
|
|
79
|
+
])
|
|
80
|
+
})
|
|
81
|
+
})
|
|
@@ -1,20 +1,14 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
-
import { defineComponent, h, onBeforeUnmount
|
|
3
|
+
import { createApp, defineComponent, h, onBeforeUnmount } from 'vue'
|
|
4
4
|
import OverviewGate from '../app/components/OverviewGate.vue'
|
|
5
|
+
import { useSetupHidesPeriod } from '../app/composables/useOverviewSetup'
|
|
5
6
|
import type { SetupFacts } from '../app/utils/overview-setup'
|
|
7
|
+
import { stubApiData, type ApiDataStub } from './helpers/api-data'
|
|
6
8
|
import { flush, mount, slotStub, stubNuxtGlobals } from './helpers/nuxt-globals'
|
|
7
9
|
|
|
8
|
-
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
vi.mock('../app/composables/useMailingApi', () => ({
|
|
12
|
-
useMailingApi: () => api,
|
|
13
|
-
}))
|
|
14
|
-
|
|
15
|
-
vi.mock('../app/composables/useOverviewSetup', () => ({
|
|
16
|
-
useSetupHidesPeriod: () => hidesPeriod,
|
|
17
|
-
}))
|
|
10
|
+
let api: ApiDataStub
|
|
11
|
+
const SETUP_URL = '/api/mailing/metrics/setup'
|
|
18
12
|
|
|
19
13
|
interface ButtonAction {
|
|
20
14
|
label: string
|
|
@@ -63,6 +57,15 @@ const Gate = defineComponent({
|
|
|
63
57
|
setup: () => () => h(OverviewGate, null, { default: () => h(Dashboard) }),
|
|
64
58
|
})
|
|
65
59
|
|
|
60
|
+
/** What the page header renders of the period selector. */
|
|
61
|
+
const PeriodHolder = defineComponent({
|
|
62
|
+
props: { scope: { type: String, default: undefined } },
|
|
63
|
+
setup: (props) => {
|
|
64
|
+
const hidesPeriod = useSetupHidesPeriod(props.scope)
|
|
65
|
+
return () => h('span', { 'data-hides-period': String(hidesPeriod.value) })
|
|
66
|
+
},
|
|
67
|
+
})
|
|
68
|
+
|
|
66
69
|
const GLOBALS = {
|
|
67
70
|
DmsCard: slotStub('DmsCard'),
|
|
68
71
|
DmsEmptyState: EmptyStateStub,
|
|
@@ -85,8 +88,10 @@ const facts = (overrides: Partial<SetupFacts> = {}): SetupFacts => ({
|
|
|
85
88
|
beforeEach(() => {
|
|
86
89
|
stubNuxtGlobals()
|
|
87
90
|
vi.stubGlobal('onBeforeUnmount', onBeforeUnmount)
|
|
88
|
-
|
|
89
|
-
|
|
91
|
+
vi.stubGlobal('holdPageHeaderActions', () =>
|
|
92
|
+
defineComponent({ setup: () => () => null }),
|
|
93
|
+
)
|
|
94
|
+
api = stubApiData()
|
|
90
95
|
})
|
|
91
96
|
|
|
92
97
|
afterEach(() => {
|
|
@@ -96,34 +101,44 @@ afterEach(() => {
|
|
|
96
101
|
|
|
97
102
|
describe('OverviewGate', () => {
|
|
98
103
|
it('mounts nothing of the dashboard before the facts are known', () => {
|
|
99
|
-
api.
|
|
104
|
+
api.respond(() => new Promise(() => undefined))
|
|
100
105
|
const { host } = mount(Gate, {}, GLOBALS)
|
|
101
106
|
expect(host.querySelector('[data-dashboard]')).toBeNull()
|
|
102
107
|
expect(host.querySelector('[data-stub="USkeleton"]')).not.toBeNull()
|
|
103
108
|
})
|
|
104
109
|
|
|
105
110
|
it('says the facts failed to load and retries', async () => {
|
|
106
|
-
api.
|
|
107
|
-
api.setupFacts.mockResolvedValueOnce(facts())
|
|
111
|
+
api.respond(() => Promise.reject(new Error('500')))
|
|
108
112
|
const { host } = mount(Gate, {}, GLOBALS)
|
|
109
113
|
await flush()
|
|
110
114
|
expect(host.querySelector('[data-dashboard]')).toBeNull()
|
|
115
|
+
expect(api.reads).toEqual([{ url: SETUP_URL, query: {} }])
|
|
116
|
+
api.respond(() => facts())
|
|
111
117
|
host.querySelector<HTMLButtonElement>('[data-empty-state] button')?.click()
|
|
112
118
|
await flush()
|
|
113
119
|
expect(host.querySelector('[data-dashboard]')).not.toBeNull()
|
|
114
120
|
})
|
|
115
121
|
|
|
116
122
|
it('holds the dashboard and the period back while nothing can be sent', async () => {
|
|
117
|
-
api.
|
|
123
|
+
api.respond(() => facts({ liveTemplateCount: 0 }))
|
|
118
124
|
const { host } = mount(Gate, {}, GLOBALS)
|
|
119
125
|
await flush()
|
|
120
126
|
expect(host.querySelector('[data-dashboard]')).toBeNull()
|
|
121
127
|
expect(host.querySelectorAll('[data-step]')).toHaveLength(4)
|
|
122
|
-
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('hides the overview header period from the same facts, on no other page', async () => {
|
|
131
|
+
api.respond(() => facts({ liveTemplateCount: 0 }))
|
|
132
|
+
const overview = mount(PeriodHolder, { scope: 'mailing-overview' })
|
|
133
|
+
const sends = mount(PeriodHolder, { scope: 'mailing-sends' })
|
|
134
|
+
await flush()
|
|
135
|
+
expect(overview.host.innerHTML).toContain('data-hides-period="true"')
|
|
136
|
+
expect(sends.host.innerHTML).toContain('data-hides-period="false"')
|
|
137
|
+
expect(api.reads).toEqual([{ url: SETUP_URL, query: {} }])
|
|
123
138
|
})
|
|
124
139
|
|
|
125
140
|
it('lets an unfinished setup be resumed above the dashboard', async () => {
|
|
126
|
-
api.
|
|
141
|
+
api.respond(() => facts({ hasTestSend: false }))
|
|
127
142
|
const { host } = mount(Gate, {}, GLOBALS)
|
|
128
143
|
await flush()
|
|
129
144
|
expect(host.querySelector('[data-dashboard]')).not.toBeNull()
|
|
@@ -135,11 +150,34 @@ describe('OverviewGate', () => {
|
|
|
135
150
|
await flush()
|
|
136
151
|
expect(host.querySelectorAll('[data-step]')).toHaveLength(4)
|
|
137
152
|
expect(host.querySelector('[data-dashboard]')).not.toBeNull()
|
|
138
|
-
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('takes the props the page renderer adds without a fragment warning', async () => {
|
|
156
|
+
api.respond(() => facts())
|
|
157
|
+
const warnings: string[] = []
|
|
158
|
+
const app = createApp(() =>
|
|
159
|
+
h(
|
|
160
|
+
OverviewGate,
|
|
161
|
+
{ childCount: 0, routeParams: {} },
|
|
162
|
+
{ default: () => h(Dashboard) },
|
|
163
|
+
),
|
|
164
|
+
)
|
|
165
|
+
Object.entries(GLOBALS).forEach(([name, value]) =>
|
|
166
|
+
app.component(name, value),
|
|
167
|
+
)
|
|
168
|
+
app.config.warnHandler = (message) => warnings.push(message)
|
|
169
|
+
app.mount(document.body.appendChild(document.createElement('div')))
|
|
170
|
+
await flush()
|
|
171
|
+
app.unmount()
|
|
172
|
+
expect(
|
|
173
|
+
warnings.filter((message) =>
|
|
174
|
+
message.includes('Extraneous non-props attributes'),
|
|
175
|
+
),
|
|
176
|
+
).toEqual([])
|
|
139
177
|
})
|
|
140
178
|
|
|
141
179
|
it('offers no resume once every step is done', async () => {
|
|
142
|
-
api.
|
|
180
|
+
api.respond(() => facts())
|
|
143
181
|
const { host } = mount(Gate, {}, GLOBALS)
|
|
144
182
|
await flush()
|
|
145
183
|
expect(host.textContent).not.toContain('dms_mailing.setup.resume')
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { defineComponent, h, type VNodeChild } from 'vue'
|
|
4
|
+
import PageHeader from '../app/components/PageHeader.vue'
|
|
5
|
+
import WebhookStatus from '../app/components/WebhookStatus.vue'
|
|
6
|
+
import type { WebhookStatus as Status } from '../app/utils/webhook-status'
|
|
7
|
+
import {
|
|
8
|
+
stubApiData,
|
|
9
|
+
stubRegionalFormat,
|
|
10
|
+
type ApiDataStub,
|
|
11
|
+
} from './helpers/api-data'
|
|
12
|
+
import { flush, mount, slotStub, stubNuxtGlobals } from './helpers/nuxt-globals'
|
|
13
|
+
|
|
14
|
+
const api = { webhookTest: vi.fn() }
|
|
15
|
+
|
|
16
|
+
vi.mock('../app/composables/useMailingApi', async (importOriginal) => ({
|
|
17
|
+
...(await importOriginal<object>()),
|
|
18
|
+
useMailingApi: () => api,
|
|
19
|
+
}))
|
|
20
|
+
|
|
21
|
+
const PROVIDER_URL = '/api/mailing/provider'
|
|
22
|
+
const STATUS_URL = '/api/mailing/settings/webhook-status'
|
|
23
|
+
|
|
24
|
+
const NEVER: Status = { lastReceived: null, lastRefused: null }
|
|
25
|
+
const TESTED: Status = {
|
|
26
|
+
lastReceived: {
|
|
27
|
+
at: new Date().toISOString(),
|
|
28
|
+
provider: 'smtp',
|
|
29
|
+
type: 'delivered',
|
|
30
|
+
isMatched: false,
|
|
31
|
+
isTest: true,
|
|
32
|
+
},
|
|
33
|
+
lastRefused: null,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const PillStub = defineComponent({
|
|
37
|
+
props: { label: { type: String, default: '' } },
|
|
38
|
+
setup: (props) => () => h('span', { 'data-pill': props.label }),
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const ButtonStub = defineComponent({
|
|
42
|
+
props: { label: { type: String, default: '' } },
|
|
43
|
+
emits: ['click'],
|
|
44
|
+
setup:
|
|
45
|
+
(props, { emit }) =>
|
|
46
|
+
() =>
|
|
47
|
+
h('button', { onClick: () => emit('click') }, props.label),
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
let api$: ApiDataStub
|
|
51
|
+
let headerActions: (() => VNodeChild) | null
|
|
52
|
+
|
|
53
|
+
/** The page header: renders the actions `PageHeader` set up. */
|
|
54
|
+
const Header = defineComponent({
|
|
55
|
+
setup: () => () => h('header', headerActions?.()),
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
const HeaderOutlet = defineComponent({
|
|
59
|
+
props: { showProvider: { type: Boolean, default: true } },
|
|
60
|
+
setup: (props) => () =>
|
|
61
|
+
h('div', [h(PageHeader, { showProvider: props.showProvider }), h(Header)]),
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
stubNuxtGlobals()
|
|
66
|
+
stubRegionalFormat()
|
|
67
|
+
headerActions = null
|
|
68
|
+
vi.stubGlobal('resolveComponent', (name: string) =>
|
|
69
|
+
name === 'DmsStatusPill' ? PillStub : slotStub(name),
|
|
70
|
+
)
|
|
71
|
+
vi.stubGlobal('usePageHeaderActions', (render: () => VNodeChild) => {
|
|
72
|
+
headerActions = render
|
|
73
|
+
})
|
|
74
|
+
vi.stubGlobal('useApiError', vi.fn())
|
|
75
|
+
api$ = stubApiData()
|
|
76
|
+
api.webhookTest.mockReset()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
afterEach(() => {
|
|
80
|
+
vi.unstubAllGlobals()
|
|
81
|
+
document.body.innerHTML = ''
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
describe('PageHeader', () => {
|
|
85
|
+
it('names the provider read with the page', async () => {
|
|
86
|
+
api$.respond(() => ({ name: 'smtp', connected: true, features: null }))
|
|
87
|
+
const view = mount(HeaderOutlet, {})
|
|
88
|
+
await flush()
|
|
89
|
+
expect(api$.reads).toEqual([{ url: PROVIDER_URL, query: {} }])
|
|
90
|
+
expect(
|
|
91
|
+
view.host.querySelector('[data-pill]')?.getAttribute('data-pill'),
|
|
92
|
+
).toBe('dms_mailing.page_header.provider_connected name=smtp')
|
|
93
|
+
view.unmount()
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('says no provider when the read fails, and reads nothing when hidden', async () => {
|
|
97
|
+
api$.respond(() => Promise.reject(new Error('503')))
|
|
98
|
+
const failed = mount(HeaderOutlet, {})
|
|
99
|
+
await flush()
|
|
100
|
+
expect(
|
|
101
|
+
failed.host.querySelector('[data-pill]')?.getAttribute('data-pill'),
|
|
102
|
+
).toBe('dms_mailing.page_header.no_provider')
|
|
103
|
+
failed.unmount()
|
|
104
|
+
api$.reads.length = 0
|
|
105
|
+
const hidden = mount(HeaderOutlet, { showProvider: false })
|
|
106
|
+
await flush()
|
|
107
|
+
expect(api$.reads).toEqual([])
|
|
108
|
+
expect(hidden.host.querySelector('[data-pill]')).toBeNull()
|
|
109
|
+
hidden.unmount()
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
describe('WebhookStatus', () => {
|
|
114
|
+
it('shows the status read, then the one a test event answers', async () => {
|
|
115
|
+
api$.respond(() => NEVER)
|
|
116
|
+
api.webhookTest.mockResolvedValue({
|
|
117
|
+
isReceived: true,
|
|
118
|
+
url: 'https://example.test/hook',
|
|
119
|
+
durationMs: 12,
|
|
120
|
+
status: TESTED,
|
|
121
|
+
})
|
|
122
|
+
const view = mount(
|
|
123
|
+
WebhookStatus,
|
|
124
|
+
{},
|
|
125
|
+
{
|
|
126
|
+
UButton: ButtonStub,
|
|
127
|
+
UIcon: slotStub('UIcon'),
|
|
128
|
+
USkeleton: slotStub('USkeleton'),
|
|
129
|
+
},
|
|
130
|
+
)
|
|
131
|
+
await flush()
|
|
132
|
+
expect(view.host.textContent).toContain(
|
|
133
|
+
'dms_mailing.settings.webhook_status.never_received',
|
|
134
|
+
)
|
|
135
|
+
view.host.querySelector('button')?.click()
|
|
136
|
+
await flush()
|
|
137
|
+
expect(view.host.textContent).toContain(
|
|
138
|
+
'dms_mailing.settings.webhook_status.received_test',
|
|
139
|
+
)
|
|
140
|
+
expect(api$.reads).toEqual([{ url: STATUS_URL, query: {} }])
|
|
141
|
+
view.unmount()
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('says the status is unavailable when its read fails', async () => {
|
|
145
|
+
api$.respond(() => Promise.reject(new Error('500')))
|
|
146
|
+
const view = mount(
|
|
147
|
+
WebhookStatus,
|
|
148
|
+
{},
|
|
149
|
+
{ UButton: ButtonStub, USkeleton: slotStub('USkeleton') },
|
|
150
|
+
)
|
|
151
|
+
await flush()
|
|
152
|
+
expect(view.host.textContent).toContain(
|
|
153
|
+
'dms_mailing.settings.webhook_status.unavailable',
|
|
154
|
+
)
|
|
155
|
+
view.unmount()
|
|
156
|
+
})
|
|
157
|
+
})
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
import {
|
|
3
3
|
clampRetention,
|
|
4
|
-
formatClock,
|
|
5
4
|
isWithinADay,
|
|
6
5
|
minutesSince,
|
|
7
6
|
presetShortLabelKey,
|
|
@@ -9,14 +8,6 @@ import {
|
|
|
9
8
|
retentionChoice,
|
|
10
9
|
} from '../app/utils/period'
|
|
11
10
|
|
|
12
|
-
describe('formatClock', () => {
|
|
13
|
-
it('reads a 24-hour clock whatever the locale', () => {
|
|
14
|
-
const afternoon = new Date(2026, 9, 11, 17, 5)
|
|
15
|
-
expect(formatClock(afternoon, 'en')).toBe('17:05')
|
|
16
|
-
expect(formatClock(afternoon, 'fr-FR')).toBe('17:05')
|
|
17
|
-
})
|
|
18
|
-
})
|
|
19
|
-
|
|
20
11
|
describe('presetShortLabelKey', () => {
|
|
21
12
|
it('maps a preset id onto an i18n key', () => {
|
|
22
13
|
expect(presetShortLabelKey('last-24h')).toBe(
|
|
@@ -8,15 +8,14 @@ import {
|
|
|
8
8
|
stubNuxtGlobals,
|
|
9
9
|
type NuxtStubs,
|
|
10
10
|
} from './helpers/nuxt-globals'
|
|
11
|
+
import {
|
|
12
|
+
stubApiData,
|
|
13
|
+
stubRegionalFormat,
|
|
14
|
+
type ApiDataStub,
|
|
15
|
+
} from './helpers/api-data'
|
|
11
16
|
import type { RetentionPreview } from '../app/types/mailing'
|
|
12
17
|
|
|
13
|
-
const
|
|
14
|
-
retentionPreview: vi.fn(),
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
vi.mock('../app/composables/useMailingApi', () => ({
|
|
18
|
-
useMailingApi: () => api,
|
|
19
|
-
}))
|
|
18
|
+
const PREVIEW_URL = '/api/mailing/settings/retention-preview'
|
|
20
19
|
|
|
21
20
|
const SAVED_DAYS = 90
|
|
22
21
|
const DEBOUNCE_MS = 400
|
|
@@ -71,15 +70,37 @@ function segmented(host: HTMLElement): HTMLElement {
|
|
|
71
70
|
|
|
72
71
|
describe('RetentionInput', () => {
|
|
73
72
|
let stubs: NuxtStubs
|
|
73
|
+
let api: ApiDataStub
|
|
74
74
|
let harness: Harness | undefined
|
|
75
75
|
|
|
76
76
|
beforeEach(() => {
|
|
77
77
|
vi.useFakeTimers()
|
|
78
78
|
stubs = stubNuxtGlobals()
|
|
79
79
|
vi.stubGlobal('computed', computed)
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
stubRegionalFormat()
|
|
81
|
+
api = stubApiData()
|
|
82
|
+
api.respond(({ query }) => {
|
|
83
|
+
const days = Number(query.days)
|
|
84
|
+
return preview(days, days < SAVED_DAYS ? 3 : 0)
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('reads the saved days at once, then the days picked once they hold still', async () => {
|
|
89
|
+
harness = mountRetention()
|
|
90
|
+
await flush()
|
|
91
|
+
expect(api.reads).toEqual([
|
|
92
|
+
{ url: PREVIEW_URL, query: { days: String(SAVED_DAYS) } },
|
|
93
|
+
])
|
|
94
|
+
expect(harness.host.textContent).toContain(
|
|
95
|
+
'dms_mailing.settings.retention.preview_none',
|
|
82
96
|
)
|
|
97
|
+
harness.state.days = 30
|
|
98
|
+
await nextTick()
|
|
99
|
+
harness.state.days = 365
|
|
100
|
+
await nextTick()
|
|
101
|
+
await vi.advanceTimersByTimeAsync(DEBOUNCE_MS)
|
|
102
|
+
await flush()
|
|
103
|
+
expect(api.reads.map((read) => read.query.days)).toEqual(['90', '365'])
|
|
83
104
|
})
|
|
84
105
|
|
|
85
106
|
afterEach(() => {
|