@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.
Files changed (86) hide show
  1. package/README.md +61 -339
  2. package/dist/data/templates-table.js +2 -10
  3. package/dist/data/templates-table.js.map +1 -1
  4. package/dist/db/models/index.d.ts +1 -0
  5. package/dist/db/models/index.js +1 -0
  6. package/dist/db/models/index.js.map +1 -1
  7. package/dist/db/models/send-events.model.d.ts +1 -0
  8. package/dist/db/models/sends.model.d.ts +1 -0
  9. package/dist/db/models/settings.model.d.ts +1 -0
  10. package/dist/db/models/suppressions.model.d.ts +1 -0
  11. package/dist/db/models/template-slugs.model.d.ts +27 -0
  12. package/dist/db/models/template-slugs.model.js +43 -0
  13. package/dist/db/models/template-slugs.model.js.map +1 -0
  14. package/dist/db/models/template-versions.model.d.ts +1 -0
  15. package/dist/db/models/templates.model.d.ts +1 -0
  16. package/dist/db/models/webhook-status.model.d.ts +1 -0
  17. package/dist/db/tables/index.d.ts +1 -0
  18. package/dist/db/tables/index.js +1 -0
  19. package/dist/db/tables/index.js.map +1 -1
  20. package/dist/db/tables/template-slugs.table.d.ts +7 -0
  21. package/dist/db/tables/template-slugs.table.js +35 -0
  22. package/dist/db/tables/template-slugs.table.js.map +1 -0
  23. package/dist/routes/templates.js +1 -0
  24. package/dist/routes/templates.js.map +1 -1
  25. package/dist/services/templates.d.ts +2 -0
  26. package/dist/services/templates.js +26 -0
  27. package/dist/services/templates.js.map +1 -1
  28. package/frontend-vue/app/components/CategoriesInput.vue +18 -24
  29. package/frontend-vue/app/components/DuplicateModal.vue +9 -3
  30. package/frontend-vue/app/components/Editor.vue +3 -3
  31. package/frontend-vue/app/components/NewTemplateModal.vue +9 -3
  32. package/frontend-vue/app/components/OverviewGate.vue +20 -29
  33. package/frontend-vue/app/components/PageHeader.vue +8 -12
  34. package/frontend-vue/app/components/RealSendModal.vue +3 -1
  35. package/frontend-vue/app/components/RenderingModal.vue +5 -7
  36. package/frontend-vue/app/components/RetentionInput.vue +13 -23
  37. package/frontend-vue/app/components/SendDrawer.vue +5 -2
  38. package/frontend-vue/app/components/StarterGallery.vue +11 -9
  39. package/frontend-vue/app/components/TemplateCard.vue +19 -4
  40. package/frontend-vue/app/components/TemplateDrawer.vue +8 -2
  41. package/frontend-vue/app/components/TemplateGallery.vue +25 -20
  42. package/frontend-vue/app/components/TemplatePreviewFrame.vue +8 -6
  43. package/frontend-vue/app/components/TestSendModal.vue +3 -1
  44. package/frontend-vue/app/components/VersionHistory.vue +3 -3
  45. package/frontend-vue/app/components/WebhookStatus.vue +20 -17
  46. package/frontend-vue/app/components/editor/EditorBlockList.vue +33 -30
  47. package/frontend-vue/app/components/editor/EditorPaletteGroup.vue +14 -14
  48. package/frontend-vue/app/components/editor/EditorWorkspace.vue +5 -0
  49. package/frontend-vue/app/composables/useEditorActions.ts +3 -1
  50. package/frontend-vue/app/composables/useEditorSession.ts +6 -4
  51. package/frontend-vue/app/composables/useMailingApi.ts +5 -58
  52. package/frontend-vue/app/composables/useMailingReads.ts +229 -0
  53. package/frontend-vue/app/composables/useOverviewSetup.ts +15 -7
  54. package/frontend-vue/app/composables/useSortableList.ts +159 -0
  55. package/frontend-vue/app/composables/useTemplateCategories.ts +8 -27
  56. package/frontend-vue/app/utils/blocks/dnd.ts +2 -0
  57. package/frontend-vue/app/utils/gallery.ts +19 -7
  58. package/frontend-vue/app/utils/period.ts +0 -5
  59. package/frontend-vue/i18n/locales/mailing-en-GB.json +1 -1
  60. package/frontend-vue/i18n/locales/mailing-fr-FR.json +1 -1
  61. package/frontend-vue/package.json +4 -3
  62. package/frontend-vue/pnpm-lock.yaml +6 -13
  63. package/frontend-vue/tests/categories-input.test.ts +43 -13
  64. package/frontend-vue/tests/editor-block-list.test.ts +46 -6
  65. package/frontend-vue/tests/editor-session.test.ts +4 -0
  66. package/frontend-vue/tests/gallery.test.ts +16 -0
  67. package/frontend-vue/tests/helpers/api-data.ts +173 -0
  68. package/frontend-vue/tests/new-template-modal.test.ts +17 -5
  69. package/frontend-vue/tests/on-demand-reads.test.ts +81 -0
  70. package/frontend-vue/tests/overview-gate.test.ts +59 -21
  71. package/frontend-vue/tests/page-reads.test.ts +157 -0
  72. package/frontend-vue/tests/period.test.ts +0 -9
  73. package/frontend-vue/tests/retention-input.test.ts +30 -9
  74. package/frontend-vue/tests/send-modals.test.ts +8 -1
  75. package/frontend-vue/tests/sortable-list.test.ts +253 -0
  76. package/frontend-vue/tests/template-drawer.test.ts +30 -14
  77. package/frontend-vue/tests/template-gallery.test.ts +45 -24
  78. package/package.json +31 -14
  79. package/skills/dms-mailing/SKILL.md +63 -0
  80. package/src/data/templates-table.ts +7 -27
  81. package/src/db/models/index.ts +1 -0
  82. package/src/db/models/template-slugs.model.ts +61 -0
  83. package/src/db/tables/index.ts +1 -0
  84. package/src/db/tables/template-slugs.table.ts +29 -0
  85. package/src/routes/templates.ts +2 -0
  86. 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
- api.listCategories.mockResolvedValue({
58
- categories: [{ id: 'billing', label: 'Billing', icon: 'i-ph-file-text' }],
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, ref } from 'vue'
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
- const api = { setupFacts: vi.fn() }
9
- const hidesPeriod = ref(false)
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
- api.setupFacts.mockReset()
89
- hidesPeriod.value = false
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.setupFacts.mockReturnValue(new Promise(() => undefined))
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.setupFacts.mockRejectedValueOnce(new Error('500'))
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.setupFacts.mockResolvedValue(facts({ liveTemplateCount: 0 }))
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
- 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: {} }])
123
138
  })
124
139
 
125
140
  it('lets an unfinished setup be resumed above the dashboard', async () => {
126
- api.setupFacts.mockResolvedValue(facts({ hasTestSend: false }))
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
- 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([])
139
177
  })
140
178
 
141
179
  it('offers no resume once every step is done', async () => {
142
- api.setupFacts.mockResolvedValue(facts())
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 api = {
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
- api.retentionPreview.mockImplementation(async (days: number) =>
81
- preview(days, days < SAVED_DAYS ? 3 : 0),
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(() => {