feat: sites and authors integration into existing screens (& formated all frontend files kinda accidentally)
This commit is contained in:
1 parent
3d81d2c717
commit
6b8990c55e
239 files changed
+9194
-1354
No files matched your search
@@ -396,7 +396,9 @@ describe('Failed lookup', () => {
|
||||
handler: () => Promise.reject(new TypeError('Failed to fetch')),
|
||||
},
|
||||
])('shows the alert and a Try again button when the lookup $name', async ({ handler }) => {
|
||||
const { wrapper } = await mountAccept('/invite/accept?c=abc123', { [lookupKey]: handler })
|
||||
const { wrapper } = await mountAccept('/invite/accept?c=abc123', {
|
||||
[lookupKey]: handler,
|
||||
})
|
||||
|
||||
expectFailedLookup(wrapper)
|
||||
expect(wrapper.find('[role="alert"]').exists()).toBe(true)
|
||||
@@ -413,7 +415,9 @@ describe('Failed lookup', () => {
|
||||
setTimeout(
|
||||
() =>
|
||||
resolve(
|
||||
mockResponse(200, { email: 'lea.martin@renew.example' }),
|
||||
mockResponse(200, {
|
||||
email: 'lea.martin@renew.example',
|
||||
}),
|
||||
),
|
||||
0,
|
||||
),
|
||||
|
||||
@@ -98,7 +98,10 @@ describe('Cards: the toggle cycle keeps card, chip, segment and request in sync'
|
||||
expect(findButton('Clear all')).toBeDefined()
|
||||
expect(segmentSelection()).toBe(segment)
|
||||
expect(callsTo(LIST_PATH)).toHaveLength(listCalls + 1)
|
||||
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, ...query })
|
||||
expect(queryObject(lastListQuery())).toEqual({
|
||||
...DEFAULT_QUERY,
|
||||
...query,
|
||||
})
|
||||
expect(callsTo(SUMMARY_PATH)).toHaveLength(1)
|
||||
|
||||
await click(card(index))
|
||||
|
||||
@@ -145,7 +145,12 @@ describe('Details: content', () => {
|
||||
[1, 'Body edited (+1 character)'],
|
||||
[-48, 'Body edited (-48 characters)'],
|
||||
])('words a body delta of %i', async (delta, text) => {
|
||||
await openWith(makeItem({ id: 1, changes: [{ field: 'body', kind: 'body', delta }] }))
|
||||
await openWith(
|
||||
makeItem({
|
||||
id: 1,
|
||||
changes: [{ field: 'body', kind: 'body', delta }],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(textOf(details()!.querySelector('[data-testid="activity-body-change"]'))).toBe(text)
|
||||
})
|
||||
@@ -153,8 +158,18 @@ describe('Details: content', () => {
|
||||
it('labels the fields, reads online as Draft and Published, and keeps unknown codes', async () => {
|
||||
const changes: ActivityChange[] = [
|
||||
{ field: 'online', kind: 'text', before: 'false', after: 'true' },
|
||||
{ field: 'filename', kind: 'text', before: 'a.png', after: 'b.png' },
|
||||
{ field: 'display_name', kind: 'text', before: 'Sam', after: 'Samuel' },
|
||||
{
|
||||
field: 'filename',
|
||||
kind: 'text',
|
||||
before: 'a.png',
|
||||
after: 'b.png',
|
||||
},
|
||||
{
|
||||
field: 'display_name',
|
||||
kind: 'text',
|
||||
before: 'Sam',
|
||||
after: 'Samuel',
|
||||
},
|
||||
{ field: 'tagline', kind: 'text', before: 'x', after: 'y' },
|
||||
]
|
||||
await openWith(makeItem({ id: 1, changes }))
|
||||
@@ -196,7 +211,12 @@ describe('Details: content', () => {
|
||||
before: LONG_VALUE,
|
||||
after: `${LONG_VALUE}more`,
|
||||
},
|
||||
{ field: 'filename', kind: 'text', before: 'a.png', after: 'b.png' },
|
||||
{
|
||||
field: 'filename',
|
||||
kind: 'text',
|
||||
before: 'a.png',
|
||||
after: 'b.png',
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
@@ -373,7 +393,10 @@ describe('Details: content', () => {
|
||||
describe('Details: Previous and Next across pages', () => {
|
||||
function numbered(page: number) {
|
||||
return Array.from({ length: 25 }, (_, index) =>
|
||||
makeItem({ id: (page - 1) * 25 + index + 1, created_at: '2026-10-03T12:30:00Z' }),
|
||||
makeItem({
|
||||
id: (page - 1) * 25 + index + 1,
|
||||
created_at: '2026-10-03T12:30:00Z',
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -413,7 +436,9 @@ describe('Details: Previous and Next across pages', () => {
|
||||
).toContain('Spring release')
|
||||
|
||||
await click(previous())
|
||||
expect(queryObject(callsTo(LIST_PATH).at(-1))).toMatchObject({ page: '1' })
|
||||
expect(queryObject(callsTo(LIST_PATH).at(-1))).toMatchObject({
|
||||
page: '1',
|
||||
})
|
||||
expect(footer()).toContain('Event 25 of 1,284')
|
||||
expect(
|
||||
rows()
|
||||
@@ -424,7 +449,10 @@ describe('Details: Previous and Next across pages', () => {
|
||||
|
||||
it('disables Next on the very last event', async () => {
|
||||
await mountActivity({
|
||||
list: () => pageOf([makeItem({ id: 1 }), makeItem({ id: 2 })], { total: 2 }),
|
||||
list: () =>
|
||||
pageOf([makeItem({ id: 1 }), makeItem({ id: 2 })], {
|
||||
total: 2,
|
||||
}),
|
||||
})
|
||||
await openRow(1)
|
||||
|
||||
@@ -480,7 +508,9 @@ describe('Details below md: a bottom sheet', () => {
|
||||
|
||||
describe('Details: counts', () => {
|
||||
it('counts positions against the filtered total', async () => {
|
||||
await mountActivity({ list: () => pageOf(activityItems, { total: 41 }) })
|
||||
await mountActivity({
|
||||
list: () => pageOf(activityItems, { total: 41 }),
|
||||
})
|
||||
await openRow(0)
|
||||
|
||||
expect(textOf(panel())).toContain('Event 1 of 41')
|
||||
|
||||
@@ -148,7 +148,9 @@ describe('Export menu: structure', () => {
|
||||
})
|
||||
|
||||
it('lists both items with their sub-lines and total 1,284, and Escape closes it', async () => {
|
||||
await mountActivity({ list: () => pageOf([makeItem({ id: 1 })], { total: 1284 }) })
|
||||
await mountActivity({
|
||||
list: () => pageOf([makeItem({ id: 1 })], { total: 1284 }),
|
||||
})
|
||||
|
||||
await openExportMenu()
|
||||
|
||||
@@ -162,7 +164,11 @@ describe('Export menu: structure', () => {
|
||||
)
|
||||
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', bubbles: true }),
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
code: 'Escape',
|
||||
bubbles: true,
|
||||
}),
|
||||
)
|
||||
await flushPromises()
|
||||
await vi.waitFor(() => expect(menuItems()).toHaveLength(0))
|
||||
@@ -172,7 +178,9 @@ describe('Export menu: structure', () => {
|
||||
it('states the filtered count and the singular form', async () => {
|
||||
await mountActivity({
|
||||
list: (query) =>
|
||||
pageOf([makeItem({ id: 1 })], { total: query.get('outcome') ? 6 : 1284 }),
|
||||
pageOf([makeItem({ id: 1 })], {
|
||||
total: query.get('outcome') ? 6 : 1284,
|
||||
}),
|
||||
})
|
||||
await click(stripCards()[2])
|
||||
|
||||
@@ -184,7 +192,9 @@ describe('Export menu: structure', () => {
|
||||
})
|
||||
|
||||
it('uses the singular for one event', async () => {
|
||||
await mountActivity({ list: () => pageOf([makeItem({ id: 1 })], { total: 1 }) })
|
||||
await mountActivity({
|
||||
list: () => pageOf([makeItem({ id: 1 })], { total: 1 }),
|
||||
})
|
||||
|
||||
await openExportMenu()
|
||||
|
||||
@@ -199,7 +209,11 @@ describe('Export menu: structure', () => {
|
||||
button.focus()
|
||||
|
||||
button.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }),
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'ArrowDown',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
@@ -281,7 +295,10 @@ describe('Export current view', () => {
|
||||
await vi.waitFor(() => expect(clicked).toHaveLength(1))
|
||||
|
||||
expect(callsTo(EXPORT_PATH)).toHaveLength(1)
|
||||
expect(queryObject(callsTo(EXPORT_PATH)[0])).toEqual({ category: 'auth', range: '30d' })
|
||||
expect(queryObject(callsTo(EXPORT_PATH)[0])).toEqual({
|
||||
category: 'auth',
|
||||
range: '30d',
|
||||
})
|
||||
expect(clicked[0]!.download).toMatch(CURRENT_FILE)
|
||||
expect(URL.createObjectURL).toHaveBeenCalledTimes(1)
|
||||
expect(toastCall()).toEqual({
|
||||
@@ -455,7 +472,9 @@ describe('Export all: the dialog', () => {
|
||||
})
|
||||
|
||||
it('aborts the request on Cancel while preparing, with no download and no toast', async () => {
|
||||
await mountActivity({ exportFile: () => new Promise<Blob>(() => undefined) })
|
||||
await mountActivity({
|
||||
exportFile: () => new Promise<Blob>(() => undefined),
|
||||
})
|
||||
await openDialog()
|
||||
|
||||
await submitDialog()
|
||||
|
||||
@@ -93,7 +93,10 @@ describe('Filter bar: each control sends its query parameter and resets the page
|
||||
list: (query) => pageOf(activityItems, { page: Number(query.get('page')) }),
|
||||
})
|
||||
await goToPage(3)
|
||||
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, page: '3' })
|
||||
expect(queryObject(lastListQuery())).toEqual({
|
||||
...DEFAULT_QUERY,
|
||||
page: '3',
|
||||
})
|
||||
}
|
||||
|
||||
it('types "spring" and waits 300 ms', async () => {
|
||||
@@ -108,7 +111,10 @@ describe('Filter bar: each control sends its query parameter and resets the page
|
||||
vi.advanceTimersByTime(1)
|
||||
await flushPromises()
|
||||
expect(callsTo(LIST_PATH)).toHaveLength(before + 1)
|
||||
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, q: 'spring' })
|
||||
expect(queryObject(lastListQuery())).toEqual({
|
||||
...DEFAULT_QUERY,
|
||||
q: 'spring',
|
||||
})
|
||||
})
|
||||
|
||||
it('types "spring" and presses Enter at once', async () => {
|
||||
@@ -119,7 +125,10 @@ describe('Filter bar: each control sends its query parameter and resets the page
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
|
||||
await flushPromises()
|
||||
expect(callsTo(LIST_PATH)).toHaveLength(before + 1)
|
||||
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, q: 'spring' })
|
||||
expect(queryObject(lastListQuery())).toEqual({
|
||||
...DEFAULT_QUERY,
|
||||
q: 'spring',
|
||||
})
|
||||
|
||||
vi.advanceTimersByTime(500)
|
||||
await flushPromises()
|
||||
@@ -135,7 +144,10 @@ describe('Filter bar: each control sends its query parameter and resets the page
|
||||
harness.wrapper!.findComponent(ActivityPersonSelect).vm.$emit('select', value, label)
|
||||
await flushPromises()
|
||||
|
||||
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, ...expected })
|
||||
expect(queryObject(lastListQuery())).toEqual({
|
||||
...DEFAULT_QUERY,
|
||||
...expected,
|
||||
})
|
||||
expect(chipTexts()).toEqual([`Person: ${label}`])
|
||||
})
|
||||
|
||||
@@ -144,13 +156,19 @@ describe('Filter bar: each control sends its query parameter and resets the page
|
||||
harness.wrapper!.findComponent(ActivityCategorySelect).vm.$emit('select', 'users')
|
||||
await flushPromises()
|
||||
|
||||
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, category: 'users' })
|
||||
expect(queryObject(lastListQuery())).toEqual({
|
||||
...DEFAULT_QUERY,
|
||||
category: 'users',
|
||||
})
|
||||
})
|
||||
|
||||
it('chooses Outcome "Succeeded", then "All"', async () => {
|
||||
await onPage3()
|
||||
await clickIn('Outcome', 'Succeeded')
|
||||
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, outcome: 'success' })
|
||||
expect(queryObject(lastListQuery())).toEqual({
|
||||
...DEFAULT_QUERY,
|
||||
outcome: 'success',
|
||||
})
|
||||
expect(chipTexts()).toEqual(['Outcome: Succeeded'])
|
||||
|
||||
await clickIn('Outcome', 'All')
|
||||
@@ -165,7 +183,9 @@ describe('Filter bar: each control sends its query parameter and resets the page
|
||||
await clickIn('Dates', label)
|
||||
|
||||
expect(queryObject(lastListQuery())).toEqual(expected)
|
||||
expect(queryObject(callsTo(SUMMARY_PATH).at(-1))).toEqual({ range: expected.range })
|
||||
expect(queryObject(callsTo(SUMMARY_PATH).at(-1))).toEqual({
|
||||
range: expected.range,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -192,8 +212,16 @@ describe('Filter bar: custom dates', () => {
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const expected = { range: 'custom', from: '2026-09-01', to: '2026-09-30' }
|
||||
expect(queryObject(lastListQuery())).toEqual({ ...expected, page: '1', size: '25' })
|
||||
const expected = {
|
||||
range: 'custom',
|
||||
from: '2026-09-01',
|
||||
to: '2026-09-30',
|
||||
}
|
||||
expect(queryObject(lastListQuery())).toEqual({
|
||||
...expected,
|
||||
page: '1',
|
||||
size: '25',
|
||||
})
|
||||
expect(queryObject(callsTo(SUMMARY_PATH).at(-1))).toEqual(expected)
|
||||
expect(chipTexts()).toEqual(['Dates: 1 – 30 Sep 2026'])
|
||||
})
|
||||
@@ -238,7 +266,9 @@ describe('Chips', () => {
|
||||
|
||||
await clickButton('Clear all')
|
||||
expect(queryObject(lastListQuery())).toEqual(DEFAULT_QUERY)
|
||||
expect(queryObject(callsTo(SUMMARY_PATH).at(-1))).toEqual({ range: '7d' })
|
||||
expect(queryObject(callsTo(SUMMARY_PATH).at(-1))).toEqual({
|
||||
range: '7d',
|
||||
})
|
||||
expect(chipTexts()).toEqual([])
|
||||
expect(findButton('Clear all')).toBeUndefined()
|
||||
expect(searchInput().value).toBe('')
|
||||
@@ -272,7 +302,10 @@ describe('Strip cards apply their filter', () => {
|
||||
await mountActivity()
|
||||
await click(stripCards()[2])
|
||||
|
||||
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, outcome: 'failed' })
|
||||
expect(queryObject(lastListQuery())).toEqual({
|
||||
...DEFAULT_QUERY,
|
||||
outcome: 'failed',
|
||||
})
|
||||
expect(chipTexts()).toEqual(['Outcome: Failed'])
|
||||
expect(findButton('Clear all')?.disabled).toBe(false)
|
||||
expect(stripCards()[2]!.getAttribute('aria-pressed')).toBe('true')
|
||||
@@ -283,7 +316,10 @@ describe('Strip cards apply their filter', () => {
|
||||
await mountActivity()
|
||||
await click(stripCards()[3])
|
||||
|
||||
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, security: 'true' })
|
||||
expect(queryObject(lastListQuery())).toEqual({
|
||||
...DEFAULT_QUERY,
|
||||
security: 'true',
|
||||
})
|
||||
expect(chipTexts()).toEqual(['Type: Security events'])
|
||||
expect(stripCards()[3]!.getAttribute('aria-pressed')).toBe('true')
|
||||
})
|
||||
@@ -291,7 +327,9 @@ describe('Strip cards apply their filter', () => {
|
||||
it('stacks the Failed card on other filters without asking for the summary again', async () => {
|
||||
await mountActivity({
|
||||
list: (query) =>
|
||||
pageOf([makeItem({ id: 3 })], { total: query.get('outcome') ? 6 : 1284 }),
|
||||
pageOf([makeItem({ id: 3 })], {
|
||||
total: query.get('outcome') ? 6 : 1284,
|
||||
}),
|
||||
})
|
||||
harness.wrapper!.findComponent(ActivityCategorySelect).vm.$emit('select', 'auth')
|
||||
await flushPromises()
|
||||
@@ -304,7 +342,9 @@ describe('Strip cards apply their filter', () => {
|
||||
'Dates: 30 days',
|
||||
])
|
||||
expect(callsTo(SUMMARY_PATH)).toHaveLength(2)
|
||||
expect(queryObject(callsTo(SUMMARY_PATH).at(-1))).toEqual({ range: '30d' })
|
||||
expect(queryObject(callsTo(SUMMARY_PATH).at(-1))).toEqual({
|
||||
range: '30d',
|
||||
})
|
||||
expect(textOf(document.body.querySelector('[data-testid="activity-pager"]'))).toContain(
|
||||
'Showing 1–6 of 6',
|
||||
)
|
||||
@@ -322,8 +362,19 @@ describe('The Person menu', () => {
|
||||
display_name: 'Marta Alves',
|
||||
role: 'owner',
|
||||
},
|
||||
{ kind: 'user', id: 4, username: 'jonas', display_name: '', role: 'admin' },
|
||||
{ kind: 'invite', id: 9, email: 'x@example.com', status: 'pending' },
|
||||
{
|
||||
kind: 'user',
|
||||
id: 4,
|
||||
username: 'jonas',
|
||||
display_name: '',
|
||||
role: 'admin',
|
||||
},
|
||||
{
|
||||
kind: 'invite',
|
||||
id: 9,
|
||||
email: 'x@example.com',
|
||||
status: 'pending',
|
||||
},
|
||||
],
|
||||
keys: () => [{ id: 7, name: 'Website sync' }],
|
||||
})
|
||||
@@ -371,7 +422,10 @@ describe('Pagination', () => {
|
||||
const pager = () => textOf(document.body.querySelector('[data-testid="activity-pager"]'))
|
||||
|
||||
await goToPage(2)
|
||||
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, page: '2' })
|
||||
expect(queryObject(lastListQuery())).toEqual({
|
||||
...DEFAULT_QUERY,
|
||||
page: '2',
|
||||
})
|
||||
expect(pager()).toContain('Showing 26–50 of 1,284')
|
||||
const activePage = document.body.querySelector(
|
||||
'[data-testid="activity-pager"] [aria-current="page"]',
|
||||
@@ -380,7 +434,11 @@ describe('Pagination', () => {
|
||||
|
||||
harness.wrapper!.findComponent(ActivityPager).vm.$emit('update:size', 50)
|
||||
await flushPromises()
|
||||
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, page: '1', size: '50' })
|
||||
expect(queryObject(lastListQuery())).toEqual({
|
||||
...DEFAULT_QUERY,
|
||||
page: '1',
|
||||
size: '50',
|
||||
})
|
||||
expect(pager()).toContain('Showing 1–50 of 1,284')
|
||||
|
||||
harness.wrapper!.findComponent(ActivityPager).vm.$emit('update:size', 25)
|
||||
|
||||
@@ -61,7 +61,10 @@ describe('Activity list remembers the chosen page size', () => {
|
||||
|
||||
await chooseSize(50)
|
||||
expect(localStorage.getItem(KEY)).toBe('50')
|
||||
expect(queryObject(lastListQuery())).toMatchObject({ page: '1', size: '50' })
|
||||
expect(queryObject(lastListQuery())).toMatchObject({
|
||||
page: '1',
|
||||
size: '50',
|
||||
})
|
||||
expect(url()).toBe('/activity?size=50')
|
||||
|
||||
unmountActivity()
|
||||
@@ -123,7 +126,10 @@ describe('Activity list remembers the chosen page size', () => {
|
||||
expect(queryObject(lastListQuery()).size).toBe('25')
|
||||
|
||||
await chooseSize(100)
|
||||
expect(queryObject(lastListQuery())).toMatchObject({ page: '1', size: '100' })
|
||||
expect(queryObject(lastListQuery())).toMatchObject({
|
||||
page: '1',
|
||||
size: '100',
|
||||
})
|
||||
expect(url()).toBe('/activity?size=100')
|
||||
})
|
||||
})
|
||||
@@ -67,7 +67,11 @@ describe('My activity: a role that is not owner or admin sees its own events', (
|
||||
expect(document.body.querySelector('[data-testid="activity-actor"]')).toBeNull()
|
||||
expect(callsTo(SUMMARY_PATH)).toHaveLength(0)
|
||||
expect(callsTo(EXPORT_PATH)).toHaveLength(0)
|
||||
expect(queryObject(callsTo(LIST_PATH)[0])).toEqual({ range: '7d', page: '1', size: '25' })
|
||||
expect(queryObject(callsTo(LIST_PATH)[0])).toEqual({
|
||||
range: '7d',
|
||||
page: '1',
|
||||
size: '25',
|
||||
})
|
||||
expect(textOf(document.body.querySelector('[data-testid="activity-pager"]'))).toContain(
|
||||
'Showing 1–25 of 41',
|
||||
)
|
||||
@@ -81,7 +85,10 @@ describe('My activity: a role that is not owner or admin sees its own events', (
|
||||
})
|
||||
|
||||
it('shows its own empty copy', async () => {
|
||||
await mountActivity({ role: 'editor', list: () => pageOf([], { total: 0 }) })
|
||||
await mountActivity({
|
||||
role: 'editor',
|
||||
list: () => pageOf([], { total: 0 }),
|
||||
})
|
||||
|
||||
expect(textOf(document.body)).toContain('No activity yet')
|
||||
expect(textOf(document.body)).toContain(
|
||||
@@ -90,7 +97,10 @@ describe('My activity: a role that is not owner or admin sees its own events', (
|
||||
})
|
||||
|
||||
it('shows the same details panel with the user position', async () => {
|
||||
await mountActivity({ role: 'editor', list: () => pageOf(activityItems, { total: 41 }) })
|
||||
await mountActivity({
|
||||
role: 'editor',
|
||||
list: () => pageOf(activityItems, { total: 41 }),
|
||||
})
|
||||
rows()[0]!.click()
|
||||
await flushPromises()
|
||||
await vi.waitFor(() => expect(document.body.querySelector('[role="dialog"]')).toBeTruthy())
|
||||
@@ -115,7 +125,12 @@ describe('The French interface', () => {
|
||||
id: 2,
|
||||
created_at: '2026-10-03T12:20:00Z',
|
||||
outcome: 'failed',
|
||||
target: { type: 'article', id: '8', name: 'Spring release', exists: true },
|
||||
target: {
|
||||
type: 'article',
|
||||
id: '8',
|
||||
name: 'Spring release',
|
||||
exists: true,
|
||||
},
|
||||
}),
|
||||
]),
|
||||
})
|
||||
|
||||
@@ -79,7 +79,11 @@ describe('The detail line under the sentence', () => {
|
||||
],
|
||||
[
|
||||
'media.variant_generate_all',
|
||||
{ generated: 3, failed: 2, causes: ['too_large', 'unsupported_format'] },
|
||||
{
|
||||
generated: 3,
|
||||
failed: 2,
|
||||
causes: ['too_large', 'unsupported_format'],
|
||||
},
|
||||
'3 generated · 2 failed (Image too large, Unsupported source format)',
|
||||
],
|
||||
[
|
||||
@@ -102,8 +106,15 @@ describe('The detail line under the sentence', () => {
|
||||
|
||||
it('shows the line in the row, under the sentence', async () => {
|
||||
const events = [
|
||||
variantEvent({ params: { generated: 4, failed: 1, causes: ['too_large'] } }),
|
||||
makeItem({ id: 2, action: 'media.rename', category: 'media', target: IMAGE }),
|
||||
variantEvent({
|
||||
params: { generated: 4, failed: 1, causes: ['too_large'] },
|
||||
}),
|
||||
makeItem({
|
||||
id: 2,
|
||||
action: 'media.rename',
|
||||
category: 'media',
|
||||
target: IMAGE,
|
||||
}),
|
||||
]
|
||||
await mountActivity({ list: () => pageOf(events) })
|
||||
|
||||
@@ -147,11 +158,18 @@ describe('The French interface', () => {
|
||||
'A généré toutes les variantes de north-wall.jpg',
|
||||
)
|
||||
expect(
|
||||
sentenceText(variantEvent({ action: 'media.variant_purge_image', outcome: 'failed' })),
|
||||
sentenceText(
|
||||
variantEvent({
|
||||
action: 'media.variant_purge_image',
|
||||
outcome: 'failed',
|
||||
}),
|
||||
),
|
||||
).toBe('Échec de la purge des variantes de north-wall.jpg')
|
||||
expect(
|
||||
detailLine(
|
||||
variantEvent({ params: { generated: 4, failed: 1, causes: ['too_large'] } }),
|
||||
variantEvent({
|
||||
params: { generated: 4, failed: 1, causes: ['too_large'] },
|
||||
}),
|
||||
),
|
||||
).toBe('4 générées · 1 en échec (Image trop grande)')
|
||||
})
|
||||
|
||||
@@ -89,7 +89,10 @@ describe('Navigation', () => {
|
||||
expect(activity.getAttribute('aria-current')).toBe('page')
|
||||
await expectIcon(activity, 'history')
|
||||
expect(textOf(document.body.querySelector('nav[aria-label="breadcrumb"]'))).toBe('Activity')
|
||||
expect(router.currentRoute.value.meta).toEqual({ auth: 'verified', title: 'Activity' })
|
||||
expect(router.currentRoute.value.meta).toEqual({
|
||||
auth: 'verified',
|
||||
title: 'Activity',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -140,7 +143,11 @@ describe('Default: an owner sees the strip, the filter bar and a day-grouped tab
|
||||
await mountActivity()
|
||||
|
||||
expect(callsTo(LIST_PATH)).toHaveLength(1)
|
||||
expect(queryObject(callsTo(LIST_PATH)[0])).toEqual({ range: '7d', page: '1', size: '25' })
|
||||
expect(queryObject(callsTo(LIST_PATH)[0])).toEqual({
|
||||
range: '7d',
|
||||
page: '1',
|
||||
size: '25',
|
||||
})
|
||||
expect(callsTo(SUMMARY_PATH)).toHaveLength(1)
|
||||
expect(queryObject(callsTo(SUMMARY_PATH)[0])).toEqual({ range: '7d' })
|
||||
})
|
||||
@@ -223,7 +230,11 @@ describe('Rows: the Actor cell and the sentence follow the event', () => {
|
||||
},
|
||||
{
|
||||
name: 'failed publish',
|
||||
item: makeItem({ id: 5, actor: user(4, 'Inès Duarte'), outcome: 'failed' }),
|
||||
item: makeItem({
|
||||
id: 5,
|
||||
actor: user(4, 'Inès Duarte'),
|
||||
outcome: 'failed',
|
||||
}),
|
||||
actor: 'Inès Duarte',
|
||||
sentence: 'Failed to publish the article Spring release',
|
||||
badge: 'Failed',
|
||||
@@ -370,7 +381,14 @@ describe('Rows: targets', () => {
|
||||
|
||||
it('renders an unknown action code as it is and still opens the details', async () => {
|
||||
await mountActivity({
|
||||
list: () => pageOf([makeItem({ id: 1, action: 'article.archive', target: null })]),
|
||||
list: () =>
|
||||
pageOf([
|
||||
makeItem({
|
||||
id: 1,
|
||||
action: 'article.archive',
|
||||
target: null,
|
||||
}),
|
||||
]),
|
||||
})
|
||||
|
||||
const row = rows()[0]!
|
||||
@@ -486,7 +504,11 @@ describe('Strip', () => {
|
||||
[40, 40, 'No change'],
|
||||
])('reads the Total sub-line for %i against %i as %s', async (total, previous, subline) => {
|
||||
await mountActivity({
|
||||
summary: () => ({ ...summaryDefault, total, previous_total: previous }),
|
||||
summary: () => ({
|
||||
...summaryDefault,
|
||||
total,
|
||||
previous_total: previous,
|
||||
}),
|
||||
})
|
||||
|
||||
expect(stripTexts()[0]).toContain(subline)
|
||||
@@ -530,7 +552,10 @@ describe('Strip', () => {
|
||||
|
||||
describe('Empty states', () => {
|
||||
it('shows No activity yet with Export disabled and no table or pager', async () => {
|
||||
await mountActivity({ list: () => pageOf([], { total: 0 }), summary: () => summaryNone })
|
||||
await mountActivity({
|
||||
list: () => pageOf([], { total: 0 }),
|
||||
summary: () => summaryNone,
|
||||
})
|
||||
|
||||
expect(pageText()).toContain('No activity yet')
|
||||
expect(pageText()).toContain(
|
||||
|
||||
@@ -205,11 +205,18 @@ async function mountPage(handlers: Handlers = {}, options: MountOptions = {}) {
|
||||
throw new Error(`Unexpected request: ${path}`)
|
||||
})
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
getMe: vi.fn().mockResolvedValue({ verified: true, username, id: 1, role: sessionRole }),
|
||||
getMe: vi.fn().mockResolvedValue({
|
||||
verified: true,
|
||||
username,
|
||||
id: 1,
|
||||
role: sessionRole,
|
||||
}),
|
||||
request: requestMock,
|
||||
})
|
||||
await router.push('/integration/api-keys')
|
||||
const Host = defineComponent({ render: () => h(UApp, null, () => h(ApiKeysView)) })
|
||||
const Host = defineComponent({
|
||||
render: () => h(UApp, null, () => h(ApiKeysView)),
|
||||
})
|
||||
wrapper = mount(Host, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router, uiPlugin] },
|
||||
@@ -378,7 +385,10 @@ function alertText() {
|
||||
|
||||
function stubClipboard() {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
})
|
||||
return writeText
|
||||
}
|
||||
|
||||
@@ -618,7 +628,12 @@ describe('Create key: validation', () => {
|
||||
it.each([
|
||||
{ name: '', expiry: '2026-03-12', nameError: true, expiryError: true },
|
||||
{ name: '', expiry: '', nameError: true, expiryError: false },
|
||||
{ name: 'Deploy', expiry: '2026-03-12', nameError: false, expiryError: true },
|
||||
{
|
||||
name: 'Deploy',
|
||||
expiry: '2026-03-12',
|
||||
nameError: false,
|
||||
expiryError: true,
|
||||
},
|
||||
])(
|
||||
'name "$name" and expiry "$expiry" sends nothing',
|
||||
async ({ name, expiry, nameError, expiryError }) => {
|
||||
@@ -1007,8 +1022,16 @@ describe('Not drawn: Create sending', () => {
|
||||
|
||||
describe('Not drawn: Create failed', () => {
|
||||
it.each([
|
||||
{ name: 'status 500', failure: () => new ApiError(500), copy: CREATE_FAILED_COPY },
|
||||
{ name: 'a network error', failure: () => new NetworkError(), copy: CONNECTION_COPY },
|
||||
{
|
||||
name: 'status 500',
|
||||
failure: () => new ApiError(500),
|
||||
copy: CREATE_FAILED_COPY,
|
||||
},
|
||||
{
|
||||
name: 'a network error',
|
||||
failure: () => new NetworkError(),
|
||||
copy: CONNECTION_COPY,
|
||||
},
|
||||
{
|
||||
name: 'status 429',
|
||||
failure: () => new ApiError(429),
|
||||
@@ -1069,8 +1092,16 @@ describe('Not drawn: Revoke sending', () => {
|
||||
})
|
||||
|
||||
describe.each([
|
||||
{ name: 'status 500', failure: () => new ApiError(500), copy: REVOKE_FAILED_COPY },
|
||||
{ name: 'a network error', failure: () => new NetworkError(), copy: CONNECTION_COPY },
|
||||
{
|
||||
name: 'status 500',
|
||||
failure: () => new ApiError(500),
|
||||
copy: REVOKE_FAILED_COPY,
|
||||
},
|
||||
{
|
||||
name: 'a network error',
|
||||
failure: () => new NetworkError(),
|
||||
copy: CONNECTION_COPY,
|
||||
},
|
||||
])('Not drawn: Revoke failed: $name', ({ failure, copy }) => {
|
||||
it('shows the notice in the open dialog, leaves the row and lets a retry succeed', async () => {
|
||||
let attempts = 0
|
||||
|
||||
@@ -6,6 +6,7 @@ import router from '@/router'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import ArticleView from '../ArticleView.vue'
|
||||
import { oneSite } from './editorMetaKit'
|
||||
import { articleEnvelope, articleListRoute } from './articleEnvelope'
|
||||
|
||||
const NOW = new Date('2026-08-26T10:00:00Z')
|
||||
@@ -58,9 +59,13 @@ function routeFor(url: string, init?: RequestInit) {
|
||||
}
|
||||
|
||||
function fetchRouter(handlers: Record<string, () => Response | Promise<Response>>) {
|
||||
const routes = {
|
||||
'GET /v1/site/': () => mockResponse(200, oneSite),
|
||||
...handlers,
|
||||
}
|
||||
return vi.fn((url: string, init?: RequestInit) => {
|
||||
const key = routeFor(url, init)
|
||||
const handler = handlers[key]
|
||||
const handler = routes[key]
|
||||
if (!handler) {
|
||||
throw new Error(`Unexpected fetch call: ${key}`)
|
||||
}
|
||||
@@ -157,7 +162,11 @@ async function clickTab(label: string) {
|
||||
) as HTMLButtonElement | undefined
|
||||
button?.focus()
|
||||
button?.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'Enter',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
)
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useSessionStore } from '@/stores/session'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
||||
import ArticleView from '../ArticleView.vue'
|
||||
import { oneSite } from './editorMetaKit'
|
||||
import { articleEnvelope, parseListUrl, type EnvelopeParams } from './articleEnvelope'
|
||||
|
||||
const KEY = 'renewcms-page-size-articles'
|
||||
@@ -36,6 +37,14 @@ let wrapper: Awaited<ReturnType<typeof mount>> | null = null
|
||||
function installFetch() {
|
||||
global.fetch = vi.fn((url: string, init?: RequestInit) => {
|
||||
const method = init?.method ?? 'GET'
|
||||
if (method === 'GET' && url === '/v1/site/') {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: () => null },
|
||||
json: async () => oneSite,
|
||||
} as unknown as Response)
|
||||
}
|
||||
if (method === 'GET' && url.startsWith('/v1/article/?')) {
|
||||
const params = parseListUrl(url)
|
||||
listCalls.push(params)
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useSessionStore } from '@/stores/session'
|
||||
import type { Article, ArticlePage } from '@/composables/useArticles'
|
||||
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
||||
import ArticleView from '../ArticleView.vue'
|
||||
import { oneSite } from './editorMetaKit'
|
||||
import { articleEnvelope, parseListUrl, type EnvelopeParams } from './articleEnvelope'
|
||||
|
||||
const TOTAL = 312
|
||||
@@ -81,6 +82,9 @@ function respondFromFixture(params: EnvelopeParams) {
|
||||
function installFetch() {
|
||||
global.fetch = vi.fn((url: string, init?: RequestInit) => {
|
||||
const method = init?.method ?? 'GET'
|
||||
if (method === 'GET' && url === '/v1/site/') {
|
||||
return Promise.resolve(mockResponse(200, oneSite))
|
||||
}
|
||||
if (method === 'GET' && url.startsWith('/v1/article/?')) {
|
||||
const params = parseListUrl(url)
|
||||
listCalls.push(params)
|
||||
@@ -252,7 +256,10 @@ describe('3 Last page', () => {
|
||||
|
||||
describe('4 Single page', () => {
|
||||
it('keeps the summary and the page-size select, hides the paginator and reads the tab counts', async () => {
|
||||
articles = buildArticles(6).map((article, index) => ({ ...article, is_online: index < 4 }))
|
||||
articles = buildArticles(6).map((article, index) => ({
|
||||
...article,
|
||||
is_online: index < 4,
|
||||
}))
|
||||
const wrapper = await mountAt('/article')
|
||||
|
||||
expect(summary()).toBe('Showing 1–6 of 6')
|
||||
@@ -463,14 +470,22 @@ describe('Keyboard, the page-size list', () => {
|
||||
trigger.focus()
|
||||
|
||||
trigger.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }),
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'ArrowDown',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
)
|
||||
await flushPromises()
|
||||
expect(document.body.querySelector('[role="listbox"]')).not.toBeNull()
|
||||
|
||||
const focused = document.activeElement ?? document.body
|
||||
focused.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }),
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
)
|
||||
await flushPromises()
|
||||
expect(document.body.querySelector('[role="listbox"]')).toBeNull()
|
||||
@@ -487,7 +502,11 @@ describe('Tabs filter on the server', () => {
|
||||
)
|
||||
tab?.focus()
|
||||
tab?.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'Enter',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
@@ -623,13 +642,23 @@ describe('8c Sort is kept across paging, page size and search', () => {
|
||||
|
||||
await click(control('Next page'))
|
||||
expect(listCalls.at(-1)).toMatchObject({ page: 2, sort: 'title:asc' })
|
||||
expect(router.currentRoute.value.query).toMatchObject({ page: '2', sort: 'title:asc' })
|
||||
expect(router.currentRoute.value.query).toMatchObject({
|
||||
page: '2',
|
||||
sort: 'title:asc',
|
||||
})
|
||||
|
||||
await wrapper.find('input[type="search"]').setValue('plaster')
|
||||
await vi.advanceTimersByTimeAsync(120)
|
||||
await flushPromises()
|
||||
expect(listCalls.at(-1)).toMatchObject({ page: 1, q: 'plaster', sort: 'title:asc' })
|
||||
expect(router.currentRoute.value.query).toMatchObject({ q: 'plaster', sort: 'title:asc' })
|
||||
expect(listCalls.at(-1)).toMatchObject({
|
||||
page: 1,
|
||||
q: 'plaster',
|
||||
sort: 'title:asc',
|
||||
})
|
||||
expect(router.currentRoute.value.query).toMatchObject({
|
||||
q: 'plaster',
|
||||
sort: 'title:asc',
|
||||
})
|
||||
expect(router.currentRoute.value.query.page).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -657,7 +686,10 @@ describe('8e No client-side re-sorting', () => {
|
||||
it('renders the rows in exactly the order the server returned', async () => {
|
||||
listHandler = (params) => {
|
||||
const page = articleEnvelope(articles, params)
|
||||
return mockResponse(200, { ...page, items: [...page.items].reverse() })
|
||||
return mockResponse(200, {
|
||||
...page,
|
||||
items: [...page.items].reverse(),
|
||||
})
|
||||
}
|
||||
await mountAt('/article?sort=title:asc')
|
||||
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import router from '@/router'
|
||||
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import {
|
||||
articleOf,
|
||||
bylines,
|
||||
callsTo,
|
||||
createMetaBackend,
|
||||
failWith,
|
||||
mountMeta,
|
||||
renewSites,
|
||||
textOf,
|
||||
typeInto,
|
||||
unmountMeta,
|
||||
type MetaBackend,
|
||||
} from './metaTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
const SEARCH_WAIT_MS = 320
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(unmountMeta)
|
||||
|
||||
const FIVE = ['Inès Duarte', 'Jonas Pereira', 'Camille Roche', 'Paul Huber', 'Léa Fontaine']
|
||||
|
||||
function backendWith(sites: 'one' | 'several', articles: Article[]): MetaBackend {
|
||||
const backend = createMetaBackend({
|
||||
sites: sites === 'one' ? renewSites().slice(0, 1) : renewSites(),
|
||||
})
|
||||
backend.articles = articles
|
||||
return backend
|
||||
}
|
||||
|
||||
function withAuthors(id: number, names: string[], overrides: Partial<Article> = {}) {
|
||||
return articleOf(id, `Article ${id}`, {
|
||||
authors: bylines(...names),
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
function bodyRows() {
|
||||
return Array.from(document.body.querySelectorAll<HTMLElement>('tbody tr'))
|
||||
}
|
||||
|
||||
function cell(row: HTMLElement, index: number) {
|
||||
return row.children[index] as HTMLElement
|
||||
}
|
||||
|
||||
function headers() {
|
||||
return Array.from(document.body.querySelectorAll('thead th')).map(textOf)
|
||||
}
|
||||
|
||||
function siteSelect() {
|
||||
return document.body.querySelector<HTMLElement>('button[aria-label="Site"]')
|
||||
}
|
||||
|
||||
function listCalls(backend: MetaBackend) {
|
||||
return callsTo(backend, 'GET', '/v1/article/')
|
||||
}
|
||||
|
||||
async function chooseSite(label: string) {
|
||||
siteSelect()!.click()
|
||||
await flushPromises()
|
||||
Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]'))
|
||||
.find((item) => textOf(item) === label)!
|
||||
.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function currentQuery() {
|
||||
return { ...router.currentRoute.value.query }
|
||||
}
|
||||
|
||||
describe('Articles: the Authors column on every frame', () => {
|
||||
it.each([
|
||||
{ authors: 'no authors', names: [], avatars: 0, more: '', text: '—' },
|
||||
{
|
||||
authors: 'one author',
|
||||
names: FIVE.slice(0, 1),
|
||||
avatars: 1,
|
||||
more: '',
|
||||
text: '',
|
||||
},
|
||||
{
|
||||
authors: 'three',
|
||||
names: FIVE.slice(0, 3),
|
||||
avatars: 3,
|
||||
more: '',
|
||||
text: '',
|
||||
},
|
||||
{ authors: 'five', names: FIVE, avatars: 3, more: '+2', text: '' },
|
||||
])('$authors: $avatars avatar(s) and "$more"', async ({ names, avatars, more, text }) => {
|
||||
await mountMeta('articles', '/article', {
|
||||
backend: backendWith('one', [withAuthors(1, names)]),
|
||||
})
|
||||
|
||||
const authorsCell = cell(bodyRows()[0]!, 1)
|
||||
const stack = authorsCell.querySelector('[data-testid="author-stack"]')
|
||||
expect(stack?.querySelectorAll('[aria-hidden="true"]').length ?? 0).toBe(avatars)
|
||||
expect(
|
||||
authorsCell.querySelector('[data-testid="author-stack-more"]')?.textContent ?? '',
|
||||
).toBe(more)
|
||||
if (text) {
|
||||
expect(textOf(authorsCell)).toBe(text)
|
||||
expect(authorsCell.querySelector('[aria-label="No authors"]')).not.toBeNull()
|
||||
}
|
||||
expect(headers()[1]).toBe('Authors')
|
||||
expect(authorsCell.querySelector('[title]')?.getAttribute('title')).toBe(names.join(', '))
|
||||
})
|
||||
|
||||
it('draws avatars with the first name and no site on the mobile cards', async () => {
|
||||
await mountMeta('articles', '/article', {
|
||||
width: MOBILE_WIDTH,
|
||||
backend: backendWith('several', [
|
||||
withAuthors(1, FIVE.slice(0, 3), { site_id: 2 }),
|
||||
withAuthors(2, []),
|
||||
]),
|
||||
})
|
||||
|
||||
const cards = Array.from(
|
||||
document.body.querySelectorAll<HTMLElement>('li [data-row-title]'),
|
||||
).map((title) => title.closest('li') as HTMLElement)
|
||||
expect(textOf(cards[0])).toContain('Inès Duarte +2')
|
||||
expect(
|
||||
cards[0]!.querySelectorAll('[data-testid="author-stack"] [aria-hidden="true"]'),
|
||||
).toHaveLength(3)
|
||||
expect(textOf(cards[1])).toContain('No authors')
|
||||
expect(textOf(cards[0])).not.toContain('Journal')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Articles: the Site column and the Site filter', () => {
|
||||
it('has neither with one site', async () => {
|
||||
await mountMeta('articles', '/article', {
|
||||
backend: backendWith('one', [withAuthors(1, ['Inès Duarte'])]),
|
||||
})
|
||||
|
||||
expect(headers()).toEqual(['Title', 'Authors', 'Status', 'Created', 'Updated', ''])
|
||||
expect(siteSelect()).toBeNull()
|
||||
expect(textOf(document.body)).not.toContain('All sites')
|
||||
})
|
||||
|
||||
it.each(['owner', 'admin', 'verified'] as const)(
|
||||
'shows a read-only Site column and a Site select for a %s with several sites',
|
||||
async (role) => {
|
||||
const backend = backendWith('several', [
|
||||
withAuthors(1, ['Inès Duarte'], { site_id: 2 }),
|
||||
withAuthors(2, ['Inès Duarte'], { site_id: 1 }),
|
||||
])
|
||||
backend.me.role = role === 'verified' ? undefined : role
|
||||
await mountMeta('articles', '/article', { backend })
|
||||
|
||||
expect(headers()).toEqual([
|
||||
'Title',
|
||||
'Authors',
|
||||
'Site',
|
||||
'Status',
|
||||
'Created',
|
||||
'Updated',
|
||||
'',
|
||||
])
|
||||
expect(bodyRows().map((row) => textOf(cell(row, 2)))).toEqual(['Journal', 'Renew'])
|
||||
expect(cell(bodyRows()[0]!, 2).querySelector('button, a, input')).toBeNull()
|
||||
siteSelect()!.click()
|
||||
await flushPromises()
|
||||
expect(
|
||||
Array.from(document.body.querySelectorAll('[role="option"]')).map(textOf),
|
||||
).toEqual(['All sites', 'Renew', 'Journal', 'Projects', 'Archive'])
|
||||
},
|
||||
)
|
||||
|
||||
it('drops the Site column and filter when the sites cannot be loaded', async () => {
|
||||
const backend = backendWith('several', [withAuthors(1, ['Inès Duarte'])])
|
||||
backend.responders['GET /v1/site/'] = failWith(500)
|
||||
await mountMeta('articles', '/article', { backend })
|
||||
|
||||
expect(headers()).toEqual(['Title', 'Authors', 'Status', 'Created', 'Updated', ''])
|
||||
expect(siteSelect()).toBeNull()
|
||||
expect(document.body.querySelector('[role="alert"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Articles: choosing a site', () => {
|
||||
function journalAndRenew(count: number) {
|
||||
return Array.from({ length: count }, (_, index) =>
|
||||
withAuthors(index + 1, ['Inès Duarte'], {
|
||||
site_id: index % 2 === 0 ? 2 : 1,
|
||||
is_online: index % 3 !== 0,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it('puts site=journal in the URL without the page, and asks for that site with the same q, status and sort', async () => {
|
||||
const backend = backendWith('several', journalAndRenew(40))
|
||||
await mountMeta('articles', '/article?page=3&size=5&status=online&sort=title:asc', {
|
||||
backend,
|
||||
})
|
||||
const before = textOf(document.body.querySelector('[role="tablist"]'))
|
||||
|
||||
await chooseSite('Journal')
|
||||
|
||||
expect(currentQuery()).toEqual({
|
||||
site: 'journal',
|
||||
size: '5',
|
||||
status: 'online',
|
||||
sort: 'title:asc',
|
||||
})
|
||||
const last = listCalls(backend).at(-1)!
|
||||
expect(last.query.get('site')).toBe('journal')
|
||||
expect(last.query.get('page')).toBe('1')
|
||||
expect(last.query.get('status')).toBe('online')
|
||||
expect(last.query.get('sort')).toBe('title:asc')
|
||||
expect(textOf(document.body.querySelector('[role="tablist"]'))).toBe(before)
|
||||
expect(siteSelect()!.textContent).toContain('Journal')
|
||||
})
|
||||
|
||||
it('goes back to the full list and page 1 with All sites', async () => {
|
||||
const backend = backendWith('several', journalAndRenew(40))
|
||||
await mountMeta('articles', '/article?site=journal&page=2&size=5', {
|
||||
backend,
|
||||
})
|
||||
|
||||
await chooseSite('All sites')
|
||||
|
||||
expect(currentQuery()).toEqual({ size: '5' })
|
||||
const last = listCalls(backend).at(-1)!
|
||||
expect(last.query.has('site')).toBe(false)
|
||||
expect(last.query.get('page')).toBe('1')
|
||||
})
|
||||
|
||||
it.each(['__all', 'all'])('filters by a real site whose key is %s', async (key) => {
|
||||
const backend = backendWith('several', journalAndRenew(4))
|
||||
backend.sites = backend.sites.map((site) =>
|
||||
site.key === 'journal' ? { ...site, key } : site,
|
||||
)
|
||||
await mountMeta('articles', '/article', { backend })
|
||||
|
||||
await chooseSite('Journal')
|
||||
|
||||
expect(currentQuery()).toEqual({ site: key })
|
||||
expect(listCalls(backend).at(-1)!.query.get('site')).toBe(key)
|
||||
expect(siteSelect()!.textContent).toContain('Journal')
|
||||
})
|
||||
|
||||
it('reads ?site=journal&page=2 on arrival and shows Journal in the filter', async () => {
|
||||
const backend = backendWith('several', journalAndRenew(40))
|
||||
await mountMeta('articles', '/article?site=journal&page=2&size=5', {
|
||||
backend,
|
||||
})
|
||||
|
||||
expect(siteSelect()!.textContent).toContain('Journal')
|
||||
const first = listCalls(backend)[0]!
|
||||
expect(first.query.get('site')).toBe('journal')
|
||||
expect(first.query.get('page')).toBe('2')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ query: '?site=nope', sites: 'several' },
|
||||
{ query: '?site=renew', sites: 'one' },
|
||||
] as const)('drops $query silently with $sites site(s)', async ({ query, sites }) => {
|
||||
const backend = backendWith(sites, journalAndRenew(8))
|
||||
await mountMeta('articles', `/article${query}`, { backend })
|
||||
|
||||
expect(currentQuery()).toEqual({})
|
||||
expect(listCalls(backend).every((call) => !call.query.has('site'))).toBe(true)
|
||||
expect(document.body.querySelector('[role="alert"], [role="status"]')).toBeNull()
|
||||
expect(bodyRows().length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
action: 'searches for "plaster"',
|
||||
act: async () => {
|
||||
await typeInto(
|
||||
document.body.querySelector<HTMLInputElement>('input[type="search"]')!,
|
||||
'plaster',
|
||||
)
|
||||
await new Promise((resolve) => setTimeout(resolve, SEARCH_WAIT_MS))
|
||||
await flushPromises()
|
||||
},
|
||||
},
|
||||
{
|
||||
action: 'changes the tab',
|
||||
act: async () => {
|
||||
const tab = Array.from(
|
||||
document.body.querySelectorAll<HTMLElement>('[role="tab"]'),
|
||||
).find((item) => textOf(item).startsWith('Drafts'))!
|
||||
tab.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
|
||||
tab.click()
|
||||
await flushPromises()
|
||||
},
|
||||
},
|
||||
{
|
||||
action: 'changes the sort',
|
||||
act: async () => {
|
||||
Array.from(document.body.querySelectorAll<HTMLElement>('thead button'))
|
||||
.find((item) => textOf(item).startsWith('Title'))!
|
||||
.click()
|
||||
await flushPromises()
|
||||
},
|
||||
},
|
||||
{
|
||||
action: 'goes to page 2',
|
||||
act: async () => {
|
||||
document.body.querySelector<HTMLElement>('button[aria-label="Next page"]')!.click()
|
||||
await flushPromises()
|
||||
},
|
||||
},
|
||||
{
|
||||
action: 'changes the page size',
|
||||
act: async () => {
|
||||
document.body.querySelector<HTMLElement>('[aria-label="Rows per page"]')!.click()
|
||||
await flushPromises()
|
||||
Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]'))
|
||||
.find((item) => textOf(item) === '50 per page')!
|
||||
.click()
|
||||
await flushPromises()
|
||||
},
|
||||
},
|
||||
])('keeps ?site=journal when the user $action', async ({ act }) => {
|
||||
const backend = backendWith('several', journalAndRenew(40))
|
||||
await mountMeta('articles', '/article?site=journal&size=5', { backend })
|
||||
|
||||
await act()
|
||||
|
||||
expect(currentQuery().site).toBe('journal')
|
||||
expect(listCalls(backend).at(-1)!.query.get('site')).toBe('journal')
|
||||
})
|
||||
|
||||
it('shows the empty state of a site and returns to the full list with Show all sites', async () => {
|
||||
const backend = backendWith('several', journalAndRenew(6))
|
||||
await mountMeta('articles', '/article?site=archive', { backend })
|
||||
|
||||
const text = textOf(document.body)
|
||||
expect(text).toContain('No articles on "Archive"')
|
||||
expect(text).toContain('Move articles here from the editor, or choose another site.')
|
||||
|
||||
Array.from(document.body.querySelectorAll<HTMLElement>('button'))
|
||||
.find((item) => textOf(item) === 'Show all sites')!
|
||||
.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(currentQuery()).toEqual({})
|
||||
expect(listCalls(backend).at(-1)!.query.has('site')).toBe(false)
|
||||
expect(bodyRows().length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('keeps the existing No results and No drafts states when a search or a tab is active on a site', async () => {
|
||||
const backend = backendWith('several', journalAndRenew(6))
|
||||
await mountMeta('articles', '/article?site=archive&q=tiles', {
|
||||
backend,
|
||||
})
|
||||
expect(textOf(document.body)).toContain('No articles match "tiles"')
|
||||
expect(textOf(document.body)).not.toContain('No articles on "Archive"')
|
||||
await unmountMeta()
|
||||
|
||||
await mountMeta('articles', '/article?site=archive&status=draft', {
|
||||
backend,
|
||||
})
|
||||
expect(textOf(document.body)).toContain('No drafts')
|
||||
expect(textOf(document.body)).not.toContain('No articles on "Archive"')
|
||||
})
|
||||
})
|
||||
@@ -317,7 +317,10 @@ describe('Authors form: linked user, choose', () => {
|
||||
|
||||
it('says so when every user already has an author', async () => {
|
||||
const backend = createBackend()
|
||||
backend.authors = authorsFixture().map((item, index) => ({ ...item, user_id: index + 1 }))
|
||||
backend.authors = authorsFixture().map((item, index) => ({
|
||||
...item,
|
||||
user_id: index + 1,
|
||||
}))
|
||||
backend.users = backend.users.slice(0, 1)
|
||||
await openAdd({ backend })
|
||||
userTrigger().click()
|
||||
@@ -444,7 +447,9 @@ describe('Authors form: avatar picker', () => {
|
||||
expect(textOf(box())).toContain('face.jpg')
|
||||
await submit('Save changes')
|
||||
|
||||
expect(callsTo(backend, 'PATCH', '/v1/author/7')[0]!.body).toEqual({ avatar_id: 55 })
|
||||
expect(callsTo(backend, 'PATCH', '/v1/author/7')[0]!.body).toEqual({
|
||||
avatar_id: 55,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -482,7 +487,9 @@ describe('Authors form: role changed while open', () => {
|
||||
|
||||
describe('Authors form: deep link', () => {
|
||||
it('opens the edit slide-over for an owner or admin', async () => {
|
||||
await mountDirectory('authors', '/authors?edit=camille-roche', { role: 'admin' })
|
||||
await mountDirectory('authors', '/authors?edit=camille-roche', {
|
||||
role: 'admin',
|
||||
})
|
||||
|
||||
expect(textOf(box())).toContain('Edit Camille Roche')
|
||||
})
|
||||
@@ -525,7 +532,9 @@ describe('Authors form: own author, any role, by deep link only', () => {
|
||||
await typeInto(bioField(), 'Mine')
|
||||
await submit('Save changes')
|
||||
|
||||
expect(callsTo(backend, 'PATCH', '/v1/author/2')[0]!.body).toEqual({ bio: 'Mine' })
|
||||
expect(callsTo(backend, 'PATCH', '/v1/author/2')[0]!.body).toEqual({
|
||||
bio: 'Mine',
|
||||
})
|
||||
})
|
||||
|
||||
it('opens the read-only detail for another author', async () => {
|
||||
|
||||
@@ -193,7 +193,9 @@ describe('Authors: search and pagination in the URL', () => {
|
||||
it('requests the page and size found in the URL', async () => {
|
||||
const backend = createBackend()
|
||||
backend.authors = manyAuthors(130)
|
||||
await mountDirectory('authors', '/authors?q=writer&page=2&size=50', { backend })
|
||||
await mountDirectory('authors', '/authors?q=writer&page=2&size=50', {
|
||||
backend,
|
||||
})
|
||||
|
||||
expect(listCalls(backend)[0]!.raw).toBe('/v1/author/?q=writer&page=2&size=50')
|
||||
expect(document.body.textContent).toContain('Showing 51–100 of 130')
|
||||
@@ -259,7 +261,10 @@ describe('Authors: row menu for an owner or admin', () => {
|
||||
|
||||
describe('Authors: read-only list for other roles', () => {
|
||||
it('has no Add author button, no pinned button, no row menu and a chevron on each row', async () => {
|
||||
await mountDirectory('authors', '/authors', { role: 'member', width: MOBILE_WIDTH })
|
||||
await mountDirectory('authors', '/authors', {
|
||||
role: 'member',
|
||||
width: MOBILE_WIDTH,
|
||||
})
|
||||
|
||||
expect(document.body.textContent).toContain(READ_ONLY_DESCRIPTION)
|
||||
expect(button('Add author')).toBeUndefined()
|
||||
@@ -271,7 +276,9 @@ describe('Authors: read-only list for other roles', () => {
|
||||
|
||||
it('keeps search working and shows the Authors sidebar entry on desktop', async () => {
|
||||
vi.useFakeTimers()
|
||||
const backend = await mountDirectory('authors', '/authors', { role: 'member' })
|
||||
const backend = await mountDirectory('authors', '/authors', {
|
||||
role: 'member',
|
||||
})
|
||||
|
||||
expect(
|
||||
document.body.querySelector('nav[aria-label="Main"] a[href="/authors"]'),
|
||||
@@ -314,7 +321,9 @@ describe('Authors: read-only detail', () => {
|
||||
})
|
||||
|
||||
it('opens the same panel from /authors?edit=ines-duarte', async () => {
|
||||
await mountDirectory('authors', '/authors?edit=ines-duarte', { role: 'member' })
|
||||
await mountDirectory('authors', '/authors?edit=ines-duarte', {
|
||||
role: 'member',
|
||||
})
|
||||
|
||||
expect(textOf(dialog())).toContain('Only an owner or admin can change this author.')
|
||||
expect(dialog()!.querySelectorAll('input, textarea')).toHaveLength(0)
|
||||
|
||||
@@ -108,7 +108,11 @@ function setViewport(desktop: boolean) {
|
||||
}
|
||||
|
||||
async function mountPage(
|
||||
options: { desktop?: boolean; role?: string | null; routes?: Record<string, Handler> } = {},
|
||||
options: {
|
||||
desktop?: boolean
|
||||
role?: string | null
|
||||
routes?: Record<string, Handler>
|
||||
} = {},
|
||||
) {
|
||||
const { desktop = true, role = 'owner', routes = {} } = options
|
||||
setViewport(desktop)
|
||||
@@ -134,14 +138,22 @@ async function mountPage(
|
||||
)
|
||||
})
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
getMe: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ verified: true, username: 'marta', id: 1, role: sessionRole }),
|
||||
getMe: vi.fn().mockResolvedValue({
|
||||
verified: true,
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
role: sessionRole,
|
||||
}),
|
||||
request: requestMock,
|
||||
})
|
||||
await router.push('/settings/media/cache')
|
||||
const Host = defineComponent({ render: () => h(UApp, null, () => h(CachePage)) })
|
||||
wrapper = mount(Host, { attachTo: document.body, global: { plugins: [router, uiPlugin] } })
|
||||
const Host = defineComponent({
|
||||
render: () => h(UApp, null, () => h(CachePage)),
|
||||
})
|
||||
wrapper = mount(Host, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router, uiPlugin] },
|
||||
})
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
@@ -361,7 +373,14 @@ describe('Rule: Cache screen, loaded', () => {
|
||||
[STATS]: () =>
|
||||
Promise.resolve(
|
||||
stats({
|
||||
by_variant: [{ name: 'hero', enabled: true, files: 1, bytes: MB }],
|
||||
by_variant: [
|
||||
{
|
||||
name: 'hero',
|
||||
enabled: true,
|
||||
files: 1,
|
||||
bytes: MB,
|
||||
},
|
||||
],
|
||||
top_images: [
|
||||
{
|
||||
id: 1,
|
||||
@@ -417,7 +436,9 @@ describe('Rule: Cache screen, loading, empty and error', () => {
|
||||
})
|
||||
|
||||
it('Empty cache', async () => {
|
||||
await mountPage({ routes: { [STATS]: () => Promise.resolve(emptyStats()) } })
|
||||
await mountPage({
|
||||
routes: { [STATS]: () => Promise.resolve(emptyStats()) },
|
||||
})
|
||||
|
||||
expect(tiles()).toEqual([
|
||||
'Generated files 0 Nothing cached',
|
||||
@@ -441,7 +462,9 @@ describe('Rule: Cache screen, loading, empty and error', () => {
|
||||
])('Error loading: %s', async (_, failure) => {
|
||||
let attempt = 0
|
||||
await mountPage({
|
||||
routes: { [STATS]: () => (attempt++ === 0 ? failure() : Promise.resolve(stats())) },
|
||||
routes: {
|
||||
[STATS]: () => (attempt++ === 0 ? failure() : Promise.resolve(stats())),
|
||||
},
|
||||
})
|
||||
|
||||
const alert = document.body.querySelector<HTMLElement>('[role="alert"]')!
|
||||
@@ -624,7 +647,9 @@ describe('Rule: Cache screen, purging, done and failed', () => {
|
||||
await click(buttonIn(alertDialog()!, 'Purge 312 files'))
|
||||
|
||||
await vi.waitFor(() => expect(toastCalls()).toHaveLength(1))
|
||||
expect(toastCalls()[0]).toMatchObject({ title: 'Purged 312 files, freed 41 MB' })
|
||||
expect(toastCalls()[0]).toMatchObject({
|
||||
title: 'Purged 312 files, freed 41 MB',
|
||||
})
|
||||
await vi.waitFor(() => expect(calls(STATS)).toHaveLength(2))
|
||||
})
|
||||
|
||||
@@ -647,7 +672,10 @@ describe('Rule: Cache screen, purging, done and failed', () => {
|
||||
description: '640 of 1,284 files were removed. Try again.',
|
||||
})
|
||||
await vi.waitFor(() => expect(calls(STATS)).toHaveLength(2))
|
||||
const [action] = toast.actions as { label: string; onClick: () => void }[]
|
||||
const [action] = toast.actions as {
|
||||
label: string
|
||||
onClick: () => void
|
||||
}[]
|
||||
expect(action!.label).toBe('Try again')
|
||||
|
||||
action!.onClick()
|
||||
@@ -687,14 +715,20 @@ describe('Rule: Cache screen, purging, done and failed', () => {
|
||||
await startPurgeAll({ [PURGE]: () => Promise.reject(failure) })
|
||||
|
||||
await vi.waitFor(() => expect(toastCalls()).toHaveLength(1))
|
||||
expect(toastCalls()[0]).toMatchObject({ color: 'error', title, description })
|
||||
expect(toastCalls()[0]).toMatchObject({
|
||||
color: 'error',
|
||||
title,
|
||||
description,
|
||||
})
|
||||
expect(document.body.querySelector('[data-testid="cache-purge-banner"]')).toBeNull()
|
||||
expect(tiles()).toHaveLength(4)
|
||||
expect(calls(STATS)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('A 401 is left to the API client', async () => {
|
||||
await startPurgeAll({ [PURGE]: () => Promise.reject(new ApiError(401)) })
|
||||
await startPurgeAll({
|
||||
[PURGE]: () => Promise.reject(new ApiError(401)),
|
||||
})
|
||||
|
||||
expect(toastCalls()).toHaveLength(0)
|
||||
expect(document.body.querySelector('[data-testid="cache-purge-banner"]')).toBeNull()
|
||||
@@ -738,7 +772,9 @@ describe('Rule: Cache screen, purging the variants of one image', () => {
|
||||
})
|
||||
|
||||
it('The image no longer exists', async () => {
|
||||
await mountPage({ routes: { [PURGE]: () => Promise.reject(new ApiError(404)) } })
|
||||
await mountPage({
|
||||
routes: { [PURGE]: () => Promise.reject(new ApiError(404)) },
|
||||
})
|
||||
|
||||
await click(buttonByLabel('Purge north-wall.jpg'))
|
||||
|
||||
|
||||
@@ -13,7 +13,10 @@ import SitesView from '../SitesView.vue'
|
||||
let wrapper: VueWrapper | null = null
|
||||
|
||||
function reply(status: number, body = '') {
|
||||
return new Response(body, { status, headers: { 'Content-Type': 'text/plain' } })
|
||||
return new Response(body, {
|
||||
status,
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
})
|
||||
}
|
||||
|
||||
async function mountWith(
|
||||
@@ -24,13 +27,24 @@ async function mountWith(
|
||||
setViewportWidth(DESKTOP_WIDTH)
|
||||
setActivePinia(createPinia())
|
||||
const session = useSessionStore()
|
||||
session.session = { verified, username: 'marta', id: 1, displayName: '', role: 'owner' }
|
||||
session.session = {
|
||||
verified,
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
displayName: '',
|
||||
role: 'owner',
|
||||
}
|
||||
session.status = 'ready'
|
||||
await router.push(path)
|
||||
wrapper = mount(defineComponent({ render: () => h(UApp, null, { default: () => h(view) }) }), {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router, uiPlugin] },
|
||||
})
|
||||
wrapper = mount(
|
||||
defineComponent({
|
||||
render: () => h(UApp, null, { default: () => h(view) }),
|
||||
}),
|
||||
{
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router, uiPlugin] },
|
||||
},
|
||||
)
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
@@ -60,7 +74,10 @@ describe('Session problems use the app-wide handling', () => {
|
||||
|
||||
expect(calls.some((call) => call.startsWith(api))).toBe(true)
|
||||
expect(router.currentRoute.value.path).toBe('/login')
|
||||
expect(router.currentRoute.value.query).toEqual({ redirect: path, reason: 'expired' })
|
||||
expect(router.currentRoute.value.query).toEqual({
|
||||
redirect: path,
|
||||
reason: 'expired',
|
||||
})
|
||||
expect(document.body.querySelector('[role="alert"]')).toBeNull()
|
||||
})
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import App from '@/App.vue'
|
||||
import { createAppRouter } from '@/router'
|
||||
import { setLocale } from '@/i18n'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { oneSite } from './editorMetaKit'
|
||||
|
||||
type Handler = () => Response | Promise<Response>
|
||||
|
||||
@@ -38,10 +39,14 @@ let calls: string[] = []
|
||||
|
||||
function installFetch(handlers: Record<string, Handler> = {}) {
|
||||
calls = []
|
||||
const routes: Record<string, Handler> = {
|
||||
'GET /v1/site/': () => mockResponse(200, oneSite),
|
||||
...handlers,
|
||||
}
|
||||
global.fetch = vi.fn((url: string, init?: RequestInit) => {
|
||||
const key = `${init?.method ?? 'GET'} ${url}`
|
||||
calls.push(key)
|
||||
const handler = handlers[key] ?? handlers[key.split('?')[0]]
|
||||
const handler = routes[key] ?? routes[key.split('?')[0]]
|
||||
return handler ? handler() : NEVER
|
||||
}) as unknown as typeof fetch
|
||||
}
|
||||
@@ -54,7 +59,10 @@ async function boot(path: string, signedIn: boolean, handlers: Record<string, Ha
|
||||
installFetch(handlers)
|
||||
const router = createAppRouter(createMemoryHistory())
|
||||
await router.push(path)
|
||||
wrapper = mount(App, { attachTo: document.body, global: { plugins: [router, uiPlugin] } })
|
||||
wrapper = mount(App, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router, uiPlugin] },
|
||||
})
|
||||
await flushPromises()
|
||||
return { router, session }
|
||||
}
|
||||
@@ -204,7 +212,10 @@ describe('Editor tab', () => {
|
||||
const exposed = editor.vm as unknown as {
|
||||
$: { setupState: { article: { title: string } } }
|
||||
}
|
||||
exposed.$.setupState.article = { ...exposed.$.setupState.article, title: 'Hello again' }
|
||||
exposed.$.setupState.article = {
|
||||
...exposed.$.setupState.article,
|
||||
title: 'Hello again',
|
||||
}
|
||||
await flushPromises()
|
||||
|
||||
expect(document.title).toBe('Hello again · RenewCMS')
|
||||
@@ -215,7 +226,9 @@ describe('Editor tab', () => {
|
||||
['the article fails to load', () => mockResponse(500, { error: 'boom' })],
|
||||
['the article has an empty title', () => mockResponse(200, article(''))],
|
||||
])('Editor tab falls back to "Editor" when %s', async (_situation, handler) => {
|
||||
await boot('/article/42/edit', true, { 'GET /v1/article/42': handler })
|
||||
await boot('/article/42/edit', true, {
|
||||
'GET /v1/article/42': handler,
|
||||
})
|
||||
expect(document.title).toBe('Editor · RenewCMS')
|
||||
})
|
||||
|
||||
@@ -266,7 +279,10 @@ describe('Not-found layout', () => {
|
||||
installFetch()
|
||||
const router = createAppRouter(createMemoryHistory())
|
||||
await router.push('/nowhere/at/all')
|
||||
wrapper = mount(App, { attachTo: document.body, global: { plugins: [router, uiPlugin] } })
|
||||
wrapper = mount(App, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router, uiPlugin] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(useSessionStore().status).toBe('checking')
|
||||
@@ -278,11 +294,18 @@ describe('Not-found layout', () => {
|
||||
setActivePinia(createPinia())
|
||||
installFetch({
|
||||
'GET /v1/auth/me': () =>
|
||||
mockResponse(200, { is_verified: true, username: 'marta', display_name: 'Marta' }),
|
||||
mockResponse(200, {
|
||||
is_verified: true,
|
||||
username: 'marta',
|
||||
display_name: 'Marta',
|
||||
}),
|
||||
})
|
||||
const router = createAppRouter(createMemoryHistory())
|
||||
await router.push('/nowhere/at/all')
|
||||
wrapper = mount(App, { attachTo: document.body, global: { plugins: [router, uiPlugin] } })
|
||||
wrapper = mount(App, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router, uiPlugin] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-testid="auth-card"]').exists()).toBe(false)
|
||||
|
||||
@@ -0,0 +1,493 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { META_SECTION_OPEN_KEY, hintDismissedKey } from '@/lib/metaSectionMemory'
|
||||
import {
|
||||
MARTA_EMAIL,
|
||||
authorRows,
|
||||
bylines,
|
||||
callsTo,
|
||||
click,
|
||||
createMetaBackend,
|
||||
dialog,
|
||||
failWith,
|
||||
mountMeta,
|
||||
networkFailure,
|
||||
patchCalls,
|
||||
renewSites,
|
||||
rowNames,
|
||||
textOf,
|
||||
toasts,
|
||||
typeInto,
|
||||
unmountMeta,
|
||||
type MetaBackend,
|
||||
type MetaRole,
|
||||
} from './metaTestKit'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
const EDITOR = '/article/12/edit'
|
||||
const WAIT_FOR_SEARCH_MS = 320
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
localStorage.setItem(META_SECTION_OPEN_KEY, 'true')
|
||||
})
|
||||
|
||||
afterEach(unmountMeta)
|
||||
|
||||
function backendWith(role: MetaRole, ...names: string[]): MetaBackend {
|
||||
const backend = createMetaBackend({ sites: renewSites().slice(0, 1), role })
|
||||
backend.article.authors = bylines(...names)
|
||||
return backend
|
||||
}
|
||||
|
||||
function trigger() {
|
||||
return Array.from(
|
||||
document.body.querySelectorAll<HTMLElement>('button[aria-haspopup="listbox"]'),
|
||||
).find((element) => textOf(element) === 'Add an author')!
|
||||
}
|
||||
|
||||
async function openCombobox() {
|
||||
trigger().click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function options() {
|
||||
return Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]'))
|
||||
}
|
||||
|
||||
function option(name: string) {
|
||||
return options().find((item) => textOf(item).includes(name))!
|
||||
}
|
||||
|
||||
function searchInput() {
|
||||
return document.body.querySelector<HTMLInputElement>('input[placeholder="Search authors"]')!
|
||||
}
|
||||
|
||||
function createRow() {
|
||||
return document.body.querySelector<HTMLElement>('[data-testid="create-author-row"]')
|
||||
}
|
||||
|
||||
async function search(term: string) {
|
||||
await typeInto(searchInput(), term)
|
||||
await new Promise((resolve) => setTimeout(resolve, WAIT_FOR_SEARCH_MS))
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function authorListCalls(backend: MetaBackend) {
|
||||
return callsTo(backend, 'GET', '/v1/author/')
|
||||
}
|
||||
|
||||
describe('Add author: the combobox', () => {
|
||||
it('opens with a Search authors field, requests the first 100 authors and dims the ones already on the article', async () => {
|
||||
const backend = backendWith('owner', 'Inès Duarte')
|
||||
await mountMeta('editor', EDITOR, { backend })
|
||||
await openCombobox()
|
||||
|
||||
expect(searchInput()).not.toBeNull()
|
||||
expect(authorListCalls(backend)).toHaveLength(1)
|
||||
expect(authorListCalls(backend)[0]!.query.get('size')).toBe('100')
|
||||
expect(option('Inès Duarte').className).toContain('opacity-60')
|
||||
expect(option('Camille Roche').className).not.toContain('opacity-60')
|
||||
})
|
||||
|
||||
it('does nothing when a checked author is activated, and appends a new author on pick', async () => {
|
||||
const backend = backendWith('owner', 'Inès Duarte')
|
||||
await mountMeta('editor', EDITOR, { backend })
|
||||
await openCombobox()
|
||||
|
||||
option('Inès Duarte').click()
|
||||
await flushPromises()
|
||||
expect(rowNames()).toEqual(['Inès Duarte'])
|
||||
expect(textOf(document.body)).not.toContain('Unsaved changes')
|
||||
|
||||
option('Camille Roche').click()
|
||||
await flushPromises()
|
||||
expect(rowNames()).toEqual(['Inès Duarte', 'Camille Roche'])
|
||||
expect(textOf(document.body)).toContain('Unsaved changes')
|
||||
expect(patchCalls(backend)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps the section open when the first author is added to an article that had none', async () => {
|
||||
localStorage.clear()
|
||||
const backend = backendWith('owner')
|
||||
await mountMeta('editor', EDITOR, { backend })
|
||||
expect(document.body.querySelector('[data-testid="meta-summary"]')).toBeNull()
|
||||
await openCombobox()
|
||||
|
||||
option('Camille Roche').click()
|
||||
await flushPromises()
|
||||
|
||||
expect(rowNames()).toEqual(['Camille Roche'])
|
||||
expect(document.body.querySelector('[data-testid="meta-summary"]')).toBeNull()
|
||||
expect(trigger()).toBeDefined()
|
||||
expect(localStorage.getItem(META_SECTION_OPEN_KEY)).toBeNull()
|
||||
})
|
||||
|
||||
it('searches the server with q when the install has more than 100 authors', async () => {
|
||||
const backend = backendWith('owner', 'Inès Duarte')
|
||||
const crowd = Array.from({ length: 120 }, (_, index) => ({
|
||||
...backend.authors[0]!,
|
||||
id: 1000 + index,
|
||||
name: `Writer ${String(index).padStart(3, '0')}`,
|
||||
slug: `writer-${index}`,
|
||||
}))
|
||||
backend.authors = [
|
||||
...crowd,
|
||||
{
|
||||
...backend.authors[0]!,
|
||||
id: 2000,
|
||||
name: 'Zoe Adams',
|
||||
slug: 'zoe-adams',
|
||||
},
|
||||
]
|
||||
await mountMeta('editor', EDITOR, { backend })
|
||||
await openCombobox()
|
||||
expect(options()).toHaveLength(100)
|
||||
expect(textOf(document.body)).not.toContain('Zoe Adams')
|
||||
|
||||
await search('Zoe')
|
||||
|
||||
const last = authorListCalls(backend).at(-1)!
|
||||
expect(last.query.get('q')).toBe('Zoe')
|
||||
expect(last.query.get('size')).toBe('100')
|
||||
expect(options().map(textOf)).toEqual(['ZA Zoe Adams zoe-adams'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Add author: "Create author…" only for roles with author rights', () => {
|
||||
it.each([
|
||||
{ role: 'owner', shown: true },
|
||||
{ role: 'admin', shown: true },
|
||||
{ role: 'verified', shown: false },
|
||||
] as const)('the $role sees the row: $shown', async ({ role, shown }) => {
|
||||
await mountMeta('editor', EDITOR, {
|
||||
backend: backendWith(role, 'Inès Duarte'),
|
||||
})
|
||||
await openCombobox()
|
||||
|
||||
expect(createRow() !== null).toBe(shown)
|
||||
if (shown) {
|
||||
expect(textOf(createRow())).toBe('Create author…')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Add author: no match', () => {
|
||||
it.each([
|
||||
{ role: 'owner', create: true },
|
||||
{ role: 'verified', create: false },
|
||||
] as const)(
|
||||
'shows the message for a $role, with the create row: $create',
|
||||
async ({ role, create }) => {
|
||||
await mountMeta('editor', EDITOR, {
|
||||
backend: backendWith(role, 'Inès Duarte'),
|
||||
})
|
||||
await openCombobox()
|
||||
await search('Zoe Lambert')
|
||||
|
||||
expect(textOf(document.body)).toContain('No author matches "Zoe Lambert".')
|
||||
expect(createRow() !== null).toBe(create)
|
||||
if (create) {
|
||||
expect(textOf(createRow())).toBe('Create author "Zoe Lambert"…')
|
||||
}
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('Add author: authors could not be loaded', () => {
|
||||
it.each([
|
||||
{ failure: 'status 500', responder: failWith(500) },
|
||||
{ failure: 'a network error', responder: networkFailure },
|
||||
])('shows the error and retries when the load fails with $failure', async ({ responder }) => {
|
||||
const backend = backendWith('owner', 'Inès Duarte')
|
||||
backend.responders['GET /v1/author/'] = responder
|
||||
await mountMeta('editor', EDITOR, { backend })
|
||||
await openCombobox()
|
||||
|
||||
const text = textOf(document.body)
|
||||
expect(text).toContain("Couldn't load authors")
|
||||
expect(text).toContain('Check your connection and try again.')
|
||||
expect(createRow()).toBeNull()
|
||||
expect(rowNames()).toEqual(['Inès Duarte'])
|
||||
|
||||
delete backend.responders['GET /v1/author/']
|
||||
await click('Try again')
|
||||
|
||||
expect(authorListCalls(backend)).toHaveLength(2)
|
||||
expect(options().length).toBeGreaterThan(0)
|
||||
expect(rowNames()).toEqual(['Inès Duarte'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Create author dialog', () => {
|
||||
async function openDialog(backend: MetaBackend) {
|
||||
await mountMeta('editor', EDITOR, { backend })
|
||||
await openCombobox()
|
||||
await search('Zoe Lambert')
|
||||
createRow()!.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function nameField() {
|
||||
return dialog()!.querySelector<HTMLInputElement>('input[placeholder="e.g. Camille Roche"]')!
|
||||
}
|
||||
|
||||
function slugField() {
|
||||
return dialog()!.querySelector<HTMLInputElement>(
|
||||
'input[placeholder="Generated from the name"]',
|
||||
)!
|
||||
}
|
||||
|
||||
it('opens over the editor with the name, the auto slug and both actions', async () => {
|
||||
const backend = backendWith('owner', 'Inès Duarte')
|
||||
await openDialog(backend)
|
||||
|
||||
const text = textOf(dialog())
|
||||
expect(text).toContain('Create author')
|
||||
expect(text).toContain(
|
||||
'Adds a byline for this article. You can add a bio and avatar later in Authors.',
|
||||
)
|
||||
expect(nameField().value).toBe('Zoe Lambert')
|
||||
expect(slugField().value).toBe('zoe-lambert')
|
||||
expect(text).toContain('Auto')
|
||||
expect(text).toContain('Cancel')
|
||||
expect(patchCalls(backend)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('creates with name and slug, appends the author and waits for Save to send the authors', async () => {
|
||||
const backend = backendWith('owner', 'Inès Duarte')
|
||||
await openDialog(backend)
|
||||
|
||||
await click('Create author', dialog()!)
|
||||
|
||||
const posts = callsTo(backend, 'POST', '/v1/author/')
|
||||
expect(posts).toHaveLength(1)
|
||||
expect(posts[0]!.body).toEqual({
|
||||
name: 'Zoe Lambert',
|
||||
slug: 'zoe-lambert',
|
||||
})
|
||||
expect(dialog()).toBeNull()
|
||||
expect(rowNames()).toEqual(['Inès Duarte', 'Zoe Lambert'])
|
||||
expect(textOf(document.body)).toContain('Unsaved changes')
|
||||
expect(patchCalls(backend)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('offers "This is me" only without an author profile, unchecked, and sends user_id when checked', async () => {
|
||||
const backend = backendWith('owner', 'Inès Duarte')
|
||||
await openDialog(backend)
|
||||
|
||||
const checkbox = dialog()!.querySelector<HTMLElement>('button[role="checkbox"]')!
|
||||
expect(textOf(dialog())).toContain('This is me')
|
||||
expect(textOf(dialog())).toContain(`Links the author to ${MARTA_EMAIL}.`)
|
||||
expect(checkbox.getAttribute('aria-checked')).toBe('false')
|
||||
|
||||
checkbox.click()
|
||||
await flushPromises()
|
||||
await click('Create author', dialog()!)
|
||||
|
||||
expect(callsTo(backend, 'POST', '/v1/author/')[0]!.body).toEqual({
|
||||
name: 'Zoe Lambert',
|
||||
slug: 'zoe-lambert',
|
||||
user_id: 1,
|
||||
})
|
||||
expect(useSessionStore().session?.author).toMatchObject({
|
||||
name: 'Zoe Lambert',
|
||||
})
|
||||
})
|
||||
|
||||
it('has no "This is me" and never sends a user_id when the user already has an author', async () => {
|
||||
const backend = createMetaBackend({
|
||||
sites: renewSites().slice(0, 1),
|
||||
author: { id: 2, name: 'Inès Duarte', slug: 'ines-duarte' },
|
||||
})
|
||||
await openDialog(backend)
|
||||
|
||||
expect(textOf(dialog())).not.toContain('This is me')
|
||||
await click('Create author', dialog()!)
|
||||
expect(callsTo(backend, 'POST', '/v1/author/')[0]!.body).not.toHaveProperty('user_id')
|
||||
})
|
||||
|
||||
it('shows the taken slug with a suggestion that keeps the name', async () => {
|
||||
const backend = backendWith('owner', 'Inès Duarte')
|
||||
backend.responders['POST /v1/author/'] = failWith(409, 'author_slug_taken')
|
||||
await openDialog(backend)
|
||||
await click('Create author', dialog()!)
|
||||
|
||||
expect(textOf(dialog())).toContain(
|
||||
'The slug "zoe-lambert" is already used by another author.',
|
||||
)
|
||||
await click('Use zoe-lambert-2', dialog()!)
|
||||
|
||||
expect(slugField().value).toBe('zoe-lambert-2')
|
||||
expect(nameField().value).toBe('Zoe Lambert')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ failure: 'status 500', responder: failWith(500) },
|
||||
{ failure: 'a network error', responder: networkFailure },
|
||||
])(
|
||||
'keeps the dialog and the values open when creating fails with $failure',
|
||||
async ({ responder }) => {
|
||||
const backend = backendWith('owner', 'Inès Duarte')
|
||||
backend.responders['POST /v1/author/'] = responder
|
||||
await openDialog(backend)
|
||||
await click('Create author', dialog()!)
|
||||
|
||||
expect(textOf(dialog())).toContain("Couldn't create the author")
|
||||
expect(textOf(dialog())).toContain('The article is unchanged. Try again in a moment.')
|
||||
expect(nameField().value).toBe('Zoe Lambert')
|
||||
expect(rowNames()).toEqual(['Inès Duarte'])
|
||||
expect(patchCalls(backend)).toHaveLength(0)
|
||||
},
|
||||
)
|
||||
|
||||
it('shows the rights toast and closes when the role changed while the dialog was open', async () => {
|
||||
const backend = backendWith('owner', 'Inès Duarte')
|
||||
backend.responders['POST /v1/author/'] = failWith(403, 'author_rights')
|
||||
await openDialog(backend)
|
||||
await click('Create author', dialog()!)
|
||||
|
||||
expect(toasts()).toContain('Only an owner or admin can change authors')
|
||||
expect(dialog()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Create my author profile hint', () => {
|
||||
function hint() {
|
||||
return document.body.querySelector<HTMLElement>('[data-testid="author-hint"]') ?? undefined
|
||||
}
|
||||
|
||||
it.each(['owner', 'admin', 'verified'] as const)(
|
||||
'appears for a %s with no profile, no authors on the article and no dismissal',
|
||||
async (role) => {
|
||||
await mountMeta('editor', EDITOR, { backend: backendWith(role) })
|
||||
|
||||
expect(textOf(hint())).toContain('Create one to be credited on your articles.')
|
||||
expect(textOf(hint())).toContain('Create my author profile')
|
||||
expect(textOf(document.body)).toContain('No authors. This article will have no byline.')
|
||||
},
|
||||
)
|
||||
|
||||
it('stays away when the user already has an author profile', async () => {
|
||||
const backend = createMetaBackend({
|
||||
sites: renewSites().slice(0, 1),
|
||||
author: { id: 2, name: 'Inès Duarte', slug: 'ines-duarte' },
|
||||
})
|
||||
backend.article.authors = []
|
||||
await mountMeta('editor', EDITOR, { backend })
|
||||
|
||||
expect(hint()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('stays away when the article has an author', async () => {
|
||||
await mountMeta('editor', EDITOR, {
|
||||
backend: backendWith('owner', 'Inès Duarte'),
|
||||
})
|
||||
|
||||
expect(hint()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('stays away once this user dismissed it', async () => {
|
||||
localStorage.setItem(hintDismissedKey(1), 'true')
|
||||
await mountMeta('editor', EDITOR, { backend: backendWith('owner') })
|
||||
|
||||
expect(hint()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('creates the profile directly: one POST with no body, no dialog, the author first and a toast', async () => {
|
||||
const backend = backendWith('verified', 'Inès Duarte')
|
||||
backend.article.authors = []
|
||||
await mountMeta('editor', EDITOR, { backend })
|
||||
|
||||
await click('Create my author profile')
|
||||
|
||||
const posts = callsTo(backend, 'POST', '/v1/author/me')
|
||||
expect(posts).toHaveLength(1)
|
||||
expect(posts[0]!.body).toBeUndefined()
|
||||
expect(dialog()).toBeNull()
|
||||
expect(rowNames()).toEqual(['Marta Alves'])
|
||||
expect(textOf(document.body)).toContain('Unsaved changes')
|
||||
expect(hint()).toBeUndefined()
|
||||
expect(useSessionStore().session?.author).toMatchObject({
|
||||
name: 'Marta Alves',
|
||||
})
|
||||
expect(toasts()).toContain('Author profile created')
|
||||
expect(toasts()).toContain('Marta Alves is now your byline.')
|
||||
expect(patchCalls(backend)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('puts the new author before the others as the primary byline', async () => {
|
||||
const backend = backendWith('owner')
|
||||
await mountMeta('editor', EDITOR, { backend })
|
||||
await click('Create my author profile')
|
||||
await openCombobox()
|
||||
option('Camille Roche').click()
|
||||
await flushPromises()
|
||||
|
||||
expect(rowNames()).toEqual(['Marta Alves', 'Camille Roche'])
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ failure: 'status 500', responder: failWith(500) },
|
||||
{ failure: 'a network error', responder: networkFailure },
|
||||
])(
|
||||
'keeps the hint and shows an error toast when it fails with $failure',
|
||||
async ({ responder }) => {
|
||||
const backend = backendWith('owner')
|
||||
backend.responders['POST /v1/author/me'] = responder
|
||||
await mountMeta('editor', EDITOR, { backend })
|
||||
await click('Create my author profile')
|
||||
|
||||
expect(textOf(hint())).toContain('Create my author profile')
|
||||
expect(toasts()).toContain("Couldn't create your author profile")
|
||||
expect(toasts()).toContain('Try again in a moment.')
|
||||
expect(rowNames()).toEqual([])
|
||||
expect(textOf(document.body)).not.toContain('Unsaved changes')
|
||||
},
|
||||
)
|
||||
|
||||
it('reloads the session and drops the hint when the profile already exists (409)', async () => {
|
||||
const backend = backendWith('owner')
|
||||
const conflict = failWith(409, 'user_already_linked')
|
||||
backend.responders['POST /v1/author/me'] = (call) => {
|
||||
backend.me.author = {
|
||||
id: 300,
|
||||
name: 'Marta Alves',
|
||||
slug: 'marta-alves',
|
||||
}
|
||||
return conflict(call)
|
||||
}
|
||||
await mountMeta('editor', EDITOR, { backend })
|
||||
await click('Create my author profile')
|
||||
|
||||
expect(callsTo(backend, 'GET', '/v1/auth/me')).toHaveLength(1)
|
||||
expect(hint()).toBeUndefined()
|
||||
expect(useSessionStore().session?.author).toMatchObject({ id: 300 })
|
||||
expect(rowNames()).toEqual([])
|
||||
})
|
||||
|
||||
it('is dismissed without a request, kept per user in localStorage, and does not return', async () => {
|
||||
const backend = backendWith('owner')
|
||||
await mountMeta('editor', EDITOR, { backend })
|
||||
const before = backend.calls.length
|
||||
|
||||
document.body.querySelector<HTMLElement>('button[aria-label="Dismiss"]')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(hint()).toBeUndefined()
|
||||
expect(localStorage.getItem(hintDismissedKey(1))).toBe('true')
|
||||
expect(backend.calls).toHaveLength(before)
|
||||
await unmountMeta()
|
||||
|
||||
const other = backendWith('owner')
|
||||
await mountMeta('editor', '/article/13/edit', { backend: other })
|
||||
expect(hint()).toBeUndefined()
|
||||
expect(authorRows()).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,247 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { META_SECTION_OPEN_KEY } from '@/lib/metaSectionMemory'
|
||||
import {
|
||||
authorRows,
|
||||
bylines,
|
||||
createMetaBackend,
|
||||
dragTo,
|
||||
handleOf,
|
||||
mountMeta,
|
||||
patchCalls,
|
||||
pressKey,
|
||||
renewSites,
|
||||
rowNames,
|
||||
saveButtonEl,
|
||||
textOf,
|
||||
unmountMeta,
|
||||
type MetaBackend,
|
||||
} from './metaTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
const EDITOR = '/article/12/edit'
|
||||
const LONG_NAME = 'Dr. Alexandra Konstantinopoulou-Whitfield'
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
localStorage.setItem(META_SECTION_OPEN_KEY, 'true')
|
||||
})
|
||||
|
||||
afterEach(unmountMeta)
|
||||
|
||||
function backendWith(...names: string[]): MetaBackend {
|
||||
const backend = createMetaBackend({ sites: renewSites().slice(0, 1) })
|
||||
backend.article.authors = bylines(...names)
|
||||
return backend
|
||||
}
|
||||
|
||||
function live() {
|
||||
return textOf(document.body.querySelector('[data-testid="author-live"]'))
|
||||
}
|
||||
|
||||
function removeButton(name: string) {
|
||||
return document.body.querySelector<HTMLElement>(`button[aria-label="Remove ${name}"]`)!
|
||||
}
|
||||
|
||||
describe('Author list: an ordered list with the first author as the primary byline', () => {
|
||||
it('labels the first row Primary, gives each row a handle and a remove button, and shows the hint', async () => {
|
||||
await mountMeta('editor', EDITOR, {
|
||||
backend: backendWith('Inès Duarte', 'Jonas Pereira', 'Camille Roche', LONG_NAME),
|
||||
})
|
||||
|
||||
const rows = authorRows()
|
||||
expect(rows).toHaveLength(4)
|
||||
expect(textOf(rows[0])).toContain('Primary')
|
||||
rows.slice(1).forEach((row) => expect(textOf(row)).not.toContain('Primary'))
|
||||
rows.forEach((row) => {
|
||||
expect(row.querySelector('button[aria-label^="Reorder "]')).not.toBeNull()
|
||||
expect(row.querySelector('button[aria-label^="Remove "]')).not.toBeNull()
|
||||
})
|
||||
expect(textOf(document.body)).toContain(
|
||||
'Drag to reorder. The first author is the primary byline.',
|
||||
)
|
||||
})
|
||||
|
||||
it('truncates a long name with an ellipsis and keeps the full name in title and aria-label', async () => {
|
||||
await mountMeta('editor', EDITOR, {
|
||||
backend: backendWith('Inès Duarte', LONG_NAME),
|
||||
})
|
||||
|
||||
const name = authorRows()[1]!.querySelector<HTMLElement>('[title]')!
|
||||
expect(name.className).toContain('truncate')
|
||||
expect(name.getAttribute('title')).toBe(LONG_NAME)
|
||||
expect(name.getAttribute('aria-label')).toBe(LONG_NAME)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Author list: reorder with the keyboard', () => {
|
||||
async function mountThree() {
|
||||
const backend = backendWith('Inès Duarte', 'Jonas Pereira', 'Camille Roche')
|
||||
await mountMeta('editor', EDITOR, { backend })
|
||||
return backend
|
||||
}
|
||||
|
||||
it('grabs with Space, moves with ArrowUp, drops with Space and marks the draft unsaved', async () => {
|
||||
await mountThree()
|
||||
const handle = handleOf('Jonas Pereira')
|
||||
|
||||
await pressKey(handle, ' ')
|
||||
expect(live()).toBe(
|
||||
'Jonas Pereira grabbed. Position 2 of 3. Use the up and down arrow keys to move, Space to drop, Escape to cancel.',
|
||||
)
|
||||
expect(textOf(document.body)).toContain('Saved')
|
||||
|
||||
await pressKey(handle, 'ArrowUp')
|
||||
expect(rowNames()).toEqual(['Jonas Pereira', 'Inès Duarte', 'Camille Roche'])
|
||||
expect(live()).toBe('Jonas Pereira moved to position 1 of 3.')
|
||||
|
||||
await pressKey(handle, ' ')
|
||||
expect(rowNames()).toEqual(['Jonas Pereira', 'Inès Duarte', 'Camille Roche'])
|
||||
expect(live()).toBe('Jonas Pereira dropped. Position 1 of 3.')
|
||||
expect(textOf(document.body)).toContain('Unsaved changes')
|
||||
})
|
||||
|
||||
it('grabs with Enter and moves down with ArrowDown', async () => {
|
||||
await mountThree()
|
||||
const handle = handleOf('Jonas Pereira')
|
||||
|
||||
await pressKey(handle, 'Enter')
|
||||
await pressKey(handle, 'ArrowDown')
|
||||
await pressKey(handle, 'Enter')
|
||||
|
||||
expect(rowNames()).toEqual(['Inès Duarte', 'Camille Roche', 'Jonas Pereira'])
|
||||
})
|
||||
|
||||
it('returns the row to its original position on Escape and leaves the status alone', async () => {
|
||||
await mountThree()
|
||||
const handle = handleOf('Jonas Pereira')
|
||||
|
||||
await pressKey(handle, ' ')
|
||||
await pressKey(handle, 'ArrowUp')
|
||||
expect(rowNames()[0]).toBe('Jonas Pereira')
|
||||
await pressKey(handle, 'Escape')
|
||||
|
||||
expect(rowNames()).toEqual(['Inès Duarte', 'Jonas Pereira', 'Camille Roche'])
|
||||
expect(live()).toBe('Reorder cancelled. Jonas Pereira is back at position 2 of 3.')
|
||||
expect(textOf(document.body)).not.toContain('Unsaved changes')
|
||||
})
|
||||
|
||||
it('does not move past either end of the list', async () => {
|
||||
await mountThree()
|
||||
const handle = handleOf('Inès Duarte')
|
||||
|
||||
await pressKey(handle, ' ')
|
||||
await pressKey(handle, 'ArrowUp')
|
||||
await pressKey(handle, ' ')
|
||||
|
||||
expect(rowNames()).toEqual(['Inès Duarte', 'Jonas Pereira', 'Camille Roche'])
|
||||
expect(textOf(document.body)).not.toContain('Unsaved changes')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Author list: reorder with the pointer', () => {
|
||||
it('lifts the row, draws the drop line and applies the new order on release', async () => {
|
||||
await mountMeta('editor', EDITOR, {
|
||||
backend: backendWith('Inès Duarte', 'Jonas Pereira', 'Camille Roche'),
|
||||
})
|
||||
|
||||
const drag = await dragTo('Inès Duarte', 60)
|
||||
expect(authorRows()[0]!.className).toContain('shadow-')
|
||||
expect(document.body.querySelectorAll('[data-testid="drop-line"]')).toHaveLength(1)
|
||||
|
||||
await drag.release()
|
||||
|
||||
expect(rowNames()).toEqual(['Jonas Pereira', 'Inès Duarte', 'Camille Roche'])
|
||||
expect(document.body.querySelector('[data-testid="drop-line"]')).toBeNull()
|
||||
expect(textOf(document.body)).toContain('Unsaved changes')
|
||||
})
|
||||
|
||||
it('drops below the last row and keeps the order when cancelled', async () => {
|
||||
await mountMeta('editor', EDITOR, {
|
||||
backend: backendWith('Inès Duarte', 'Jonas Pereira', 'Camille Roche'),
|
||||
})
|
||||
|
||||
const cancelled = await dragTo('Inès Duarte', 200)
|
||||
await cancelled.cancel()
|
||||
expect(rowNames()).toEqual(['Inès Duarte', 'Jonas Pereira', 'Camille Roche'])
|
||||
expect(textOf(document.body)).not.toContain('Unsaved changes')
|
||||
|
||||
const drag = await dragTo('Inès Duarte', 200)
|
||||
await drag.release()
|
||||
expect(rowNames()).toEqual(['Jonas Pereira', 'Camille Roche', 'Inès Duarte'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Author list: remove', () => {
|
||||
it('promotes the next author to primary and shows the no-byline text when the list empties', async () => {
|
||||
await mountMeta('editor', EDITOR, {
|
||||
backend: backendWith('Inès Duarte', 'Jonas Pereira'),
|
||||
})
|
||||
|
||||
removeButton('Inès Duarte').click()
|
||||
await flushPromises()
|
||||
|
||||
expect(rowNames()).toEqual(['Jonas Pereira'])
|
||||
expect(textOf(document.body)).toContain('Unsaved changes')
|
||||
|
||||
removeButton('Jonas Pereira').click()
|
||||
await flushPromises()
|
||||
|
||||
expect(authorRows()).toHaveLength(0)
|
||||
expect(textOf(document.body)).toContain('No authors. This article will have no byline.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Author list: saving', () => {
|
||||
it('sends author_ids in the list order and nothing else', async () => {
|
||||
const backend = backendWith('Inès Duarte', 'Jonas Pereira')
|
||||
await mountMeta('editor', EDITOR, { backend })
|
||||
const handle = handleOf('Jonas Pereira')
|
||||
await pressKey(handle, ' ')
|
||||
await pressKey(handle, 'ArrowUp')
|
||||
await pressKey(handle, ' ')
|
||||
|
||||
saveButtonEl().click()
|
||||
await flushPromises()
|
||||
|
||||
expect(patchCalls(backend)).toHaveLength(1)
|
||||
expect(patchCalls(backend)[0]!.body).toEqual({ author_ids: [3, 2] })
|
||||
expect(textOf(document.body)).toContain('Saved')
|
||||
})
|
||||
|
||||
it('sends an emptied list as author_ids []', async () => {
|
||||
const backend = backendWith('Inès Duarte')
|
||||
await mountMeta('editor', EDITOR, { backend })
|
||||
|
||||
removeButton('Inès Duarte').click()
|
||||
await flushPromises()
|
||||
saveButtonEl().click()
|
||||
await flushPromises()
|
||||
|
||||
expect(patchCalls(backend)[0]!.body).toEqual({ author_ids: [] })
|
||||
})
|
||||
|
||||
it('does not send author_ids when only the order was touched and put back', async () => {
|
||||
const backend = backendWith('Inès Duarte', 'Jonas Pereira')
|
||||
await mountMeta('editor', EDITOR, { backend })
|
||||
const handle = handleOf('Jonas Pereira')
|
||||
await pressKey(handle, ' ')
|
||||
await pressKey(handle, 'ArrowUp')
|
||||
await pressKey(handle, ' ')
|
||||
const again = handleOf('Jonas Pereira')
|
||||
await pressKey(again, ' ')
|
||||
await pressKey(again, 'ArrowDown')
|
||||
await pressKey(again, ' ')
|
||||
|
||||
saveButtonEl().click()
|
||||
await flushPromises()
|
||||
|
||||
expect(patchCalls(backend)).toHaveLength(0)
|
||||
expect(textOf(document.body)).toContain('Saved')
|
||||
})
|
||||
})
|
||||
@@ -7,16 +7,22 @@ import { useSessionStore } from '@/stores/session'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import ArticleEditor from '@/components/ArticleEditor.vue'
|
||||
import RootComponent from '@/App.vue'
|
||||
import { articleMeta, oneSite } from './editorMetaKit'
|
||||
|
||||
const FIXED_NOW = '2026-09-26T11:02:00Z'
|
||||
|
||||
vi.mock('@/lib/fileHash', () => ({ hashFile: vi.fn(async () => 'a'.repeat(64)) }))
|
||||
vi.mock('@/lib/fileHash', () => ({
|
||||
hashFile: vi.fn(async () => 'a'.repeat(64)),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/imageCrop', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/imageCrop')>()
|
||||
return {
|
||||
...actual,
|
||||
readNaturalImageSize: vi.fn(async () => ({ width: 4032, height: 3024 })),
|
||||
readNaturalImageSize: vi.fn(async () => ({
|
||||
width: 4032,
|
||||
height: 3024,
|
||||
})),
|
||||
renderCroppedImage: vi.fn(async () => new Blob([new Uint8Array(430080)])),
|
||||
}
|
||||
})
|
||||
@@ -86,7 +92,10 @@ function mib(value: number) {
|
||||
|
||||
function fileFixture(name: string, type: string, sizeMB: number) {
|
||||
const file = new File(['x'], name, { type })
|
||||
Object.defineProperty(file, 'size', { value: mib(sizeMB), configurable: true })
|
||||
Object.defineProperty(file, 'size', {
|
||||
value: mib(sizeMB),
|
||||
configurable: true,
|
||||
})
|
||||
return file
|
||||
}
|
||||
|
||||
@@ -105,9 +114,13 @@ function routeFor(url: string, init?: RequestInit) {
|
||||
}
|
||||
|
||||
function fetchRouter(handlers: Record<string, () => Response | Promise<Response>>) {
|
||||
const routes = {
|
||||
'GET /v1/site/': () => mockResponse(200, oneSite),
|
||||
...handlers,
|
||||
}
|
||||
return vi.fn((url: string, init?: RequestInit) => {
|
||||
const key = routeFor(url, init)
|
||||
const handler = handlers[key]
|
||||
const handler = routes[key]
|
||||
if (!handler) {
|
||||
throw new Error(`Unexpected fetch call: ${key}`)
|
||||
}
|
||||
@@ -121,6 +134,7 @@ function articleFixture(overrides: Partial<Article> = {}): Article {
|
||||
title: 'How we price a bathroom renovation',
|
||||
body: '<p>a</p>',
|
||||
is_online: false,
|
||||
...articleMeta,
|
||||
created_at: '2026-03-12T09:14:00Z',
|
||||
updated_at: FIXED_NOW,
|
||||
...overrides,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useSessionStore } from '@/stores/session'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import ArticleEditor from '@/components/ArticleEditor.vue'
|
||||
import RootComponent from '@/App.vue'
|
||||
import { articleMeta, oneSite } from './editorMetaKit'
|
||||
import { fixture } from './mediaTestKit'
|
||||
|
||||
const FIXED_NOW = '2026-09-26T11:02:00Z'
|
||||
@@ -29,6 +30,7 @@ function articleFixture(body = BODY_WITH_TWO_IMAGES): Article {
|
||||
title: 'How we price a bathroom renovation',
|
||||
body,
|
||||
is_online: false,
|
||||
...articleMeta,
|
||||
created_at: '2026-03-12T09:14:00Z',
|
||||
updated_at: FIXED_NOW,
|
||||
}
|
||||
@@ -38,7 +40,13 @@ function libraryPage() {
|
||||
const items = fixture().map((item) =>
|
||||
item.id === 2 ? { ...item, url: '/static/uploadedImages/2.webp' } : item,
|
||||
)
|
||||
return { items, total: 48, page: 1, size: 25, counts: { all: 48, unused: 2, duplicates: 2 } }
|
||||
return {
|
||||
items,
|
||||
total: 48,
|
||||
page: 1,
|
||||
size: 25,
|
||||
counts: { all: 48, unused: 2, duplicates: 2 },
|
||||
}
|
||||
}
|
||||
|
||||
let toastAdd: ReturnType<typeof vi.fn>
|
||||
@@ -60,6 +68,9 @@ async function mountEditor(body = BODY_WITH_TWO_IMAGES) {
|
||||
if (url === '/v1/article/12?format=html') {
|
||||
return mockResponse(200, articleFixture(body))
|
||||
}
|
||||
if (url === '/v1/site/') {
|
||||
return mockResponse(200, oneSite)
|
||||
}
|
||||
if (url === '/v1/media/?page=1&size=25') {
|
||||
return mockResponse(200, libraryPage())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import router from '@/router'
|
||||
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||
import { META_SECTION_OPEN_KEY } from '@/lib/metaSectionMemory'
|
||||
import {
|
||||
allMetaSections,
|
||||
bylines,
|
||||
callsTo,
|
||||
click,
|
||||
createMetaBackend,
|
||||
failWith,
|
||||
metaSection,
|
||||
mountMeta,
|
||||
patchCalls,
|
||||
pending,
|
||||
renewSites,
|
||||
saveButtonEl,
|
||||
textOf,
|
||||
unmountMeta,
|
||||
type MetaBackend,
|
||||
} from './metaTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(unmountMeta)
|
||||
|
||||
const EDITOR = '/article/12/edit'
|
||||
|
||||
function backendWith(sites: 'one' | 'several', authors: string[], siteId = 1): MetaBackend {
|
||||
const backend = createMetaBackend({
|
||||
sites: sites === 'one' ? renewSites().slice(0, 1) : renewSites(),
|
||||
})
|
||||
backend.article.authors = bylines(...authors)
|
||||
backend.article.site_id = siteId
|
||||
return backend
|
||||
}
|
||||
|
||||
function summary() {
|
||||
return textOf(document.body.querySelector('[data-testid="meta-summary"]'))
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
return document.body.querySelector<HTMLElement>('[data-testid="meta-toggle"]')!
|
||||
}
|
||||
|
||||
function listboxTriggers() {
|
||||
return Array.from(
|
||||
document.body.querySelectorAll<HTMLElement>('button[aria-haspopup="listbox"]'),
|
||||
)
|
||||
}
|
||||
|
||||
function bodyOpen() {
|
||||
return document.body.querySelector('[data-testid="meta-body"]') !== null
|
||||
}
|
||||
|
||||
function toasts() {
|
||||
return Array.from(document.body.querySelectorAll('[data-slot="viewport"]'))
|
||||
.map(textOf)
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
describe('Editor section: collapsed by default with one site and an author', () => {
|
||||
it('shows the one-line summary in the details sidebar, above Created, and no Site select', async () => {
|
||||
await mountMeta('editor', EDITOR, {
|
||||
backend: backendWith('one', ['Inès Duarte']),
|
||||
})
|
||||
|
||||
const details = document.body.querySelector('[data-testid="article-details"]')!
|
||||
const section = metaSection()!
|
||||
expect(details.contains(section)).toBe(true)
|
||||
expect(textOf(section)).toContain('Site and authors')
|
||||
expect(summary()).toBe('Inès Duarte')
|
||||
expect(toggle().getAttribute('aria-expanded')).toBe('false')
|
||||
expect(bodyOpen()).toBe(false)
|
||||
expect(listboxTriggers()).toHaveLength(0)
|
||||
const text = textOf(details)
|
||||
expect(text.indexOf('Site and authors')).toBeLessThan(text.indexOf('Created'))
|
||||
expect(text.indexOf('Details')).toBeLessThan(text.indexOf('Site and authors'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Editor section: loading and not found', () => {
|
||||
it('shows the header with a skeleton summary while the article is pending', async () => {
|
||||
const backend = backendWith('one', ['Inès Duarte'])
|
||||
const gate = pending<unknown>()
|
||||
backend.responders['GET /v1/article/12'] = () => gate.promise
|
||||
await mountMeta('editor', EDITOR, { backend })
|
||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||
await flushPromises()
|
||||
|
||||
const skeleton = document.body.querySelector('[data-testid="skeleton-details"]')!
|
||||
expect(textOf(skeleton)).toContain('Site and authors')
|
||||
expect(skeleton.querySelector('[data-testid="skeleton-meta"]')).not.toBeNull()
|
||||
expect(metaSection()).toBeNull()
|
||||
})
|
||||
|
||||
it('shows no section in the Not found state', async () => {
|
||||
const backend = backendWith('one', ['Inès Duarte'])
|
||||
backend.responders['GET /v1/article/12'] = failWith(404)
|
||||
await mountMeta('editor', EDITOR, { backend })
|
||||
|
||||
expect(textOf(document.body)).toContain("This article doesn't exist")
|
||||
expect(metaSection()).toBeNull()
|
||||
expect(document.body.textContent).not.toContain('Site and authors')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Editor section: it opens by itself when it needs attention', () => {
|
||||
it.each([
|
||||
{ sites: 'one', authors: ['Inès Duarte'], state: 'collapsed' },
|
||||
{ sites: 'several', authors: ['Inès Duarte'], state: 'open' },
|
||||
{ sites: 'one', authors: [], state: 'open' },
|
||||
{ sites: 'several', authors: [], state: 'open' },
|
||||
] as const)(
|
||||
'$sites site(s) and $authors.length author(s): $state',
|
||||
async ({ sites, authors, state }) => {
|
||||
await mountMeta('editor', EDITOR, {
|
||||
backend: backendWith(sites, [...authors]),
|
||||
})
|
||||
|
||||
expect(bodyOpen()).toBe(state === 'open')
|
||||
expect(toggle().getAttribute('aria-expanded')).toBe(String(state === 'open'))
|
||||
},
|
||||
)
|
||||
|
||||
it('stays open while the user adds the first author, instead of collapsing under them', async () => {
|
||||
await mountMeta('editor', EDITOR, { backend: backendWith('one', []) })
|
||||
expect(bodyOpen()).toBe(true)
|
||||
|
||||
document.body.querySelector<HTMLElement>('button[aria-haspopup="listbox"]')!.click()
|
||||
await flushPromises()
|
||||
const option = Array.from(
|
||||
document.body.querySelectorAll<HTMLElement>('[role="option"]'),
|
||||
).find((item) => textOf(item).includes('Camille Roche'))!
|
||||
option.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(bodyOpen()).toBe(true)
|
||||
expect(textOf(metaSection())).toContain('Camille Roche')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Editor section: the choice is remembered per browser', () => {
|
||||
it('keeps an opening for the next article, and a collapse over every auto-open, without any request', async () => {
|
||||
const backend = backendWith('one', ['Inès Duarte'])
|
||||
await mountMeta('editor', EDITOR, { backend })
|
||||
toggle().click()
|
||||
await flushPromises()
|
||||
expect(bodyOpen()).toBe(true)
|
||||
expect(localStorage.getItem(META_SECTION_OPEN_KEY)).toBe('true')
|
||||
await unmountMeta()
|
||||
|
||||
await mountMeta('editor', '/article/13/edit', {
|
||||
backend: backendWith('one', ['Inès Duarte']),
|
||||
})
|
||||
expect(bodyOpen()).toBe(true)
|
||||
toggle().click()
|
||||
await flushPromises()
|
||||
expect(localStorage.getItem(META_SECTION_OPEN_KEY)).toBe('false')
|
||||
await unmountMeta()
|
||||
|
||||
const crowded = backendWith('several', [])
|
||||
await mountMeta('editor', EDITOR, { backend: crowded })
|
||||
expect(bodyOpen()).toBe(false)
|
||||
expect(summary()).toContain('No authors')
|
||||
expect(
|
||||
crowded.calls.filter(
|
||||
(call) => call.method !== 'GET' || call.path.startsWith('/v1/author'),
|
||||
),
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Editor section: mobile, inside the Details sheet', () => {
|
||||
function openSheet() {
|
||||
return click('Details')
|
||||
}
|
||||
|
||||
function sheet() {
|
||||
return document.body.querySelector<HTMLElement>('[role="dialog"]')
|
||||
}
|
||||
|
||||
it('hides the desktop sidebar, starts closed in the sheet and shares the choice with it', async () => {
|
||||
await mountMeta('editor', EDITOR, {
|
||||
width: MOBILE_WIDTH,
|
||||
backend: backendWith('several', ['Inès Duarte'], 2),
|
||||
})
|
||||
|
||||
const sidebar = document.body.querySelector('[data-testid="article-details"]')!
|
||||
expect(sidebar.className).toContain('hidden')
|
||||
expect(sheet()).toBeNull()
|
||||
|
||||
await openSheet()
|
||||
const inSheet = sheet()!.querySelector<HTMLElement>('[data-testid="meta-section"]')!
|
||||
expect(inSheet.querySelector('[data-testid="meta-body"]')).toBeNull()
|
||||
expect(textOf(inSheet)).toContain('Journal · Inès Duarte')
|
||||
|
||||
inSheet.querySelector<HTMLElement>('[data-testid="meta-toggle"]')!.click()
|
||||
await flushPromises()
|
||||
expect(inSheet.querySelector('[data-testid="meta-body"]')).not.toBeNull()
|
||||
expect(localStorage.getItem(META_SECTION_OPEN_KEY)).toBe('true')
|
||||
|
||||
sheet()!.querySelector<HTMLElement>('button[aria-label="Close"]')!.click()
|
||||
await flushPromises()
|
||||
await new Promise((resolve) => setTimeout(resolve, 400))
|
||||
await openSheet()
|
||||
const again = sheet()!.querySelector<HTMLElement>('[data-testid="meta-section"]')!
|
||||
expect(again.querySelector('[data-testid="meta-body"]')).not.toBeNull()
|
||||
expect(allMetaSections().length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Editor section: collapsed summary variants', () => {
|
||||
it.each([
|
||||
{
|
||||
sites: 'one',
|
||||
authors: ['Inès Duarte'],
|
||||
siteId: 1,
|
||||
line: 'Inès Duarte',
|
||||
},
|
||||
{
|
||||
sites: 'one',
|
||||
authors: ['Inès Duarte', 'Jonas Pereira', 'Camille Roche'],
|
||||
siteId: 1,
|
||||
line: 'Inès Duarte +2',
|
||||
},
|
||||
{ sites: 'one', authors: [], siteId: 1, line: 'No authors' },
|
||||
{
|
||||
sites: 'several',
|
||||
authors: ['Inès Duarte'],
|
||||
siteId: 2,
|
||||
line: 'Journal · Inès Duarte',
|
||||
},
|
||||
{
|
||||
sites: 'several',
|
||||
authors: [],
|
||||
siteId: 2,
|
||||
line: 'Journal · No authors',
|
||||
},
|
||||
] as const)(
|
||||
'$sites site(s), $authors.length author(s): "$line"',
|
||||
async ({ sites, authors, siteId, line }) => {
|
||||
localStorage.setItem(META_SECTION_OPEN_KEY, 'false')
|
||||
await mountMeta('editor', EDITOR, {
|
||||
backend: backendWith(sites, [...authors], siteId),
|
||||
})
|
||||
|
||||
expect(summary()).toBe(line)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('Editor section: the Site select', () => {
|
||||
it('is absent with one site and the Authors list is the first control', async () => {
|
||||
localStorage.setItem(META_SECTION_OPEN_KEY, 'true')
|
||||
await mountMeta('editor', EDITOR, {
|
||||
backend: backendWith('one', ['Inès Duarte']),
|
||||
})
|
||||
|
||||
const body = document.body.querySelector('[data-testid="meta-body"]')!
|
||||
expect(textOf(body)).not.toContain('Canonical host')
|
||||
expect(textOf(body.firstElementChild)).toContain('Authors')
|
||||
expect(listboxTriggers().map(textOf)).toEqual(['Add an author'])
|
||||
})
|
||||
|
||||
it.each(['owner', 'admin', 'verified'] as const)(
|
||||
'is above the authors with the canonical host as helper text for a %s',
|
||||
async (role) => {
|
||||
const backend = backendWith('several', ['Inès Duarte'], 2)
|
||||
backend.me.role = role === 'verified' ? undefined : role
|
||||
await mountMeta('editor', EDITOR, { backend })
|
||||
|
||||
const body = textOf(document.body.querySelector('[data-testid="meta-body"]'))
|
||||
expect(listboxTriggers().map(textOf)[0]).toBe('Journal · journal.renew.example')
|
||||
expect(body).toContain('Canonical host: journal.renew.example')
|
||||
expect(body.indexOf('Site')).toBeLessThan(body.indexOf('Authors'))
|
||||
},
|
||||
)
|
||||
|
||||
it('lists every site with its host, the default first', async () => {
|
||||
await mountMeta('editor', EDITOR, {
|
||||
backend: backendWith('several', ['Inès Duarte'], 2),
|
||||
})
|
||||
listboxTriggers()[0]!.click()
|
||||
await flushPromises()
|
||||
|
||||
const options = Array.from(
|
||||
document.body.querySelectorAll<HTMLElement>('[role="option"]'),
|
||||
).map(textOf)
|
||||
expect(options).toEqual([
|
||||
'Renew renew.example',
|
||||
'Journal journal.renew.example',
|
||||
'Projects projects.renew.example',
|
||||
'Archive archive.renew.example',
|
||||
])
|
||||
})
|
||||
|
||||
it('treats a change as an edit: nothing is sent until Save, then one PATCH with only site_id', async () => {
|
||||
const backend = backendWith('several', ['Inès Duarte'], 1)
|
||||
await mountMeta('editor', EDITOR, { backend })
|
||||
expect(textOf(document.body)).toContain('Saved')
|
||||
expect(saveButtonEl().disabled).toBe(true)
|
||||
|
||||
listboxTriggers()[0]!.click()
|
||||
await flushPromises()
|
||||
Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]'))
|
||||
.find((item) => textOf(item).startsWith('Journal'))!
|
||||
.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(textOf(document.body)).toContain('Unsaved changes')
|
||||
expect(saveButtonEl().disabled).toBe(false)
|
||||
expect(patchCalls(backend)).toHaveLength(0)
|
||||
|
||||
saveButtonEl().click()
|
||||
await flushPromises()
|
||||
|
||||
expect(patchCalls(backend)).toHaveLength(1)
|
||||
expect(patchCalls(backend)[0]!.body).toEqual({ site_id: 2 })
|
||||
expect(textOf(document.body)).toContain('Saved')
|
||||
expect(textOf(document.body.querySelector('[data-testid="meta-body"]'))).toContain(
|
||||
'Canonical host: journal.renew.example',
|
||||
)
|
||||
})
|
||||
|
||||
it('still loads the editor when the sites cannot be loaded, with no select and no notice', async () => {
|
||||
const backend = backendWith('several', ['Inès Duarte'])
|
||||
backend.responders['GET /v1/site/'] = failWith(500)
|
||||
await mountMeta('editor', EDITOR, { backend })
|
||||
|
||||
expect(textOf(document.body)).toContain('How we price a bathroom renovation')
|
||||
expect(summary()).toBe('Inès Duarte')
|
||||
expect(listboxTriggers()).toHaveLength(0)
|
||||
expect(document.body.querySelector('[role="alert"]')).toBeNull()
|
||||
expect(toasts()).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Editor section: a site or author that no longer exists', () => {
|
||||
it('keeps the changes, shows Save failed, an info toast, and reloads the sites', async () => {
|
||||
const backend = backendWith('several', ['Inès Duarte'], 1)
|
||||
backend.responders['PATCH /v1/article/12'] = failWith(404, 'site_not_found')
|
||||
await mountMeta('editor', EDITOR, { backend })
|
||||
listboxTriggers()[0]!.click()
|
||||
await flushPromises()
|
||||
Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]'))
|
||||
.find((item) => textOf(item).startsWith('Journal'))!
|
||||
.click()
|
||||
await flushPromises()
|
||||
|
||||
saveButtonEl().click()
|
||||
await flushPromises()
|
||||
|
||||
expect(textOf(document.body)).toContain('Save failed')
|
||||
expect(toasts()).toContain('That site no longer exists')
|
||||
expect(callsTo(backend, 'GET', '/v1/site/')).toHaveLength(2)
|
||||
expect(listboxTriggers()[0]!.textContent).toContain('Journal')
|
||||
})
|
||||
|
||||
it('keeps the added author and shows the author toast on author_not_found', async () => {
|
||||
const backend = backendWith('one', ['Inès Duarte'])
|
||||
backend.responders['PATCH /v1/article/12'] = failWith(404, 'author_not_found')
|
||||
localStorage.setItem(META_SECTION_OPEN_KEY, 'true')
|
||||
await mountMeta('editor', EDITOR, { backend })
|
||||
listboxTriggers()[0]!.click()
|
||||
await flushPromises()
|
||||
Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]'))
|
||||
.find((item) => textOf(item).includes('Camille Roche'))!
|
||||
.click()
|
||||
await flushPromises()
|
||||
|
||||
saveButtonEl().click()
|
||||
await flushPromises()
|
||||
|
||||
expect(textOf(document.body)).toContain('Save failed')
|
||||
expect(toasts()).toContain('That author no longer exists')
|
||||
expect(textOf(metaSection())).toContain('Camille Roche')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Editor section: the existing save behaviour treats the change like any other edit', () => {
|
||||
async function changeSite(backend: MetaBackend) {
|
||||
await mountMeta('editor', EDITOR, { backend })
|
||||
listboxTriggers()[0]!.click()
|
||||
await flushPromises()
|
||||
Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]'))
|
||||
.find((item) => textOf(item).startsWith('Journal'))!
|
||||
.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
it('offers Save and publish and saves with Ctrl+S', async () => {
|
||||
const backend = backendWith('several', ['Inès Duarte'])
|
||||
await changeSite(backend)
|
||||
|
||||
expect(textOf(document.body)).toContain('Save and publish')
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 's', ctrlKey: true }))
|
||||
await flushPromises()
|
||||
|
||||
expect(patchCalls(backend)).toHaveLength(1)
|
||||
expect(patchCalls(backend)[0]!.body).toEqual({ site_id: 2 })
|
||||
})
|
||||
|
||||
it('asks before leaving with an unsaved site change', async () => {
|
||||
await changeSite(backendWith('several', ['Inès Duarte']))
|
||||
|
||||
const leaving = router.push('/article')
|
||||
await flushPromises()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
await flushPromises()
|
||||
|
||||
expect(document.body.querySelector('[role="alertdialog"]')?.textContent).toContain(
|
||||
'Leave without saving?',
|
||||
)
|
||||
await click('Keep editing')
|
||||
await leaving
|
||||
expect(router.currentRoute.value.path).toBe(EDITOR)
|
||||
})
|
||||
|
||||
it('still loads the article when sessionStorage throws', async () => {
|
||||
const spies = ['getItem', 'setItem', 'removeItem'].map((method) =>
|
||||
vi.spyOn(Storage.prototype, method as 'getItem').mockImplementation(() => {
|
||||
throw new Error('storage blocked')
|
||||
}),
|
||||
)
|
||||
try {
|
||||
await mountMeta('editor', EDITOR, {
|
||||
backend: backendWith('several', ['Inès Duarte']),
|
||||
})
|
||||
|
||||
expect(metaSection()).not.toBeNull()
|
||||
expect(textOf(document.body)).not.toContain('Unsaved changes')
|
||||
} finally {
|
||||
spies.forEach((spy) => spy.mockRestore())
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the chosen site in sessionStorage when the session expires and restores it', async () => {
|
||||
const backend = backendWith('several', ['Inès Duarte'])
|
||||
backend.responders['PATCH /v1/article/12'] = failWith(401)
|
||||
await changeSite(backend)
|
||||
saveButtonEl().click()
|
||||
await flushPromises()
|
||||
|
||||
expect(JSON.parse(sessionStorage.getItem('renewcms:article-draft-meta:12')!)).toMatchObject(
|
||||
{
|
||||
site_id: 2,
|
||||
},
|
||||
)
|
||||
expect(sessionStorage.getItem('renewcms:article-draft:12')).toBeNull()
|
||||
await unmountMeta()
|
||||
|
||||
await mountMeta('editor', EDITOR, {
|
||||
backend: backendWith('several', ['Inès Duarte']),
|
||||
})
|
||||
expect(textOf(document.body)).toContain('Unsaved changes')
|
||||
expect(listboxTriggers()[0]!.textContent).toContain('Journal')
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,7 @@ import type { Article } from '@/composables/useArticles'
|
||||
import ArticleEditor from '@/components/ArticleEditor.vue'
|
||||
import RootComponent from '@/App.vue'
|
||||
import { messages } from '@/i18n/messages'
|
||||
import { articleMeta, oneSite } from './editorMetaKit'
|
||||
import { fixture, textOf } from './mediaTestKit'
|
||||
|
||||
const FIXED_NOW = '2026-09-26T11:02:00Z'
|
||||
@@ -67,6 +68,7 @@ function article(body: string): Article {
|
||||
title: 'How we price a bathroom renovation',
|
||||
body,
|
||||
is_online: false,
|
||||
...articleMeta,
|
||||
created_at: '2026-03-12T09:14:00Z',
|
||||
updated_at: FIXED_NOW,
|
||||
}
|
||||
@@ -78,7 +80,10 @@ function libraryPage() {
|
||||
2: '/static/uploadedImages/2.webp',
|
||||
4: '/static/uploadedImages/4.svg',
|
||||
}
|
||||
const items = fixture().map((item) => ({ ...item, url: urls[item.id] ?? item.url }))
|
||||
const items = fixture().map((item) => ({
|
||||
...item,
|
||||
url: urls[item.id] ?? item.url,
|
||||
}))
|
||||
return {
|
||||
items,
|
||||
total: items.length,
|
||||
@@ -119,6 +124,9 @@ async function mountEditor(options: EditorOptions = {}) {
|
||||
if (url === '/v1/article/12?format=html') {
|
||||
return reply(200, article(options.body ?? '<p>Intro</p>'))
|
||||
}
|
||||
if (url === '/v1/site/') {
|
||||
return reply(200, oneSite)
|
||||
}
|
||||
if (url === '/v1/media/?page=1&size=25') {
|
||||
return reply(200, libraryPage())
|
||||
}
|
||||
@@ -322,7 +330,12 @@ describe('The Variant select', () => {
|
||||
'Only disabled variants count as none',
|
||||
{
|
||||
status: 200,
|
||||
body: { items: CATALOG.map((entry) => ({ ...entry, enabled: false })) },
|
||||
body: {
|
||||
items: CATALOG.map((entry) => ({
|
||||
...entry,
|
||||
enabled: false,
|
||||
})),
|
||||
},
|
||||
},
|
||||
],
|
||||
])('%s', async (_label, result) => {
|
||||
@@ -412,7 +425,10 @@ describe('Inserting a variant', () => {
|
||||
expect(editorBody(wrapper)).toContain(`<img src="${src}" alt="A wall" data-image-id="1">`)
|
||||
expect(wrapper.text()).toContain('Unsaved changes')
|
||||
expect(toastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'Image added', icon: 'i-lucide-circle-check' }),
|
||||
expect.objectContaining({
|
||||
title: 'Image added',
|
||||
icon: 'i-lucide-circle-check',
|
||||
}),
|
||||
)
|
||||
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
|
||||
expect(writeRequests()).toHaveLength(0)
|
||||
@@ -486,7 +502,10 @@ describe('A block whose variant was deleted or disabled', () => {
|
||||
const items = CATALOG.map((entry) =>
|
||||
entry.name === 'hero' ? { ...entry, enabled: false } : entry,
|
||||
)
|
||||
await mountEditor({ body: HERO_BODY, variants: () => ({ status: 200, body: { items } }) })
|
||||
await mountEditor({
|
||||
body: HERO_BODY,
|
||||
variants: () => ({ status: 200, body: { items } }),
|
||||
})
|
||||
|
||||
expect(textOf(block())).toContain("Variant 'hero' is disabled.")
|
||||
expect(textOf(block())).toContain('This image returns an error on the public site.')
|
||||
@@ -546,7 +565,10 @@ describe('A block whose variant was deleted or disabled', () => {
|
||||
|
||||
it('keeps the Pick another variant button 44 px high on touch screens', async () => {
|
||||
const items = CATALOG.filter((entry) => entry.name !== 'hero')
|
||||
await mountEditor({ body: HERO_BODY, variants: () => ({ status: 200, body: { items } }) })
|
||||
await mountEditor({
|
||||
body: HERO_BODY,
|
||||
variants: () => ({ status: 200, body: { items } }),
|
||||
})
|
||||
|
||||
const button = findButton('Pick another variant', block()!)!
|
||||
expect(button.className).toContain('h-11')
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useSessionStore } from '@/stores/session'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import ArticleEditor from '@/components/ArticleEditor.vue'
|
||||
import RootComponent from '@/App.vue'
|
||||
import { articleMeta, oneSite } from './editorMetaKit'
|
||||
|
||||
const FIXED_NOW = '2026-09-26T11:02:00Z'
|
||||
|
||||
@@ -26,9 +27,13 @@ function routeFor(url: string, init?: RequestInit) {
|
||||
}
|
||||
|
||||
function fetchRouter(handlers: Record<string, () => Response | Promise<Response>>) {
|
||||
const routes = {
|
||||
'GET /v1/site/': () => mockResponse(200, oneSite),
|
||||
...handlers,
|
||||
}
|
||||
return vi.fn((url: string, init?: RequestInit) => {
|
||||
const key = routeFor(url, init)
|
||||
const handler = handlers[key]
|
||||
const handler = routes[key]
|
||||
if (!handler) {
|
||||
throw new Error(`Unexpected fetch call: ${key}`)
|
||||
}
|
||||
@@ -47,6 +52,7 @@ function articleFixture(overrides: Partial<Article> = {}): Article {
|
||||
{ id: 3, url: '/images/3.png' },
|
||||
],
|
||||
is_online: false,
|
||||
...articleMeta,
|
||||
created_at: '2026-03-12T09:14:00Z',
|
||||
updated_at: FIXED_NOW,
|
||||
...overrides,
|
||||
@@ -684,7 +690,7 @@ describe('Requesting removal of an inline image opens the neutral confirm dialog
|
||||
expect(props.confirmLabel).toBe('Remove image')
|
||||
expect(props.cancelLabel).toBe('Cancel')
|
||||
expect(props.tone).toBe('neutral')
|
||||
expect(requestCalls(wrapper)).toEqual(['GET /v1/article/12?format=html'])
|
||||
expect(requestCalls(wrapper)).toEqual(['GET /v1/article/12?format=html', 'GET /v1/site/'])
|
||||
expect(articleEditorBody(wrapper)).toContain('data-image-id="10"')
|
||||
})
|
||||
|
||||
@@ -696,7 +702,9 @@ describe('Requesting removal of an inline image opens the neutral confirm dialog
|
||||
|
||||
await requestRemoveImage(wrapper)
|
||||
|
||||
const props = overlayMock.getProps() as { preview: Record<string, string> }
|
||||
const props = overlayMock.getProps() as {
|
||||
preview: Record<string, string>
|
||||
}
|
||||
expect(props.preview).toEqual({
|
||||
src: '/static/uploadedImages/before.png',
|
||||
filename: 'before.png',
|
||||
@@ -711,7 +719,9 @@ describe('Requesting removal of an inline image opens the neutral confirm dialog
|
||||
|
||||
await requestRemoveImage(wrapper)
|
||||
|
||||
const props = overlayMock.getProps() as { preview: Record<string, string> }
|
||||
const props = overlayMock.getProps() as {
|
||||
preview: Record<string, string>
|
||||
}
|
||||
expect(props.preview).toEqual({
|
||||
src: '/static/uploadedImages/before.png',
|
||||
filename: undefined,
|
||||
@@ -733,7 +743,7 @@ describe('Cancelling the confirm changes nothing', () => {
|
||||
expect(articleEditorBody(wrapper)).toContain('data-image-id="10"')
|
||||
expect(wrapper.text()).not.toContain('Unsaved changes')
|
||||
expect(toastAdd).not.toHaveBeenCalled()
|
||||
expect(requestCalls(wrapper)).toEqual(['GET /v1/article/12?format=html'])
|
||||
expect(requestCalls(wrapper)).toEqual(['GET /v1/article/12?format=html', 'GET /v1/site/'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -757,7 +767,7 @@ describe('Confirming removes the image from the body only, marks the draft dirty
|
||||
title: 'Image removed',
|
||||
description: "It's still in your library.",
|
||||
})
|
||||
expect(requestCalls(wrapper)).toEqual(['GET /v1/article/12?format=html'])
|
||||
expect(requestCalls(wrapper)).toEqual(['GET /v1/article/12?format=html', 'GET /v1/site/'])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -200,7 +200,12 @@ describe('Home rows, unknown action, failed login and not interactive', () => {
|
||||
})
|
||||
|
||||
it('truncates a long sentence on one line', async () => {
|
||||
const items = [makeItem({ id: 1, target: target('article', '8', 'T'.repeat(150)) })]
|
||||
const items = [
|
||||
makeItem({
|
||||
id: 1,
|
||||
target: target('article', '8', 'T'.repeat(150)),
|
||||
}),
|
||||
]
|
||||
await mountHome('owner', {
|
||||
[ACTIVITY_ROUTE]: () => page(items),
|
||||
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
|
||||
|
||||
@@ -126,7 +126,10 @@ describe('Home banner for the owner, update available', () => {
|
||||
|
||||
it('shows again when latest changes to a newer version', async () => {
|
||||
localStorage.setItem(DISMISSED_VERSION_KEY, NEW_TAG)
|
||||
await mountHome({ role: 'owner', status: newerAvailable({ latest: 'v2.4.0-beta' }) })
|
||||
await mountHome({
|
||||
role: 'owner',
|
||||
status: newerAvailable({ latest: 'v2.4.0-beta' }),
|
||||
})
|
||||
|
||||
expect(banner()).not.toBeNull()
|
||||
expect(banner()!.textContent).toContain('Version 2.4.0-beta is available')
|
||||
@@ -163,7 +166,11 @@ describe('Settings sidebar pill and mobile dot', () => {
|
||||
})
|
||||
|
||||
it('shows the dot on the mobile menu icon', async () => {
|
||||
await mountHome({ role: 'owner', status: newerAvailable(), width: MOBILE_WIDTH })
|
||||
await mountHome({
|
||||
role: 'owner',
|
||||
status: newerAvailable(),
|
||||
width: MOBILE_WIDTH,
|
||||
})
|
||||
|
||||
expect(mobileDot()).not.toBeNull()
|
||||
expect(banner()).not.toBeNull()
|
||||
@@ -171,7 +178,11 @@ describe('Settings sidebar pill and mobile dot', () => {
|
||||
|
||||
it.each([
|
||||
{ name: 'an admin', role: 'admin' as const, status: newerAvailable() },
|
||||
{ name: 'an owner who is up to date', role: 'owner' as const, status: upToDate() },
|
||||
{
|
||||
name: 'an owner who is up to date',
|
||||
role: 'owner' as const,
|
||||
status: upToDate(),
|
||||
},
|
||||
])('shows no pill and no dot for $name', async ({ role, status }) => {
|
||||
await mountHome({ role, status })
|
||||
|
||||
|
||||
@@ -156,7 +156,9 @@ describe('Home banner, ready to install (owner)', () => {
|
||||
|
||||
describe('Home banner while restarting', () => {
|
||||
it('shows no update banner and does not poll', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] })
|
||||
vi.useFakeTimers({
|
||||
toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'],
|
||||
})
|
||||
await mountHome('owner', restarting())
|
||||
|
||||
expect(banner()).toBeNull()
|
||||
|
||||
@@ -43,7 +43,10 @@ function fetchRouter(handlers: Record<string, () => Response | Promise<Response>
|
||||
})
|
||||
}
|
||||
|
||||
const HOME_ARTICLES_ROUTE = articleListRoute({ size: 5, sort: 'updated_at:desc' })
|
||||
const HOME_ARTICLES_ROUTE = articleListRoute({
|
||||
size: 5,
|
||||
sort: 'updated_at:desc',
|
||||
})
|
||||
|
||||
const sixArticles: Article[] = [
|
||||
{
|
||||
|
||||
@@ -88,7 +88,10 @@ function listing(items: ImageVariant[]): ListHandler {
|
||||
|
||||
function stubClipboard() {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
})
|
||||
return writeText
|
||||
}
|
||||
|
||||
@@ -117,11 +120,18 @@ async function mountPage(options: MountOptions = {}) {
|
||||
throw new Error(`Unexpected request: ${init?.method ?? 'GET'} ${path}`)
|
||||
})
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
getMe: vi.fn().mockResolvedValue({ verified: true, username: 'marta', id: 1, role }),
|
||||
getMe: vi.fn().mockResolvedValue({
|
||||
verified: true,
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
role,
|
||||
}),
|
||||
request: requestMock,
|
||||
})
|
||||
await router.push('/integration')
|
||||
const Host = defineComponent({ render: () => h(UApp, null, () => h(IntegrationView)) })
|
||||
const Host = defineComponent({
|
||||
render: () => h(UApp, null, () => h(IntegrationView)),
|
||||
})
|
||||
wrapper = mount(Host, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router, uiPlugin] },
|
||||
@@ -313,7 +323,9 @@ describe('I1. Many variants', () => {
|
||||
|
||||
describe('I2. One variant', () => {
|
||||
it('uses the single variant everywhere and draws no scroll limit and no disabled note', async () => {
|
||||
await mountPage({ list: listing([variant({ name: 'thumb', width: 400, height: 300 })]) })
|
||||
await mountPage({
|
||||
list: listing([variant({ name: 'thumb', width: 400, height: 300 })]),
|
||||
})
|
||||
|
||||
expect(normalized(headingBlock('Variants'))).toContain('1 enabled variant')
|
||||
expect(normalized(headingBlock('Variants'))).not.toContain('1 enabled variants')
|
||||
|
||||
@@ -23,6 +23,7 @@ const SECTION_LABELS = [
|
||||
'Users and invites',
|
||||
'Settings',
|
||||
'Sites',
|
||||
'Authors',
|
||||
'Images and variants',
|
||||
'Session only',
|
||||
'Errors',
|
||||
@@ -366,16 +367,16 @@ describe('Authentication', () => {
|
||||
})
|
||||
|
||||
describe('Session only', () => {
|
||||
it('renders the banner, 21 rows without expand controls and the 403 chips', async () => {
|
||||
it('renders the banner, 25 rows without expand controls and the 403 chips', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
const session = section(wrapper, 'session')
|
||||
|
||||
expect(session.text()).toContain('21 routes')
|
||||
expect(session.text()).toContain('25 routes')
|
||||
expect(session.text()).toContain(
|
||||
'These routes are made for a signed-in browser session. With an API key, verify, DELETE /auth/register, PATCH /settings, /activity/*, /api-key/* and /system/* answer 403 "This action needs a real session."',
|
||||
)
|
||||
const rows = session.findAll('[data-session-row]')
|
||||
expect(rows).toHaveLength(21)
|
||||
expect(rows).toHaveLength(25)
|
||||
expect(session.findAll('[data-session-row] button')).toHaveLength(0)
|
||||
expect(session.findAll('[data-session-row] [aria-expanded]')).toHaveLength(0)
|
||||
|
||||
@@ -391,6 +392,10 @@ describe('Session only', () => {
|
||||
'PATCH /v1/site/{id}',
|
||||
'PATCH /v1/site/{id}/default',
|
||||
'DELETE /v1/site/{id}',
|
||||
'POST /v1/author/',
|
||||
'PATCH /v1/author/{id}',
|
||||
'DELETE /v1/author/{id}',
|
||||
'POST /v1/author/me',
|
||||
'PATCH /v1/settings',
|
||||
'GET /v1/activity',
|
||||
'GET /v1/activity/summary',
|
||||
|
||||
@@ -5,11 +5,7 @@ import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import router from '@/router'
|
||||
import LoginView from '../LoginView.vue'
|
||||
|
||||
function mockResponse(
|
||||
status: number,
|
||||
body: unknown = null,
|
||||
headers: Record<string, string> = {},
|
||||
) {
|
||||
function mockResponse(status: number, body: unknown = null, headers: Record<string, string> = {}) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
@@ -23,9 +19,7 @@ function routeFor(url: string, init?: RequestInit) {
|
||||
return `${method} ${url}`
|
||||
}
|
||||
|
||||
function fetchRouter(
|
||||
handlers: Record<string, () => Response | Promise<Response>>,
|
||||
) {
|
||||
function fetchRouter(handlers: Record<string, () => Response | Promise<Response>>) {
|
||||
return vi.fn((url: string, init?: RequestInit) => {
|
||||
const key = routeFor(url, init)
|
||||
const handler = handlers[key]
|
||||
@@ -65,9 +59,7 @@ async function fillAndSubmit(
|
||||
password: string,
|
||||
) {
|
||||
await wrapper.find('input[placeholder="Your username"]').setValue(username)
|
||||
await wrapper
|
||||
.find('input[autocomplete="current-password"]')
|
||||
.setValue(password)
|
||||
await wrapper.find('input[autocomplete="current-password"]').setValue(password)
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
}
|
||||
@@ -81,21 +73,16 @@ afterEach(async () => {
|
||||
describe('Default — registration is closed', () => {
|
||||
it('renders the default state with no create-account footer link', async () => {
|
||||
const wrapper = await mountLogin('/login', {
|
||||
'GET /v1/auth/registration': () =>
|
||||
mockResponse(200, { open: false }),
|
||||
'GET /v1/auth/registration': () => mockResponse(200, { open: false }),
|
||||
})
|
||||
|
||||
expect(wrapper.find('h1').text()).toBe('Log in')
|
||||
expect(wrapper.text()).toContain(
|
||||
'Welcome back. Log in to manage your articles.',
|
||||
)
|
||||
expect(wrapper.text()).toContain('Welcome back. Log in to manage your articles.')
|
||||
|
||||
const usernameInput = wrapper.find('input[placeholder="Your username"]')
|
||||
expect((usernameInput.element as HTMLInputElement).value).toBe('')
|
||||
|
||||
const passwordInput = wrapper.find(
|
||||
'input[autocomplete="current-password"]',
|
||||
)
|
||||
const passwordInput = wrapper.find('input[autocomplete="current-password"]')
|
||||
expect((passwordInput.element as HTMLInputElement).value).toBe('')
|
||||
|
||||
expect(wrapper.find('button[type="submit"]').text()).toBe('Log in')
|
||||
@@ -106,8 +93,7 @@ describe('Default — registration is closed', () => {
|
||||
describe('Fresh install — registration is open', () => {
|
||||
it('renders the same fields plus the create-account footer link', async () => {
|
||||
const wrapper = await mountLogin('/login', {
|
||||
'GET /v1/auth/registration': () =>
|
||||
mockResponse(200, { open: true }),
|
||||
'GET /v1/auth/registration': () => mockResponse(200, { open: true }),
|
||||
})
|
||||
|
||||
expect(wrapper.find('h1').text()).toBe('Log in')
|
||||
@@ -162,26 +148,18 @@ describe('Submitting — fields and button are disabled while the request is in
|
||||
}),
|
||||
})
|
||||
|
||||
await wrapper
|
||||
.find('input[placeholder="Your username"]')
|
||||
.setValue('marta')
|
||||
await wrapper
|
||||
.find('input[autocomplete="current-password"]')
|
||||
.setValue('limewash2026')
|
||||
await wrapper.find('input[placeholder="Your username"]').setValue('marta')
|
||||
await wrapper.find('input[autocomplete="current-password"]').setValue('limewash2026')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(
|
||||
(
|
||||
wrapper.find('input[placeholder="Your username"]')
|
||||
.element as HTMLInputElement
|
||||
).disabled,
|
||||
(wrapper.find('input[placeholder="Your username"]').element as HTMLInputElement)
|
||||
.disabled,
|
||||
).toBe(true)
|
||||
expect(
|
||||
(
|
||||
wrapper.find('input[autocomplete="current-password"]')
|
||||
.element as HTMLInputElement
|
||||
).disabled,
|
||||
(wrapper.find('input[autocomplete="current-password"]').element as HTMLInputElement)
|
||||
.disabled,
|
||||
).toBe(true)
|
||||
const submitButton = wrapper.find('button[type="submit"]')
|
||||
expect(submitButton.text()).toBe('Logging in…')
|
||||
@@ -200,19 +178,13 @@ describe('Wrong credentials — the password is cleared and focused', () => {
|
||||
|
||||
await fillAndSubmit(wrapper, 'marta', 'wrong-password')
|
||||
|
||||
expect(wrapper.text()).toContain(
|
||||
"That username and password don't match",
|
||||
)
|
||||
expect(wrapper.text()).toContain(
|
||||
'Check both and try again. Passwords are case-sensitive.',
|
||||
)
|
||||
expect(wrapper.text()).toContain("That username and password don't match")
|
||||
expect(wrapper.text()).toContain('Check both and try again. Passwords are case-sensitive.')
|
||||
|
||||
const usernameInput = wrapper.find('input[placeholder="Your username"]')
|
||||
expect((usernameInput.element as HTMLInputElement).value).toBe('marta')
|
||||
|
||||
const passwordInput = wrapper.find(
|
||||
'input[autocomplete="current-password"]',
|
||||
)
|
||||
const passwordInput = wrapper.find('input[autocomplete="current-password"]')
|
||||
expect((passwordInput.element as HTMLInputElement).value).toBe('')
|
||||
expect(passwordInput.element).toBe(document.activeElement)
|
||||
|
||||
@@ -226,28 +198,20 @@ describe('Too many attempts — the button is disabled with a live countdown', (
|
||||
it('counts down from the Retry-After value and re-enables at zero', async () => {
|
||||
vi.useFakeTimers()
|
||||
const wrapper = await mountLogin('/login', {
|
||||
'POST /v1/auth/login': () =>
|
||||
mockResponse(429, null, { 'Retry-After': '42' }),
|
||||
'POST /v1/auth/login': () => mockResponse(429, null, { 'Retry-After': '42' }),
|
||||
})
|
||||
|
||||
await fillAndSubmit(wrapper, 'marta', 'limewash')
|
||||
|
||||
expect(wrapper.text()).toContain('Too many attempts')
|
||||
expect(wrapper.text()).toContain(
|
||||
'For your security, wait a moment before trying again.',
|
||||
)
|
||||
expect(wrapper.text()).toContain('For your security, wait a moment before trying again.')
|
||||
|
||||
expect(
|
||||
(
|
||||
wrapper.find('input[placeholder="Your username"]')
|
||||
.element as HTMLInputElement
|
||||
).value,
|
||||
(wrapper.find('input[placeholder="Your username"]').element as HTMLInputElement).value,
|
||||
).toBe('marta')
|
||||
expect(
|
||||
(
|
||||
wrapper.find('input[autocomplete="current-password"]')
|
||||
.element as HTMLInputElement
|
||||
).value,
|
||||
(wrapper.find('input[autocomplete="current-password"]').element as HTMLInputElement)
|
||||
.value,
|
||||
).toBe('limewash')
|
||||
|
||||
const button = wrapper.find('button[type="submit"]')
|
||||
@@ -255,10 +219,8 @@ describe('Too many attempts — the button is disabled with a live countdown', (
|
||||
expect((button.element as HTMLButtonElement).disabled).toBe(true)
|
||||
|
||||
const fields = () => [
|
||||
wrapper.find('input[placeholder="Your username"]')
|
||||
.element as HTMLInputElement,
|
||||
wrapper.find('input[autocomplete="current-password"]')
|
||||
.element as HTMLInputElement,
|
||||
wrapper.find('input[placeholder="Your username"]').element as HTMLInputElement,
|
||||
wrapper.find('input[autocomplete="current-password"]').element as HTMLInputElement,
|
||||
]
|
||||
expect(fields().map((field) => field.disabled)).toEqual([true, true])
|
||||
|
||||
@@ -266,10 +228,9 @@ describe('Too many attempts — the button is disabled with a live countdown', (
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('button[type="submit"]').text()).toBe('Log in')
|
||||
expect(
|
||||
(wrapper.find('button[type="submit"]').element as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(false)
|
||||
expect((wrapper.find('button[type="submit"]').element as HTMLButtonElement).disabled).toBe(
|
||||
false,
|
||||
)
|
||||
expect(fields().map((field) => field.disabled)).toEqual([false, false])
|
||||
vi.useRealTimers()
|
||||
})
|
||||
@@ -277,19 +238,14 @@ describe('Too many attempts — the button is disabled with a live countdown', (
|
||||
|
||||
describe('Session expired — redirected from a 401 elsewhere', () => {
|
||||
it('shows the session-expired alert and focuses the password field', async () => {
|
||||
const wrapper = await mountLogin(
|
||||
'/login?redirect=/article/12/edit&reason=expired',
|
||||
{},
|
||||
)
|
||||
const wrapper = await mountLogin('/login?redirect=/article/12/edit&reason=expired', {})
|
||||
|
||||
expect(wrapper.text()).toContain('Your session expired')
|
||||
expect(wrapper.text()).toContain(
|
||||
'Log in again to pick up where you left off. Unsaved changes are kept on this device.',
|
||||
)
|
||||
|
||||
const passwordInput = wrapper.find(
|
||||
'input[autocomplete="current-password"]',
|
||||
)
|
||||
const passwordInput = wrapper.find('input[autocomplete="current-password"]')
|
||||
expect(passwordInput.element).toBe(document.activeElement)
|
||||
expect(wrapper.find('button[type="submit"]').text()).toBe('Log in')
|
||||
})
|
||||
@@ -337,28 +293,20 @@ describe('Server or network error — the fields keep their values', () => {
|
||||
|
||||
expect(login).toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain("Couldn't reach RenewCMS")
|
||||
expect(wrapper.text()).toContain(
|
||||
'Check your connection and try again.',
|
||||
)
|
||||
expect(wrapper.text()).toContain('Check your connection and try again.')
|
||||
|
||||
expect(
|
||||
(
|
||||
wrapper.find('input[placeholder="Your username"]')
|
||||
.element as HTMLInputElement
|
||||
).value,
|
||||
(wrapper.find('input[placeholder="Your username"]').element as HTMLInputElement)
|
||||
.value,
|
||||
).toBe('marta')
|
||||
expect(
|
||||
(
|
||||
wrapper.find('input[autocomplete="current-password"]')
|
||||
.element as HTMLInputElement
|
||||
).value,
|
||||
(wrapper.find('input[autocomplete="current-password"]').element as HTMLInputElement)
|
||||
.value,
|
||||
).toBe(password)
|
||||
|
||||
const submitButton = wrapper.find('button[type="submit"]')
|
||||
expect(submitButton.text()).toBe('Log in')
|
||||
expect((submitButton.element as HTMLButtonElement).disabled).toBe(
|
||||
false,
|
||||
)
|
||||
expect((submitButton.element as HTMLButtonElement).disabled).toBe(false)
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -377,15 +325,12 @@ describe('A successful log in navigates to the right destination', () => {
|
||||
async ({ path, verified, destination }) => {
|
||||
const wrapper = await mountLogin(path, {
|
||||
'POST /v1/auth/login': () => mockResponse(200),
|
||||
'GET /v1/auth/me': () =>
|
||||
mockResponse(200, { is_verified: verified }),
|
||||
'GET /v1/auth/me': () => mockResponse(200, { is_verified: verified }),
|
||||
})
|
||||
|
||||
await fillAndSubmit(wrapper, 'marta', 'limewash2026')
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(router.currentRoute.value.path).toBe(destination),
|
||||
)
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe(destination))
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -89,7 +89,9 @@ describe('The Duplicates tab', () => {
|
||||
duplicates.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 }))
|
||||
await settle()
|
||||
|
||||
expect(router.currentRoute.value.query).toEqual({ filter: 'duplicates' })
|
||||
expect(router.currentRoute.value.query).toEqual({
|
||||
filter: 'duplicates',
|
||||
})
|
||||
expect(lastListPath()).toBe('/v1/media/?filter=duplicates&sort=newest&page=1&size=100')
|
||||
expect(tab('Duplicates').getAttribute('aria-selected')).toBe('true')
|
||||
expect(tabs().map(textOf)).toEqual(['All 12', 'Unused 2', 'Duplicates 2'])
|
||||
@@ -296,13 +298,20 @@ describe('Merge', () => {
|
||||
|
||||
const [call] = callsTo('POST', /merge/)
|
||||
expect(call![0]).toBe('/v1/media/merge')
|
||||
expect(JSON.parse(call![1].body)).toEqual({ keep_id: 12, merge_ids: [5, 6] })
|
||||
expect(JSON.parse(call![1].body)).toEqual({
|
||||
keep_id: 12,
|
||||
merge_ids: [5, 6],
|
||||
})
|
||||
expect(findButton('Cancel', dialog()!)!.disabled).toBe(true)
|
||||
const confirm = findButton('Merging…', dialog()!)!
|
||||
expect(confirm.disabled).toBe(true)
|
||||
await vi.waitFor(() => expect(confirm.querySelector('svg.iconify--lucide')).toBeTruthy())
|
||||
dialog()!.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }),
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
)
|
||||
await flushPromises()
|
||||
expect(dialog()).not.toBeNull()
|
||||
@@ -399,7 +408,9 @@ describe('Merge', () => {
|
||||
)
|
||||
|
||||
it('a 409 keeps the dialog open with the conflict alert and reloads the list', async () => {
|
||||
await openMerge('floorboards.jpg', { merge: () => Promise.reject(new ApiError(409)) })
|
||||
await openMerge('floorboards.jpg', {
|
||||
merge: () => Promise.reject(new ApiError(409)),
|
||||
})
|
||||
const before = listCalls().length
|
||||
|
||||
findButton('Merge 2 copies', dialog()!)!.click()
|
||||
@@ -412,7 +423,9 @@ describe('Merge', () => {
|
||||
})
|
||||
|
||||
it('a 404 closes the dialog, shows the info toast and reloads the list', async () => {
|
||||
await openMerge('floorboards.jpg', { merge: () => Promise.reject(new ApiError(404)) })
|
||||
await openMerge('floorboards.jpg', {
|
||||
merge: () => Promise.reject(new ApiError(404)),
|
||||
})
|
||||
const before = listCalls().length
|
||||
|
||||
findButton('Merge 2 copies', dialog()!)!.click()
|
||||
|
||||
@@ -11,7 +11,10 @@ vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
|
||||
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
|
||||
return { ...actual, useToast: () => ({ add: vi.fn(), remove: vi.fn(), update: vi.fn() }) }
|
||||
return {
|
||||
...actual,
|
||||
useToast: () => ({ add: vi.fn(), remove: vi.fn(), update: vi.fn() }),
|
||||
}
|
||||
})
|
||||
|
||||
const KEY = 'renewcms-page-size-media'
|
||||
|
||||
@@ -28,7 +28,10 @@ vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
|
||||
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
|
||||
return { ...actual, useToast: () => ({ add: vi.fn(), remove: vi.fn(), update: vi.fn() }) }
|
||||
return {
|
||||
...actual,
|
||||
useToast: () => ({ add: vi.fn(), remove: vi.fn(), update: vi.fn() }),
|
||||
}
|
||||
})
|
||||
|
||||
const FIRST_PAGE_REQUEST = '/v1/media/?filter=all&sort=newest&page=1&size=25'
|
||||
@@ -138,7 +141,13 @@ describe('46 Last page', () => {
|
||||
|
||||
describe('Single page', () => {
|
||||
it('keeps the summary and the page-size select and hides the paginator', async () => {
|
||||
await mountPage({ list: pagedHandler(library(12), { all: 12, unused: 2, duplicates: 2 }) })
|
||||
await mountPage({
|
||||
list: pagedHandler(library(12), {
|
||||
all: 12,
|
||||
unused: 2,
|
||||
duplicates: 2,
|
||||
}),
|
||||
})
|
||||
|
||||
expect(summary()).toBe('Showing 1–12 of 12')
|
||||
expect(sizeTrigger()).not.toBeNull()
|
||||
@@ -329,7 +338,10 @@ describe('51 Page size menu', () => {
|
||||
|
||||
await choose(label)
|
||||
|
||||
expect(parsePaged(listPaths().at(-1)!)).toMatchObject({ page: 1, size })
|
||||
expect(parsePaged(listPaths().at(-1)!)).toMatchObject({
|
||||
page: 1,
|
||||
size,
|
||||
})
|
||||
expect(path()).toBe(url)
|
||||
})
|
||||
})
|
||||
@@ -378,12 +390,20 @@ describe('52 Focus-visible and keyboard', () => {
|
||||
trigger.focus()
|
||||
|
||||
trigger.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }),
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'ArrowDown',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
)
|
||||
await flushPromises()
|
||||
expect(document.body.querySelector('[role="listbox"]')).not.toBeNull()
|
||||
;(document.activeElement ?? document.body).dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }),
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
)
|
||||
await flushPromises()
|
||||
expect(document.body.querySelector('[role="listbox"]')).toBeNull()
|
||||
@@ -411,7 +431,13 @@ describe('The Duplicates tab', () => {
|
||||
it('loads the whole set at size 100, groups it, shows no paginator and drops page and size', async () => {
|
||||
const all = library(130, true)
|
||||
await mountPage(
|
||||
{ list: pagedHandler(all, { all: 312, unused: 2, duplicates: 65 }) },
|
||||
{
|
||||
list: pagedHandler(all, {
|
||||
all: 312,
|
||||
unused: 2,
|
||||
duplicates: 65,
|
||||
}),
|
||||
},
|
||||
'/media?filter=duplicates&page=4&size=5',
|
||||
)
|
||||
|
||||
@@ -446,7 +472,11 @@ describe('Deleting the last image of the last page', () => {
|
||||
{
|
||||
list: (requested) => {
|
||||
const live = removed ? all.slice(0, 25) : all
|
||||
const counts = { all: live.length, unused: 2, duplicates: 2 }
|
||||
const counts = {
|
||||
all: live.length,
|
||||
unused: 2,
|
||||
duplicates: 2,
|
||||
}
|
||||
return pagedBody(live, requested, counts)
|
||||
},
|
||||
remove: () => {
|
||||
|
||||
@@ -38,7 +38,10 @@ const COUNTS = { all: 12, unused: 2, duplicates: 2 }
|
||||
function duplicatesList(ids = DUPLICATE_IDS): Handlers['list'] {
|
||||
return (path) =>
|
||||
path.includes('filter=duplicates')
|
||||
? listBody(fixture(ids), { ...COUNTS, duplicates: ids.length ? 2 : 0 })
|
||||
? listBody(fixture(ids), {
|
||||
...COUNTS,
|
||||
duplicates: ids.length ? 2 : 0,
|
||||
})
|
||||
: listBody(fixture())
|
||||
}
|
||||
|
||||
@@ -62,7 +65,9 @@ afterEach(unmountPage)
|
||||
|
||||
describe('The Duplicates toolbar button', () => {
|
||||
it.each(['owner', 'admin'])('shows for %s on the Duplicates tab with groups', async (role) => {
|
||||
await mountPage({ list: duplicatesList() }, DUPLICATES, undefined, { role })
|
||||
await mountPage({ list: duplicatesList() }, DUPLICATES, undefined, {
|
||||
role,
|
||||
})
|
||||
|
||||
expect(purgeButton()).not.toBeNull()
|
||||
expect(textOf(purgeButton())).toBe('Purge duplicates')
|
||||
|
||||
@@ -34,7 +34,11 @@ vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
const toasts = vi.hoisted(() => ({ add: vi.fn(), remove: vi.fn(), update: vi.fn() }))
|
||||
const toasts = vi.hoisted(() => ({
|
||||
add: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
update: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
|
||||
@@ -237,7 +241,11 @@ describe('Escape and Clear', () => {
|
||||
expect(panel()).not.toBeNull()
|
||||
|
||||
panel()!.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }),
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
)
|
||||
await settle()
|
||||
|
||||
@@ -390,7 +398,11 @@ describe('Bulk delete', () => {
|
||||
expect(confirm.disabled).toBe(true)
|
||||
await vi.waitFor(() => expect(confirm.querySelector('svg.iconify--lucide')).toBeTruthy())
|
||||
dialog()!.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }),
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
)
|
||||
await flushPromises()
|
||||
expect(dialog()).not.toBeNull()
|
||||
@@ -471,7 +483,10 @@ describe('Bulk delete', () => {
|
||||
duration: 0,
|
||||
})
|
||||
expect(toast.actions).toHaveLength(1)
|
||||
expect(toast.actions[0]).toMatchObject({ label: 'Retry', icon: 'i-lucide-rotate-cw' })
|
||||
expect(toast.actions[0]).toMatchObject({
|
||||
label: 'Retry',
|
||||
icon: 'i-lucide-rotate-cw',
|
||||
})
|
||||
})
|
||||
|
||||
it('Retry sends only the failed DELETE with no dialog and replaces the toast on success', async () => {
|
||||
|
||||
@@ -29,7 +29,10 @@ vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
||||
return { ...actual, useToast: () => toasts }
|
||||
})
|
||||
|
||||
const LOGIN = { path: '/login', query: { redirect: '/media', reason: 'expired' } }
|
||||
const LOGIN = {
|
||||
path: '/login',
|
||||
query: { redirect: '/media', reason: 'expired' },
|
||||
}
|
||||
const DUPLICATES_LOGIN = {
|
||||
path: '/login',
|
||||
query: { redirect: '/media?filter=duplicates', reason: 'expired' },
|
||||
@@ -95,7 +98,9 @@ describe('Session errors are global for the new calls', () => {
|
||||
['DELETE /v1/media/{id}', 403, PENDING],
|
||||
['DELETE /v1/media/{id}', 401, LOGIN],
|
||||
])('%s resolving %i redirects and shows no local copy', async (_call, status, destination) => {
|
||||
await mountUnverified({ remove: () => Promise.reject(new ApiError(status)) })
|
||||
await mountUnverified({
|
||||
remove: () => Promise.reject(new ApiError(status)),
|
||||
})
|
||||
checkboxFor('north-wall.jpg').click()
|
||||
await flushPromises()
|
||||
findButton('Delete', bulkBar()!)!.click()
|
||||
|
||||
@@ -104,7 +104,9 @@ describe('Upload entry points', () => {
|
||||
)
|
||||
|
||||
it('and the library has no image: "Upload an image" opens the file chooser', async () => {
|
||||
await mountPage({ list: () => listBody([], { all: 0, unused: 0, duplicates: 0 }) })
|
||||
await mountPage({
|
||||
list: () => listBody([], { all: 0, unused: 0, duplicates: 0 }),
|
||||
})
|
||||
const click = vi.spyOn(HTMLInputElement.prototype, 'click')
|
||||
|
||||
findButton('Upload an image')!.click()
|
||||
@@ -398,7 +400,11 @@ describe('The upload panel and its rows', () => {
|
||||
list: () =>
|
||||
++calls === 1
|
||||
? listBody(fixture())
|
||||
: listBody(fixture(), { all: 13, unused: 2, duplicates: 2 }),
|
||||
: listBody(fixture(), {
|
||||
all: 13,
|
||||
unused: 2,
|
||||
duplicates: 2,
|
||||
}),
|
||||
})
|
||||
expect(listCalls()).toHaveLength(1)
|
||||
|
||||
@@ -418,7 +424,10 @@ describe('A row whose content already exists', () => {
|
||||
const existingText = 'Same as "floorboards.jpg", uploaded 14 Jun 2026 by Inès Duarte.'
|
||||
|
||||
async function mountWithDuplicate(handlers: Handlers = {}) {
|
||||
await mountPage({ hash: hashHandlerFor('floorboards-2.jpg'), ...handlers })
|
||||
await mountPage({
|
||||
hash: hashHandlerFor('floorboards-2.jpg'),
|
||||
...handlers,
|
||||
})
|
||||
await chooseFiles([fileWith('floorboards-2.jpg', 'image/jpeg', 2.4 * MEGABYTE)])
|
||||
}
|
||||
|
||||
@@ -441,7 +450,9 @@ describe('A row whose content already exists', () => {
|
||||
const first = deferred<unknown>()
|
||||
const second = deferred<unknown>()
|
||||
const answers = [first, second]
|
||||
await mountPage({ hash: () => (answers.shift() ?? deferred<unknown>()).promise })
|
||||
await mountPage({
|
||||
hash: () => (answers.shift() ?? deferred<unknown>()).promise,
|
||||
})
|
||||
|
||||
await chooseFiles([photo('a.jpg'), photo('b.jpg'), photo('c.jpg')])
|
||||
|
||||
@@ -530,7 +541,11 @@ describe('A row whose content already exists', () => {
|
||||
list: () =>
|
||||
++calls === 1
|
||||
? listBody(fixture())
|
||||
: listBody(fixture(), { all: 13, unused: 2, duplicates: 3 }),
|
||||
: listBody(fixture(), {
|
||||
all: 13,
|
||||
unused: 2,
|
||||
duplicates: 3,
|
||||
}),
|
||||
})
|
||||
|
||||
findButton('Upload anyway')!.click()
|
||||
@@ -810,7 +825,9 @@ describe('Offline', () => {
|
||||
stubDigest()
|
||||
stubXhr()
|
||||
|
||||
await mountPage({ list: () => listBody([], { all: 0, unused: 0, duplicates: 0 }) })
|
||||
await mountPage({
|
||||
list: () => listBody([], { all: 0, unused: 0, duplicates: 0 }),
|
||||
})
|
||||
await goOffline()
|
||||
expect(findButton('Upload an image')!.disabled).toBe(true)
|
||||
})
|
||||
|
||||
@@ -27,7 +27,11 @@ vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
const toasts = vi.hoisted(() => ({ add: vi.fn(), remove: vi.fn(), update: vi.fn() }))
|
||||
const toasts = vi.hoisted(() => ({
|
||||
add: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
update: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
|
||||
|
||||
@@ -31,7 +31,11 @@ vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
const toasts = vi.hoisted(() => ({ add: vi.fn(), remove: vi.fn(), update: vi.fn() }))
|
||||
const toasts = vi.hoisted(() => ({
|
||||
add: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
update: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
|
||||
@@ -104,7 +108,10 @@ function variantRequests(method = 'GET') {
|
||||
beforeEach(() => {
|
||||
toasts.add.mockClear()
|
||||
writeText.mockReset().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true })
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: { writeText },
|
||||
configurable: true,
|
||||
})
|
||||
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true)
|
||||
})
|
||||
|
||||
@@ -151,7 +158,9 @@ describe('The panel tabs', () => {
|
||||
})
|
||||
|
||||
it('gives an admin the tabs too', async () => {
|
||||
await mountTab({ variants: () => variantList() }, '/media', undefined, { role: 'admin' })
|
||||
await mountTab({ variants: () => variantList() }, '/media', undefined, {
|
||||
role: 'admin',
|
||||
})
|
||||
cardFor('north-wall.jpg').click()
|
||||
await flushPromises()
|
||||
|
||||
@@ -431,7 +440,12 @@ describe('Generating', () => {
|
||||
).toEqual(['card', 'hero', 'og', 'square'])
|
||||
expect(textOf(rowOf('thumb'))).toContain('Generated')
|
||||
|
||||
pending.resolve({ generated: 4, failed: 0, failures: [], items: variantItems() })
|
||||
pending.resolve({
|
||||
generated: 4,
|
||||
failed: 0,
|
||||
failures: [],
|
||||
items: variantItems(),
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(rows().every((row) => textOf(row).includes('Generated'))).toBe(true)
|
||||
@@ -443,7 +457,12 @@ describe('Generating', () => {
|
||||
it('A single generated variant reads in the singular', async () => {
|
||||
await mountTab({
|
||||
variants: () => variantList(PARTLY),
|
||||
generateAll: () => ({ generated: 1, failed: 0, failures: [], items: variantItems() }),
|
||||
generateAll: () => ({
|
||||
generated: 1,
|
||||
failed: 0,
|
||||
failures: [],
|
||||
items: variantItems(),
|
||||
}),
|
||||
})
|
||||
await openVariants()
|
||||
|
||||
@@ -617,7 +636,10 @@ describe('Generating', () => {
|
||||
|
||||
it('announces the progress in a polite live region', async () => {
|
||||
const pending = deferred<unknown>()
|
||||
await mountTab({ variants: () => variantList(PARTLY), generate: () => pending.promise })
|
||||
await mountTab({
|
||||
variants: () => variantList(PARTLY),
|
||||
generate: () => pending.promise,
|
||||
})
|
||||
await openVariants()
|
||||
|
||||
rowAction('og', 'Generate now')!.click()
|
||||
|
||||
@@ -281,7 +281,9 @@ function dispatcher(handlers: Handlers) {
|
||||
}
|
||||
const single = /^\/v1\/media\/(\d+)$/.exec(path)
|
||||
if (method === 'PATCH' && single) {
|
||||
const body = JSON.parse(String(options?.body)) as { filename: string }
|
||||
const body = JSON.parse(String(options?.body)) as {
|
||||
filename: string
|
||||
}
|
||||
const id = Number(single[1])
|
||||
return (
|
||||
handlers.rename ??
|
||||
@@ -302,7 +304,13 @@ async function mountPage(handlers: Handlers = {}, url = '/media', width = DESKTO
|
||||
setViewportWidth(width)
|
||||
setActivePinia(createPinia())
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta', id: 1, displayName: '', role: 'owner' }
|
||||
session.session = {
|
||||
verified: true,
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
displayName: '',
|
||||
role: 'owner',
|
||||
}
|
||||
session.status = 'ready'
|
||||
const dispatch = dispatcher(handlers)
|
||||
requestMock = vi.fn(async (path: string, options?: RequestInit) => dispatch(path, options))
|
||||
@@ -515,7 +523,12 @@ describe('Badges and accessible names', () => {
|
||||
describe('Grid, few images', () => {
|
||||
it('keeps three cards at the grid size, a checkerboard behind the SVG and no Duplicates badge', async () => {
|
||||
await mountPage({
|
||||
list: () => listBody(fixture([1, 4, 8]), { all: 3, unused: 1, duplicates: 0 }),
|
||||
list: () =>
|
||||
listBody(fixture([1, 4, 8]), {
|
||||
all: 3,
|
||||
unused: 1,
|
||||
duplicates: 0,
|
||||
}),
|
||||
})
|
||||
|
||||
expect(cards()).toHaveLength(3)
|
||||
@@ -663,7 +676,10 @@ describe('The query string is the source of truth', () => {
|
||||
tab('Unused').focus()
|
||||
press(tab('Unused'), 'Enter')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.query).toEqual({ q: 'wall', filter: 'unused' })
|
||||
expect(router.currentRoute.value.query).toEqual({
|
||||
q: 'wall',
|
||||
filter: 'unused',
|
||||
})
|
||||
expect(listCalls()).toHaveLength(3)
|
||||
|
||||
ariaLabel('Sort').dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }))
|
||||
@@ -684,7 +700,10 @@ describe('The query string is the source of truth', () => {
|
||||
tab('All').focus()
|
||||
press(tab('All'), 'Enter')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.query).toEqual({ q: 'wall', sort: 'largest' })
|
||||
expect(router.currentRoute.value.query).toEqual({
|
||||
q: 'wall',
|
||||
sort: 'largest',
|
||||
})
|
||||
})
|
||||
|
||||
it('removes q from the URL when the search is emptied', async () => {
|
||||
@@ -712,7 +731,9 @@ describe('The Duplicates tab', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(tab('Duplicates').getAttribute('aria-selected')).toBe('true')
|
||||
expect(router.currentRoute.value.query).toEqual({ filter: 'duplicates' })
|
||||
expect(router.currentRoute.value.query).toEqual({
|
||||
filter: 'duplicates',
|
||||
})
|
||||
expect(lastListPath()).toBe('/v1/media/?filter=duplicates&sort=newest&page=1&size=100')
|
||||
})
|
||||
})
|
||||
@@ -765,7 +786,9 @@ describe('Loading', () => {
|
||||
const second = deferred<unknown>()
|
||||
let calls = 0
|
||||
await mountPage(
|
||||
{ list: () => (++calls === 1 ? listBody(fixture()) : second.promise) },
|
||||
{
|
||||
list: () => (++calls === 1 ? listBody(fixture()) : second.promise),
|
||||
},
|
||||
'/media?view=list',
|
||||
)
|
||||
expect(document.body.querySelectorAll('[data-testid="media-row"]')).toHaveLength(12)
|
||||
@@ -785,7 +808,9 @@ describe('Loading', () => {
|
||||
|
||||
describe('Empty library and no results', () => {
|
||||
it('shows one call to action and hides the toolbar and tabs', async () => {
|
||||
await mountPage({ list: () => listBody([], { all: 0, unused: 0, duplicates: 0 }) })
|
||||
await mountPage({
|
||||
list: () => listBody([], { all: 0, unused: 0, duplicates: 0 }),
|
||||
})
|
||||
|
||||
expect(tabs()).toHaveLength(0)
|
||||
expect(searchInput()).toBeNull()
|
||||
@@ -885,7 +910,11 @@ describe('Hover, focus and keyboard', () => {
|
||||
cards()[1]!.focus()
|
||||
press(cards()[1]!, ' ')
|
||||
cards()[1]!.dispatchEvent(
|
||||
new KeyboardEvent('keyup', { key: ' ', bubbles: true, cancelable: true }),
|
||||
new KeyboardEvent('keyup', {
|
||||
key: ' ',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
)
|
||||
await flushPromises()
|
||||
expect(panel()).toBeNull()
|
||||
@@ -906,7 +935,11 @@ describe('Hover, focus and keyboard', () => {
|
||||
expect(panel()).not.toBeNull()
|
||||
|
||||
panel()!.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }),
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
)
|
||||
await settleFocus()
|
||||
|
||||
@@ -1012,7 +1045,9 @@ describe('Detail panel', () => {
|
||||
review.click()
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.path).toBe('/media')
|
||||
expect(router.currentRoute.value.query).toEqual({ filter: 'duplicates' })
|
||||
expect(router.currentRoute.value.query).toEqual({
|
||||
filter: 'duplicates',
|
||||
})
|
||||
expect(panel()).toBeNull()
|
||||
})
|
||||
|
||||
@@ -1042,7 +1077,10 @@ describe('Detail panel', () => {
|
||||
describe('Copy URL', () => {
|
||||
it('writes the absolute address to the clipboard and toasts', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true })
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: { writeText },
|
||||
configurable: true,
|
||||
})
|
||||
await mountPage()
|
||||
await clickCard('north-wall.jpg')
|
||||
|
||||
@@ -1147,7 +1185,11 @@ describe('Rename', () => {
|
||||
expect(confirm.querySelector('svg')).toBeTruthy()
|
||||
|
||||
dialog()!.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }),
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
)
|
||||
await flushPromises()
|
||||
expect(dialog()).not.toBeNull()
|
||||
@@ -1270,7 +1312,11 @@ describe('Delete', () => {
|
||||
expect(confirm.disabled).toBe(true)
|
||||
expect(dialog()!.getAttribute('aria-busy')).toBe('true')
|
||||
dialog()!.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }),
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
)
|
||||
await flushPromises()
|
||||
expect(dialog()).not.toBeNull()
|
||||
@@ -1415,7 +1461,9 @@ describe('List failed to load', () => {
|
||||
])('shows the error alert after %s and Try again recovers', async (_name, failure) => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
let calls = 0
|
||||
await mountPage({ list: () => (++calls === 1 ? failure() : listBody(fixture())) })
|
||||
await mountPage({
|
||||
list: () => (++calls === 1 ? failure() : listBody(fixture())),
|
||||
})
|
||||
|
||||
const alert = document.body.querySelector('[role="alert"]')!
|
||||
expect(textOf(alert)).toBe(
|
||||
@@ -1486,7 +1534,9 @@ describe('Offline', () => {
|
||||
|
||||
describe('Session errors are global', () => {
|
||||
it.each([401, 403])('a %i from the list shows no local media copy', async (status) => {
|
||||
await mountPage({ list: () => Promise.reject(new ApiError(status)) })
|
||||
await mountPage({
|
||||
list: () => Promise.reject(new ApiError(status)),
|
||||
})
|
||||
|
||||
expect(document.body.querySelector('[role="alert"]')).toBeNull()
|
||||
expect(cards()).toHaveLength(0)
|
||||
|
||||
@@ -257,7 +257,10 @@ describe('Already verified — 403 shows the verified panel and toast, then navi
|
||||
const props = overlayMock.getProps() as { action: () => Promise<void> }
|
||||
await props.action()
|
||||
|
||||
expect(session.session).toMatchObject({ verified: true, displayName: '' })
|
||||
expect(session.session).toMatchObject({
|
||||
verified: true,
|
||||
displayName: '',
|
||||
})
|
||||
expect(pushSpy).not.toHaveBeenCalledWith('/register/pending')
|
||||
|
||||
overlayMock.resolveOpen(true)
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import type { Author } from '@/composables/useAuthors'
|
||||
import {
|
||||
MARTA_EMAIL,
|
||||
boardAuthors,
|
||||
callsTo,
|
||||
click,
|
||||
createMetaBackend,
|
||||
dialog,
|
||||
failWith,
|
||||
mountMeta,
|
||||
networkFailure,
|
||||
textOf,
|
||||
toasts,
|
||||
typeInto,
|
||||
unmountMeta,
|
||||
type MetaBackend,
|
||||
type MetaRole,
|
||||
} from './metaTestKit'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(unmountMeta)
|
||||
|
||||
const PROFILE = '/profile'
|
||||
const MARTA_AUTHOR = { id: 300, name: 'Marta Alves', slug: 'marta-alves' }
|
||||
|
||||
function martaRecord(): Author {
|
||||
return {
|
||||
...boardAuthors()[0]!,
|
||||
...MARTA_AUTHOR,
|
||||
bio: '',
|
||||
website: null,
|
||||
user_id: 1,
|
||||
is_linked: true,
|
||||
user_email: MARTA_EMAIL,
|
||||
}
|
||||
}
|
||||
|
||||
function backendWith(role: MetaRole, withAuthor = false): MetaBackend {
|
||||
const backend = createMetaBackend({
|
||||
role,
|
||||
author: withAuthor ? MARTA_AUTHOR : undefined,
|
||||
})
|
||||
if (withAuthor) {
|
||||
backend.authors.push(martaRecord())
|
||||
}
|
||||
return backend
|
||||
}
|
||||
|
||||
function card() {
|
||||
return Array.from(document.body.querySelectorAll<HTMLElement>('section')).find(
|
||||
(section) => section.querySelector('h2')?.textContent?.trim() === 'Author profile',
|
||||
)!
|
||||
}
|
||||
|
||||
function cardTitles() {
|
||||
return Array.from(document.body.querySelectorAll('section h2')).map(textOf)
|
||||
}
|
||||
|
||||
describe('Profile: the Author profile card sits between Account and Password', () => {
|
||||
it.each(['owner', 'admin', 'verified'] as const)('for a %s', async (role) => {
|
||||
await mountMeta('profile', PROFILE, { backend: backendWith(role) })
|
||||
|
||||
expect(cardTitles()).toEqual(['Display name', 'Account', 'Author profile', 'Password'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Profile: no author profile', () => {
|
||||
it('shows the invitation, the button and the line with the display name', async () => {
|
||||
await mountMeta('profile', PROFILE, { backend: backendWith('owner') })
|
||||
|
||||
const text = textOf(card())
|
||||
expect(text).toContain("Your public byline on articles. It's separate from your account.")
|
||||
expect(text).toContain(
|
||||
"You don't have an author profile yet. Create one to be credited on your own articles.",
|
||||
)
|
||||
expect(text).toContain('Create my author profile')
|
||||
expect(text).toContain('Uses your display name, Marta Alves. You can change it afterwards.')
|
||||
})
|
||||
|
||||
it('falls back to the username when there is no display name', async () => {
|
||||
const backend = backendWith('owner')
|
||||
backend.me.displayName = ''
|
||||
await mountMeta('profile', PROFILE, { backend })
|
||||
|
||||
expect(textOf(card())).toContain('Uses your username, marta. You can change it afterwards.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Profile: creating the author profile', () => {
|
||||
it('sends one POST, shows the author with Edit author and keeps the author in the session', async () => {
|
||||
const backend = backendWith('verified')
|
||||
await mountMeta('profile', PROFILE, { backend })
|
||||
|
||||
await click('Create my author profile', card())
|
||||
|
||||
expect(callsTo(backend, 'POST', '/v1/author/me')).toHaveLength(1)
|
||||
expect(callsTo(backend, 'POST', '/v1/author/me')[0]!.body).toBeUndefined()
|
||||
const text = textOf(card())
|
||||
expect(text).toContain('Marta Alves')
|
||||
expect(text).toContain('marta-alves')
|
||||
expect(text).toContain('Edit author')
|
||||
expect(text).toContain('Linked to your account. New articles start with this author.')
|
||||
expect(text).not.toContain('Your slug is')
|
||||
expect(toasts()).toContain('Author profile created')
|
||||
expect(toasts()).toContain('Marta Alves is now your byline.')
|
||||
expect(useSessionStore().session?.author).toEqual(MARTA_AUTHOR)
|
||||
})
|
||||
|
||||
it('explains an adjusted slug without any error', async () => {
|
||||
const backend = backendWith('owner')
|
||||
backend.meAuthorSlug = 'marta-alves-2'
|
||||
backend.meSlugAdjusted = true
|
||||
await mountMeta('profile', PROFILE, { backend })
|
||||
|
||||
await click('Create my author profile', card())
|
||||
|
||||
const text = textOf(card())
|
||||
expect(text).toContain('Your slug is marta-alves-2')
|
||||
expect(text).toContain(
|
||||
'marta-alves was already used by another author. You can change it in Edit author.',
|
||||
)
|
||||
expect(card().querySelector('[role="alert"]')).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ name: 'Marta Alves', returned: 'marta-alves', adjusted: false },
|
||||
{ name: 'Marta Alves', returned: 'marta-alves-2', adjusted: true },
|
||||
{ name: 'Inès Duarte', returned: 'ines-duarte', adjusted: false },
|
||||
{ name: 'Inès Duarte', returned: 'ines-duarte-2', adjusted: true },
|
||||
{
|
||||
name: "Jean-Luc O'Neil",
|
||||
returned: 'jean-luc-o-neil',
|
||||
adjusted: false,
|
||||
},
|
||||
{
|
||||
name: "Jean-Luc O'Neil",
|
||||
returned: 'jean-luc-o-neil-2',
|
||||
adjusted: true,
|
||||
},
|
||||
{ name: 'Zoë Müller', returned: 'zoe-muller', adjusted: false },
|
||||
{ name: 'Agent 007', returned: 'agent-007', adjusted: false },
|
||||
{ name: 'Agent 007', returned: 'agent-007-2', adjusted: true },
|
||||
])('$name returned as $returned: notice $adjusted', async ({ name, returned, adjusted }) => {
|
||||
const backend = backendWith('owner')
|
||||
backend.me.displayName = name
|
||||
backend.meAuthorSlug = returned
|
||||
backend.meSlugAdjusted = adjusted
|
||||
await mountMeta('profile', PROFILE, { backend })
|
||||
|
||||
await click('Create my author profile', card())
|
||||
|
||||
expect(textOf(card()).includes('Your slug is')).toBe(adjusted)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ failure: 'status 500', responder: failWith(500) },
|
||||
{ failure: 'a network error', responder: networkFailure },
|
||||
])('shows the error and keeps the button after $failure', async ({ responder }) => {
|
||||
const backend = backendWith('owner')
|
||||
backend.responders['POST /v1/author/me'] = responder
|
||||
await mountMeta('profile', PROFILE, { backend })
|
||||
|
||||
await click('Create my author profile', card())
|
||||
|
||||
const text = textOf(card())
|
||||
expect(text).toContain("Couldn't create your author profile")
|
||||
expect(text).toContain('Try again in a moment.')
|
||||
expect(text).toContain('Create my author profile')
|
||||
expect(card().querySelector('button[disabled]')).toBeNull()
|
||||
})
|
||||
|
||||
it('asks the session again and shows the existing author on a 409', async () => {
|
||||
const backend = backendWith('owner')
|
||||
backend.responders['POST /v1/author/me'] = (call) => {
|
||||
backend.me.author = MARTA_AUTHOR
|
||||
return failWith(409, 'user_already_linked')(call)
|
||||
}
|
||||
await mountMeta('profile', PROFILE, { backend })
|
||||
|
||||
await click('Create my author profile', card())
|
||||
|
||||
expect(callsTo(backend, 'GET', '/v1/auth/me')).toHaveLength(1)
|
||||
expect(textOf(card())).toContain('Edit author')
|
||||
expect(textOf(card())).toContain('marta-alves')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Profile: an existing author on load', () => {
|
||||
it('shows the author, Edit author and the link line without any created notice', async () => {
|
||||
const backend = backendWith('owner', true)
|
||||
await mountMeta('profile', PROFILE, { backend })
|
||||
|
||||
expect(callsTo(backend, 'POST', '/v1/author/me')).toHaveLength(0)
|
||||
const text = textOf(card())
|
||||
expect(text).toContain('Marta Alves')
|
||||
expect(text).toContain('Edit author')
|
||||
expect(text).toContain('Linked to your account. New articles start with this author.')
|
||||
expect(text).not.toContain('Create my author profile')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Profile: editing the own author', () => {
|
||||
function panel() {
|
||||
return dialog()!
|
||||
}
|
||||
|
||||
it('opens the form for a role without author rights with the link locked and no Unlink or delete', async () => {
|
||||
const backend = backendWith('verified', true)
|
||||
await mountMeta('profile', PROFILE, { backend })
|
||||
|
||||
await click('Edit author', card())
|
||||
|
||||
const text = textOf(panel())
|
||||
expect(text).toContain('Edit Marta Alves')
|
||||
expect(text).toContain('Linked user')
|
||||
expect(text).toContain(MARTA_EMAIL)
|
||||
expect(text).toContain(
|
||||
'This is your author profile. Only an owner or admin can change the link.',
|
||||
)
|
||||
expect(text).toContain('Save changes')
|
||||
expect(text).not.toContain('Unlink')
|
||||
expect(textOf(document.body)).not.toContain('Delete')
|
||||
})
|
||||
|
||||
it('sends one PATCH with only the changed fields', async () => {
|
||||
const backend = backendWith('verified', true)
|
||||
await mountMeta('profile', PROFILE, { backend })
|
||||
await click('Edit author', card())
|
||||
|
||||
const bio = panel().querySelector<HTMLTextAreaElement>(
|
||||
'textarea[placeholder="A sentence or two about the author."]',
|
||||
)!
|
||||
await typeInto(bio, 'Writes about lime.')
|
||||
await click('Save changes', panel())
|
||||
|
||||
const patches = callsTo(backend, 'PATCH', '/v1/author/300')
|
||||
expect(patches).toHaveLength(1)
|
||||
expect(patches[0]!.body).toEqual({ bio: 'Writes about lime.' })
|
||||
})
|
||||
|
||||
it.each(['owner', 'admin'] as const)(
|
||||
'opens the full form in place for an %s, with Unlink allowed',
|
||||
async (role) => {
|
||||
const backend = backendWith(role, true)
|
||||
await mountMeta('profile', PROFILE, { backend })
|
||||
|
||||
await click('Edit author', card())
|
||||
|
||||
expect(textOf(panel())).toContain('Edit Marta Alves')
|
||||
expect(textOf(panel())).toContain('Unlink')
|
||||
expect(textOf(panel())).not.toContain(
|
||||
'This is your author profile. Only an owner or admin can change the link.',
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
it('shows the no-profile card again after an owner unlinks the author', async () => {
|
||||
const backend = backendWith('owner', true)
|
||||
await mountMeta('profile', PROFILE, { backend })
|
||||
await click('Edit author', card())
|
||||
|
||||
await click('Unlink', panel())
|
||||
await flushPromises()
|
||||
await click('Unlink user')
|
||||
|
||||
expect(callsTo(backend, 'PATCH', '/v1/author/300')[0]!.body).toEqual({
|
||||
user_id: null,
|
||||
})
|
||||
expect(useSessionStore().session?.author).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -106,7 +106,10 @@ async function mountApp(options: MountOptions = {}): Promise<Mounted> {
|
||||
return Promise.resolve([])
|
||||
})
|
||||
const getMe = vi.fn(options.getMe ?? (() => Promise.resolve({ ...MARTA })))
|
||||
vi.mocked(useApi).mockReturnValue({ getMe, request } as unknown as ReturnType<typeof useApi>)
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
getMe,
|
||||
request,
|
||||
} as unknown as ReturnType<typeof useApi>)
|
||||
await router.push(path)
|
||||
const wrapper = mount(App, {
|
||||
attachTo: document.body,
|
||||
@@ -130,7 +133,11 @@ async function typeInto(value: string) {
|
||||
|
||||
function pressEnter() {
|
||||
displayNameField().dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'Enter',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -198,7 +205,11 @@ function activate(element: HTMLElement) {
|
||||
}
|
||||
|
||||
function savedResponse(displayName: string) {
|
||||
return Promise.resolve({ id: 1, username: 'marta', display_name: displayName })
|
||||
return Promise.resolve({
|
||||
id: 1,
|
||||
username: 'marta',
|
||||
display_name: displayName,
|
||||
})
|
||||
}
|
||||
|
||||
const NETWORK_ERROR = () => Promise.reject(new NetworkError())
|
||||
@@ -287,18 +298,18 @@ describe('Default: the usual first visit with no display name', () => {
|
||||
links.forEach((link) => expect(link.getAttribute('aria-current')).toBeNull())
|
||||
})
|
||||
|
||||
it('places the three cards in order Display name, Account, Password', async () => {
|
||||
it('places the four cards in order Display name, Account, Author profile, Password', async () => {
|
||||
await mountApp()
|
||||
|
||||
const titles = Array.from(document.body.querySelectorAll('section h2')).map((el) =>
|
||||
el.textContent?.trim(),
|
||||
)
|
||||
expect(titles).toEqual(['Display name', 'Account', 'Password'])
|
||||
expect(titles).toEqual(['Display name', 'Account', 'Author profile', 'Password'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Loading: opened by URL before /auth/me has returned', () => {
|
||||
it('shows the header and three skeleton cards without AppBoot, then the Default state', async () => {
|
||||
it('shows the header and four skeleton cards without AppBoot, then the Default state', async () => {
|
||||
const pending = deferred<Session>()
|
||||
await mountApp({
|
||||
session: null,
|
||||
@@ -311,7 +322,7 @@ describe('Loading: opened by URL before /auth/me has returned', () => {
|
||||
)
|
||||
expect(document.body.textContent).toContain('Your account on this RenewCMS workspace.')
|
||||
const busy = document.body.querySelector('[aria-busy="true"]')!
|
||||
expect(busy.querySelectorAll('[data-testid="profile-skeleton-card"]')).toHaveLength(3)
|
||||
expect(busy.querySelectorAll('[data-testid="profile-skeleton-card"]')).toHaveLength(4)
|
||||
expect(document.body.querySelector('[aria-label="Loading"]')).toBeNull()
|
||||
expect(displayNameField()).toBeNull()
|
||||
|
||||
@@ -430,7 +441,9 @@ describe('Dirty is measured on the trimmed draft against the saved name', () =>
|
||||
describe('Enter saves when Save is enabled', () => {
|
||||
it('sends PATCH /v1/user/me once with the trimmed display name', async () => {
|
||||
const { request } = await mountApp({
|
||||
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('Marta Alves') },
|
||||
handlers: {
|
||||
[`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('Marta Alves'),
|
||||
},
|
||||
})
|
||||
await typeInto('Marta Alves')
|
||||
|
||||
@@ -477,7 +490,9 @@ describe('Saving: the field is disabled and a second submit is ignored', () => {
|
||||
describe('Saved: a 200 updates the session, the chip, the footer and raises a toast', () => {
|
||||
it('replaces the session name, syncs the field, and raises the success toast', async () => {
|
||||
const { request } = await mountApp({
|
||||
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('Marta Alves') },
|
||||
handlers: {
|
||||
[`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('Marta Alves'),
|
||||
},
|
||||
})
|
||||
await typeInto('Marta Alves')
|
||||
|
||||
@@ -503,7 +518,9 @@ describe('Saved: a 200 updates the session, the chip, the footer and raises a to
|
||||
|
||||
it('opens no leave confirmation when navigating away afterwards', async () => {
|
||||
await mountApp({
|
||||
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('Marta Alves') },
|
||||
handlers: {
|
||||
[`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('Marta Alves'),
|
||||
},
|
||||
})
|
||||
await typeInto('Marta Alves')
|
||||
await clickSave()
|
||||
@@ -519,7 +536,9 @@ describe('Saved: a 200 updates the session, the chip, the footer and raises a to
|
||||
describe("The server's value wins over what was typed", () => {
|
||||
it('uses the response display_name in the field, the chip and the toast', async () => {
|
||||
await mountApp({
|
||||
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('Marta Alves') },
|
||||
handlers: {
|
||||
[`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('Marta Alves'),
|
||||
},
|
||||
})
|
||||
await typeInto(' Marta Alves ')
|
||||
|
||||
@@ -535,7 +554,9 @@ describe('Clearing the display name goes back to the username', () => {
|
||||
it('sends an empty display_name and shows the username with the removed toast', async () => {
|
||||
const { request } = await mountApp({
|
||||
session: { ...MARTA, displayName: 'Marta Alves' },
|
||||
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('') },
|
||||
handlers: {
|
||||
[`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse(''),
|
||||
},
|
||||
})
|
||||
await typeInto('')
|
||||
|
||||
@@ -565,7 +586,9 @@ describe('Save failed: a 500 or a network failure keeps the text and offers a re
|
||||
async (_label, failure) => {
|
||||
const attempts = [failure, () => savedResponse('Marta Alves')]
|
||||
const { request } = await mountApp({
|
||||
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => attempts.shift()!() },
|
||||
handlers: {
|
||||
[`PATCH ${DISPLAY_NAME_PATH}`]: () => attempts.shift()!(),
|
||||
},
|
||||
})
|
||||
await typeInto('Marta Alves')
|
||||
|
||||
@@ -594,7 +617,9 @@ describe('Save failed: a 500 or a network failure keeps the text and offers a re
|
||||
const retry = deferred()
|
||||
const attempts = [SERVER_ERROR, () => retry.promise]
|
||||
await mountApp({
|
||||
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => attempts.shift()!() },
|
||||
handlers: {
|
||||
[`PATCH ${DISPLAY_NAME_PATH}`]: () => attempts.shift()!(),
|
||||
},
|
||||
})
|
||||
await typeInto('Marta Alves')
|
||||
await clickSave()
|
||||
@@ -727,7 +752,9 @@ describe('The Home greeting follows a display name saved on /profile', () => {
|
||||
it('shows the saved name on /home without a reload', async () => {
|
||||
vi.spyOn(Date.prototype, 'getHours').mockReturnValue(9)
|
||||
await mountApp({
|
||||
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('Marta Alves') },
|
||||
handlers: {
|
||||
[`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('Marta Alves'),
|
||||
},
|
||||
})
|
||||
await typeInto('Marta Alves')
|
||||
await clickSave()
|
||||
@@ -851,7 +878,9 @@ describe('Reset: failed leaves the button pressable', () => {
|
||||
async (_label, failure) => {
|
||||
const responses = [failure, () => Promise.resolve({ message: 'ok' })]
|
||||
await mountApp({
|
||||
handlers: { [`POST ${RESET_PATH}`]: () => responses.shift()!() },
|
||||
handlers: {
|
||||
[`POST ${RESET_PATH}`]: () => responses.shift()!(),
|
||||
},
|
||||
})
|
||||
|
||||
await clickReset()
|
||||
|
||||
@@ -41,7 +41,11 @@ vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
const toasts = vi.hoisted(() => ({ add: vi.fn(), remove: vi.fn(), update: vi.fn() }))
|
||||
const toasts = vi.hoisted(() => ({
|
||||
add: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
update: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
|
||||
@@ -284,7 +288,9 @@ describe('Purge dialog: leaving', () => {
|
||||
|
||||
expect(dialogEl()).toBeNull()
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/media'))
|
||||
expect(router.currentRoute.value.query).toEqual({ filter: 'duplicates' })
|
||||
expect(router.currentRoute.value.query).toEqual({
|
||||
filter: 'duplicates',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -313,9 +319,12 @@ describe('Purge dialog: purging', () => {
|
||||
await vi.waitFor(() => expect(confirm.querySelector('svg.iconify--lucide')).toBeTruthy())
|
||||
expect(findButton('Cancel', dialogEl()!)!.disabled).toBe(true)
|
||||
pressEscape(dialogEl())
|
||||
document.body
|
||||
.querySelector('[data-slot="overlay"]')
|
||||
?.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, cancelable: true }))
|
||||
document.body.querySelector('[data-slot="overlay"]')?.dispatchEvent(
|
||||
new MouseEvent('pointerdown', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
)
|
||||
await flushPromises()
|
||||
expect(dialogEl()).not.toBeNull()
|
||||
|
||||
@@ -325,7 +334,9 @@ describe('Purge dialog: purging', () => {
|
||||
})
|
||||
|
||||
it('closes, shows the toast, refreshes Last purge in place and returns focus on success', async () => {
|
||||
const opener = await opened({ settings: afterRefresh(purgedSettings()) })
|
||||
const opener = await opened({
|
||||
settings: afterRefresh(purgedSettings()),
|
||||
})
|
||||
expect(lastLine()).toBe(LAST_RUN_LINE)
|
||||
|
||||
confirmButton('Purge 4 groups').click()
|
||||
@@ -351,7 +362,10 @@ describe('Purge dialog: purging', () => {
|
||||
it('shows no Next purge when automatic purge is off', async () => {
|
||||
await opened({
|
||||
settings: afterRefresh(
|
||||
purgedSettings({ auto_purge_enabled: false, next_purge_at: null }),
|
||||
purgedSettings({
|
||||
auto_purge_enabled: false,
|
||||
next_purge_at: null,
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
@@ -363,13 +377,17 @@ describe('Purge dialog: purging', () => {
|
||||
})
|
||||
|
||||
it('pluralises the toast', async () => {
|
||||
await opened({ purge: () => purgeResult({ merged: 1, articles_updated: 1 }) })
|
||||
await opened({
|
||||
purge: () => purgeResult({ merged: 1, articles_updated: 1 }),
|
||||
})
|
||||
|
||||
confirmButton('Purge 4 groups').click()
|
||||
await flushPromises()
|
||||
|
||||
expect(toasts.add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ description: '1 copy merged. 1 article updated.' }),
|
||||
expect.objectContaining({
|
||||
description: '1 copy merged. 1 article updated.',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -402,7 +420,10 @@ describe('Purge dialog: failures', () => {
|
||||
])(
|
||||
'keeps the dialog open after %s and retries with the same fingerprint',
|
||||
async (_name, purge) => {
|
||||
await opened({ preview: () => previewOf({ fingerprint: 'ab12' }), purge })
|
||||
await opened({
|
||||
preview: () => previewOf({ fingerprint: 'ab12' }),
|
||||
purge,
|
||||
})
|
||||
|
||||
confirmButton('Purge 4 groups').click()
|
||||
await flushPromises()
|
||||
@@ -513,11 +534,15 @@ describe('Purge dialog: failures', () => {
|
||||
confirmButton('Purge 3 groups').click()
|
||||
await flushPromises()
|
||||
|
||||
expect(bodyOf(callsTo('POST', PURGE_PATH)[1]!)).toEqual({ fingerprint: 'cd34' })
|
||||
expect(bodyOf(callsTo('POST', PURGE_PATH)[1]!)).toEqual({
|
||||
fingerprint: 'cd34',
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the server text when a purge is already running', async () => {
|
||||
await opened({ purge: () => Promise.reject(new ApiError(409, undefined, BUSY_COPY)) })
|
||||
await opened({
|
||||
purge: () => Promise.reject(new ApiError(409, undefined, BUSY_COPY)),
|
||||
})
|
||||
|
||||
confirmButton('Purge 4 groups').click()
|
||||
await flushPromises()
|
||||
|
||||
@@ -40,7 +40,11 @@ vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
const toasts = vi.hoisted(() => ({ add: vi.fn(), remove: vi.fn(), update: vi.fn() }))
|
||||
const toasts = vi.hoisted(() => ({
|
||||
add: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
update: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
|
||||
@@ -151,7 +155,10 @@ describe('Media cleanup card: the switch', () => {
|
||||
expect(switchEl().disabled).toBe(false)
|
||||
|
||||
patch.resolve(
|
||||
enabledSettings({ last_purge_at: null, next_purge_at: '2026-11-04T09:00:00Z' }),
|
||||
enabledSettings({
|
||||
last_purge_at: null,
|
||||
next_purge_at: '2026-11-04T09:00:00Z',
|
||||
}),
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
@@ -169,7 +176,10 @@ describe('Media cleanup card: the switch', () => {
|
||||
it('turning it off sends exactly the flag and hides the schedule but keeps Last purge', async () => {
|
||||
await mountSettings({
|
||||
settings: patching(() =>
|
||||
enabledSettings({ auto_purge_enabled: false, next_purge_at: null }),
|
||||
enabledSettings({
|
||||
auto_purge_enabled: false,
|
||||
next_purge_at: null,
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
@@ -290,7 +300,9 @@ describe('Media cleanup card: save failures', () => {
|
||||
})
|
||||
|
||||
it('reverts the switch after a 500 and shows the message under Automatic purge', async () => {
|
||||
await mountSettings({ settings: patching(() => Promise.reject(new ApiError(500))) })
|
||||
await mountSettings({
|
||||
settings: patching(() => Promise.reject(new ApiError(500))),
|
||||
})
|
||||
|
||||
switchEl().click()
|
||||
await flushPromises()
|
||||
@@ -321,7 +333,9 @@ describe('Media cleanup card: save failures', () => {
|
||||
})
|
||||
|
||||
it('reverts silently on a 403, refreshes the session and becomes read-only', async () => {
|
||||
await mountSettings({ settings: patching(() => Promise.reject(new ApiError(403))) })
|
||||
await mountSettings({
|
||||
settings: patching(() => Promise.reject(new ApiError(403))),
|
||||
})
|
||||
const refresh = vi.spyOn(useSessionStore(), 'refresh')
|
||||
purgeKit.meRole = 'editor'
|
||||
|
||||
@@ -400,7 +414,9 @@ describe('Media cleanup card: loading and failure to load', () => {
|
||||
['a network failure', () => Promise.reject(new NetworkError())],
|
||||
])('shows the inline alert after %s and Try again loads it again', async (_name, failure) => {
|
||||
let failing = true
|
||||
await mountSettings({ settings: () => (failing ? failure() : purgeSettings()) })
|
||||
await mountSettings({
|
||||
settings: () => (failing ? failure() : purgeSettings()),
|
||||
})
|
||||
|
||||
const alert = cardEl().querySelector('[role="alert"]')!
|
||||
expect(textOf(alert)).toContain(UNREACHABLE_COPY)
|
||||
|
||||
@@ -74,7 +74,11 @@ let overlayMock: ReturnType<typeof createOverlayMock>
|
||||
|
||||
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
|
||||
return { ...actual, useToast: () => ({ add: toastAdd }), useOverlay: () => overlayMock }
|
||||
return {
|
||||
...actual,
|
||||
useToast: () => ({ add: toastAdd }),
|
||||
useOverlay: () => overlayMock,
|
||||
}
|
||||
})
|
||||
|
||||
type Outcome = UpdateStatus | Error
|
||||
@@ -121,7 +125,12 @@ async function mountSettings(options: MountOptions) {
|
||||
toastAdd = vi.fn()
|
||||
overlayMock = createOverlayMock()
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta', id: 1, role: options.role }
|
||||
session.session = {
|
||||
verified: true,
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
role: options.role,
|
||||
}
|
||||
session.status = 'ready'
|
||||
const statusHandler = asHandler(options.status)
|
||||
const restartHandler = asHandler(options.restart ?? new ApiError(500))
|
||||
@@ -171,7 +180,9 @@ async function mountSettings(options: MountOptions) {
|
||||
}
|
||||
|
||||
function useTimers() {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] })
|
||||
vi.useFakeTimers({
|
||||
toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'],
|
||||
})
|
||||
}
|
||||
|
||||
async function tick(milliseconds: number) {
|
||||
@@ -257,7 +268,11 @@ async function press(label: string) {
|
||||
|
||||
async function pressEscape() {
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', bubbles: true }),
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
code: 'Escape',
|
||||
bubbles: true,
|
||||
}),
|
||||
)
|
||||
await flushPromises()
|
||||
}
|
||||
@@ -314,7 +329,10 @@ describe('Settings, owner, Restart buttons', () => {
|
||||
})
|
||||
|
||||
it('idle (Up to date): Restart is an enabled secondary button next to Check for updates', async () => {
|
||||
await mountSettings({ role: 'owner', status: upToDate({ can_apply: true }) })
|
||||
await mountSettings({
|
||||
role: 'owner',
|
||||
status: upToDate({ can_apply: true }),
|
||||
})
|
||||
|
||||
expect(cardButtons()).toEqual(['Check for updates', 'Restart'])
|
||||
const restart = findButton('Restart') as HTMLButtonElement
|
||||
@@ -348,8 +366,16 @@ describe('Settings, owner, Restart buttons', () => {
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ name: 'Not prepared', role: 'owner' as const, status: newerAvailable() },
|
||||
{ name: 'an admin, up to date', role: 'admin' as const, status: upToDate() },
|
||||
{
|
||||
name: 'Not prepared',
|
||||
role: 'owner' as const,
|
||||
status: newerAvailable(),
|
||||
},
|
||||
{
|
||||
name: 'an admin, up to date',
|
||||
role: 'admin' as const,
|
||||
status: upToDate(),
|
||||
},
|
||||
{
|
||||
name: 'an admin, helper installed',
|
||||
role: 'admin' as const,
|
||||
@@ -418,14 +444,21 @@ describe('Settings, Restart: confirm (Install dialog)', () => {
|
||||
|
||||
it('Restart now sends {"kind":"update"}', async () => {
|
||||
useTimers()
|
||||
await mountSettings({ role: 'owner', status: ready(), restart: restarting() })
|
||||
await mountSettings({
|
||||
role: 'owner',
|
||||
status: ready(),
|
||||
restart: restarting(),
|
||||
})
|
||||
await press('Restart')
|
||||
|
||||
await confirmRestart()
|
||||
|
||||
expect(requestMock).toHaveBeenCalledWith(
|
||||
RESTART_PATH,
|
||||
expect.objectContaining({ method: 'POST', body: JSON.stringify({ kind: 'update' }) }),
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ kind: 'update' }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -454,12 +487,18 @@ describe('Settings, Plain restart: confirm', () => {
|
||||
|
||||
expect(requestMock).toHaveBeenCalledWith(
|
||||
RESTART_PATH,
|
||||
expect.objectContaining({ method: 'POST', body: JSON.stringify({ kind: 'restart' }) }),
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ kind: 'restart' }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('renders with focus on Cancel and closing returns focus to Restart', async () => {
|
||||
await mountSettings({ role: 'owner', status: upToDate({ can_apply: true }) })
|
||||
await mountSettings({
|
||||
role: 'owner',
|
||||
status: upToDate({ can_apply: true }),
|
||||
})
|
||||
await press('Restart')
|
||||
|
||||
const dialog = mountRealDialog(dialogProps())
|
||||
@@ -487,9 +526,16 @@ describe('Settings, Restart: request failed', () => {
|
||||
async ({ error }) => {
|
||||
for (const scenario of [
|
||||
{ status: ready(), confirm: 'Restart now' },
|
||||
{ status: upToDate({ can_apply: true }), confirm: 'Restart site' },
|
||||
{
|
||||
status: upToDate({ can_apply: true }),
|
||||
confirm: 'Restart site',
|
||||
},
|
||||
]) {
|
||||
await mountSettings({ role: 'owner', status: scenario.status, restart: error })
|
||||
await mountSettings({
|
||||
role: 'owner',
|
||||
status: scenario.status,
|
||||
restart: error,
|
||||
})
|
||||
await press('Restart')
|
||||
const dialog = mountRealDialog(dialogProps())
|
||||
await flushPromises()
|
||||
@@ -531,7 +577,10 @@ describe('Settings, this tab remembers that it started the restart', () => {
|
||||
await mountSettings({
|
||||
role: 'owner',
|
||||
status: ready(),
|
||||
restart: startable({ state: 'restarting', started_at: '2026-09-30T09:51:07Z' }),
|
||||
restart: startable({
|
||||
state: 'restarting',
|
||||
started_at: '2026-09-30T09:51:07Z',
|
||||
}),
|
||||
})
|
||||
const storageBefore = JSON.stringify({ ...localStorage })
|
||||
await press('Restart')
|
||||
@@ -539,7 +588,10 @@ describe('Settings, this tab remembers that it started the restart', () => {
|
||||
await confirmRestart()
|
||||
|
||||
expect(sessionStorage.length).toBe(1)
|
||||
expect(remembered()).toEqual({ kind: 'update', started_at: '2026-09-30T09:51:07Z' })
|
||||
expect(remembered()).toEqual({
|
||||
kind: 'update',
|
||||
started_at: '2026-09-30T09:51:07Z',
|
||||
})
|
||||
expect(JSON.stringify({ ...localStorage })).toBe(storageBefore)
|
||||
expect(overlayElement()).not.toBeNull()
|
||||
})
|
||||
@@ -548,7 +600,11 @@ describe('Settings, this tab remembers that it started the restart', () => {
|
||||
describe('Settings, Restarting to install', () => {
|
||||
async function startInstallRestart() {
|
||||
useTimers()
|
||||
await mountSettings({ role: 'owner', status: ready(), restart: restarting() })
|
||||
await mountSettings({
|
||||
role: 'owner',
|
||||
status: ready(),
|
||||
restart: restarting(),
|
||||
})
|
||||
await press('Restart')
|
||||
await confirmRestart()
|
||||
}
|
||||
@@ -617,7 +673,11 @@ describe('Settings, Plain restart: restarting', () => {
|
||||
describe('Settings, Restarting, taking longer', () => {
|
||||
it('after 60 seconds without an answer shows Still restarting and a Reload page button', async () => {
|
||||
useTimers()
|
||||
await mountSettings({ role: 'owner', status: ready(), restart: restarting() })
|
||||
await mountSettings({
|
||||
role: 'owner',
|
||||
status: ready(),
|
||||
restart: restarting(),
|
||||
})
|
||||
await press('Restart')
|
||||
await confirmRestart()
|
||||
silenceStatusReads()
|
||||
@@ -834,7 +894,10 @@ describe('A later visit shows the card with no toast and no focus move', () => {
|
||||
|
||||
expect(toastAdd).not.toHaveBeenCalled()
|
||||
expect(document.activeElement?.id).not.toBe('updates-h')
|
||||
expect(remembered()).toEqual({ kind: 'update', started_at: RESTART_FINISHED_AT })
|
||||
expect(remembered()).toEqual({
|
||||
kind: 'update',
|
||||
started_at: RESTART_FINISHED_AT,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -907,12 +970,18 @@ describe('Settings, a stale sessionStorage entry', () => {
|
||||
await mountSettings({
|
||||
role: 'owner',
|
||||
status: upToDate({ can_apply: true }),
|
||||
restart: startable({ state: 'restarting', started_at: '2026-09-30T10:00:00Z' }),
|
||||
restart: startable({
|
||||
state: 'restarting',
|
||||
started_at: '2026-09-30T10:00:00Z',
|
||||
}),
|
||||
})
|
||||
await press('Restart')
|
||||
await confirmRestart()
|
||||
|
||||
expect(remembered()).toEqual({ kind: 'restart', started_at: '2026-09-30T10:00:00Z' })
|
||||
expect(remembered()).toEqual({
|
||||
kind: 'restart',
|
||||
started_at: '2026-09-30T10:00:00Z',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1027,7 +1096,11 @@ describe('Settings, 403 and 401 on Restart', () => {
|
||||
})
|
||||
|
||||
it('401: the dialog closes and no local copy shows', async () => {
|
||||
await mountSettings({ role: 'owner', status: ready(), restart: new ApiError(401) })
|
||||
await mountSettings({
|
||||
role: 'owner',
|
||||
status: ready(),
|
||||
restart: new ApiError(401),
|
||||
})
|
||||
await press('Restart')
|
||||
|
||||
await confirmRestart()
|
||||
|
||||
@@ -75,7 +75,10 @@ async function mountPage(
|
||||
throw new Error(`Unexpected request: ${path}`)
|
||||
})
|
||||
getMeMock = vi.fn().mockResolvedValue({ verified: true, username: 'marta', id: 1, role })
|
||||
vi.mocked(useApi).mockReturnValue({ getMe: getMeMock, request: requestMock })
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
getMe: getMeMock,
|
||||
request: requestMock,
|
||||
})
|
||||
await router.push('/settings')
|
||||
wrapper = mount(SettingsView, {
|
||||
attachTo: document.body,
|
||||
@@ -353,7 +356,10 @@ describe('Settings retention, shortening asks first', () => {
|
||||
it('describes a shortening from Forever without a date', async () => {
|
||||
await mountPage('owner', () =>
|
||||
Promise.resolve(
|
||||
settings({ activity_retention_days: 0, activity_next_cleanup_at: null }),
|
||||
settings({
|
||||
activity_retention_days: 0,
|
||||
activity_next_cleanup_at: null,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -440,7 +446,12 @@ describe('Settings retention, save error reverts the control and Try again retri
|
||||
() => Promise.resolve(settings()),
|
||||
() => Promise.reject(new ApiError(403)),
|
||||
)
|
||||
getMeMock.mockResolvedValue({ verified: true, username: 'marta', id: 1, role: 'admin' })
|
||||
getMeMock.mockResolvedValue({
|
||||
verified: true,
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
role: 'admin',
|
||||
})
|
||||
|
||||
await choose(365)
|
||||
|
||||
@@ -535,7 +546,10 @@ describe('Settings retention size limit, states', () => {
|
||||
it('Forever with no size limit', async () => {
|
||||
await mountPage('owner', () =>
|
||||
Promise.resolve(
|
||||
settings({ activity_retention_days: 0, activity_next_cleanup_at: null }),
|
||||
settings({
|
||||
activity_retention_days: 0,
|
||||
activity_next_cleanup_at: null,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -551,7 +565,10 @@ describe('Settings retention size limit, states', () => {
|
||||
])('formats %s bytes as "%s"', async (bytes, shown) => {
|
||||
await mountPage('owner', () =>
|
||||
Promise.resolve(
|
||||
settings({ activity_retention_max_gb: 5, activity_log_size_bytes: bytes }),
|
||||
settings({
|
||||
activity_retention_max_gb: 5,
|
||||
activity_log_size_bytes: bytes,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -673,7 +690,10 @@ describe('Settings retention size limit, one warning at a time', () => {
|
||||
() => Promise.resolve(settings({ activity_retention_max_gb: 10 })),
|
||||
() =>
|
||||
Promise.resolve(
|
||||
settings({ activity_retention_days: 365, activity_retention_max_gb: 10 }),
|
||||
settings({
|
||||
activity_retention_days: 365,
|
||||
activity_retention_max_gb: 10,
|
||||
}),
|
||||
),
|
||||
)
|
||||
await chooseSize(5)
|
||||
@@ -776,7 +796,12 @@ describe('Settings retention size limit, save errors revert only the size select
|
||||
() => Promise.resolve(settings({ activity_retention_max_gb: 5 })),
|
||||
() => Promise.reject(new ApiError(403)),
|
||||
)
|
||||
getMeMock.mockResolvedValue({ verified: true, username: 'marta', id: 1, role: 'admin' })
|
||||
getMeMock.mockResolvedValue({
|
||||
verified: true,
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
role: 'admin',
|
||||
})
|
||||
|
||||
await chooseSize(10)
|
||||
|
||||
|
||||
@@ -77,7 +77,12 @@ async function mountSettings(options: MountOptions) {
|
||||
setActivePinia(createPinia())
|
||||
toastAdd = vi.fn()
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta', id: 1, role: options.role }
|
||||
session.session = {
|
||||
verified: true,
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
role: options.role,
|
||||
}
|
||||
session.status = 'ready'
|
||||
const statusHandler = asHandler(options.status)
|
||||
const downloadHandler = asHandler(options.download ?? new ApiError(500))
|
||||
@@ -103,7 +108,10 @@ async function mountSettings(options: MountOptions) {
|
||||
displayName: '',
|
||||
role: options.meAfterRefresh ?? options.role,
|
||||
})
|
||||
vi.mocked(useApi).mockReturnValue({ getMe: getMeMock, request: requestMock })
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
getMe: getMeMock,
|
||||
request: requestMock,
|
||||
})
|
||||
await router.push('/settings')
|
||||
wrapper = mount(SettingsView, {
|
||||
attachTo: document.body,
|
||||
@@ -197,12 +205,18 @@ describe('Settings, owner, Update available with the helper detected', () => {
|
||||
describe('Settings, owner, Downloading', () => {
|
||||
it('shows the card copy, the progressbar, the text and a disabled Updating button', async () => {
|
||||
useTimers()
|
||||
await mountSettings({ role: 'owner', status: startable(), download: downloading() })
|
||||
await mountSettings({
|
||||
role: 'owner',
|
||||
status: startable(),
|
||||
download: downloading(),
|
||||
})
|
||||
|
||||
await pressUpdate()
|
||||
|
||||
expect(callsTo(DOWNLOAD_PATH)).toHaveLength(1)
|
||||
expect(requestMock).toHaveBeenCalledWith(DOWNLOAD_PATH, { method: 'POST' })
|
||||
expect(requestMock).toHaveBeenCalledWith(DOWNLOAD_PATH, {
|
||||
method: 'POST',
|
||||
})
|
||||
expect(cardText()).toContain('Downloading version 2.3.0-beta…')
|
||||
expect(cardText()).toContain(
|
||||
'You can leave this page. The download continues and your site stays online.',
|
||||
@@ -349,7 +363,11 @@ describe('Settings, Download failed', () => {
|
||||
download: downloading(),
|
||||
},
|
||||
{ name: 'a 500', status: startable(), download: new ApiError(500) },
|
||||
{ name: 'a network error', status: startable(), download: new NetworkError() },
|
||||
{
|
||||
name: 'a network error',
|
||||
status: startable(),
|
||||
download: new NetworkError(),
|
||||
},
|
||||
])('shows the error card and focuses the heading after $name', async (scenario) => {
|
||||
useTimers()
|
||||
await mountSettings({
|
||||
@@ -411,7 +429,11 @@ describe('Settings, Verification failed', () => {
|
||||
})
|
||||
|
||||
it('shows the warning card after a 422', async () => {
|
||||
await mountSettings({ role: 'owner', status: startable(), download: new ApiError(422) })
|
||||
await mountSettings({
|
||||
role: 'owner',
|
||||
status: startable(),
|
||||
download: new ApiError(422),
|
||||
})
|
||||
|
||||
await pressUpdate()
|
||||
|
||||
|
||||
@@ -65,7 +65,12 @@ async function mountSettings(options: MountOptions) {
|
||||
setActivePinia(createPinia())
|
||||
toastAdd = vi.fn()
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta', id: 1, role: options.role }
|
||||
session.session = {
|
||||
verified: true,
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
role: options.role,
|
||||
}
|
||||
session.status = 'ready'
|
||||
const statusHandler = asHandler(options.status, () => Promise.resolve(upToDate()))
|
||||
const checkHandler = asHandler(options.check, () => Promise.resolve(upToDate()))
|
||||
@@ -91,7 +96,10 @@ async function mountSettings(options: MountOptions) {
|
||||
displayName: '',
|
||||
role: options.meAfterRefresh ?? options.role,
|
||||
})
|
||||
vi.mocked(useApi).mockReturnValue({ getMe: getMeMock, request: requestMock })
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
getMe: getMeMock,
|
||||
request: requestMock,
|
||||
})
|
||||
await router.push('/settings')
|
||||
wrapper = mount(SettingsView, {
|
||||
attachTo: document.body,
|
||||
@@ -161,7 +169,11 @@ describe('Settings, owner, Up to date', () => {
|
||||
|
||||
describe('Settings, owner, Checking', () => {
|
||||
it('shows the polite block, a disabled Checking button and sends one POST', async () => {
|
||||
await mountSettings({ role: 'owner', status: upToDate(), check: pendingForever() })
|
||||
await mountSettings({
|
||||
role: 'owner',
|
||||
status: upToDate(),
|
||||
check: pendingForever(),
|
||||
})
|
||||
|
||||
findButton('Check for updates')!.click()
|
||||
await flushPromises()
|
||||
@@ -178,7 +190,11 @@ describe('Settings, owner, Checking', () => {
|
||||
})
|
||||
|
||||
it('shows the result once the check resolves', async () => {
|
||||
await mountSettings({ role: 'owner', status: upToDate(), check: newerAvailable() })
|
||||
await mountSettings({
|
||||
role: 'owner',
|
||||
status: upToDate(),
|
||||
check: newerAvailable(),
|
||||
})
|
||||
|
||||
findButton('Check for updates')!.click()
|
||||
await flushPromises()
|
||||
@@ -190,9 +206,16 @@ describe('Settings, owner, Checking', () => {
|
||||
describe('Settings, owner, Check failed', () => {
|
||||
it.each([
|
||||
{ name: 'a 500', failure: () => Promise.reject(new ApiError(500)) },
|
||||
{ name: 'a network failure', failure: () => Promise.reject(new NetworkError()) },
|
||||
{
|
||||
name: 'a network failure',
|
||||
failure: () => Promise.reject(new NetworkError()),
|
||||
},
|
||||
])('keeps the last result and offers only Try again after $name', async ({ failure }) => {
|
||||
await mountSettings({ role: 'owner', status: upToDate(), check: failure })
|
||||
await mountSettings({
|
||||
role: 'owner',
|
||||
status: upToDate(),
|
||||
check: failure,
|
||||
})
|
||||
|
||||
findButton('Check for updates')!.click()
|
||||
await flushPromises()
|
||||
@@ -233,7 +256,10 @@ describe('Settings, owner, Check failed', () => {
|
||||
expect(cardButtons()).toEqual(['Try again'])
|
||||
|
||||
remount()
|
||||
await mountSettings({ role: 'admin', status: () => Promise.reject(new ApiError(500)) })
|
||||
await mountSettings({
|
||||
role: 'admin',
|
||||
status: () => Promise.reject(new ApiError(500)),
|
||||
})
|
||||
expect(document.body.querySelector('[data-testid="updates-card"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -243,7 +269,11 @@ describe('Settings, owner, newer version, not prepared', () => {
|
||||
await mountSettings({
|
||||
role: 'owner',
|
||||
status: newerAvailable({
|
||||
notes: { version: NEW_TAG, released_at: '2026-09-24T08:00:00Z', items: [] },
|
||||
notes: {
|
||||
version: NEW_TAG,
|
||||
released_at: '2026-09-24T08:00:00Z',
|
||||
items: [],
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -341,7 +371,10 @@ describe('Settings, admin view', () => {
|
||||
})
|
||||
|
||||
it('shows up to date with the owner-only note and no button, and never checks', async () => {
|
||||
await mountSettings({ role: 'admin', status: upToDate({ checked_at: null }) })
|
||||
await mountSettings({
|
||||
role: 'admin',
|
||||
status: upToDate({ checked_at: null }),
|
||||
})
|
||||
|
||||
expect(cardText()).toContain("You're up to date")
|
||||
expect(cardText()).toContain('Only the owner can update or restart the site.')
|
||||
@@ -410,13 +443,19 @@ describe('Settings, Too many requests (429)', () => {
|
||||
|
||||
describe('A check runs on the first open after 24 hours, for the owner only', () => {
|
||||
it('checks once when checked_at is null', async () => {
|
||||
await mountSettings({ role: 'owner', status: upToDate({ checked_at: null }) })
|
||||
await mountSettings({
|
||||
role: 'owner',
|
||||
status: upToDate({ checked_at: null }),
|
||||
})
|
||||
|
||||
expect(callsTo(CHECK_PATH)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('checks once when checked_at is older than 24 hours', async () => {
|
||||
await mountSettings({ role: 'owner', status: upToDate({ checked_at: hoursAgo(25) }) })
|
||||
await mountSettings({
|
||||
role: 'owner',
|
||||
status: upToDate({ checked_at: hoursAgo(25) }),
|
||||
})
|
||||
|
||||
expect(callsTo(CHECK_PATH)).toHaveLength(1)
|
||||
})
|
||||
@@ -432,17 +471,26 @@ describe('A check runs on the first open after 24 hours, for the owner only', ()
|
||||
})
|
||||
|
||||
it('sends no check when checked_at is under 24 hours old', async () => {
|
||||
await mountSettings({ role: 'owner', status: upToDate({ checked_at: hoursAgo(23) }) })
|
||||
await mountSettings({
|
||||
role: 'owner',
|
||||
status: upToDate({ checked_at: hoursAgo(23) }),
|
||||
})
|
||||
|
||||
expect(callsTo(CHECK_PATH)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('never sends one for an admin', async () => {
|
||||
await mountSettings({ role: 'admin', status: upToDate({ checked_at: null }) })
|
||||
await mountSettings({
|
||||
role: 'admin',
|
||||
status: upToDate({ checked_at: null }),
|
||||
})
|
||||
expect(callsTo(CHECK_PATH)).toHaveLength(0)
|
||||
|
||||
remount()
|
||||
await mountSettings({ role: 'admin', status: upToDate({ checked_at: hoursAgo(48) }) })
|
||||
await mountSettings({
|
||||
role: 'admin',
|
||||
status: upToDate({ checked_at: hoursAgo(48) }),
|
||||
})
|
||||
expect(callsTo(CHECK_PATH)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -450,9 +498,21 @@ describe('A check runs on the first open after 24 hours, for the owner only', ()
|
||||
describe('No Update, Restart or progress element exists when the server is not prepared or for admins', () => {
|
||||
it.each([
|
||||
{ name: 'up to date', role: 'owner' as const, status: upToDate() },
|
||||
{ name: 'not prepared', role: 'owner' as const, status: newerAvailable() },
|
||||
{ name: 'admin available', role: 'admin' as const, status: newerAvailable() },
|
||||
{ name: 'admin up to date', role: 'admin' as const, status: upToDate() },
|
||||
{
|
||||
name: 'not prepared',
|
||||
role: 'owner' as const,
|
||||
status: newerAvailable(),
|
||||
},
|
||||
{
|
||||
name: 'admin available',
|
||||
role: 'admin' as const,
|
||||
status: newerAvailable(),
|
||||
},
|
||||
{
|
||||
name: 'admin up to date',
|
||||
role: 'admin' as const,
|
||||
status: upToDate(),
|
||||
},
|
||||
])('renders none for $name', async ({ role, status }) => {
|
||||
await mountSettings({ role, status })
|
||||
|
||||
|
||||
@@ -33,7 +33,12 @@ function deferred<T>() {
|
||||
async function mountPage(settings: () => Promise<unknown>, role?: 'owner' | 'admin') {
|
||||
setActivePinia(createPinia())
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta', id: 1, ...(role ? { role } : {}) }
|
||||
session.session = {
|
||||
verified: true,
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
...(role ? { role } : {}),
|
||||
}
|
||||
session.status = 'ready'
|
||||
requestMock = vi.fn((path: string) => {
|
||||
if (path === SETTINGS_PATH) {
|
||||
@@ -154,8 +159,14 @@ describe('Loading: skeleton for the value after 150 ms', () => {
|
||||
|
||||
describe('Error: a 500 or a network failure replaces the row with an alert and a retry', () => {
|
||||
it.each([
|
||||
{ name: 'a 500 response', failure: () => Promise.reject(new ApiError(500)) },
|
||||
{ name: 'a network failure', failure: () => Promise.reject(new NetworkError()) },
|
||||
{
|
||||
name: 'a 500 response',
|
||||
failure: () => Promise.reject(new ApiError(500)),
|
||||
},
|
||||
{
|
||||
name: 'a network failure',
|
||||
failure: () => Promise.reject(new NetworkError()),
|
||||
},
|
||||
])('shows the alert and recovers after Try again on $name', async ({ failure }) => {
|
||||
await mountPage(failure)
|
||||
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import router from '@/router'
|
||||
import { initializeLocale, setLocale } from '@/i18n'
|
||||
import { META_SECTION_OPEN_KEY } from '@/lib/metaSectionMemory'
|
||||
import {
|
||||
authorRows,
|
||||
bylines,
|
||||
callsTo,
|
||||
click,
|
||||
createMetaBackend,
|
||||
dialog,
|
||||
mountMeta,
|
||||
patchCalls,
|
||||
renewSites,
|
||||
rowNames,
|
||||
saveButtonEl,
|
||||
textOf,
|
||||
typeInto,
|
||||
unmountMeta,
|
||||
type MetaBackend,
|
||||
} from './metaTestKit'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
const SEARCH_WAIT_MS = 320
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
initializeLocale()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await unmountMeta()
|
||||
setLocale('en')
|
||||
localStorage.clear()
|
||||
initializeLocale()
|
||||
})
|
||||
|
||||
function trigger() {
|
||||
return Array.from(
|
||||
document.body.querySelectorAll<HTMLElement>('button[aria-haspopup="listbox"]'),
|
||||
).find((element) => /^(Add an author|Ajouter un auteur)$/.test(textOf(element)))!
|
||||
}
|
||||
|
||||
function stackRows() {
|
||||
return Array.from(document.body.querySelectorAll<HTMLElement>('tbody tr'))
|
||||
}
|
||||
|
||||
async function navigate(path: string) {
|
||||
await router.push(path)
|
||||
await flushPromises()
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
describe('Sites and authors across Editor, Articles and Profile', () => {
|
||||
it('an owner creates an author inline as "This is me", saves, and finds the profile and the byline everywhere', async () => {
|
||||
localStorage.setItem(META_SECTION_OPEN_KEY, 'true')
|
||||
const backend = createMetaBackend({
|
||||
sites: renewSites().slice(0, 1),
|
||||
role: 'owner',
|
||||
})
|
||||
backend.article.authors = []
|
||||
backend.articles = [backend.article]
|
||||
await mountMeta('editor', '/article/12/edit', { backend })
|
||||
|
||||
trigger().click()
|
||||
await flushPromises()
|
||||
const search = document.body.querySelector<HTMLInputElement>(
|
||||
'input[placeholder="Search authors"]',
|
||||
)!
|
||||
await typeInto(search, 'Zoe Lambert')
|
||||
await new Promise((resolve) => setTimeout(resolve, SEARCH_WAIT_MS))
|
||||
await flushPromises()
|
||||
document.body.querySelector<HTMLElement>('[data-testid="create-author-row"]')!.click()
|
||||
await flushPromises()
|
||||
dialog()!.querySelector<HTMLElement>('button[role="checkbox"]')!.click()
|
||||
await flushPromises()
|
||||
await click('Create author', dialog()!)
|
||||
|
||||
expect(rowNames()).toEqual(['Zoe Lambert'])
|
||||
expect(patchCalls(backend)).toHaveLength(0)
|
||||
saveButtonEl().click()
|
||||
await flushPromises()
|
||||
expect(patchCalls(backend)[0]!.body).toEqual({ author_ids: [200] })
|
||||
|
||||
await navigate('/profile')
|
||||
expect(textOf(document.body)).toContain('Zoe Lambert')
|
||||
expect(textOf(document.body)).toContain('Edit author')
|
||||
expect(useSessionStore().session?.author).toMatchObject({ id: 200 })
|
||||
|
||||
await navigate('/article')
|
||||
expect(
|
||||
stackRows()[0]!.querySelectorAll('[data-testid="author-stack"] [aria-hidden="true"]'),
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('a verified account creates its profile from the hint, saves, and the Authors column shows the avatar', async () => {
|
||||
const backend = createMetaBackend({
|
||||
sites: renewSites().slice(0, 1),
|
||||
role: 'verified',
|
||||
})
|
||||
backend.article.authors = []
|
||||
backend.articles = [backend.article]
|
||||
await mountMeta('editor', '/article/12/edit', { backend })
|
||||
|
||||
await click('Create my author profile')
|
||||
expect(rowNames()).toEqual(['Marta Alves'])
|
||||
saveButtonEl().click()
|
||||
await flushPromises()
|
||||
expect(patchCalls(backend)[0]!.body).toEqual({ author_ids: [300] })
|
||||
|
||||
await navigate('/article')
|
||||
expect(
|
||||
stackRows()[0]!.querySelectorAll('[data-testid="author-stack"] [aria-hidden="true"]'),
|
||||
).toHaveLength(1)
|
||||
await navigate('/profile')
|
||||
expect(textOf(document.body)).toContain('marta-alves')
|
||||
})
|
||||
|
||||
it('filters the articles by site and keeps the shared URL working', async () => {
|
||||
const backend = createMetaBackend({
|
||||
sites: renewSites(),
|
||||
role: 'owner',
|
||||
})
|
||||
backend.articles = [
|
||||
{ ...backend.article, id: 1, title: 'On Renew', site_id: 1 },
|
||||
{ ...backend.article, id: 2, title: 'On Journal', site_id: 2 },
|
||||
]
|
||||
await mountMeta('articles', '/article', { backend })
|
||||
|
||||
document.body.querySelector<HTMLElement>('button[aria-label="Site"]')!.click()
|
||||
await flushPromises()
|
||||
Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]'))
|
||||
.find((item) => textOf(item) === 'Journal')!
|
||||
.click()
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.fullPath).toBe('/article?site=journal')
|
||||
expect(stackRows().map((row) => textOf(row.children[0] as HTMLElement))).toEqual([
|
||||
'On Journal',
|
||||
])
|
||||
await unmountMeta()
|
||||
|
||||
await mountMeta('articles', '/article?site=journal', { backend })
|
||||
expect(document.body.querySelector('button[aria-label="Site"]')!.textContent).toContain(
|
||||
'Journal',
|
||||
)
|
||||
expect(stackRows().map((row) => textOf(row.children[0] as HTMLElement))).toEqual([
|
||||
'On Journal',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sites and authors in French', () => {
|
||||
it('translates the editor section, the Articles columns and the Profile card', async () => {
|
||||
setLocale('fr')
|
||||
localStorage.setItem(META_SECTION_OPEN_KEY, 'true')
|
||||
const backend: MetaBackend = createMetaBackend({
|
||||
sites: renewSites(),
|
||||
role: 'owner',
|
||||
})
|
||||
backend.article.site_id = 2
|
||||
backend.article.authors = bylines('Inès Duarte', 'Jonas Pereira')
|
||||
backend.articles = [backend.article]
|
||||
await mountMeta('editor', '/article/12/edit', { backend })
|
||||
|
||||
const editor = textOf(document.body)
|
||||
expect(editor).toContain('Site et auteurs')
|
||||
expect(editor).toContain('Hôte canonique : journal.renew.example')
|
||||
expect(editor).toContain('Principal')
|
||||
expect(editor).toContain(
|
||||
'Faites glisser pour réorganiser. Le premier auteur est la signature principale.',
|
||||
)
|
||||
expect(textOf(trigger())).toBe('Ajouter un auteur')
|
||||
expect(authorRows()).toHaveLength(2)
|
||||
expect(callsTo(backend, 'GET', '/v1/site/')).toHaveLength(1)
|
||||
|
||||
await navigate('/article')
|
||||
const articles = textOf(document.body)
|
||||
expect(articles).toContain('Auteurs')
|
||||
expect(articles).toContain('Tous les sites')
|
||||
|
||||
await navigate('/profile')
|
||||
const profile = textOf(document.body)
|
||||
expect(profile).toContain('Profil d’auteur')
|
||||
expect(profile).toContain('Créer mon profil d’auteur')
|
||||
expect(profile).toContain(
|
||||
'Utilise votre nom affiché, Marta Alves. Vous pourrez le modifier ensuite.',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,143 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import App from '@/App.vue'
|
||||
import router from '@/router'
|
||||
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
||||
import { META_SECTION_OPEN_KEY } from '@/lib/metaSectionMemory'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { articleMeta, oneSite } from './editorMetaKit'
|
||||
|
||||
type Handler = () => Response
|
||||
|
||||
function reply(status: number, body: unknown = null) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
headers: { get: () => null },
|
||||
json: async () => body,
|
||||
text: async () => '',
|
||||
} as unknown as Response
|
||||
}
|
||||
|
||||
const article = {
|
||||
id: 12,
|
||||
title: 'How we price a bathroom renovation',
|
||||
body: '<p>Intro</p>',
|
||||
is_online: false,
|
||||
...articleMeta,
|
||||
created_at: '2026-03-12T09:14:00Z',
|
||||
updated_at: '2026-09-26T11:02:00Z',
|
||||
}
|
||||
|
||||
let wrapper: ReturnType<typeof mount> | null = null
|
||||
|
||||
function stubFetch(handlers: Record<string, Handler>) {
|
||||
const routes: Record<string, Handler> = {
|
||||
'GET /v1/site/': () => reply(200, oneSite),
|
||||
'GET /v1/article/12?format=html': () => reply(200, article),
|
||||
...handlers,
|
||||
}
|
||||
global.fetch = vi.fn((url: string, init?: RequestInit) => {
|
||||
const handler = routes[`${init?.method ?? 'GET'} ${url}`]
|
||||
if (!handler) {
|
||||
throw new Error(`Unexpected fetch call: ${init?.method ?? 'GET'} ${url}`)
|
||||
}
|
||||
return Promise.resolve(handler())
|
||||
}) as unknown as typeof fetch
|
||||
}
|
||||
|
||||
async function mountAt(path: string) {
|
||||
setActivePinia(createPinia())
|
||||
const session = useSessionStore()
|
||||
session.session = {
|
||||
verified: true,
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
displayName: 'Marta Alves',
|
||||
}
|
||||
session.status = 'ready'
|
||||
await router.push(path)
|
||||
wrapper = mount(App, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router, uiPlugin] },
|
||||
})
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function buttonNamed(label: string) {
|
||||
return Array.from(document.body.querySelectorAll<HTMLElement>('button')).find(
|
||||
(button) => button.textContent?.trim() === label,
|
||||
)!
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setViewportWidth(DESKTOP_WIDTH)
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
document.body.innerHTML = ''
|
||||
await router.push('/login')
|
||||
})
|
||||
|
||||
describe('A 401 or a 403 on a new call', () => {
|
||||
it('sends a 401 on POST /v1/author/me from Profile to /login with the path and reason=expired', async () => {
|
||||
stubFetch({ 'POST /v1/author/me': () => reply(401) })
|
||||
await mountAt('/profile')
|
||||
|
||||
buttonNamed('Create my author profile').click()
|
||||
await flushPromises()
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
|
||||
expect(router.currentRoute.value.query).toMatchObject({
|
||||
redirect: '/profile',
|
||||
reason: 'expired',
|
||||
})
|
||||
})
|
||||
|
||||
it('sends a 403 from an unverified account on POST /v1/author/me to /register/pending', async () => {
|
||||
stubFetch({ 'POST /v1/author/me': () => reply(403) })
|
||||
await mountAt('/profile')
|
||||
useSessionStore().session = {
|
||||
verified: false,
|
||||
username: 'marta',
|
||||
displayName: '',
|
||||
}
|
||||
const push = vi.spyOn(router, 'push')
|
||||
|
||||
buttonNamed('Create my author profile').click()
|
||||
await flushPromises()
|
||||
|
||||
await vi.waitFor(() => expect(push).toHaveBeenCalledWith('/register/pending'))
|
||||
})
|
||||
|
||||
it('sends a 401 on GET /v1/author/ from the editor combobox to /login with the editor path', async () => {
|
||||
localStorage.setItem(META_SECTION_OPEN_KEY, 'true')
|
||||
stubFetch({ 'GET /v1/author/?page=1&size=100': () => reply(401) })
|
||||
await mountAt('/article/12/edit')
|
||||
|
||||
document.body.querySelector<HTMLElement>('button[aria-haspopup="listbox"]')!.click()
|
||||
await flushPromises()
|
||||
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
|
||||
expect(router.currentRoute.value.query).toMatchObject({
|
||||
redirect: '/article/12/edit',
|
||||
reason: 'expired',
|
||||
})
|
||||
})
|
||||
|
||||
it('sends a 401 on GET /v1/site/ from Articles to /login with the list path', async () => {
|
||||
stubFetch({ 'GET /v1/site/': () => reply(401) })
|
||||
await mountAt('/article?site=journal')
|
||||
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
|
||||
expect(router.currentRoute.value.query).toMatchObject({
|
||||
reason: 'expired',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -51,7 +51,9 @@ describe('Sites: default (owner)', () => {
|
||||
})
|
||||
|
||||
it('draws cards below md with a full-width Add site button pinned to the bottom', async () => {
|
||||
await mountDirectory('sites', '/settings/sites', { width: MOBILE_WIDTH })
|
||||
await mountDirectory('sites', '/settings/sites', {
|
||||
width: MOBILE_WIDTH,
|
||||
})
|
||||
|
||||
expect(document.body.querySelectorAll('[data-testid="directory-card"]')).toHaveLength(4)
|
||||
expect(document.body.querySelector('[data-testid="directory-row"]')).toBeNull()
|
||||
@@ -132,7 +134,9 @@ describe('Sites: row menu', () => {
|
||||
})
|
||||
|
||||
it('opens a bottom sheet titled with the site name below md', async () => {
|
||||
await mountDirectory('sites', '/settings/sites', { width: MOBILE_WIDTH })
|
||||
await mountDirectory('sites', '/settings/sites', {
|
||||
width: MOBILE_WIDTH,
|
||||
})
|
||||
await openRowMenu('Journal')
|
||||
|
||||
const sheet = dialog()!
|
||||
@@ -164,7 +168,9 @@ describe('Sites: delete unavailable on the default site', () => {
|
||||
})
|
||||
|
||||
it('writes the explanation in the sheet below md', async () => {
|
||||
await mountDirectory('sites', '/settings/sites', { width: MOBILE_WIDTH })
|
||||
await mountDirectory('sites', '/settings/sites', {
|
||||
width: MOBILE_WIDTH,
|
||||
})
|
||||
await openRowMenu('Renew')
|
||||
|
||||
expect(textOf(dialog())).toContain(DEFAULT_SITE_TIP)
|
||||
|
||||
@@ -176,13 +176,18 @@ async function mountPage(
|
||||
throw new Error(`Unexpected request: ${path}`)
|
||||
})
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
getMe: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ verified: true, username: 'marta', id: 1, role: sessionRole }),
|
||||
getMe: vi.fn().mockResolvedValue({
|
||||
verified: true,
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
role: sessionRole,
|
||||
}),
|
||||
request: requestMock,
|
||||
})
|
||||
await router.push('/users')
|
||||
const Host = defineComponent({ render: () => h(UApp, null, () => h(UsersView)) })
|
||||
const Host = defineComponent({
|
||||
render: () => h(UApp, null, () => h(UsersView)),
|
||||
})
|
||||
wrapper = mount(Host, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router, uiPlugin] },
|
||||
@@ -424,7 +429,9 @@ describe('Cancel invite: done', () => {
|
||||
|
||||
describe('401 and 403 are left to the API client', () => {
|
||||
it.each([401, 403])('cancel with %i adds no notice and no toast', async (status) => {
|
||||
await mountPage({ cancel: () => Promise.reject(new ApiError(status)) })
|
||||
await mountPage({
|
||||
cancel: () => Promise.reject(new ApiError(status)),
|
||||
})
|
||||
await openCancelDialog()
|
||||
await confirmCancel()
|
||||
|
||||
@@ -464,7 +471,10 @@ describe('Renew invite: sending', () => {
|
||||
|
||||
it('leaves another expired row usable', async () => {
|
||||
const pending = deferred<unknown>()
|
||||
await mountPage({ fixture: twoExpiredFixture(), renew: () => pending.promise })
|
||||
await mountPage({
|
||||
fixture: twoExpiredFixture(),
|
||||
renew: () => pending.promise,
|
||||
})
|
||||
await click(buttonIn(rowOf('p.huber@gmail.com'), 'Renew invite'))
|
||||
|
||||
const other = buttonIn(rowOf('old@renew.example'), 'Renew invite')!
|
||||
@@ -504,7 +514,10 @@ describe('Renew invite: done', () => {
|
||||
describe('Renew invite: failed', () => {
|
||||
it.each([
|
||||
{ name: 'a 500 response', failure: () => new ApiError(500) },
|
||||
{ name: 'a 500 with other text', failure: () => new ApiError(500, undefined, 'smtp down') },
|
||||
{
|
||||
name: 'a 500 with other text',
|
||||
failure: () => new ApiError(500, undefined, 'smtp down'),
|
||||
},
|
||||
{ name: 'a 404 response', failure: () => new ApiError(404) },
|
||||
{ name: 'a network failure', failure: () => new NetworkError() },
|
||||
])('raises the error toast and keeps the row on $name', async ({ failure }) => {
|
||||
@@ -554,7 +567,10 @@ describe('Cancelling a just-renewed row', () => {
|
||||
expect(methodCalls('DELETE')[0][0]).toBe('/v1/user/invite/1')
|
||||
expect(rows()).toHaveLength(4)
|
||||
expect(toastAdd).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ id: 'invite-cancelled-1', title: 'Invite cancelled' }),
|
||||
expect.objectContaining({
|
||||
id: 'invite-cancelled-1',
|
||||
title: 'Invite cancelled',
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -475,8 +475,14 @@ describe('Non-owners cannot invite', () => {
|
||||
|
||||
describe('Error — a 500 or a network failure replaces the list with an alert and a retry', () => {
|
||||
it.each([
|
||||
{ name: 'a 500 response', failure: () => Promise.reject(new ApiError(500)) },
|
||||
{ name: 'a network failure', failure: () => Promise.reject(new NetworkError()) },
|
||||
{
|
||||
name: 'a 500 response',
|
||||
failure: () => Promise.reject(new ApiError(500)),
|
||||
},
|
||||
{
|
||||
name: 'a network failure',
|
||||
failure: () => Promise.reject(new NetworkError()),
|
||||
},
|
||||
])('shows the alert and recovers after Try again on $name', async ({ failure }) => {
|
||||
await mountPage({ list: failure })
|
||||
|
||||
@@ -633,7 +639,9 @@ describe('Invite sent — a 201 closes the modal, adds a Pending row on top and
|
||||
|
||||
describe('Invite sent to an address whose invite had expired replaces the old row', () => {
|
||||
it('keeps exactly one Pending row for the address, first', async () => {
|
||||
await mountPage({ invite: () => Promise.resolve(inviteFixture('p.huber@gmail.com')) })
|
||||
await mountPage({
|
||||
invite: () => Promise.resolve(inviteFixture('p.huber@gmail.com')),
|
||||
})
|
||||
await openModal()
|
||||
await submitWith('p.huber@gmail.com')
|
||||
|
||||
@@ -676,7 +684,9 @@ describe('Invite: already a user (409) — the field error depends on the respon
|
||||
error: 'This address already has an account.',
|
||||
},
|
||||
])('shows "$error" for the body "$body"', async ({ email, body, error }) => {
|
||||
await mountPage({ invite: () => Promise.reject(new ApiError(409, undefined, body)) })
|
||||
await mountPage({
|
||||
invite: () => Promise.reject(new ApiError(409, undefined, body)),
|
||||
})
|
||||
await openModal()
|
||||
await submitWith(email)
|
||||
|
||||
@@ -733,12 +743,18 @@ describe('Invite: rate limited — a 429 shows a dialog alert and keeps the addr
|
||||
|
||||
describe('Invite: failed — a 500 or a network failure keeps the address and offers a retry', () => {
|
||||
it.each([
|
||||
{ name: 'a 500 response', failure: () => Promise.reject(new ApiError(500)) },
|
||||
{
|
||||
name: 'a 500 response',
|
||||
failure: () => Promise.reject(new ApiError(500)),
|
||||
},
|
||||
{
|
||||
name: 'a 500 with the API text',
|
||||
failure: () => Promise.reject(new ApiError(500, undefined, FAILED_ALERT)),
|
||||
},
|
||||
{ name: 'a network failure', failure: () => Promise.reject(new NetworkError()) },
|
||||
{
|
||||
name: 'a network failure',
|
||||
failure: () => Promise.reject(new NetworkError()),
|
||||
},
|
||||
])('shows the alert once on $name', async ({ failure }) => {
|
||||
await mountPage({ invite: failure })
|
||||
await openModal()
|
||||
|
||||
@@ -153,7 +153,10 @@ describe('Rule: Dismissing the mobile sheet', () => {
|
||||
|
||||
it('Nothing closes while saving', async () => {
|
||||
const pending = deferred<unknown>()
|
||||
await mountPage({ desktop: false, routes: { [CREATE]: () => pending.promise } })
|
||||
await mountPage({
|
||||
desktop: false,
|
||||
routes: { [CREATE]: () => pending.promise },
|
||||
})
|
||||
await openAddForm()
|
||||
await typeInto(field('name'), 'banner')
|
||||
await typeInto(field('width'), '400')
|
||||
|
||||
@@ -143,7 +143,12 @@ async function submit(label = 'Add variant') {
|
||||
}
|
||||
|
||||
function saved(overrides: Record<string, unknown> = {}) {
|
||||
return { ...variant({ name: 'hero' }), generated_files: 0, cache_size: 0, ...overrides }
|
||||
return {
|
||||
...variant({ name: 'hero' }),
|
||||
generated_files: 0,
|
||||
cache_size: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -539,7 +544,11 @@ describe('Rule: Form states', () => {
|
||||
})
|
||||
|
||||
it('PNG hides Quality', async () => {
|
||||
await mountPage({ routes: { [CREATE]: () => Promise.resolve(saved({ name: 'logo' })) } })
|
||||
await mountPage({
|
||||
routes: {
|
||||
[CREATE]: () => Promise.resolve(saved({ name: 'logo' })),
|
||||
},
|
||||
})
|
||||
await openAddForm()
|
||||
await typeInto(field('name'), 'logo')
|
||||
await typeInto(field('width'), '300')
|
||||
@@ -585,7 +594,9 @@ describe('Rule: Form states', () => {
|
||||
|
||||
describe('Rule: Saving a new variant', () => {
|
||||
it('Add sends POST with the form values', async () => {
|
||||
await mountPage({ routes: { [CREATE]: () => Promise.resolve(saved()) } })
|
||||
await mountPage({
|
||||
routes: { [CREATE]: () => Promise.resolve(saved()) },
|
||||
})
|
||||
await openAddForm()
|
||||
await fillHero()
|
||||
|
||||
@@ -604,7 +615,11 @@ describe('Rule: Saving a new variant', () => {
|
||||
})
|
||||
|
||||
it('An empty Width or Height is sent as null', async () => {
|
||||
await mountPage({ routes: { [CREATE]: () => Promise.resolve(saved({ name: 'card' })) } })
|
||||
await mountPage({
|
||||
routes: {
|
||||
[CREATE]: () => Promise.resolve(saved({ name: 'card' })),
|
||||
},
|
||||
})
|
||||
await openAddForm()
|
||||
await typeInto(field('name'), 'card')
|
||||
await typeInto(field('width'), '800')
|
||||
@@ -651,7 +666,9 @@ describe('Rule: Saving a new variant', () => {
|
||||
|
||||
it('A successful create', async () => {
|
||||
await mountPage({
|
||||
routes: { [CREATE]: () => Promise.resolve(saved({ name: 'banner' })) },
|
||||
routes: {
|
||||
[CREATE]: () => Promise.resolve(saved({ name: 'banner' })),
|
||||
},
|
||||
})
|
||||
await openAddForm()
|
||||
const opener = document.body.querySelector<HTMLElement>('[data-testid="add-variant"]')!
|
||||
@@ -676,7 +693,11 @@ describe('Rule: Saving a new variant', () => {
|
||||
).toBe('7 variants · 1,284 generated files · 212 MB')
|
||||
expect(calls(LIST)).toHaveLength(1)
|
||||
expect(toastCalls(toasts)).toEqual([
|
||||
{ title: 'Variant “banner” added', description: undefined, color: 'success' },
|
||||
{
|
||||
title: 'Variant “banner” added',
|
||||
description: undefined,
|
||||
color: 'success',
|
||||
},
|
||||
])
|
||||
await vi.waitFor(() => expect(document.activeElement).toBe(opener))
|
||||
})
|
||||
@@ -799,7 +820,9 @@ describe('Rule: Saving a new variant', () => {
|
||||
[403, "You don't have access", 'Only admins can manage image variants.'],
|
||||
[404, 'Not found', 'This variant or image no longer exists. Refresh the page.'],
|
||||
])('Other statuses on save: %s', async (status, title, text) => {
|
||||
await mountPage({ routes: { [CREATE]: () => Promise.reject(new ApiError(status)) } })
|
||||
await mountPage({
|
||||
routes: { [CREATE]: () => Promise.reject(new ApiError(status)) },
|
||||
})
|
||||
await openAddForm()
|
||||
await fillHero()
|
||||
|
||||
@@ -820,7 +843,9 @@ describe('Rule: Saving a new variant', () => {
|
||||
['Quality must be between 1 and 100.', 'quality', QUALITY_ERROR],
|
||||
])('A 422 from the server is shown inline: %s', async (serverText, fieldName, inline) => {
|
||||
await mountPage({
|
||||
routes: { [CREATE]: () => Promise.reject(new ApiError(422, undefined, serverText)) },
|
||||
routes: {
|
||||
[CREATE]: () => Promise.reject(new ApiError(422, undefined, serverText)),
|
||||
},
|
||||
})
|
||||
await openAddForm()
|
||||
await fillHero()
|
||||
@@ -869,7 +894,11 @@ describe('Rule: Saving an edit', () => {
|
||||
routes: {
|
||||
[PUT_HERO]: () =>
|
||||
Promise.resolve({
|
||||
...variant({ quality: 70, generated_files: 0, cache_size: 0 }),
|
||||
...variant({
|
||||
quality: 70,
|
||||
generated_files: 0,
|
||||
cache_size: 0,
|
||||
}),
|
||||
purged_files: 244,
|
||||
}),
|
||||
},
|
||||
@@ -905,7 +934,10 @@ describe('Rule: Saving an edit', () => {
|
||||
await mountPage({
|
||||
routes: {
|
||||
[PUT_HERO]: () =>
|
||||
Promise.resolve({ ...variant({ generate_on_upload: true }), purged_files: 0 }),
|
||||
Promise.resolve({
|
||||
...variant({ generate_on_upload: true }),
|
||||
purged_files: 0,
|
||||
}),
|
||||
},
|
||||
})
|
||||
await openEditForm('hero')
|
||||
@@ -915,7 +947,11 @@ describe('Rule: Saving an edit', () => {
|
||||
|
||||
expect(bodyOf(PUT_HERO).generate_on_upload).toBe(true)
|
||||
expect(toastCalls(toasts)).toEqual([
|
||||
{ title: 'Variant “hero” saved.', description: undefined, color: 'success' },
|
||||
{
|
||||
title: 'Variant “hero” saved.',
|
||||
description: undefined,
|
||||
color: 'success',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -123,7 +123,9 @@ describe('Rule: Only admins and owners see the page', () => {
|
||||
})
|
||||
|
||||
it('A session whose role is not owner or admin gets the Forbidden state: owner answered 403', async () => {
|
||||
await mountPage({ routes: { [LIST]: () => Promise.reject(new ApiError(403)) } })
|
||||
await mountPage({
|
||||
routes: { [LIST]: () => Promise.reject(new ApiError(403)) },
|
||||
})
|
||||
|
||||
expectForbidden()
|
||||
expect(calls(LIST)).toHaveLength(1)
|
||||
@@ -275,7 +277,11 @@ describe('Rule: Loaded, table on desktop and cards on mobile', () => {
|
||||
[LIST]: () =>
|
||||
Promise.resolve({
|
||||
items: [
|
||||
variant({ name: 'thumb', generated_files: 1, cache_size: 18 * 1024 }),
|
||||
variant({
|
||||
name: 'thumb',
|
||||
generated_files: 1,
|
||||
cache_size: 18 * 1024,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
},
|
||||
@@ -294,7 +300,9 @@ describe('Rule: Loaded, table on desktop and cards on mobile', () => {
|
||||
describe('Rule: Loading', () => {
|
||||
it('Skeletons appear only after 150 ms', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
await mountPage({ routes: { [LIST]: () => new Promise(() => undefined) } })
|
||||
await mountPage({
|
||||
routes: { [LIST]: () => new Promise(() => undefined) },
|
||||
})
|
||||
|
||||
vi.advanceTimersByTime(149)
|
||||
await flushPromises()
|
||||
@@ -314,7 +322,10 @@ describe('Rule: Loading', () => {
|
||||
|
||||
it('Skeleton cards on mobile hold three bars', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
await mountPage({ desktop: false, routes: { [LIST]: () => new Promise(() => undefined) } })
|
||||
await mountPage({
|
||||
desktop: false,
|
||||
routes: { [LIST]: () => new Promise(() => undefined) },
|
||||
})
|
||||
|
||||
vi.advanceTimersByTime(150)
|
||||
await flushPromises()
|
||||
@@ -490,7 +501,11 @@ describe('Rule: Status switch', () => {
|
||||
routes: {
|
||||
[PATCH_HERO]: () =>
|
||||
Promise.resolve(
|
||||
variant({ enabled: false, generated_files: 5, cache_size: 2 * MB }),
|
||||
variant({
|
||||
enabled: false,
|
||||
generated_files: 5,
|
||||
cache_size: 2 * MB,
|
||||
}),
|
||||
),
|
||||
},
|
||||
})
|
||||
@@ -505,7 +520,11 @@ describe('Rule: Status switch', () => {
|
||||
[404, 'Not found', NOT_FOUND_TEXT],
|
||||
[500, 'Something went wrong', SERVER_TEXT],
|
||||
])('A failed toggle reverts and reports: %s', async (status, title, text) => {
|
||||
await mountPage({ routes: { [PATCH_HERO]: () => Promise.reject(new ApiError(status)) } })
|
||||
await mountPage({
|
||||
routes: {
|
||||
[PATCH_HERO]: () => Promise.reject(new ApiError(status)),
|
||||
},
|
||||
})
|
||||
|
||||
await click(switchOf('hero'))
|
||||
|
||||
@@ -650,7 +669,11 @@ describe('Rule: Purge cache', () => {
|
||||
it('Purge succeeds', async () => {
|
||||
await mountPage({
|
||||
routes: {
|
||||
[PURGE_HERO]: () => Promise.resolve({ purged_files: 244, purged_bytes: 88080384 }),
|
||||
[PURGE_HERO]: () =>
|
||||
Promise.resolve({
|
||||
purged_files: 244,
|
||||
purged_bytes: 88080384,
|
||||
}),
|
||||
},
|
||||
})
|
||||
await openPurge()
|
||||
@@ -663,7 +686,11 @@ describe('Rule: Purge cache', () => {
|
||||
expect(summary()).toBe('6 variants · 1,040 generated files · 128 MB')
|
||||
expect(calls(LIST)).toHaveLength(1)
|
||||
expect(toastCalls(toasts)).toEqual([
|
||||
{ title: 'Purged 244 files from “hero”', description: undefined, color: 'success' },
|
||||
{
|
||||
title: 'Purged 244 files from “hero”',
|
||||
description: undefined,
|
||||
color: 'success',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
@@ -671,7 +698,11 @@ describe('Rule: Purge cache', () => {
|
||||
[404, 'Not found', NOT_FOUND_TEXT],
|
||||
[500, 'Something went wrong', SERVER_TEXT],
|
||||
])('Purge fails: %s', async (status, title, text) => {
|
||||
await mountPage({ routes: { [PURGE_HERO]: () => Promise.reject(new ApiError(status)) } })
|
||||
await mountPage({
|
||||
routes: {
|
||||
[PURGE_HERO]: () => Promise.reject(new ApiError(status)),
|
||||
},
|
||||
})
|
||||
await openPurge()
|
||||
|
||||
await click(buttonIn(alertDialog()!, 'Purge 244 files'))
|
||||
@@ -726,7 +757,9 @@ describe('Rule: Delete variant', () => {
|
||||
})
|
||||
|
||||
it('Delete succeeds', async () => {
|
||||
await mountPage({ routes: { [DELETE_HERO]: () => Promise.resolve(undefined) } })
|
||||
await mountPage({
|
||||
routes: { [DELETE_HERO]: () => Promise.resolve(undefined) },
|
||||
})
|
||||
await openDelete()
|
||||
|
||||
await click(buttonIn(alertDialog()!, 'Delete variant'))
|
||||
@@ -741,7 +774,11 @@ describe('Rule: Delete variant', () => {
|
||||
])
|
||||
expect(summary()).toBe('5 variants · 1,040 generated files · 128 MB')
|
||||
expect(toastCalls(toasts)).toEqual([
|
||||
{ title: 'Variant “hero” deleted', description: undefined, color: 'success' },
|
||||
{
|
||||
title: 'Variant “hero” deleted',
|
||||
description: undefined,
|
||||
color: 'success',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
@@ -788,7 +825,9 @@ describe('Rule: Delete variant', () => {
|
||||
})
|
||||
|
||||
it('Delete fails: 500 keeps the row and reports', async () => {
|
||||
await mountPage({ routes: { [DELETE_HERO]: () => Promise.reject(new ApiError(500)) } })
|
||||
await mountPage({
|
||||
routes: { [DELETE_HERO]: () => Promise.reject(new ApiError(500)) },
|
||||
})
|
||||
await openDelete()
|
||||
|
||||
await click(buttonIn(alertDialog()!, 'Delete variant'))
|
||||
@@ -797,7 +836,11 @@ describe('Rule: Delete variant', () => {
|
||||
expect(rows()).toHaveLength(6)
|
||||
expect(calls(LIST)).toHaveLength(1)
|
||||
expect(toastCalls(toasts)).toEqual([
|
||||
{ title: 'Something went wrong', description: SERVER_TEXT, color: 'error' },
|
||||
{
|
||||
title: 'Something went wrong',
|
||||
description: SERVER_TEXT,
|
||||
color: 'error',
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -114,7 +114,10 @@ describe('Success — 200 refreshes the session and navigates to /home with a to
|
||||
expect(toastAdd).toHaveBeenCalledTimes(1)
|
||||
|
||||
const session = useSessionStore()
|
||||
expect(session.session).toMatchObject({ verified: true, displayName: '' })
|
||||
expect(session.session).toMatchObject({
|
||||
verified: true,
|
||||
displayName: '',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -13,14 +13,26 @@ const LONG_TITLE =
|
||||
const WEB_IP = '203.0.113.24'
|
||||
const WEB_AGENT = 'Firefox 131 on macOS'
|
||||
|
||||
export const user = (id: number, name: string): ActivityActor => ({ type: 'user', id, name })
|
||||
export const user = (id: number, name: string): ActivityActor => ({
|
||||
type: 'user',
|
||||
id,
|
||||
name,
|
||||
})
|
||||
export const apiKey = (id: number, name: string): ActivityActor => ({
|
||||
type: 'api_key',
|
||||
id,
|
||||
name,
|
||||
})
|
||||
export const system: ActivityActor = { type: 'system', id: null, name: 'System' }
|
||||
export const anonymous = (name: string): ActivityActor => ({ type: 'anonymous', id: null, name })
|
||||
export const system: ActivityActor = {
|
||||
type: 'system',
|
||||
id: null,
|
||||
name: 'System',
|
||||
}
|
||||
export const anonymous = (name: string): ActivityActor => ({
|
||||
type: 'anonymous',
|
||||
id: null,
|
||||
name,
|
||||
})
|
||||
|
||||
export const target = (type: string, id: string, name: string, exists = true): ActivityTarget => ({
|
||||
type,
|
||||
@@ -148,7 +160,12 @@ export const activityItems: ActivityItem[] = [
|
||||
actor: user(4, 'Inès Duarte'),
|
||||
target: target('user', '8', 'Sam Rivera'),
|
||||
changes: [
|
||||
{ field: 'display_name', kind: 'text', before: 'Sam Rivera', after: 'Samuel Rivera' },
|
||||
{
|
||||
field: 'display_name',
|
||||
kind: 'text',
|
||||
before: 'Sam Rivera',
|
||||
after: 'Samuel Rivera',
|
||||
},
|
||||
],
|
||||
}),
|
||||
]
|
||||
|
||||
@@ -99,7 +99,12 @@ export function authorsFixture(withEmails = true): Author[] {
|
||||
slug: 'ines-duarte',
|
||||
bio: 'Site manager and restoration carpenter.',
|
||||
website: 'https://ines-duarte.example',
|
||||
social_links: [{ type: 'linkedin', url: 'https://www.linkedin.com/in/ines-duarte' }],
|
||||
social_links: [
|
||||
{
|
||||
type: 'linkedin',
|
||||
url: 'https://www.linkedin.com/in/ines-duarte',
|
||||
},
|
||||
],
|
||||
user_id: 3,
|
||||
is_linked: true,
|
||||
article_count: 9,
|
||||
@@ -166,7 +171,7 @@ function pageOf(backend: Backend, query: URLSearchParams) {
|
||||
}
|
||||
}
|
||||
|
||||
function defaultRoute(backend: Backend, call: Call): unknown {
|
||||
export function defaultRoute(backend: Backend, call: Call): unknown {
|
||||
const { method, path, query, body } = call
|
||||
const siteMatch = /^\/v1\/site\/(\d+)(\/default)?$/.exec(path)
|
||||
const authorMatch = /^\/v1\/author\/(\d+)$/.exec(path)
|
||||
@@ -219,7 +224,12 @@ function defaultRoute(backend: Backend, call: Call): unknown {
|
||||
return pageOf(backend, query)
|
||||
}
|
||||
if (method === 'POST' && path === '/v1/author/') {
|
||||
const created = { id: 200, is_linked: body?.user_id != null, article_count: 0, ...body }
|
||||
const created = {
|
||||
id: 200,
|
||||
is_linked: body?.user_id != null,
|
||||
article_count: 0,
|
||||
...body,
|
||||
}
|
||||
backend.authors.push(created as unknown as Author)
|
||||
return created
|
||||
}
|
||||
@@ -340,7 +350,9 @@ export async function mountDirectory(
|
||||
await router.push(url)
|
||||
const component = view === 'sites' ? SitesView : AuthorsView
|
||||
kit.wrapper = mount(
|
||||
defineComponent({ render: () => h(UApp, null, { default: () => h(component) }) }),
|
||||
defineComponent({
|
||||
render: () => h(UApp, null, { default: () => h(component) }),
|
||||
}),
|
||||
{ attachTo: document.body, global: { plugins: [router, uiPlugin] } },
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Byline } from '@/composables/useArticles'
|
||||
import type { Site } from '@/composables/useSites'
|
||||
|
||||
export const defaultSite: Site = {
|
||||
id: 1,
|
||||
name: 'Renew',
|
||||
key: 'renew',
|
||||
base_url: 'https://renew.example',
|
||||
default_social_image_id: null,
|
||||
default_robots: 'index',
|
||||
locale: null,
|
||||
is_default: true,
|
||||
article_count: 268,
|
||||
}
|
||||
|
||||
export const journalSite: Site = {
|
||||
...defaultSite,
|
||||
id: 2,
|
||||
name: 'Journal',
|
||||
key: 'journal',
|
||||
base_url: 'https://journal.renew.example',
|
||||
is_default: false,
|
||||
article_count: 38,
|
||||
}
|
||||
|
||||
export const projectsSite: Site = {
|
||||
...defaultSite,
|
||||
id: 3,
|
||||
name: 'Projects',
|
||||
key: 'projects',
|
||||
base_url: 'https://projects.renew.example',
|
||||
is_default: false,
|
||||
article_count: 6,
|
||||
}
|
||||
|
||||
export const archiveSite: Site = {
|
||||
...defaultSite,
|
||||
id: 4,
|
||||
name: 'Archive',
|
||||
key: 'archive',
|
||||
base_url: 'https://archive.renew.example',
|
||||
is_default: false,
|
||||
article_count: 0,
|
||||
}
|
||||
|
||||
export const oneSite: Site[] = [defaultSite]
|
||||
export const severalSites: Site[] = [defaultSite, journalSite, projectsSite, archiveSite]
|
||||
|
||||
export const ines: Byline = { id: 2, name: 'Inès Duarte', slug: 'ines-duarte' }
|
||||
export const jonas: Byline = {
|
||||
id: 3,
|
||||
name: 'Jonas Pereira',
|
||||
slug: 'jonas-pereira',
|
||||
}
|
||||
export const camille: Byline = {
|
||||
id: 7,
|
||||
name: 'Camille Roche',
|
||||
slug: 'camille-roche',
|
||||
}
|
||||
export const paul: Byline = { id: 8, name: 'Paul Huber', slug: 'paul-huber' }
|
||||
export const leaFontaine: Byline = {
|
||||
id: 10,
|
||||
name: 'Léa Fontaine',
|
||||
slug: 'lea-fontaine',
|
||||
}
|
||||
|
||||
export const articleMeta = { site_id: defaultSite.id, authors: [ines] }
|
||||
@@ -412,13 +412,19 @@ export async function mountPage(
|
||||
} else {
|
||||
kit.requestMock = vi.fn(async (path: string, init?: RequestInit) => dispatch(path, init))
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
getMe: vi.fn().mockResolvedValue({ verified: true, username: 'marta', id: 1 }),
|
||||
getMe: vi.fn().mockResolvedValue({
|
||||
verified: true,
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
}),
|
||||
request: kit.requestMock,
|
||||
})
|
||||
}
|
||||
await router.push(url)
|
||||
const root = options.app
|
||||
? defineComponent({ render: () => h(UApp, null, { default: () => h(MediaView) }) })
|
||||
? defineComponent({
|
||||
render: () => h(UApp, null, { default: () => h(MediaView) }),
|
||||
})
|
||||
: MediaView
|
||||
kit.wrapper = mount(root, {
|
||||
attachTo: document.body,
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
import { vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import App from '@/App.vue'
|
||||
import router from '@/router'
|
||||
import { ApiError, useApi, type MeAuthor, type MeResult } from '@/composables/useApi'
|
||||
import type { Article, Byline } from '@/composables/useArticles'
|
||||
import type { Author } from '@/composables/useAuthors'
|
||||
import type { Site } from '@/composables/useSites'
|
||||
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
||||
import { slugify } from '@/lib/directory'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import {
|
||||
authorsFixture,
|
||||
defaultRoute,
|
||||
sitesFixture,
|
||||
usersFixture,
|
||||
type Backend,
|
||||
type Call,
|
||||
} from './directoryTestKit'
|
||||
|
||||
export {
|
||||
callsTo,
|
||||
textOf,
|
||||
button,
|
||||
click,
|
||||
typeInto,
|
||||
dialog,
|
||||
toasts,
|
||||
pending,
|
||||
} from './directoryTestKit'
|
||||
export { failWith, networkFailure } from './directoryTestKit'
|
||||
|
||||
export type MetaRole = 'owner' | 'admin' | 'verified'
|
||||
|
||||
export interface MetaBackend extends Backend {
|
||||
article: Article
|
||||
articles: Article[]
|
||||
me: MeResult
|
||||
meAuthorSlug?: string
|
||||
meSlugAdjusted?: boolean
|
||||
}
|
||||
|
||||
export const MARTA_EMAIL = 'marta@renew.example'
|
||||
const ARTICLE_ID = 12
|
||||
|
||||
export function renewSites(): Site[] {
|
||||
return sitesFixture().map((site) => ({
|
||||
...site,
|
||||
base_url: `https://${site.key === 'renew' ? 'renew' : `${site.key}.renew`}.example`,
|
||||
}))
|
||||
}
|
||||
|
||||
export function boardAuthors(): Author[] {
|
||||
const extra = (id: number, name: string): Author => ({
|
||||
id,
|
||||
name,
|
||||
slug: slugify(name, 100),
|
||||
bio: '',
|
||||
avatar_id: null,
|
||||
website: null,
|
||||
social_links: [],
|
||||
user_id: null,
|
||||
is_linked: false,
|
||||
article_count: 0,
|
||||
})
|
||||
return [...authorsFixture(), extra(3, 'Jonas Pereira'), extra(8, 'Paul Huber')]
|
||||
}
|
||||
|
||||
export function bylines(...names: string[]): Byline[] {
|
||||
const all = boardAuthors()
|
||||
return names.map((name) => {
|
||||
const found = all.find((author) => author.name === name)
|
||||
if (found) {
|
||||
return { id: found.id, name: found.name, slug: found.slug }
|
||||
}
|
||||
return { id: 100 + name.length, name, slug: slugify(name, 100) }
|
||||
})
|
||||
}
|
||||
|
||||
export function articleOf(id: number, title: string, overrides: Partial<Article> = {}): Article {
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
body: '<p>Intro</p>',
|
||||
is_online: false,
|
||||
site_id: 1,
|
||||
authors: bylines('Inès Duarte'),
|
||||
created_at: '2026-03-12T09:14:00Z',
|
||||
updated_at: '2026-09-26T11:02:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
export function createMetaBackend(
|
||||
options: {
|
||||
sites?: Site[]
|
||||
authors?: Author[]
|
||||
role?: MetaRole
|
||||
author?: MeAuthor
|
||||
} = {},
|
||||
): MetaBackend {
|
||||
return {
|
||||
sites: options.sites ?? sitesFixture().slice(0, 1),
|
||||
authors: options.authors ?? boardAuthors(),
|
||||
users: usersFixture(),
|
||||
calls: [],
|
||||
responders: {},
|
||||
article: articleOf(ARTICLE_ID, 'How we price a bathroom renovation'),
|
||||
articles: [],
|
||||
me: {
|
||||
id: 1,
|
||||
verified: true,
|
||||
username: 'marta',
|
||||
email: MARTA_EMAIL,
|
||||
displayName: 'Marta Alves',
|
||||
role: options.role === 'verified' ? undefined : (options.role ?? 'owner'),
|
||||
...(options.author ? { author: options.author } : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function fail(status: number, code?: string): never {
|
||||
throw new ApiError(status, undefined, code)
|
||||
}
|
||||
|
||||
function toByline(backend: MetaBackend, id: number): Byline {
|
||||
const author = backend.authors.find((candidate) => candidate.id === id)
|
||||
if (!author) {
|
||||
fail(404, 'author_not_found')
|
||||
}
|
||||
return { id: author.id, name: author.name, slug: author.slug }
|
||||
}
|
||||
|
||||
function patchArticle(backend: MetaBackend, body: Record<string, unknown>) {
|
||||
if (typeof body.site_id === 'number') {
|
||||
if (!backend.sites.some((site) => site.id === body.site_id)) {
|
||||
fail(404, 'site_not_found')
|
||||
}
|
||||
backend.article.site_id = body.site_id
|
||||
}
|
||||
if (Array.isArray(body.author_ids)) {
|
||||
backend.article.authors = (body.author_ids as number[]).map((id) => toByline(backend, id))
|
||||
}
|
||||
if (typeof body.body === 'string') {
|
||||
backend.article.body = body.body
|
||||
}
|
||||
return structuredClone(backend.article)
|
||||
}
|
||||
|
||||
function listArticles(backend: MetaBackend, query: URLSearchParams) {
|
||||
const size = Number(query.get('size') ?? 25)
|
||||
const page = Number(query.get('page') ?? 1)
|
||||
const siteKey = query.get('site')
|
||||
const status = query.get('status')
|
||||
const q = (query.get('q') ?? '').toLowerCase()
|
||||
const siteId = backend.sites.find((site) => site.key === siteKey)?.id
|
||||
const matching = backend.articles
|
||||
.filter((article) => !siteKey || article.site_id === siteId)
|
||||
.filter((article) => !status || article.is_online === (status === 'online'))
|
||||
.filter((article) => !q || article.title.toLowerCase().includes(q))
|
||||
const online = backend.articles.filter((article) => article.is_online).length
|
||||
return {
|
||||
items: structuredClone(matching.slice((page - 1) * size, page * size)),
|
||||
total: matching.length,
|
||||
page,
|
||||
size,
|
||||
counts: {
|
||||
all: backend.articles.length,
|
||||
online,
|
||||
draft: backend.articles.length - online,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createMyAuthor(backend: MetaBackend) {
|
||||
if (backend.me.author) {
|
||||
fail(409, 'user_already_linked')
|
||||
}
|
||||
const name = backend.me.displayName || backend.me.username || ''
|
||||
const created = {
|
||||
id: 300,
|
||||
name,
|
||||
slug: backend.meAuthorSlug ?? slugify(name, 100),
|
||||
bio: '',
|
||||
avatar_id: null,
|
||||
website: null,
|
||||
social_links: [],
|
||||
user_id: backend.me.id ?? null,
|
||||
is_linked: true,
|
||||
article_count: 0,
|
||||
} as Author
|
||||
backend.authors.push(created)
|
||||
backend.me.author = {
|
||||
id: created.id,
|
||||
name: created.name,
|
||||
slug: created.slug,
|
||||
}
|
||||
return {
|
||||
...created,
|
||||
slug_adjusted: backend.meSlugAdjusted ?? false,
|
||||
} as Author
|
||||
}
|
||||
|
||||
function route(backend: MetaBackend, call: Call): unknown {
|
||||
const { method, path, query, body } = call
|
||||
const articleMatch = /^\/v1\/article\/(\d+)$/.exec(path)
|
||||
if (articleMatch && method === 'GET') {
|
||||
return structuredClone(backend.article)
|
||||
}
|
||||
if (articleMatch && method === 'PATCH') {
|
||||
return patchArticle(backend, body ?? {})
|
||||
}
|
||||
if (path === '/v1/article/' && method === 'GET') {
|
||||
return listArticles(backend, query)
|
||||
}
|
||||
if (path === '/v1/author/me' && method === 'POST') {
|
||||
return createMyAuthor(backend)
|
||||
}
|
||||
if (path === '/v1/author/' && method === 'POST' && body?.user_id != null) {
|
||||
const created = defaultRoute(backend, call) as Author
|
||||
backend.me.author = {
|
||||
id: created.id,
|
||||
name: created.name,
|
||||
slug: created.slug,
|
||||
}
|
||||
return created
|
||||
}
|
||||
return defaultRoute(backend, call)
|
||||
}
|
||||
|
||||
export interface MetaMountOptions {
|
||||
role?: MetaRole
|
||||
width?: number
|
||||
backend?: MetaBackend
|
||||
author?: MeAuthor
|
||||
}
|
||||
|
||||
export const metaKit: {
|
||||
wrapper: ReturnType<typeof mount> | null
|
||||
backend: MetaBackend
|
||||
} = {
|
||||
wrapper: null,
|
||||
backend: createMetaBackend(),
|
||||
}
|
||||
|
||||
export async function mountMeta(
|
||||
_view: 'editor' | 'articles' | 'profile',
|
||||
url: string,
|
||||
options: MetaMountOptions = {},
|
||||
) {
|
||||
setViewportWidth(options.width ?? DESKTOP_WIDTH)
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
setActivePinia(createPinia())
|
||||
const backend =
|
||||
options.backend ?? createMetaBackend({ role: options.role, author: options.author })
|
||||
metaKit.backend = backend
|
||||
const session = useSessionStore()
|
||||
session.session = {
|
||||
verified: true,
|
||||
username: backend.me.username,
|
||||
id: backend.me.id,
|
||||
email: backend.me.email,
|
||||
displayName: backend.me.displayName,
|
||||
role: backend.me.role,
|
||||
...(backend.me.author ? { author: backend.me.author } : {}),
|
||||
}
|
||||
session.status = 'ready'
|
||||
const request = vi.fn(async (path: string, init?: RequestInit) => {
|
||||
const [pathname = '', search = ''] = path.split('?')
|
||||
const call: Call = {
|
||||
method: init?.method ?? 'GET',
|
||||
path: pathname,
|
||||
raw: path,
|
||||
query: new URLSearchParams(search),
|
||||
body: typeof init?.body === 'string' ? JSON.parse(init.body) : undefined,
|
||||
}
|
||||
backend.calls.push(call)
|
||||
const responder =
|
||||
backend.responders[`${call.method} ${path}`] ??
|
||||
backend.responders[`${call.method} ${pathname}`]
|
||||
return responder ? responder(call) : route(backend, call)
|
||||
})
|
||||
const getMe = vi.fn(async () => {
|
||||
backend.calls.push({
|
||||
method: 'GET',
|
||||
path: '/v1/auth/me',
|
||||
raw: '/v1/auth/me',
|
||||
query: new URLSearchParams(),
|
||||
body: undefined,
|
||||
})
|
||||
return structuredClone(backend.me)
|
||||
})
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
getMe,
|
||||
request,
|
||||
} as unknown as ReturnType<typeof useApi>)
|
||||
await router.push(url)
|
||||
metaKit.wrapper = mount(App, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router, uiPlugin] },
|
||||
})
|
||||
await flushPromises()
|
||||
return backend
|
||||
}
|
||||
|
||||
export function metaSection() {
|
||||
return document.body.querySelector<HTMLElement>('[data-testid="meta-section"]')
|
||||
}
|
||||
|
||||
export function allMetaSections() {
|
||||
return Array.from(document.body.querySelectorAll<HTMLElement>('[data-testid="meta-section"]'))
|
||||
}
|
||||
|
||||
export function authorRows(root: ParentNode = document.body) {
|
||||
return Array.from(root.querySelectorAll<HTMLElement>('[data-testid="author-row"]'))
|
||||
}
|
||||
|
||||
export function rowNames(root: ParentNode = document.body) {
|
||||
return authorRows(root).map((row) => row.querySelector('[title]')?.getAttribute('title') ?? '')
|
||||
}
|
||||
|
||||
export function handleOf(name: string) {
|
||||
return document.body.querySelector<HTMLElement>(`button[aria-label="Reorder ${name}"]`)!
|
||||
}
|
||||
|
||||
export async function pressKey(element: HTMLElement, key: string) {
|
||||
element.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }))
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
export function mockRowRects(rows: HTMLElement[], rowHeight = 32) {
|
||||
rows.forEach((row, index) => {
|
||||
row.getBoundingClientRect = () =>
|
||||
({
|
||||
top: index * rowHeight,
|
||||
bottom: (index + 1) * rowHeight,
|
||||
height: rowHeight,
|
||||
left: 0,
|
||||
right: 200,
|
||||
width: 200,
|
||||
x: 0,
|
||||
y: index * rowHeight,
|
||||
toJSON: () => ({}),
|
||||
}) as DOMRect
|
||||
})
|
||||
}
|
||||
|
||||
export async function pointer(element: HTMLElement, type: string, clientY: number) {
|
||||
const event = new MouseEvent(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clientY,
|
||||
button: 0,
|
||||
})
|
||||
element.dispatchEvent(event)
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
export async function dragTo(name: string, clientY: number) {
|
||||
const handle = handleOf(name)
|
||||
mockRowRects(authorRows())
|
||||
await pointer(handle, 'pointerdown', 0)
|
||||
await pointer(handle, 'pointermove', clientY)
|
||||
return {
|
||||
release: () => pointer(handle, 'pointerup', clientY),
|
||||
cancel: () => pointer(handle, 'pointercancel', clientY),
|
||||
}
|
||||
}
|
||||
|
||||
export function saveButtonEl() {
|
||||
return document.body.querySelector<HTMLButtonElement>('[data-testid="save-button"]')!
|
||||
}
|
||||
|
||||
export function patchCalls(backend: MetaBackend) {
|
||||
return backend.calls.filter(
|
||||
(call) => call.method === 'PATCH' && /^\/v1\/article\/\d+$/.test(call.path),
|
||||
)
|
||||
}
|
||||
|
||||
export async function unmountMeta() {
|
||||
metaKit.wrapper?.unmount()
|
||||
metaKit.wrapper = null
|
||||
vi.useRealTimers()
|
||||
await router.push('/login')
|
||||
document.body.innerHTML = ''
|
||||
}
|
||||
@@ -55,7 +55,13 @@ const GROUPS = [
|
||||
used_in: 4,
|
||||
merged: 2,
|
||||
},
|
||||
{ keep_id: 7, filename: 'IMG_4471.jpg', url: '/media/IMG_4471.jpg', used_in: 2, merged: 1 },
|
||||
{
|
||||
keep_id: 7,
|
||||
filename: 'IMG_4471.jpg',
|
||||
url: '/media/IMG_4471.jpg',
|
||||
used_in: 2,
|
||||
merged: 1,
|
||||
},
|
||||
{
|
||||
keep_id: 2,
|
||||
filename: 'kitchen-after.webp',
|
||||
@@ -63,7 +69,13 @@ const GROUPS = [
|
||||
used_in: 3,
|
||||
merged: 3,
|
||||
},
|
||||
{ keep_id: 4, filename: 'logo-renew.svg', url: '/media/logo-renew.svg', used_in: 5, merged: 3 },
|
||||
{
|
||||
keep_id: 4,
|
||||
filename: 'logo-renew.svg',
|
||||
url: '/media/logo-renew.svg',
|
||||
used_in: 5,
|
||||
merged: 3,
|
||||
},
|
||||
{
|
||||
keep_id: 11,
|
||||
filename: 'lime-plaster.jpg',
|
||||
@@ -86,11 +98,23 @@ export function previewOf(overrides: Partial<PurgePreview> = {}): PurgePreview {
|
||||
}
|
||||
|
||||
export function manyPreview() {
|
||||
return previewOf({ groups: 8, copies: 15, articles: 24, list: GROUPS, more: 3 })
|
||||
return previewOf({
|
||||
groups: 8,
|
||||
copies: 15,
|
||||
articles: 24,
|
||||
list: GROUPS,
|
||||
more: 3,
|
||||
})
|
||||
}
|
||||
|
||||
export function emptyPreview() {
|
||||
return previewOf({ groups: 0, copies: 0, articles: 0, list: [], fingerprint: 'none' })
|
||||
return previewOf({
|
||||
groups: 0,
|
||||
copies: 0,
|
||||
articles: 0,
|
||||
list: [],
|
||||
fingerprint: 'none',
|
||||
})
|
||||
}
|
||||
|
||||
export function singlePreview(usedIn: number, merged: number, articles: number, copies = merged) {
|
||||
@@ -172,7 +196,9 @@ function fetchFrom(request: (path: string, init?: RequestInit) => Promise<unknow
|
||||
|
||||
export async function mountSettings(
|
||||
mocks: PurgeMocks = {},
|
||||
options: { role?: string | undefined; realApi?: boolean } = { role: 'owner' },
|
||||
options: { role?: string | undefined; realApi?: boolean } = {
|
||||
role: 'owner',
|
||||
},
|
||||
) {
|
||||
setViewportWidth(DESKTOP_WIDTH)
|
||||
setActivePinia(createPinia())
|
||||
@@ -307,7 +333,11 @@ export async function commitByBlur(input: HTMLInputElement) {
|
||||
|
||||
export async function commitByEnter(input: HTMLInputElement) {
|
||||
input.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'Enter',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
)
|
||||
await flushPromises()
|
||||
}
|
||||
@@ -320,7 +350,11 @@ export async function goOffline() {
|
||||
|
||||
export function pressEscape(target: Element | null) {
|
||||
;(target ?? document.body).dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }),
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,11 @@ export function upToDate(overrides: Partial<UpdateStatus> = {}): UpdateStatus {
|
||||
latest: RUNNING_VERSION,
|
||||
available: false,
|
||||
release_url: RELEASE_URL,
|
||||
notes: { version: RUNNING_VERSION, released_at: RELEASED_AT, items: [] },
|
||||
notes: {
|
||||
version: RUNNING_VERSION,
|
||||
released_at: RELEASED_AT,
|
||||
items: [],
|
||||
},
|
||||
checked_at: hoursAgo(1),
|
||||
can_apply: false,
|
||||
state: 'idle',
|
||||
@@ -46,7 +50,11 @@ export function newerAvailable(overrides: Partial<UpdateStatus> = {}): UpdateSta
|
||||
return upToDate({
|
||||
latest: NEW_TAG,
|
||||
available: true,
|
||||
notes: { version: NEW_TAG, released_at: RELEASED_AT, items: NOTE_ITEMS },
|
||||
notes: {
|
||||
version: NEW_TAG,
|
||||
released_at: RELEASED_AT,
|
||||
items: NOTE_ITEMS,
|
||||
},
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
@@ -82,7 +90,11 @@ export function downloading(percent = 42): UpdateStatus {
|
||||
return startable({
|
||||
state: 'downloading',
|
||||
started_at: '2026-09-30T09:51:00Z',
|
||||
progress: { percent, downloaded_bytes: downloaded, total_bytes: BOARD_TOTAL_BYTES },
|
||||
progress: {
|
||||
percent,
|
||||
downloaded_bytes: downloaded,
|
||||
total_bytes: BOARD_TOTAL_BYTES,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -95,7 +107,11 @@ export function ready(): UpdateStatus {
|
||||
}
|
||||
|
||||
export function failedWith(error: 'download' | 'verification'): UpdateStatus {
|
||||
return startable({ state: 'failed', error, started_at: '2026-09-30T09:51:00Z' })
|
||||
return startable({
|
||||
state: 'failed',
|
||||
error,
|
||||
started_at: '2026-09-30T09:51:00Z',
|
||||
})
|
||||
}
|
||||
|
||||
export const RESTART_STARTED_AT = '2026-09-30T09:51:00Z'
|
||||
|
||||
@@ -143,13 +143,18 @@ export async function mountPage(options: MountOptions = {}) {
|
||||
)
|
||||
})
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
getMe: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ verified: true, username: 'marta', id: 1, role: sessionRole }),
|
||||
getMe: vi.fn().mockResolvedValue({
|
||||
verified: true,
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
role: sessionRole,
|
||||
}),
|
||||
request: requestMock,
|
||||
})
|
||||
await router.push('/settings/media/variants')
|
||||
const Host = defineComponent({ render: () => h(UApp, null, () => h(VariantsPage)) })
|
||||
const Host = defineComponent({
|
||||
render: () => h(UApp, null, () => h(VariantsPage)),
|
||||
})
|
||||
wrapper = mount(Host, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router, uiPlugin] },
|
||||
@@ -255,7 +260,12 @@ export async function blur(input: HTMLInputElement | null | undefined) {
|
||||
|
||||
export async function pressKey(element: Element, key: string, init: KeyboardEventInit = {}) {
|
||||
element.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init }),
|
||||
new KeyboardEvent('keydown', {
|
||||
key,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
...init,
|
||||
}),
|
||||
)
|
||||
await flushPromises()
|
||||
}
|
||||
@@ -263,7 +273,11 @@ export async function pressKey(element: Element, key: string, init: KeyboardEven
|
||||
export async function pressEscape() {
|
||||
const target = document.activeElement ?? document.body
|
||||
target.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }),
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
)
|
||||
await flushPromises()
|
||||
}
|
||||
@@ -345,6 +359,9 @@ export async function clickBackdrop() {
|
||||
|
||||
export function stubClipboard() {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
})
|
||||
return writeText
|
||||
}
|
||||
Reference in new issue
Block a user