@antelopejs/dms-mailing 0.4.1 → 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 (43) hide show
  1. package/README.md +1 -1
  2. package/frontend-vue/app/components/CategoriesInput.vue +7 -14
  3. package/frontend-vue/app/components/DuplicateModal.vue +9 -3
  4. package/frontend-vue/app/components/Editor.vue +3 -3
  5. package/frontend-vue/app/components/NewTemplateModal.vue +9 -3
  6. package/frontend-vue/app/components/OverviewGate.vue +20 -29
  7. package/frontend-vue/app/components/PageHeader.vue +8 -12
  8. package/frontend-vue/app/components/RealSendModal.vue +3 -1
  9. package/frontend-vue/app/components/RenderingModal.vue +5 -7
  10. package/frontend-vue/app/components/RetentionInput.vue +13 -23
  11. package/frontend-vue/app/components/SendDrawer.vue +5 -2
  12. package/frontend-vue/app/components/StarterGallery.vue +11 -9
  13. package/frontend-vue/app/components/TemplateCard.vue +19 -4
  14. package/frontend-vue/app/components/TemplateDrawer.vue +8 -2
  15. package/frontend-vue/app/components/TemplateGallery.vue +25 -20
  16. package/frontend-vue/app/components/TemplatePreviewFrame.vue +8 -6
  17. package/frontend-vue/app/components/TestSendModal.vue +3 -1
  18. package/frontend-vue/app/components/VersionHistory.vue +3 -3
  19. package/frontend-vue/app/components/WebhookStatus.vue +20 -17
  20. package/frontend-vue/app/components/editor/EditorWorkspace.vue +5 -0
  21. package/frontend-vue/app/composables/useEditorActions.ts +3 -1
  22. package/frontend-vue/app/composables/useEditorSession.ts +6 -4
  23. package/frontend-vue/app/composables/useMailingApi.ts +5 -58
  24. package/frontend-vue/app/composables/useMailingReads.ts +229 -0
  25. package/frontend-vue/app/composables/useOverviewSetup.ts +15 -7
  26. package/frontend-vue/app/composables/useTemplateCategories.ts +8 -27
  27. package/frontend-vue/app/utils/gallery.ts +19 -7
  28. package/frontend-vue/app/utils/period.ts +0 -5
  29. package/frontend-vue/package.json +1 -1
  30. package/frontend-vue/tests/categories-input.test.ts +43 -13
  31. package/frontend-vue/tests/editor-session.test.ts +4 -0
  32. package/frontend-vue/tests/gallery.test.ts +16 -0
  33. package/frontend-vue/tests/helpers/api-data.ts +173 -0
  34. package/frontend-vue/tests/new-template-modal.test.ts +17 -5
  35. package/frontend-vue/tests/on-demand-reads.test.ts +81 -0
  36. package/frontend-vue/tests/overview-gate.test.ts +59 -21
  37. package/frontend-vue/tests/page-reads.test.ts +157 -0
  38. package/frontend-vue/tests/period.test.ts +0 -9
  39. package/frontend-vue/tests/retention-input.test.ts +30 -9
  40. package/frontend-vue/tests/send-modals.test.ts +8 -1
  41. package/frontend-vue/tests/template-drawer.test.ts +30 -14
  42. package/frontend-vue/tests/template-gallery.test.ts +45 -24
  43. package/package.json +2 -2
@@ -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(() => {
@@ -17,6 +17,7 @@ import {
17
17
  typeInto,
18
18
  type NuxtStubs,
19
19
  } from './helpers/nuxt-globals'
20
+ import { mailingReads, stubApiData } from './helpers/api-data'
20
21
 
21
22
  const api = {
22
23
  templateContent: vi.fn(),
@@ -25,7 +26,8 @@ const api = {
25
26
  realSend: vi.fn(),
26
27
  }
27
28
 
28
- vi.mock('../app/composables/useMailingApi', () => ({
29
+ vi.mock('../app/composables/useMailingApi', async (importOriginal) => ({
30
+ ...(await importOriginal<object>()),
29
31
  useMailingApi: () => api,
30
32
  }))
31
33
 
@@ -102,6 +104,11 @@ let stubs: NuxtStubs
102
104
 
103
105
  beforeEach(() => {
104
106
  stubs = stubNuxtGlobals('camille@acme.com')
107
+ stubApiData().respond(
108
+ mailingReads({ templateContent: api.templateContent }, ({ url }) => {
109
+ throw new Error(`unexpected read ${url}`)
110
+ }),
111
+ )
105
112
  api.preview.mockResolvedValue({
106
113
  html: '',
107
114
  subject: '20% off every chair',
@@ -17,6 +17,7 @@ import {
17
17
  typeInto,
18
18
  type NuxtStubs,
19
19
  } from './helpers/nuxt-globals'
20
+ import { mailingReads, stubApiData } from './helpers/api-data'
20
21
 
21
22
  const api = {
22
23
  templateContent: vi.fn(),
@@ -29,12 +30,12 @@ const api = {
29
30
  listTemplates: vi.fn(),
30
31
  duplicate: vi.fn(),
31
32
  starters: vi.fn(),
32
- listCategories: vi.fn(),
33
33
  createTemplate: vi.fn(),
34
34
  saveTemplateDetails: vi.fn(),
35
35
  }
36
36
 
37
- vi.mock('../app/composables/useMailingApi', () => ({
37
+ vi.mock('../app/composables/useMailingApi', async (importOriginal) => ({
38
+ ...(await importOriginal<object>()),
38
39
  useMailingApi: () => api,
39
40
  }))
40
41
 
@@ -108,9 +109,27 @@ const globals = {
108
109
 
109
110
  let stubs: NuxtStubs
110
111
 
112
+ let starters: unknown[] = []
113
+
111
114
  beforeEach(() => {
112
115
  stubs = stubNuxtGlobals()
113
116
  menuGroups = []
117
+ starters = []
118
+ stubApiData().respond(
119
+ mailingReads(
120
+ {
121
+ templateContent: api.templateContent,
122
+ performance: api.performance,
123
+ listTemplates: api.listTemplates,
124
+ },
125
+ ({ url }) => {
126
+ if (url === '/api/mailing/templates/categories')
127
+ return { categories: [] }
128
+ if (url === '/api/mailing/templates/starters') return { starters }
129
+ throw new Error(`unexpected read ${url}`)
130
+ },
131
+ ),
132
+ )
114
133
  api.performance.mockResolvedValue({
115
134
  id: 't1',
116
135
  slug: 'order-confirmed',
@@ -403,18 +422,15 @@ describe('DuplicateModal', () => {
403
422
 
404
423
  describe('StarterGallery', () => {
405
424
  beforeEach(() => {
406
- api.starters.mockResolvedValue({
407
- starters: [
408
- {
409
- id: 'order-confirmed',
410
- name: 'Order confirmation',
411
- slug: 'order-confirmed',
412
- category: 'orders',
413
- locales: ['en'],
414
- },
415
- ],
416
- })
417
- api.listCategories.mockResolvedValue({ categories: [] })
425
+ starters = [
426
+ {
427
+ id: 'order-confirmed',
428
+ name: 'Order confirmation',
429
+ slug: 'order-confirmed',
430
+ category: 'orders',
431
+ locales: ['en'],
432
+ },
433
+ ]
418
434
  })
419
435
 
420
436
  it('creates a starter as a draft and opens it', async () => {