bolts/frontend/tests/entity-configuration.spec.ts

243 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.getByLabel('User relationship type').selectOption('ONE_TO_ONE')
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 · One to one')).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()
})