1182 lines
38 KiB
TypeScript
1182 lines
38 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||
import { createPinia, setActivePinia } from 'pinia'
|
||
import { defineComponent, h } from 'vue'
|
||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||
import UButton from '@nuxt/ui/components/Button.vue'
|
||
import UApp from '@nuxt/ui/components/App.vue'
|
||
import router from '@/router'
|
||
import { ApiError, NetworkError, useApi } from '@/composables/useApi'
|
||
import type { ApiKey, CreatedApiKey } from '@/composables/useApiKeys'
|
||
import { useSessionStore } from '@/stores/session'
|
||
import { initializeLocale, setLocale } from '@/i18n'
|
||
import ApiKeysView from '../ApiKeysView.vue'
|
||
|
||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||
return {
|
||
...actual,
|
||
useApi: vi.fn(),
|
||
}
|
||
})
|
||
|
||
let toastAdd: ReturnType<typeof vi.fn>
|
||
|
||
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
||
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
|
||
return {
|
||
...actual,
|
||
useToast: () => ({ add: toastAdd }),
|
||
}
|
||
})
|
||
|
||
const LIST_PATH = '/v1/api-key'
|
||
const FULL_KEY = 'rcms_Kf3x9QwL0aBcT7nV2uYdE5sHjR8mPz4WqXg6C1oIyNe'
|
||
const CONNECTION_COPY = "Couldn't reach RenewCMS. Check your connection and try again."
|
||
const CREATE_FAILED_COPY = "Couldn't create the key. Try again in a moment."
|
||
const REVOKE_FAILED_COPY = "Couldn't revoke the key. Try again in a moment."
|
||
const DAY_MS = 24 * 60 * 60 * 1000
|
||
|
||
function ownerFixture(): ApiKey[] {
|
||
return [
|
||
{
|
||
id: 4,
|
||
user_id: 1,
|
||
name: 'Website build',
|
||
prefix: 'rcms_Kf3x9Qw',
|
||
status: 'active',
|
||
created_at: '2026-03-12T09:00:00Z',
|
||
last_used_at: '2026-10-03T09:00:00Z',
|
||
expires_at: null,
|
||
revoked_at: null,
|
||
created_by_name: 'Marta Alves',
|
||
},
|
||
{
|
||
id: 2,
|
||
user_id: 2,
|
||
name: 'Nightly media import',
|
||
prefix: 'rcms_Mq7bT2d',
|
||
status: 'active',
|
||
created_at: '2026-04-02T09:00:00Z',
|
||
last_used_at: '2026-10-03T09:00:00Z',
|
||
expires_at: '2026-12-31T12:00:00Z',
|
||
revoked_at: null,
|
||
created_by_name: 'Jonas',
|
||
},
|
||
{
|
||
id: 3,
|
||
user_id: 1,
|
||
name: 'Staging preview',
|
||
prefix: 'rcms_Zt2dL8a',
|
||
status: 'expired',
|
||
created_at: '2026-06-14T09:00:00Z',
|
||
last_used_at: '2026-08-20T09:00:00Z',
|
||
expires_at: '2026-08-30T12:00:00Z',
|
||
revoked_at: null,
|
||
created_by_name: 'Marta Alves',
|
||
},
|
||
{
|
||
id: 1,
|
||
user_id: 3,
|
||
name: 'Old migration script',
|
||
prefix: 'rcms_Hn5wR4c',
|
||
status: 'revoked',
|
||
created_at: '2026-04-03T09:00:00Z',
|
||
last_used_at: '2026-05-09T09:00:00Z',
|
||
expires_at: null,
|
||
revoked_at: '2026-05-10T09:00:00Z',
|
||
created_by_name: 'Inès Duarte',
|
||
},
|
||
]
|
||
}
|
||
|
||
function adminFixture(): ApiKey[] {
|
||
return [
|
||
{
|
||
id: 2,
|
||
user_id: 2,
|
||
name: 'Nightly media import',
|
||
prefix: 'rcms_Mq7bT2d',
|
||
status: 'active',
|
||
created_at: '2026-04-02T09:00:00Z',
|
||
last_used_at: '2026-10-03T09:00:00Z',
|
||
expires_at: '2026-12-31T12:00:00Z',
|
||
revoked_at: null,
|
||
},
|
||
{
|
||
id: 1,
|
||
user_id: 2,
|
||
name: 'Old CI token',
|
||
prefix: 'rcms_Lp9cE1x',
|
||
status: 'revoked',
|
||
created_at: '2026-01-18T09:00:00Z',
|
||
last_used_at: '2026-02-02T09:00:00Z',
|
||
expires_at: null,
|
||
revoked_at: '2026-02-03T09:00:00Z',
|
||
},
|
||
]
|
||
}
|
||
|
||
function createdResponse(
|
||
name = 'Nightly media import',
|
||
expiresAt: string | null = null,
|
||
): CreatedApiKey {
|
||
return {
|
||
id: 9,
|
||
name,
|
||
prefix: FULL_KEY.slice(0, 12),
|
||
key: FULL_KEY,
|
||
created_at: new Date().toISOString(),
|
||
expires_at: expiresAt,
|
||
}
|
||
}
|
||
|
||
function deferred<T>() {
|
||
let resolve!: (value: T) => void
|
||
let reject!: (reason: unknown) => void
|
||
const promise = new Promise<T>((res, rej) => {
|
||
resolve = res
|
||
reject = rej
|
||
})
|
||
return { promise, resolve, reject }
|
||
}
|
||
|
||
function setViewport(desktop: boolean) {
|
||
window.matchMedia = ((query: string) => ({
|
||
matches: desktop,
|
||
media: query,
|
||
onchange: null,
|
||
addEventListener: () => undefined,
|
||
removeEventListener: () => undefined,
|
||
addListener: () => undefined,
|
||
removeListener: () => undefined,
|
||
dispatchEvent: () => false,
|
||
})) as unknown as typeof window.matchMedia
|
||
}
|
||
|
||
let requestMock: ReturnType<typeof vi.fn>
|
||
let wrapper: VueWrapper | null = null
|
||
|
||
interface Handlers {
|
||
list?: () => Promise<unknown>
|
||
create?: (body: Record<string, unknown>) => Promise<unknown>
|
||
revoke?: (path: string) => Promise<unknown>
|
||
}
|
||
|
||
interface MountOptions {
|
||
desktop?: boolean
|
||
role?: string | null
|
||
username?: string
|
||
displayName?: string
|
||
}
|
||
|
||
async function mountPage(handlers: Handlers = {}, options: MountOptions = {}) {
|
||
const {
|
||
desktop = true,
|
||
role = 'owner',
|
||
username = 'marta',
|
||
displayName = 'Marta Alves',
|
||
} = options
|
||
setViewport(desktop)
|
||
setActivePinia(createPinia())
|
||
const session = useSessionStore()
|
||
const sessionRole = role ?? undefined
|
||
session.session = {
|
||
verified: true,
|
||
username,
|
||
id: 1,
|
||
displayName,
|
||
role: sessionRole as 'owner' | 'admin' | undefined,
|
||
}
|
||
session.status = 'ready'
|
||
requestMock = vi.fn((path: string, init?: RequestInit) => {
|
||
if (path === LIST_PATH && (init?.method ?? 'GET') === 'GET') {
|
||
return (handlers.list ?? (() => Promise.resolve(ownerFixture())))()
|
||
}
|
||
if (path === LIST_PATH && init?.method === 'POST') {
|
||
const body = JSON.parse(String(init.body)) as Record<string, unknown>
|
||
return (handlers.create ?? (() => Promise.resolve(createdResponse(String(body.name)))))(
|
||
body,
|
||
)
|
||
}
|
||
if (init?.method === 'DELETE') {
|
||
return (handlers.revoke ?? (() => Promise.resolve(undefined)))(path)
|
||
}
|
||
throw new Error(`Unexpected request: ${path}`)
|
||
})
|
||
vi.mocked(useApi).mockReturnValue({
|
||
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)) })
|
||
wrapper = mount(Host, {
|
||
attachTo: document.body,
|
||
global: { plugins: [router, uiPlugin] },
|
||
})
|
||
await flushPromises()
|
||
return wrapper
|
||
}
|
||
|
||
function textOf(element: Element) {
|
||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT)
|
||
const parts: string[] = []
|
||
while (walker.nextNode()) {
|
||
const text = walker.currentNode.textContent?.trim()
|
||
if (text) {
|
||
parts.push(text)
|
||
}
|
||
}
|
||
return parts.join(' ')
|
||
}
|
||
|
||
function rows() {
|
||
return Array.from(
|
||
document.body.querySelectorAll<HTMLElement>('tbody tr, [data-testid="api-key-card"]'),
|
||
)
|
||
}
|
||
|
||
function rowTexts() {
|
||
return rows().map(textOf)
|
||
}
|
||
|
||
function rowOf(name: string) {
|
||
return rows().find((row) => row.textContent?.includes(name))!
|
||
}
|
||
|
||
function buttonsIn(root: ParentNode) {
|
||
return Array.from(root.querySelectorAll('button'))
|
||
}
|
||
|
||
function buttonIn(root: ParentNode, text: string) {
|
||
return buttonsIn(root).find((button) => button.textContent?.trim() === text)
|
||
}
|
||
|
||
function findButton(text: string) {
|
||
return buttonIn(document.body, text)
|
||
}
|
||
|
||
function formModal() {
|
||
return document.body.querySelector<HTMLElement>('[role="dialog"]')
|
||
}
|
||
|
||
function alertDialog() {
|
||
return document.body.querySelector<HTMLElement>('[role="alertdialog"]')
|
||
}
|
||
|
||
function modalButton(text: string) {
|
||
return buttonIn(formModal()!, text)
|
||
}
|
||
|
||
async function click(button: HTMLElement | undefined) {
|
||
button?.click()
|
||
await flushPromises()
|
||
}
|
||
|
||
function nameInput() {
|
||
return document.body.querySelector<HTMLInputElement>('input[name="name"]')!
|
||
}
|
||
|
||
function expiryInput() {
|
||
return document.body.querySelector<HTMLInputElement>('input[type="date"]')!
|
||
}
|
||
|
||
async function typeInto(input: HTMLInputElement, value: string) {
|
||
input.value = value
|
||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||
await flushPromises()
|
||
}
|
||
|
||
async function openCreateModal(label = 'Create key') {
|
||
const opener = findButton(label)!
|
||
opener.focus()
|
||
opener.click()
|
||
await flushPromises()
|
||
}
|
||
|
||
async function fillAndSubmit(name: string, expiry = '', label = 'Create key') {
|
||
await typeInto(nameInput(), name)
|
||
if (expiry) {
|
||
await typeInto(expiryInput(), expiry)
|
||
}
|
||
await click(modalButton(label))
|
||
}
|
||
|
||
function postCalls() {
|
||
return requestMock.mock.calls.filter(([, init]) => init?.method === 'POST')
|
||
}
|
||
|
||
function deleteCalls() {
|
||
return requestMock.mock.calls.filter(([, init]) => init?.method === 'DELETE')
|
||
}
|
||
|
||
function listCalls() {
|
||
return requestMock.mock.calls.filter(
|
||
([path, init]) => path === LIST_PATH && (init?.method ?? 'GET') === 'GET',
|
||
)
|
||
}
|
||
|
||
function postedBody() {
|
||
return JSON.parse(String(postCalls()[0]![1].body)) as Record<string, unknown>
|
||
}
|
||
|
||
function iconOf(button: HTMLElement) {
|
||
return wrapper!
|
||
.findAllComponents(UButton)
|
||
.find((component) => component.element === button)
|
||
?.props('icon')
|
||
}
|
||
|
||
function pressEscape() {
|
||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||
}
|
||
|
||
async function escape() {
|
||
pressEscape()
|
||
await flushPromises()
|
||
}
|
||
|
||
async function clickBackdrop() {
|
||
const overlay = document.body.querySelector<HTMLElement>('[data-slot="overlay"]')!
|
||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||
const down = new MouseEvent('pointerdown', { bubbles: true, button: 0 })
|
||
Object.defineProperty(down, 'pointerType', { value: 'mouse' })
|
||
overlay.dispatchEvent(down)
|
||
overlay.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||
await flushPromises()
|
||
}
|
||
|
||
function formatLocalDate(dateValue: string) {
|
||
const [year, month, day] = dateValue.split('-').map(Number)
|
||
const months = [
|
||
'Jan',
|
||
'Feb',
|
||
'Mar',
|
||
'Apr',
|
||
'May',
|
||
'Jun',
|
||
'Jul',
|
||
'Aug',
|
||
'Sep',
|
||
'Oct',
|
||
'Nov',
|
||
'Dec',
|
||
]
|
||
return `${day} ${months[month! - 1]} ${year}`
|
||
}
|
||
|
||
function localDate(offsetDays: number) {
|
||
const date = new Date(Date.now() + offsetDays * DAY_MS)
|
||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||
const day = String(date.getDate()).padStart(2, '0')
|
||
return `${date.getFullYear()}-${month}-${day}`
|
||
}
|
||
|
||
function alertText() {
|
||
return document.body.querySelector('[role="alert"]')?.textContent?.trim() ?? ''
|
||
}
|
||
|
||
function stubClipboard() {
|
||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
|
||
return writeText
|
||
}
|
||
|
||
beforeEach(() => {
|
||
toastAdd = vi.fn()
|
||
localStorage.clear()
|
||
sessionStorage.clear()
|
||
initializeLocale()
|
||
})
|
||
|
||
afterEach(async () => {
|
||
wrapper?.unmount()
|
||
wrapper = null
|
||
vi.useRealTimers()
|
||
document.body.innerHTML = ''
|
||
await router.push('/login')
|
||
localStorage.clear()
|
||
initializeLocale()
|
||
})
|
||
|
||
describe('Owner: list', () => {
|
||
it('shows the header, the four rows in fixture order and the note on desktop', async () => {
|
||
await mountPage()
|
||
|
||
const breadcrumb = document.body.querySelector('[data-testid="api-keys-breadcrumb"]')!
|
||
expect(textOf(breadcrumb)).toBe('Integration › API keys')
|
||
expect(breadcrumb.querySelector('a[href="/integration"]')?.textContent).toContain(
|
||
'Integration',
|
||
)
|
||
expect(document.body.querySelector('h1')?.textContent).toBe('API keys')
|
||
expect(document.body.textContent).toContain(
|
||
"Keys act as the user who created them. As an owner you see everyone's keys.",
|
||
)
|
||
expect(findButton('Create key')).toBeTruthy()
|
||
expect(document.body.textContent).toContain(
|
||
"Resetting a user's password revokes all of their keys.",
|
||
)
|
||
|
||
const header = document.body.querySelector('thead tr')!
|
||
expect(textOf(header)).toBe('Name Key Status Created by Created Last used Expires')
|
||
expect(rowTexts()).toEqual([
|
||
'Website build rcms_Kf3x… active Marta Alves 12 Mar 2026 3 Oct 2026 None Revoke',
|
||
'Nightly media import rcms_Mq7b… active Jonas 2 Apr 2026 3 Oct 2026 31 Dec 2026 Revoke',
|
||
'Staging preview rcms_Zt2d… expired Marta Alves 14 Jun 2026 20 Aug 2026 30 Aug 2026 Revoke',
|
||
'Old migration script rcms_Hn5w… revoked Inès Duarte 3 Apr 2026 9 May 2026 None',
|
||
])
|
||
expect(document.body.textContent).not.toContain('rcms_Kf3x9')
|
||
})
|
||
|
||
it('mutes the revoked row and offers no Revoke on it', async () => {
|
||
await mountPage()
|
||
|
||
const revoked = rowOf('Old migration script')
|
||
expect(revoked.className).toContain('bg-muted')
|
||
expect(buttonIn(revoked, 'Revoke')).toBeUndefined()
|
||
expect(buttonIn(rowOf('Staging preview'), 'Revoke')).toBeTruthy()
|
||
expect(rowOf('Website build').className).not.toContain('bg-muted')
|
||
})
|
||
|
||
it('shows an em dash when the creator name is empty', async () => {
|
||
const fixture = ownerFixture()
|
||
fixture[0]!.created_by_name = ''
|
||
await mountPage({ list: () => Promise.resolve(fixture) })
|
||
|
||
expect(rowTexts()[0]).toContain('rcms_Kf3x… active — 12 Mar 2026')
|
||
})
|
||
|
||
it('renders the same keys as cards below md', async () => {
|
||
await mountPage({}, { desktop: false })
|
||
|
||
expect(rowTexts()).toEqual([
|
||
'Website build active rcms_Kf3x… Created 12 Mar 2026 by Marta Alves · Last used 3 Oct 2026 · Expires None Revoke',
|
||
'Nightly media import active rcms_Mq7b… Created 2 Apr 2026 by Jonas · Last used 3 Oct 2026 · Expires 31 Dec 2026 Revoke',
|
||
'Staging preview expired rcms_Zt2d… Created 14 Jun 2026 by Marta Alves · Last used 20 Aug 2026 · Expires 30 Aug 2026 Revoke',
|
||
'Old migration script revoked rcms_Hn5w… Created 3 Apr 2026 by Inès Duarte · Last used 9 May 2026 · Expires None',
|
||
])
|
||
expect(document.body.querySelector('table')).toBeNull()
|
||
expect(document.body.querySelector('thead')).toBeNull()
|
||
expect(buttonIn(rowOf('Website build'), 'Revoke')!.className).toContain('h-11')
|
||
})
|
||
|
||
it('colors the status badges success, warning and neutral', async () => {
|
||
await mountPage()
|
||
|
||
const badge = (name: string, word: string) =>
|
||
Array.from(rowOf(name).querySelectorAll('span')).find(
|
||
(el) => el.textContent?.trim() === word,
|
||
)!
|
||
expect(badge('Website build', 'active').className).toContain('text-success')
|
||
expect(badge('Staging preview', 'expired').className).toContain('text-warning')
|
||
expect(badge('Old migration script', 'revoked').className).toContain('text-muted')
|
||
})
|
||
|
||
it('gives the Integration sidebar link aria-current on /integration/api-keys', async () => {
|
||
await mountPage()
|
||
|
||
const link = document.body.querySelector('nav[aria-label="Main"] a[href="/integration"]')
|
||
expect(link?.getAttribute('aria-current')).toBe('page')
|
||
})
|
||
})
|
||
|
||
describe('Admin: own keys only', () => {
|
||
it('shows two rows with no Created by column and the admin description', async () => {
|
||
await mountPage({ list: () => Promise.resolve(adminFixture()) }, { role: 'admin' })
|
||
|
||
expect(textOf(document.body.querySelector('thead tr')!)).toBe(
|
||
'Name Key Status Created Last used Expires',
|
||
)
|
||
expect(rowTexts()).toEqual([
|
||
'Nightly media import rcms_Mq7b… active 2 Apr 2026 3 Oct 2026 31 Dec 2026 Revoke',
|
||
'Old CI token rcms_Lp9c… revoked 18 Jan 2026 2 Feb 2026 None',
|
||
])
|
||
expect(document.body.textContent).toContain(
|
||
'Keys act as you, with your role. You only see your own keys.',
|
||
)
|
||
expect(document.body.textContent).not.toContain('Created by')
|
||
})
|
||
|
||
it('reads the cards without " by {name}" below md', async () => {
|
||
await mountPage(
|
||
{ list: () => Promise.resolve(adminFixture()) },
|
||
{ role: 'admin', desktop: false, username: 'jonas' },
|
||
)
|
||
|
||
expect(rowTexts()[0]).toBe(
|
||
'Nightly media import active rcms_Mq7b… Created 2 Apr 2026 · Last used 3 Oct 2026 · Expires 31 Dec 2026 Revoke',
|
||
)
|
||
})
|
||
|
||
it('renders a session without a role like an admin', async () => {
|
||
await mountPage({ list: () => Promise.resolve(adminFixture()) }, { role: null })
|
||
|
||
expect(document.body.textContent).toContain('You only see your own keys.')
|
||
expect(document.body.textContent).not.toContain('Created by')
|
||
})
|
||
})
|
||
|
||
describe('Loading', () => {
|
||
it('draws four skeleton rows with aria-busy, keeps Create key, then shows the rows', async () => {
|
||
const pending = deferred<ApiKey[]>()
|
||
await mountPage({ list: () => pending.promise })
|
||
|
||
const busy = document.body.querySelector('[aria-busy="true"]')!
|
||
expect(busy.querySelector('table')).toBeTruthy()
|
||
expect(busy.querySelectorAll('tbody tr')).toHaveLength(4)
|
||
expect(busy.querySelectorAll('.animate-pulse').length).toBeGreaterThanOrEqual(4)
|
||
expect(findButton('Create key')).toBeTruthy()
|
||
|
||
pending.resolve(ownerFixture())
|
||
await flushPromises()
|
||
expect(document.body.querySelector('[aria-busy="true"]')).toBeNull()
|
||
expect(rows()).toHaveLength(4)
|
||
})
|
||
|
||
it('draws two-bar cards below md', async () => {
|
||
await mountPage({ list: () => deferred<ApiKey[]>().promise }, { desktop: false })
|
||
|
||
const busy = document.body.querySelector('[aria-busy="true"]')!
|
||
expect(busy.querySelectorAll('li')).toHaveLength(4)
|
||
expect(busy.querySelectorAll('.animate-pulse')).toHaveLength(8)
|
||
})
|
||
})
|
||
|
||
describe('Empty', () => {
|
||
it('shows the panel with its own Create key and no table', async () => {
|
||
await mountPage({ list: () => Promise.resolve([]) })
|
||
|
||
expect(document.body.textContent).toContain('No API keys yet')
|
||
expect(document.body.textContent).toContain(
|
||
'Create a key to call the API from a script or service.',
|
||
)
|
||
expect(document.body.querySelector('table')).toBeNull()
|
||
expect(
|
||
buttonsIn(document.body).filter(
|
||
(button) => button.textContent?.trim() === 'Create key',
|
||
),
|
||
).toHaveLength(2)
|
||
})
|
||
})
|
||
|
||
describe.each([
|
||
{ name: 'status 500', failure: () => new ApiError(500) },
|
||
{ name: 'a network error', failure: () => new NetworkError() },
|
||
])('Error on load: $name', ({ failure }) => {
|
||
it('shows the alert with Try again and nothing else, then recovers', async () => {
|
||
let attempts = 0
|
||
await mountPage({
|
||
list: () =>
|
||
++attempts === 1 ? Promise.reject(failure()) : Promise.resolve(ownerFixture()),
|
||
})
|
||
|
||
expect(alertText()).toContain("Couldn't load your API keys")
|
||
expect(alertText()).toContain('Check your connection and try again.')
|
||
expect(rows()).toHaveLength(0)
|
||
expect(document.body.querySelector('table')).toBeNull()
|
||
expect(document.body.textContent).not.toContain('No API keys yet')
|
||
|
||
await click(findButton('Try again'))
|
||
expect(listCalls()).toHaveLength(2)
|
||
expect(document.body.querySelector('[role="alert"]')).toBeNull()
|
||
expect(rows()).toHaveLength(4)
|
||
})
|
||
})
|
||
|
||
describe.each([401, 403])('Session problems: %i', (status) => {
|
||
it('leaves the redirect to the API client and shows no alert', async () => {
|
||
await mountPage({ list: () => Promise.reject(new ApiError(status)) })
|
||
|
||
expect(document.body.textContent).not.toContain("Couldn't load your API keys")
|
||
expect(document.body.querySelector('[aria-busy="true"]')).toBeTruthy()
|
||
})
|
||
})
|
||
|
||
describe('Create key: modal opened', () => {
|
||
it('shows the form copy, counter, helps, buttons and focuses Name', async () => {
|
||
await mountPage()
|
||
await openCreateModal()
|
||
|
||
const modal = formModal()!
|
||
expect(modal.textContent).toContain('Create API key')
|
||
expect(modal.textContent).toContain(
|
||
'The key acts as you, with your role. It is shown in full once.',
|
||
)
|
||
expect(modal.textContent).toContain('0 / 60')
|
||
expect(modal.textContent).toContain('Required. Up to 60 characters.')
|
||
expect(modal.textContent).toContain('Expiry date (optional)')
|
||
expect(modal.textContent).toContain(
|
||
'Optional. Must be in the future. Leave it empty for a key that does not expire.',
|
||
)
|
||
expect(modalButton('Cancel')).toBeTruthy()
|
||
expect(modalButton('Create key')).toBeTruthy()
|
||
expect(document.activeElement).toBe(nameInput())
|
||
})
|
||
})
|
||
|
||
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 "$name" and expiry "$expiry" sends nothing',
|
||
async ({ name, expiry, nameError, expiryError }) => {
|
||
await mountPage()
|
||
await openCreateModal()
|
||
|
||
await fillAndSubmit(name, expiry)
|
||
|
||
const text = formModal()!.textContent
|
||
expect(text?.includes('Enter a name.')).toBe(nameError)
|
||
expect(text?.includes('Choose a date in the future.')).toBe(expiryError)
|
||
expect(postCalls()).toHaveLength(0)
|
||
expect(formModal()).toBeTruthy()
|
||
},
|
||
)
|
||
|
||
it('colors the errors red and clears them once fixed', async () => {
|
||
await mountPage()
|
||
await openCreateModal()
|
||
await fillAndSubmit('', '2026-03-12')
|
||
|
||
const errors = Array.from(formModal()!.querySelectorAll('.text-error')).map((el) =>
|
||
el.textContent?.trim(),
|
||
)
|
||
expect(errors).toContain('Enter a name.')
|
||
expect(errors).toContain('Choose a date in the future.')
|
||
|
||
await typeInto(nameInput(), 'Deploy')
|
||
await typeInto(expiryInput(), localDate(5))
|
||
expect(formModal()!.textContent).not.toContain('Enter a name.')
|
||
expect(formModal()!.textContent).not.toContain('Choose a date in the future.')
|
||
})
|
||
})
|
||
|
||
describe('Create key: name too long', () => {
|
||
it('turns the counter and the error red at 75 characters and sends nothing', async () => {
|
||
await mountPage()
|
||
await openCreateModal()
|
||
|
||
await typeInto(nameInput(), 'x'.repeat(75))
|
||
|
||
const counter = Array.from(formModal()!.querySelectorAll('span')).findLast(
|
||
(el) => el.textContent?.trim() === '75 / 60',
|
||
)!
|
||
expect(counter.className).toContain('text-error')
|
||
expect(formModal()!.textContent).toContain('Use 60 characters or fewer.')
|
||
|
||
await click(modalButton('Create key'))
|
||
expect(postCalls()).toHaveLength(0)
|
||
})
|
||
})
|
||
|
||
describe('Create key: success reveals the key once', () => {
|
||
it('posts only the name and replaces the form with the reveal', async () => {
|
||
await mountPage()
|
||
await openCreateModal()
|
||
|
||
await fillAndSubmit('Nightly media import')
|
||
|
||
expect(postCalls()).toHaveLength(1)
|
||
expect(postCalls()[0]![0]).toBe('/v1/api-key')
|
||
expect(postedBody()).toEqual({ name: 'Nightly media import' })
|
||
const modal = formModal()!
|
||
expect(modal.textContent).toContain('Your new API key')
|
||
expect(modal.textContent).not.toContain('Create API key')
|
||
expect(modal.textContent).toContain(
|
||
'"Nightly media import" is ready. Copy the key and store it somewhere safe.',
|
||
)
|
||
const warning = modal.querySelector('[role="alert"]')!
|
||
expect(warning.textContent).toContain("You won't be able to see this key again.")
|
||
expect(modal.textContent).toContain(FULL_KEY)
|
||
expect(modal.querySelector('.font-mono')?.textContent?.trim()).toBe(FULL_KEY)
|
||
expect(modal.textContent).toContain(
|
||
'Closing this window discards the key from the screen. Copy it first.',
|
||
)
|
||
expect(buttonsIn(modal).map((button) => button.textContent?.trim())).toEqual([
|
||
'Copy',
|
||
'Done',
|
||
])
|
||
})
|
||
|
||
it('puts initial focus on Copy', async () => {
|
||
await mountPage()
|
||
await openCreateModal()
|
||
await fillAndSubmit('Nightly media import')
|
||
|
||
expect(document.activeElement).toBe(modalButton('Copy'))
|
||
})
|
||
})
|
||
|
||
describe('Create key: with an expiry', () => {
|
||
it('sends 23:59:59 local on D + 30 days as an RFC3339 instant with the offset', async () => {
|
||
await mountPage()
|
||
await openCreateModal()
|
||
const date = localDate(30)
|
||
|
||
await fillAndSubmit('temp', date)
|
||
|
||
const body = postedBody()
|
||
expect(body.name).toBe('temp')
|
||
const sent = String(body.expires_at)
|
||
expect(sent).toMatch(/^\d{4}-\d{2}-\d{2}T23:59:59[+-]\d{2}:\d{2}$/)
|
||
expect(sent.startsWith(`${date}T23:59:59`)).toBe(true)
|
||
const instant = new Date(sent)
|
||
expect([instant.getHours(), instant.getMinutes(), instant.getSeconds()]).toEqual([
|
||
23, 59, 59,
|
||
])
|
||
expect(localDate(0) < date).toBe(true)
|
||
})
|
||
|
||
it.each([
|
||
{ label: 'today', offset: 0, sent: true },
|
||
{ label: 'yesterday', offset: -1, sent: false },
|
||
{ label: 'tomorrow', offset: 1, sent: true },
|
||
])('$label: request sent is $sent', async ({ offset, sent }) => {
|
||
await mountPage()
|
||
await openCreateModal()
|
||
|
||
await fillAndSubmit('temp', localDate(offset))
|
||
|
||
expect(postCalls()).toHaveLength(sent ? 1 : 0)
|
||
expect(formModal()!.textContent?.includes('Choose a date in the future.')).toBe(!sent)
|
||
})
|
||
})
|
||
|
||
describe('One-time reveal: copied', () => {
|
||
it('writes the key, shows Copied announced for 2 s, then reverts', async () => {
|
||
const writeText = stubClipboard()
|
||
await mountPage()
|
||
await openCreateModal()
|
||
await fillAndSubmit('Nightly media import')
|
||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||
|
||
await click(modalButton('Copy'))
|
||
|
||
expect(writeText).toHaveBeenCalledWith(FULL_KEY)
|
||
const copied = modalButton('Copied')!
|
||
expect(copied).toBeTruthy()
|
||
expect(copied.className).toContain('text-success')
|
||
expect(iconOf(copied)).toBe('i-lucide-check')
|
||
expect(copied.getAttribute('aria-live')).toBe('polite')
|
||
expect(formModal()!.querySelector('[aria-live="polite"]')?.textContent?.trim()).toBe(
|
||
'Copied',
|
||
)
|
||
|
||
vi.advanceTimersByTime(2000)
|
||
await flushPromises()
|
||
const copy = modalButton('Copy')!
|
||
expect(copy).toBeTruthy()
|
||
expect(modalButton('Copied')).toBeUndefined()
|
||
expect(iconOf(copy)).toBe('i-lucide-copy')
|
||
expect(formModal()!.querySelector('[aria-live="polite"]')?.textContent?.trim()).toBe('Copy')
|
||
})
|
||
})
|
||
|
||
describe.each([
|
||
{ name: 'Done', dismiss: async () => click(modalButton('Done')) },
|
||
{ name: 'Escape', dismiss: async () => escape() },
|
||
])('One-time reveal: $name discards the key', ({ dismiss }) => {
|
||
it('closes, forgets the key everywhere, lists the new key first and makes no second GET', async () => {
|
||
await mountPage({
|
||
create: (body) =>
|
||
Promise.resolve(createdResponse('Nightly media import', String(body.expires_at))),
|
||
})
|
||
await openCreateModal()
|
||
await fillAndSubmit('Nightly media import', localDate(30))
|
||
|
||
await dismiss()
|
||
|
||
expect(formModal()).toBeNull()
|
||
expect(document.body.innerHTML).not.toContain(FULL_KEY)
|
||
expect(wrapper!.html()).not.toContain(FULL_KEY)
|
||
expect(router.currentRoute.value.fullPath).not.toContain(FULL_KEY)
|
||
expect(JSON.stringify({ ...localStorage })).not.toContain(FULL_KEY)
|
||
expect(JSON.stringify({ ...sessionStorage })).not.toContain(FULL_KEY)
|
||
expect(rows()).toHaveLength(5)
|
||
const first = textOf(rows()[0]!)
|
||
expect(first).toContain('Nightly media import rcms_Kf3x… active Marta Alves')
|
||
expect(first).toContain('Never')
|
||
expect(first).toContain(formatLocalDate(localDate(30)))
|
||
expect(first).not.toContain('None')
|
||
expect(first).toContain('Revoke')
|
||
expect(listCalls()).toHaveLength(1)
|
||
|
||
await openCreateModal()
|
||
expect(nameInput().value).toBe('')
|
||
expect(expiryInput().value).toBe('')
|
||
})
|
||
})
|
||
|
||
describe('One-time reveal: Done lists an empty expiry as None', () => {
|
||
it('shows None in the Expires cell of the new row', async () => {
|
||
await mountPage()
|
||
await openCreateModal()
|
||
await fillAndSubmit('Nightly media import')
|
||
|
||
await click(modalButton('Done'))
|
||
|
||
expect(textOf(rows()[0]!)).toContain('active Marta Alves')
|
||
expect(textOf(rows()[0]!)).toContain('Never None Revoke')
|
||
})
|
||
})
|
||
|
||
describe('Dismissing the modals', () => {
|
||
it('keeps the reveal open on a backdrop click', async () => {
|
||
await mountPage()
|
||
await openCreateModal()
|
||
await fillAndSubmit('Nightly media import')
|
||
|
||
await clickBackdrop()
|
||
|
||
expect(formModal()!.textContent).toContain('Your new API key')
|
||
})
|
||
|
||
it('keeps the Create key form open on a backdrop click', async () => {
|
||
await mountPage()
|
||
await openCreateModal()
|
||
|
||
await clickBackdrop()
|
||
|
||
expect(formModal()).toBeTruthy()
|
||
})
|
||
|
||
it('closes the Create key form on Escape with no request', async () => {
|
||
await mountPage()
|
||
await openCreateModal()
|
||
|
||
await escape()
|
||
|
||
expect(formModal()).toBeNull()
|
||
expect(postCalls()).toHaveLength(0)
|
||
})
|
||
})
|
||
|
||
describe('Backdrop helper control', () => {
|
||
it('closes the revoke dialog, which is dismissible', async () => {
|
||
await mountPage()
|
||
await click(buttonIn(rowOf('Nightly media import'), 'Revoke'))
|
||
|
||
await clickBackdrop()
|
||
|
||
expect(alertDialog()).toBeNull()
|
||
expect(deleteCalls()).toHaveLength(0)
|
||
})
|
||
})
|
||
|
||
describe('Create key: Cancel discards the form', () => {
|
||
it('closes with no request, returns focus to the opener and reopens empty', async () => {
|
||
await mountPage()
|
||
await openCreateModal()
|
||
await typeInto(nameInput(), 'Draft name')
|
||
await typeInto(expiryInput(), localDate(5))
|
||
|
||
await click(modalButton('Cancel'))
|
||
|
||
expect(formModal()).toBeNull()
|
||
expect(postCalls()).toHaveLength(0)
|
||
expect(document.activeElement).toBe(findButton('Create key'))
|
||
await openCreateModal()
|
||
expect(nameInput().value).toBe('')
|
||
expect(expiryInput().value).toBe('')
|
||
})
|
||
})
|
||
|
||
describe('Revoke: confirm', () => {
|
||
it('opens the dialog with the 9-character prefix and both buttons', async () => {
|
||
await mountPage()
|
||
|
||
await click(buttonIn(rowOf('Nightly media import'), 'Revoke'))
|
||
|
||
const dialog = alertDialog()!
|
||
expect(dialog.textContent).toContain('Revoke this key?')
|
||
expect(dialog.textContent).toContain('(rcms_Mq7b…)')
|
||
expect(dialog.textContent).not.toContain('rcms_Mq7bT2d')
|
||
expect(dialog.textContent).toContain(
|
||
'"Nightly media import" (rcms_Mq7b…) will stop working right away. Requests that use it answer 401. This can\'t be undone.',
|
||
)
|
||
expect(buttonsIn(dialog).map((button) => button.textContent?.trim())).toEqual([
|
||
'Keep key',
|
||
'Revoke key',
|
||
])
|
||
})
|
||
|
||
it('closes on Keep key with no request and the row unchanged', async () => {
|
||
await mountPage()
|
||
await click(buttonIn(rowOf('Nightly media import'), 'Revoke'))
|
||
|
||
await click(buttonIn(alertDialog()!, 'Keep key'))
|
||
|
||
expect(alertDialog()).toBeNull()
|
||
expect(deleteCalls()).toHaveLength(0)
|
||
expect(textOf(rowOf('Nightly media import'))).toContain('active')
|
||
})
|
||
})
|
||
|
||
describe('Revoked', () => {
|
||
it('sends one DELETE, keeps the row as revoked and muted, toasts and makes no second GET', async () => {
|
||
await mountPage()
|
||
await click(buttonIn(rowOf('Nightly media import'), 'Revoke'))
|
||
|
||
await click(buttonIn(alertDialog()!, 'Revoke key'))
|
||
|
||
expect(deleteCalls()).toHaveLength(1)
|
||
expect(deleteCalls()[0]![0]).toBe('/v1/api-key/2')
|
||
expect(alertDialog()).toBeNull()
|
||
const row = rowOf('Nightly media import')
|
||
expect(textOf(row)).toContain('revoked')
|
||
expect(row.className).toContain('bg-muted')
|
||
expect(buttonIn(row, 'Revoke')).toBeUndefined()
|
||
expect(listCalls()).toHaveLength(1)
|
||
expect(toastAdd).toHaveBeenCalledTimes(1)
|
||
const toast = toastAdd.mock.calls[0]![0]
|
||
expect(toast.color).toBe('success')
|
||
expect(toast.title).toBe('Key revoked')
|
||
expect(toast.description).toBe('"Nightly media import" can no longer be used.')
|
||
})
|
||
})
|
||
|
||
describe('Mobile Create key button, not drawn on board', () => {
|
||
it.each([
|
||
{ name: 'list', list: () => Promise.resolve(ownerFixture()) },
|
||
{ name: 'loading', list: () => deferred<ApiKey[]>().promise },
|
||
{ name: 'error', list: () => Promise.reject(new ApiError(500)) },
|
||
])('is pinned in a sticky footer, full width and 48 px in $name with no header button', async ({ list }) => {
|
||
await mountPage({ list }, { desktop: false })
|
||
|
||
const buttons = buttonsIn(document.body).filter(
|
||
(button) => button.textContent?.trim() === 'Create key',
|
||
)
|
||
expect(buttons).toHaveLength(1)
|
||
expect(buttons[0]!.parentElement!.className).toContain('sticky')
|
||
expect(buttons[0]!.className).toContain('h-12')
|
||
expect(buttons[0]!.className).toContain('w-full')
|
||
expect(buttons[0]!.parentElement!.style.display).not.toBe('none')
|
||
})
|
||
|
||
it('is not drawn in the Empty state', async () => {
|
||
await mountPage({ list: () => Promise.resolve([]) }, { desktop: false })
|
||
|
||
const buttons = buttonsIn(document.body).filter(
|
||
(button) => button.textContent?.trim() === 'Create key',
|
||
)
|
||
expect(buttons).toHaveLength(1)
|
||
expect(buttons[0]!.parentElement!.className).not.toContain('sticky')
|
||
})
|
||
|
||
it('is hidden while a modal is open', async () => {
|
||
await mountPage({}, { desktop: false })
|
||
|
||
await openCreateModal()
|
||
|
||
const pinned = buttonsIn(document.body).find(
|
||
(button) =>
|
||
button.textContent?.trim() === 'Create key' &&
|
||
button.parentElement!.className.includes('sticky'),
|
||
)!
|
||
expect(pinned.parentElement!.style.display).toBe('none')
|
||
})
|
||
})
|
||
|
||
describe('Not drawn: Create sending', () => {
|
||
it('reads Creating…, disables the fields and Cancel and ignores Escape', async () => {
|
||
const pending = deferred<CreatedApiKey>()
|
||
await mountPage({ create: () => pending.promise })
|
||
await openCreateModal()
|
||
await fillAndSubmit('Nightly media import')
|
||
|
||
expect(modalButton('Creating…')).toBeTruthy()
|
||
expect(nameInput().disabled).toBe(true)
|
||
expect(expiryInput().disabled).toBe(true)
|
||
expect(modalButton('Cancel')!.disabled).toBe(true)
|
||
|
||
await escape()
|
||
expect(formModal()).toBeTruthy()
|
||
|
||
pending.resolve(createdResponse())
|
||
await flushPromises()
|
||
expect(formModal()!.textContent).toContain('Your new API key')
|
||
})
|
||
})
|
||
|
||
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 429',
|
||
failure: () => new ApiError(429),
|
||
copy: 'Too many requests. Wait a moment, then try again.',
|
||
},
|
||
{
|
||
name: 'status 400',
|
||
failure: () => new ApiError(400),
|
||
copy: "Something in the form isn't valid. Check the highlighted fields.",
|
||
},
|
||
])(
|
||
'keeps the values, re-enables the form and retries after $name',
|
||
async ({ failure, copy }) => {
|
||
let attempts = 0
|
||
await mountPage({
|
||
create: () =>
|
||
++attempts === 1
|
||
? Promise.reject(failure())
|
||
: Promise.resolve(createdResponse()),
|
||
})
|
||
await openCreateModal()
|
||
|
||
await fillAndSubmit('Nightly media import')
|
||
|
||
expect(formModal()!.querySelector('[role="alert"]')?.textContent?.trim()).toBe(copy)
|
||
expect(nameInput().value).toBe('Nightly media import')
|
||
expect(nameInput().disabled).toBe(false)
|
||
expect(modalButton('Create key')!.disabled).toBe(false)
|
||
expect(rows()).toHaveLength(4)
|
||
expect(formModal()!.textContent).not.toContain('Enter a name.')
|
||
expect(formModal()!.textContent).not.toContain('Use 60 characters or fewer.')
|
||
|
||
await click(modalButton('Create key'))
|
||
expect(formModal()!.textContent).toContain('Your new API key')
|
||
await click(modalButton('Done'))
|
||
expect(rows()).toHaveLength(5)
|
||
},
|
||
)
|
||
})
|
||
|
||
describe('Not drawn: Revoke sending', () => {
|
||
it('reads Revoking…, disables both buttons and ignores Escape', async () => {
|
||
const pending = deferred<undefined>()
|
||
await mountPage({ revoke: () => pending.promise })
|
||
await click(buttonIn(rowOf('Nightly media import'), 'Revoke'))
|
||
await click(buttonIn(alertDialog()!, 'Revoke key'))
|
||
|
||
const dialog = alertDialog()!
|
||
expect(buttonIn(dialog, 'Revoking…')!.disabled).toBe(true)
|
||
expect(buttonIn(dialog, 'Keep key')!.disabled).toBe(true)
|
||
await escape()
|
||
expect(alertDialog()).toBeTruthy()
|
||
|
||
pending.resolve(undefined)
|
||
await flushPromises()
|
||
expect(alertDialog()).toBeNull()
|
||
})
|
||
})
|
||
|
||
describe.each([
|
||
{ 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
|
||
await mountPage({
|
||
revoke: () =>
|
||
++attempts === 1 ? Promise.reject(failure()) : Promise.resolve(undefined),
|
||
})
|
||
await click(buttonIn(rowOf('Nightly media import'), 'Revoke'))
|
||
|
||
await click(buttonIn(alertDialog()!, 'Revoke key'))
|
||
|
||
expect(alertDialog()!.querySelector('[role="alert"]')?.textContent?.trim()).toBe(copy)
|
||
expect(buttonIn(alertDialog()!, 'Revoke key')!.disabled).toBe(false)
|
||
expect(buttonIn(alertDialog()!, 'Keep key')!.disabled).toBe(false)
|
||
expect(textOf(rowOf('Nightly media import'))).toContain('active')
|
||
expect(toastAdd).not.toHaveBeenCalled()
|
||
|
||
await click(buttonIn(alertDialog()!, 'Revoke key'))
|
||
expect(alertDialog()).toBeNull()
|
||
expect(textOf(rowOf('Nightly media import'))).toContain('revoked')
|
||
})
|
||
})
|
||
|
||
describe('Not drawn: Revoke 404', () => {
|
||
it('closes the dialog, removes the row and toasts an error', async () => {
|
||
await mountPage({ revoke: () => Promise.reject(new ApiError(404)) })
|
||
await click(buttonIn(rowOf('Nightly media import'), 'Revoke'))
|
||
|
||
await click(buttonIn(alertDialog()!, 'Revoke key'))
|
||
|
||
expect(alertDialog()).toBeNull()
|
||
expect(rows()).toHaveLength(3)
|
||
expect(document.body.textContent).not.toContain('Nightly media import')
|
||
const toast = toastAdd.mock.calls[0]![0]
|
||
expect(toast.color).toBe('error')
|
||
expect(toast.title).toBe('That key no longer exists.')
|
||
})
|
||
})
|
||
|
||
describe('Not drawn: Last used, never', () => {
|
||
it('reads Never in the cell and on the card', async () => {
|
||
const fixture = ownerFixture()
|
||
fixture[1]!.last_used_at = null
|
||
await mountPage({ list: () => Promise.resolve(fixture) })
|
||
expect(textOf(rowOf('Nightly media import'))).toContain('Never')
|
||
wrapper?.unmount()
|
||
wrapper = null
|
||
document.body.innerHTML = ''
|
||
|
||
await mountPage({ list: () => Promise.resolve(fixture) }, { desktop: false })
|
||
expect(textOf(rowOf('Nightly media import'))).toContain('Last used Never')
|
||
})
|
||
})
|
||
|
||
describe('Language: every string has a French entry', () => {
|
||
function frenchText() {
|
||
return document.body.textContent ?? ''
|
||
}
|
||
|
||
it('translates the list, the cards and the dates', async () => {
|
||
setLocale('fr')
|
||
await mountPage()
|
||
|
||
expect(frenchText()).toContain('Clés d’API')
|
||
expect(frenchText()).toContain('Les clés agissent comme l’utilisateur qui les a créées.')
|
||
expect(textOf(document.body.querySelector('thead tr')!)).toBe(
|
||
'Nom Clé Statut Créée par Créé Dernière utilisation Expire le',
|
||
)
|
||
expect(rowTexts()[0]).toContain('active')
|
||
expect(rowTexts()[2]).toContain('expirée')
|
||
expect(rowTexts()[3]).toContain('révoquée')
|
||
expect(rowTexts()[0]).toContain('12 mars 2026')
|
||
expect(rowTexts()[0]).toContain('Aucune')
|
||
expect(frenchText()).toContain('Créer une clé')
|
||
expect(frenchText()).toContain('Réinitialiser le mot de passe d’un utilisateur')
|
||
expect(frenchText()).not.toContain('Revoke')
|
||
})
|
||
|
||
it('translates the empty panel and the create form', async () => {
|
||
setLocale('fr')
|
||
await mountPage({ list: () => Promise.resolve([]) })
|
||
expect(frenchText()).toContain('Aucune clé d’API pour l’instant')
|
||
expect(frenchText()).toContain('Créez une clé pour appeler l’API')
|
||
|
||
await openCreateModal('Créer une clé')
|
||
expect(formModal()!.textContent).toContain('Créer une clé d’API')
|
||
expect(formModal()!.textContent).toContain('Obligatoire. 60 caractères maximum.')
|
||
expect(formModal()!.textContent).toContain('Date d’expiration (facultative)')
|
||
expect(modalButton('Annuler')).toBeTruthy()
|
||
await fillAndSubmit('', '', 'Créer une clé')
|
||
expect(formModal()!.textContent).toContain('Saisissez un nom.')
|
||
})
|
||
|
||
it('translates the reveal, the revoke dialog and the toast', async () => {
|
||
setLocale('fr')
|
||
await mountPage()
|
||
await openCreateModal('Créer une clé')
|
||
await fillAndSubmit('Nightly media import', '', 'Créer une clé')
|
||
expect(formModal()!.textContent).toContain('Votre nouvelle clé d’API')
|
||
expect(formModal()!.textContent).toContain('Vous ne pourrez plus revoir cette clé.')
|
||
expect(modalButton('Copier')).toBeTruthy()
|
||
expect(modalButton('Terminé')).toBeTruthy()
|
||
await click(modalButton('Terminé'))
|
||
|
||
await click(buttonIn(rowOf('Website build'), 'Révoquer'))
|
||
expect(alertDialog()!.textContent).toContain('Révoquer cette clé ?')
|
||
expect(buttonIn(alertDialog()!, 'Garder la clé')).toBeTruthy()
|
||
await click(buttonIn(alertDialog()!, 'Révoquer la clé'))
|
||
const toast = toastAdd.mock.calls[0]![0]
|
||
expect(toast.title).toBe('Clé révoquée')
|
||
expect(toast.description).toBe('« Website build » ne peut plus être utilisée.')
|
||
})
|
||
})
|