@antelopejs/dms-mailing 0.4.0 → 0.5.0

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.
Files changed (62) hide show
  1. package/README.md +1 -1
  2. package/dist/pages/sends.js +2 -2
  3. package/dist/pages/sends.js.map +1 -1
  4. package/dist/pages/suppressions.js +2 -2
  5. package/dist/pages/suppressions.js.map +1 -1
  6. package/dist/pages/templates.js +3 -3
  7. package/dist/pages/templates.js.map +1 -1
  8. package/frontend-vue/app/components/CategoriesInput.vue +7 -14
  9. package/frontend-vue/app/components/DuplicateModal.vue +9 -3
  10. package/frontend-vue/app/components/Editor.vue +3 -3
  11. package/frontend-vue/app/components/NewTemplateModal.vue +9 -3
  12. package/frontend-vue/app/components/OverviewGate.vue +20 -29
  13. package/frontend-vue/app/components/PageHeader.vue +8 -12
  14. package/frontend-vue/app/components/RealSendModal.vue +3 -1
  15. package/frontend-vue/app/components/RenderingModal.vue +5 -7
  16. package/frontend-vue/app/components/RetentionInput.vue +13 -23
  17. package/frontend-vue/app/components/SendDrawer.vue +5 -2
  18. package/frontend-vue/app/components/StarterGallery.vue +11 -9
  19. package/frontend-vue/app/components/TemplateCard.vue +19 -4
  20. package/frontend-vue/app/components/TemplateDrawer.vue +8 -2
  21. package/frontend-vue/app/components/TemplateGallery.vue +25 -20
  22. package/frontend-vue/app/components/TemplatePreviewFrame.vue +8 -6
  23. package/frontend-vue/app/components/TestSendModal.vue +3 -1
  24. package/frontend-vue/app/components/VersionHistory.vue +3 -3
  25. package/frontend-vue/app/components/WebhookStatus.vue +20 -17
  26. package/frontend-vue/app/components/editor/EditorWorkspace.vue +5 -0
  27. package/frontend-vue/app/composables/useEditorActions.ts +3 -1
  28. package/frontend-vue/app/composables/useEditorSession.ts +6 -4
  29. package/frontend-vue/app/composables/useMailingApi.ts +5 -58
  30. package/frontend-vue/app/composables/useMailingReads.ts +229 -0
  31. package/frontend-vue/app/composables/useOverviewSetup.ts +15 -7
  32. package/frontend-vue/app/composables/useTemplateCategories.ts +8 -27
  33. package/frontend-vue/app/utils/gallery.ts +19 -7
  34. package/frontend-vue/app/utils/period.ts +0 -5
  35. package/frontend-vue/package.json +1 -1
  36. package/frontend-vue/tests/block-components.test.ts +0 -3
  37. package/frontend-vue/tests/categories-input.test.ts +44 -13
  38. package/frontend-vue/tests/conditions.test.ts +0 -2
  39. package/frontend-vue/tests/editor-block-list.test.ts +1 -0
  40. package/frontend-vue/tests/editor-session.test.ts +5 -0
  41. package/frontend-vue/tests/footer-block.test.ts +1 -0
  42. package/frontend-vue/tests/funnel-card.test.ts +1 -0
  43. package/frontend-vue/tests/gallery.test.ts +16 -0
  44. package/frontend-vue/tests/helpers/api-data.ts +173 -0
  45. package/frontend-vue/tests/i18n-editor.test.ts +0 -9
  46. package/frontend-vue/tests/new-template-modal.test.ts +18 -5
  47. package/frontend-vue/tests/on-demand-reads.test.ts +81 -0
  48. package/frontend-vue/tests/overview-gate.test.ts +60 -21
  49. package/frontend-vue/tests/page-reads.test.ts +157 -0
  50. package/frontend-vue/tests/period.test.ts +0 -9
  51. package/frontend-vue/tests/retention-input.test.ts +31 -9
  52. package/frontend-vue/tests/send-modals.test.ts +9 -1
  53. package/frontend-vue/tests/shortcuts.test.ts +1 -0
  54. package/frontend-vue/tests/template-drawer.test.ts +31 -14
  55. package/frontend-vue/tests/template-gallery.test.ts +46 -24
  56. package/frontend-vue/tests/variable-path.test.ts +1 -0
  57. package/frontend-vue/vitest.config.ts +4 -1
  58. package/package.json +5 -5
  59. package/src/pages/sends.ts +2 -2
  60. package/src/pages/suppressions.ts +2 -2
  61. package/src/pages/templates.ts +3 -3
  62. package/frontend-vue/tests/smoke.test.ts +0 -8
@@ -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
+ }
@@ -2,7 +2,6 @@ import { readFileSync, readdirSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import { describe, expect, it } from 'vitest'
4
4
  import en from '../i18n/locales/mailing-en-GB.json'
5
- import fr from '../i18n/locales/mailing-fr-FR.json'
6
5
 
7
6
  const APP = join(__dirname, '..', 'app')
8
7
  const EDITOR_SOURCES = [
@@ -40,14 +39,6 @@ function keysOf(value: unknown, prefix = ''): string[] {
40
39
  }
41
40
 
42
41
  describe('editor locales', () => {
43
- it('carry the same key set', () => {
44
- expect(keysOf(fr).sort()).toEqual(keysOf(en).sort())
45
- })
46
- it('cover the namespaces the editor uses', () => {
47
- for (const namespace of ['editor', 'blocks']) {
48
- expect(en.dms_mailing).toHaveProperty(namespace)
49
- }
50
- })
51
42
  it('define every literal key the editor components use', () => {
52
43
  const known = new Set(keysOf(en))
53
44
  const used = EDITOR_SOURCES.flatMap((file) =>
@@ -1,3 +1,4 @@
1
+ // @vitest-environment jsdom
1
2
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
3
  import NewTemplateModal from '../app/components/NewTemplateModal.vue'
3
4
  import {
@@ -9,15 +10,16 @@ import {
9
10
  typeInto,
10
11
  type NuxtStubs,
11
12
  } from './helpers/nuxt-globals'
13
+ import { mailingReads, stubApiData } from './helpers/api-data'
12
14
 
13
15
  const api = {
14
16
  listTemplates: vi.fn(),
15
17
  starters: vi.fn(),
16
- listCategories: vi.fn(),
17
18
  createTemplate: vi.fn(),
18
19
  }
19
20
 
20
- vi.mock('../app/composables/useMailingApi', () => ({
21
+ vi.mock('../app/composables/useMailingApi', async (importOriginal) => ({
22
+ ...(await importOriginal<object>()),
21
23
  useMailingApi: () => api,
22
24
  }))
23
25
 
@@ -53,9 +55,20 @@ beforeEach(() => {
53
55
  },
54
56
  ],
55
57
  })
56
- api.listCategories.mockResolvedValue({
57
- categories: [{ id: 'billing', label: 'Billing', icon: 'i-ph-file-text' }],
58
- })
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
+ )
59
72
  api.createTemplate.mockResolvedValue(['new-id'])
60
73
  })
61
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,19 +1,14 @@
1
+ // @vitest-environment jsdom
1
2
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
- import { defineComponent, h, onBeforeUnmount, ref } from 'vue'
3
+ import { createApp, defineComponent, h, onBeforeUnmount } from 'vue'
3
4
  import OverviewGate from '../app/components/OverviewGate.vue'
5
+ import { useSetupHidesPeriod } from '../app/composables/useOverviewSetup'
4
6
  import type { SetupFacts } from '../app/utils/overview-setup'
7
+ import { stubApiData, type ApiDataStub } from './helpers/api-data'
5
8
  import { flush, mount, slotStub, stubNuxtGlobals } from './helpers/nuxt-globals'
6
9
 
7
- const api = { setupFacts: vi.fn() }
8
- const hidesPeriod = ref(false)
9
-
10
- vi.mock('../app/composables/useMailingApi', () => ({
11
- useMailingApi: () => api,
12
- }))
13
-
14
- vi.mock('../app/composables/useOverviewSetup', () => ({
15
- useSetupHidesPeriod: () => hidesPeriod,
16
- }))
10
+ let api: ApiDataStub
11
+ const SETUP_URL = '/api/mailing/metrics/setup'
17
12
 
18
13
  interface ButtonAction {
19
14
  label: string
@@ -62,6 +57,15 @@ const Gate = defineComponent({
62
57
  setup: () => () => h(OverviewGate, null, { default: () => h(Dashboard) }),
63
58
  })
64
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
+
65
69
  const GLOBALS = {
66
70
  DmsCard: slotStub('DmsCard'),
67
71
  DmsEmptyState: EmptyStateStub,
@@ -84,8 +88,10 @@ const facts = (overrides: Partial<SetupFacts> = {}): SetupFacts => ({
84
88
  beforeEach(() => {
85
89
  stubNuxtGlobals()
86
90
  vi.stubGlobal('onBeforeUnmount', onBeforeUnmount)
87
- api.setupFacts.mockReset()
88
- hidesPeriod.value = false
91
+ vi.stubGlobal('holdPageHeaderActions', () =>
92
+ defineComponent({ setup: () => () => null }),
93
+ )
94
+ api = stubApiData()
89
95
  })
90
96
 
91
97
  afterEach(() => {
@@ -95,34 +101,44 @@ afterEach(() => {
95
101
 
96
102
  describe('OverviewGate', () => {
97
103
  it('mounts nothing of the dashboard before the facts are known', () => {
98
- api.setupFacts.mockReturnValue(new Promise(() => undefined))
104
+ api.respond(() => new Promise(() => undefined))
99
105
  const { host } = mount(Gate, {}, GLOBALS)
100
106
  expect(host.querySelector('[data-dashboard]')).toBeNull()
101
107
  expect(host.querySelector('[data-stub="USkeleton"]')).not.toBeNull()
102
108
  })
103
109
 
104
110
  it('says the facts failed to load and retries', async () => {
105
- api.setupFacts.mockRejectedValueOnce(new Error('500'))
106
- api.setupFacts.mockResolvedValueOnce(facts())
111
+ api.respond(() => Promise.reject(new Error('500')))
107
112
  const { host } = mount(Gate, {}, GLOBALS)
108
113
  await flush()
109
114
  expect(host.querySelector('[data-dashboard]')).toBeNull()
115
+ expect(api.reads).toEqual([{ url: SETUP_URL, query: {} }])
116
+ api.respond(() => facts())
110
117
  host.querySelector<HTMLButtonElement>('[data-empty-state] button')?.click()
111
118
  await flush()
112
119
  expect(host.querySelector('[data-dashboard]')).not.toBeNull()
113
120
  })
114
121
 
115
122
  it('holds the dashboard and the period back while nothing can be sent', async () => {
116
- api.setupFacts.mockResolvedValue(facts({ liveTemplateCount: 0 }))
123
+ api.respond(() => facts({ liveTemplateCount: 0 }))
117
124
  const { host } = mount(Gate, {}, GLOBALS)
118
125
  await flush()
119
126
  expect(host.querySelector('[data-dashboard]')).toBeNull()
120
127
  expect(host.querySelectorAll('[data-step]')).toHaveLength(4)
121
- expect(hidesPeriod.value).toBe(true)
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: {} }])
122
138
  })
123
139
 
124
140
  it('lets an unfinished setup be resumed above the dashboard', async () => {
125
- api.setupFacts.mockResolvedValue(facts({ hasTestSend: false }))
141
+ api.respond(() => facts({ hasTestSend: false }))
126
142
  const { host } = mount(Gate, {}, GLOBALS)
127
143
  await flush()
128
144
  expect(host.querySelector('[data-dashboard]')).not.toBeNull()
@@ -134,11 +150,34 @@ describe('OverviewGate', () => {
134
150
  await flush()
135
151
  expect(host.querySelectorAll('[data-step]')).toHaveLength(4)
136
152
  expect(host.querySelector('[data-dashboard]')).not.toBeNull()
137
- expect(hidesPeriod.value).toBe(false)
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([])
138
177
  })
139
178
 
140
179
  it('offers no resume once every step is done', async () => {
141
- api.setupFacts.mockResolvedValue(facts())
180
+ api.respond(() => facts())
142
181
  const { host } = mount(Gate, {}, GLOBALS)
143
182
  await flush()
144
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(