242 lines
11 KiB
TypeScript
242 lines
11 KiB
TypeScript
import { expect, type Page, type Route, test } from '@playwright/test'
|
|
|
|
interface EntityField {
|
|
id: number
|
|
name: string
|
|
identifier: string
|
|
type: string
|
|
targetEntityId?: number
|
|
relationshipType?: string
|
|
}
|
|
|
|
interface EntityIndex { id: number, type: string, fields: EntityField[] }
|
|
|
|
interface EntityDefinition {
|
|
id: number
|
|
name: string
|
|
identifier: string
|
|
fields: EntityField[]
|
|
indexes: EntityIndex[]
|
|
}
|
|
|
|
async function useAdminSession (page: Page) {
|
|
await page.addInitScript(() => {
|
|
sessionStorage.setItem('bolts-session', JSON.stringify({
|
|
username: 'admin',
|
|
role: 'admin',
|
|
token: 'admin-token',
|
|
}))
|
|
})
|
|
}
|
|
|
|
function formValues (route: Route) {
|
|
return Object.fromEntries(new URLSearchParams(route.request().postData() ?? ''))
|
|
}
|
|
|
|
test('only admins can access entity configuration', async ({ page }) => {
|
|
await page.addInitScript(() => {
|
|
sessionStorage.setItem('bolts-session', JSON.stringify({
|
|
username: 'member',
|
|
role: 'user',
|
|
token: 'user-token',
|
|
}))
|
|
})
|
|
|
|
await page.goto('/admin/entities')
|
|
|
|
await expect(page).toHaveURL('/')
|
|
await expect(page.getByRole('heading', { name: 'Entity Configuration' })).not.toBeVisible()
|
|
})
|
|
|
|
test('admin can add, edit, and remove an entity and its fields', async ({ page }) => {
|
|
const entities: EntityDefinition[] = []
|
|
let nextEntityId = 1
|
|
let nextFieldId = 1
|
|
let nextIndexId = 1
|
|
|
|
await useAdminSession(page)
|
|
await page.route('**/api/entity-definitions**', async route => {
|
|
const request = route.request()
|
|
const url = new URL(request.url())
|
|
const segments = url.pathname.split('/').filter(Boolean)
|
|
const entityId = Number(segments[2])
|
|
const fieldId = Number(segments[4])
|
|
const entity = entities.find(item => item.id === entityId)
|
|
|
|
if (request.method() === 'GET') {
|
|
await route.fulfill({ json: entities })
|
|
} else if (request.method() === 'POST' && segments.at(-1) === 'entity-definitions') {
|
|
const values = formValues(route)
|
|
const created = { id: nextEntityId++, name: values.name, identifier: values.identifier, fields: [], indexes: [] }
|
|
entities.push(created)
|
|
await route.fulfill({ status: 201, json: created })
|
|
} else if (request.method() === 'PATCH' && segments.length === 3 && entity) {
|
|
entity.name = formValues(route).name
|
|
entity.identifier = formValues(route).identifier
|
|
await route.fulfill({ json: entity })
|
|
} else if (request.method() === 'DELETE' && segments.length === 3 && entity) {
|
|
entities.splice(entities.indexOf(entity), 1)
|
|
await route.fulfill({ status: 204 })
|
|
} else if (request.method() === 'POST' && segments.at(-1) === 'fields' && entity) {
|
|
const values = formValues(route)
|
|
const created = {
|
|
id: nextFieldId++,
|
|
name: values.name,
|
|
identifier: values.identifier,
|
|
type: values.type,
|
|
...(values.targetEntityId ? { targetEntityId: Number(values.targetEntityId) } : {}),
|
|
...(values.relationshipType ? { relationshipType: values.relationshipType } : {}),
|
|
}
|
|
entity.fields.push(created)
|
|
await route.fulfill({ status: 201, json: created })
|
|
} else if (request.method() === 'POST' && segments.at(-1) === 'indexes' && entity) {
|
|
const values = formValues(route)
|
|
const created = {
|
|
id: nextIndexId++,
|
|
type: values.type,
|
|
fields: values.fieldIds.split(',').map(id => entity.fields.find(field => field.id === Number(id))!),
|
|
}
|
|
entity.indexes.push(created)
|
|
await route.fulfill({ status: 201, json: created })
|
|
} else if (request.method() === 'PATCH' && entity) {
|
|
const field = entity.fields.find(item => item.id === fieldId)!
|
|
Object.assign(field, formValues(route))
|
|
await route.fulfill({ json: field })
|
|
} else if (request.method() === 'DELETE' && segments[3] === 'indexes' && entity) {
|
|
entity.indexes = entity.indexes.filter(item => item.id !== Number(segments[4]))
|
|
await route.fulfill({ status: 204 })
|
|
} else if (request.method() === 'DELETE' && entity) {
|
|
const index = entity.fields.findIndex(item => item.id === fieldId)
|
|
entity.fields.splice(index, 1)
|
|
await route.fulfill({ status: 204 })
|
|
} else {
|
|
await route.fulfill({ status: 404 })
|
|
}
|
|
})
|
|
|
|
await page.goto('/admin/entities')
|
|
await expect(page.getByRole('heading', { name: 'Entity Configuration' })).toBeVisible()
|
|
await expect(page.getByRole('link', { name: 'Entity Configuration' })).toBeVisible()
|
|
|
|
await page.getByLabel('Entity name', { exact: true }).fill('Société & Company')
|
|
await expect(page.getByLabel('Entity identifier', { exact: true })).toHaveValue('societeCompany')
|
|
await page.getByLabel('Entity identifier', { exact: true }).fill('business')
|
|
await page.getByRole('button', { name: 'Add entity' }).click()
|
|
await expect(page.getByRole('heading', { name: 'Société & Company' })).toBeVisible()
|
|
await expect(page.getByText('business', { exact: true })).toBeVisible()
|
|
|
|
await page.getByRole('button', { name: 'Edit entity' }).click()
|
|
const entityEditForm = page.getByRole('button', { name: 'Save entity' }).locator('..')
|
|
await entityEditForm.getByLabel('Entity name', { exact: true }).fill('MyOrganization')
|
|
await expect(entityEditForm.getByLabel('Entity identifier')).toHaveValue('myOrganization')
|
|
await entityEditForm.getByLabel('Entity name', { exact: true }).fill('Organization')
|
|
await entityEditForm.getByRole('button', { name: 'Save entity' }).click()
|
|
await expect(page.getByRole('heading', { name: 'Organization' })).toBeVisible()
|
|
|
|
await page.getByLabel('New field').fill('Website')
|
|
await expect(page.getByLabel('Field identifier')).toHaveValue('website')
|
|
await page.getByLabel('Field identifier').fill('websiteUrl')
|
|
await page.getByLabel('Field type').selectOption('EMAIL')
|
|
await page.getByRole('button', { name: 'Add field' }).click()
|
|
await expect(page.getByRole('strong').filter({ hasText: 'Website' })).toBeVisible()
|
|
await expect(page.locator('.type-badge')).toHaveText('Email')
|
|
|
|
await page.getByLabel('New field').fill('Domain')
|
|
await page.getByRole('button', { name: 'Add field' }).click()
|
|
const indexForm = page.getByRole('form', { name: 'Add constraint or index to Organization' })
|
|
await indexForm.getByLabel('Website').check()
|
|
await indexForm.getByLabel('Domain').check()
|
|
await indexForm.getByLabel('Type').selectOption('UNIQUE')
|
|
await indexForm.getByRole('button', { name: 'Add to configuration' }).click()
|
|
await expect(page.getByText('Website + Domain')).toBeVisible()
|
|
await expect(page.locator('span.type-badge', { hasText: 'Unique constraint' })).toBeVisible()
|
|
await page.getByRole('button', { name: 'Remove', exact: true }).click()
|
|
await expect(page.getByText('Website + Domain')).not.toBeVisible()
|
|
|
|
await page.locator('.field-row', { hasText: 'Website' }).getByRole('button', { name: 'Edit field' }).click()
|
|
const fieldEditForm = page.getByRole('button', { name: 'Save field' }).locator('..')
|
|
await fieldEditForm.getByLabel('Field name').fill('Annual revenue')
|
|
await expect(fieldEditForm.getByLabel('Field identifier')).toHaveValue('annualRevenue')
|
|
await fieldEditForm.getByLabel('Field type').selectOption('NUMBER')
|
|
await fieldEditForm.getByRole('button', { name: 'Save field' }).click()
|
|
const revenueRow = page.locator('.field-row', { hasText: 'Annual revenue' })
|
|
await expect(revenueRow.getByRole('strong')).toHaveText('Annual revenue')
|
|
await expect(revenueRow.locator('.type-badge')).toHaveText('Number')
|
|
|
|
page.on('dialog', dialog => dialog.accept())
|
|
await page.locator('.field-row', { hasText: 'Annual revenue' }).getByRole('button', { name: 'Remove field' }).click()
|
|
await expect(page.getByRole('strong').filter({ hasText: 'Annual revenue' })).not.toBeVisible()
|
|
|
|
await page.getByLabel('Entity name', { exact: true }).fill('Contact')
|
|
await page.getByRole('button', { name: 'Add entity' }).click()
|
|
|
|
const organization = page.locator('.entity-card').filter({
|
|
has: page.getByRole('heading', { name: 'Organization' }),
|
|
})
|
|
const organizationFieldForm = organization.getByRole('form', { name: 'Add field to Organization' })
|
|
await organizationFieldForm.getByLabel('New field').fill('Primary contact')
|
|
await organizationFieldForm.getByLabel('Field type').selectOption('RELATIONSHIP')
|
|
await organizationFieldForm.getByLabel('Related entity').selectOption({ label: 'Contact' })
|
|
await organizationFieldForm.getByLabel('Relationship type').selectOption('ONE_TO_ONE')
|
|
await organizationFieldForm.getByRole('button', { name: 'Add field' }).click()
|
|
await expect(organization.locator('.type-badge').filter({ hasText: 'Relationship' })).toBeVisible()
|
|
await expect(organization.getByText('Contact · One to one')).toBeVisible()
|
|
|
|
await organizationFieldForm.getByLabel('New field').fill('Account owner')
|
|
await organizationFieldForm.getByLabel('Field type').selectOption('USER')
|
|
await organizationFieldForm.getByRole('button', { name: 'Add field' }).click()
|
|
await expect(organization.locator('.type-badge').filter({ hasText: 'User reference' })).toBeVisible()
|
|
await expect(organization.getByText('Built-in User entity')).toBeVisible()
|
|
|
|
await organization.getByRole('button', { name: 'Remove entity' }).click()
|
|
await expect(page.getByRole('heading', { name: 'Organization' })).not.toBeVisible()
|
|
|
|
const contact = page.locator('.entity-card').filter({
|
|
has: page.getByRole('heading', { name: 'Contact' }),
|
|
})
|
|
await contact.getByRole('button', { name: 'Remove entity' }).click()
|
|
await expect(page.getByText('No entities yet.')).toBeVisible()
|
|
})
|
|
|
|
test('admin validates before running a migration and sees migration failures', async ({ page }) => {
|
|
await useAdminSession(page)
|
|
await page.route('**/api/entity-definitions', route => route.fulfill({
|
|
json: [{
|
|
id: 1,
|
|
name: 'Company',
|
|
identifier: 'company',
|
|
fields: [{ id: 1, name: 'Name', identifier: 'name', type: 'TEXT' }],
|
|
}],
|
|
}))
|
|
await page.route('**/api/entity-definitions/migration/validate', route => route.fulfill({
|
|
json: {
|
|
valid: true,
|
|
errors: [],
|
|
warnings: ['Table company already exists; missing fields will be added with ALTER TABLE.'],
|
|
actions: ['Add company.name (TEXT).'],
|
|
},
|
|
}))
|
|
await page.route('**/api/entity-definitions/migration/run', route => route.fulfill({
|
|
status: 400,
|
|
json: {
|
|
success: false,
|
|
message: 'Entity migration failed and was rolled back.',
|
|
actions: [],
|
|
errors: ['permission denied for table company'],
|
|
},
|
|
}))
|
|
|
|
await page.goto('/admin/entities')
|
|
const runButton = page.getByRole('button', { name: 'Run migration' })
|
|
await expect(runButton).toBeDisabled()
|
|
|
|
await page.getByRole('button', { name: 'Validate migration' }).click()
|
|
await expect(page.getByText('Validation passed')).toBeVisible()
|
|
await expect(page.getByText('Add company.name (TEXT).')).toBeVisible()
|
|
await expect(runButton).toBeEnabled()
|
|
|
|
await runButton.click()
|
|
await expect(page.getByText('Entity migration failed and was rolled back.')).toBeVisible()
|
|
await expect(page.getByText('permission denied for table company')).toBeVisible()
|
|
})
|