Skip to content
Merged
168 changes: 168 additions & 0 deletions __tests__/e2e/admin/collections/tenants.e2e.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { authTest, expect } from '../../fixtures/auth.fixture'
import {
AdminUrlUtil,
CollectionSlugs,
getSelectInputOptions,
getSelectInputValue,
openDocControls,
selectInput,
waitForFormReady,
} from '../../helpers'

const SERVER_URL = 'http://localhost:3000'
const tenantsUrl = new AdminUrlUtil(SERVER_URL, CollectionSlugs.tenants)

authTest.describe('Tenants', () => {
authTest.describe('Create', () => {
authTest.describe.configure({ timeout: 60000 })

authTest('slug dropdown only shows unused slugs', async ({ adminPage }) => {
await adminPage.goto(tenantsUrl.list)
await adminPage.waitForLoadState('networkidle')

await adminPage.goto(tenantsUrl.create)
await adminPage.waitForLoadState('networkidle')
await waitForFormReady(adminPage)

const slugField = adminPage.locator('#field-slug')
const options = await getSelectInputOptions({ selectLocator: slugField })

expect(options).not.toContain(expect.stringMatching(/nwac/i))
expect(options).not.toContain(expect.stringMatching(/dvac/i))
expect(options).not.toContain(expect.stringMatching(/sac/i))
expect(options).not.toContain(expect.stringMatching(/snfac/i))
})

authTest('selecting a slug auto-fills the name field', async ({ adminPage }) => {
await adminPage.goto(tenantsUrl.create)
await adminPage.waitForLoadState('networkidle')
await waitForFormReady(adminPage)

const slugField = adminPage.locator('#field-slug')
const nameInput = adminPage.locator('#field-name')

await expect(nameInput).toHaveValue('')

const options = await getSelectInputOptions({ selectLocator: slugField })
if (options.length === 0) {
authTest.skip()
return
}

await selectInput({ selectLocator: slugField, option: options[0] })

await expect(nameInput).not.toHaveValue('', { timeout: 5000 })
})
})

authTest.describe('Read', () => {
authTest.describe.configure({ timeout: 60000 })

authTest('slug field shows current value on existing tenant', async ({ adminPage }) => {
await adminPage.goto(tenantsUrl.list)
await adminPage.waitForLoadState('networkidle')

const firstLink = adminPage.locator('table tbody tr td a').first()
await firstLink.waitFor({ timeout: 15000 })
await firstLink.click()
await adminPage.waitForURL(/\/collections\/tenants\/\d+/)
await waitForFormReady(adminPage)

const slugField = adminPage.locator('#field-slug')
await expect(slugField).toBeVisible()

const value = await getSelectInputValue({ selectLocator: slugField })
expect(value).toBeTruthy()
})
})

authTest.describe('Delete', () => {
authTest.describe.configure({ timeout: 90000 })

authTest('shows custom confirmation modal with type-to-confirm', async ({ adminPage }) => {
await adminPage.goto(tenantsUrl.list)
await adminPage.waitForLoadState('networkidle')

// Click the first tenant to open edit view
const firstLink = adminPage.locator('table tbody tr td a').first()
await firstLink.waitFor({ timeout: 15000 })
await firstLink.click()
await adminPage.waitForURL(/\/collections\/tenants\/\d+/)
await waitForFormReady(adminPage)

// Get the tenant name for verification
const nameInput = adminPage.locator('#field-name')
const tenantName = await nameInput.inputValue()

// Open the doc controls dropdown and click Delete
await openDocControls(adminPage)
const deleteButton = adminPage.getByRole('button', { name: 'Delete', exact: true })
await deleteButton.waitFor({ timeout: 5000 })
await deleteButton.click()

// The custom modal should appear (not Payload's default)
const modal = adminPage.locator('.confirmation-modal')
await expect(modal).toBeVisible({ timeout: 10000 })

// Should show the tenant name in the heading
await expect(modal.locator('h1')).toContainText(tenantName)

// Should have a text input for typing the name
const confirmInput = modal.locator('input[type="text"]')
await expect(confirmInput).toBeVisible()

// Should have a prompt to type the name
await expect(modal).toContainText('Type')
await expect(modal.locator('strong')).toContainText(tenantName)

// Cancel to avoid actually deleting
const cancelButton = modal.getByRole('button', { name: 'Cancel' })
await cancelButton.click()

// Modal should close
await expect(modal).not.toBeVisible({ timeout: 5000 })
})

authTest('shows error when typing wrong name', async ({ adminPage }) => {
await adminPage.goto(tenantsUrl.list)
await adminPage.waitForLoadState('networkidle')

const firstLink = adminPage.locator('table tbody tr td a').first()
await firstLink.waitFor({ timeout: 15000 })
await firstLink.click()
await adminPage.waitForURL(/\/collections\/tenants\/\d+/)
await waitForFormReady(adminPage)

// Open delete modal
await openDocControls(adminPage)
const deleteButton = adminPage.getByRole('button', { name: 'Delete', exact: true })
await deleteButton.waitFor({ timeout: 5000 })
await deleteButton.click()

const modal = adminPage.locator('.confirmation-modal')
await expect(modal).toBeVisible({ timeout: 10000 })

// Type wrong name
const confirmInput = modal.locator('input[type="text"]')
await confirmInput.fill('wrong name')

// Click delete
const confirmDelete = modal.getByRole('button', { name: 'Delete', exact: true })
await confirmDelete.click()

// Should show error toast
await expect(
adminPage.locator(
'.toast-error, .Toastify__toast--error, [data-sonner-toast][data-type="error"]',
),
).toBeVisible({ timeout: 10000 })

// Modal should still be open
await expect(modal).toBeVisible()

// Cancel to clean up
const cancelButton = modal.getByRole('button', { name: 'Cancel' })
await cancelButton.click()
})
})
})
115 changes: 115 additions & 0 deletions __tests__/server/deprovisionBeforeDelete.server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import {
deprovisionBeforeDelete,
TENANT_SCOPED_COLLECTIONS,
} from '@/collections/Tenants/hooks/deprovisionBeforeDelete'
import { Logger } from 'pino'

function buildMockLogger(): jest.Mocked<Logger> {
// @ts-expect-error - partial mock of pino Logger; only methods used in tests are provided
return {
warn: jest.fn(),
info: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
}
}

function buildMockPayload(findResults: Record<string, number>) {
const mockLogger = buildMockLogger()
return {
logger: mockLogger,
find: jest.fn(({ collection }: { collection: string }) =>
Promise.resolve({ totalDocs: findResults[collection] ?? 0 }),
),
delete: jest.fn(() => Promise.resolve()),
}
}

describe('deprovisionBeforeDelete', () => {
it('deletes documents from all collections that have tenant-scoped data', async () => {
const findResults: Record<string, number> = {
pages: 5,
posts: 3,
media: 10,
}
const mockPayload = buildMockPayload(findResults)
const mockReq = { payload: mockPayload }

await deprovisionBeforeDelete(
// @ts-expect-error - partial mock; only id and req.payload are used by the hook
{ id: 42, req: mockReq },
)

// Should query every tenant-scoped collection
expect(mockPayload.find).toHaveBeenCalledTimes(TENANT_SCOPED_COLLECTIONS.length)

for (const collection of TENANT_SCOPED_COLLECTIONS) {
expect(mockPayload.find).toHaveBeenCalledWith({
collection,
where: { tenant: { equals: 42 } },
limit: 0,
depth: 0,
})
}

// Should only delete from collections with documents
expect(mockPayload.delete).toHaveBeenCalledTimes(3)
expect(mockPayload.delete).toHaveBeenCalledWith({
collection: 'pages',
where: { tenant: { equals: 42 } },
req: mockReq,
})
expect(mockPayload.delete).toHaveBeenCalledWith({
collection: 'posts',
where: { tenant: { equals: 42 } },
req: mockReq,
})
expect(mockPayload.delete).toHaveBeenCalledWith({
collection: 'media',
where: { tenant: { equals: 42 } },
req: mockReq,
})
})

it('skips delete for collections with no documents', async () => {
const mockPayload = buildMockPayload({})
const mockReq = { payload: mockPayload }

await deprovisionBeforeDelete(
// @ts-expect-error - partial mock; only id and req.payload are used by the hook
{ id: 1, req: mockReq },
)

expect(mockPayload.find).toHaveBeenCalledTimes(TENANT_SCOPED_COLLECTIONS.length)
expect(mockPayload.delete).not.toHaveBeenCalled()
})

it('passes req to delete calls for transaction atomicity', async () => {
const mockPayload = buildMockPayload({ settings: 1 })
const mockReq = { payload: mockPayload }

await deprovisionBeforeDelete(
// @ts-expect-error - partial mock; only id and req.payload are used by the hook
{ id: 7, req: mockReq },
)

expect(mockPayload.delete).toHaveBeenCalledWith(expect.objectContaining({ req: mockReq }))
})

it('logs cleanup start and completion', async () => {
const mockPayload = buildMockPayload({})
const mockReq = { payload: mockPayload }

await deprovisionBeforeDelete(
// @ts-expect-error - partial mock; only id and req.payload are used by the hook
{ id: 99, req: mockReq },
)

expect(mockPayload.logger.info).toHaveBeenCalledWith(
expect.stringContaining('Cleaning up data for tenant 99'),
)
expect(mockPayload.logger.info).toHaveBeenCalledWith(
expect.stringContaining('Cleanup complete for tenant 99'),
)
})
})
18 changes: 13 additions & 5 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,16 +73,24 @@ pnpm test:e2e -- --workers=1 --headed __tests__/e2e/admin/tenant-selector/non-te

```
__tests__/e2e/
├── auth.setup.ts # Logs in as each test user and caches browser state
├── admin/ # Admin panel tests (project: admin)
├── auth.setup.ts # Logs in as each test user and caches browser state
├── admin/ # Admin panel tests (project: admin)
│ ├── collections/ # CRUD flow tests for each collection
│ │ └── tenants.e2e.spec.ts
│ ├── tenant-selector/ # Tenant selector behavior tests
│ │ └── ...
│ └── ... # Other admin UI tests
├── frontend/ # Frontend tests (project: frontend)
│ └── ...
├── fixtures/ # Reusable setup/teardown logic
├── fixtures/ # Reusable setup/teardown logic
│ └── ...
├── helpers/ # Shared utilities
├── helpers/ # Shared utilities
│ └── ...
└── .auth/ # Cached storageState files (gitignored)
└── .auth/ # Cached storageState files (gitignored)
```

The `admin/collections/` folder contains tests for the CRUD flows of admin collections. Each file is named after the collection slug (e.g., `tenants.e2e.spec.ts`) and groups tests under `Create`, `Read`, `Update`, and `Delete` describe blocks.

### Writing Tests

Tests use custom Playwright fixtures that provide login and tenant selector helpers. Import from the fixture that matches your needs:
Expand Down
6 changes: 6 additions & 0 deletions src/app/(payload)/admin/importMap.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

27 changes: 27 additions & 0 deletions src/collections/Tenants/components/AutoFillNameFromSlug.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
'use client'

import { useDocumentInfo, useField, useFormFields } from '@payloadcms/ui'
import { useEffect } from 'react'

import { AVALANCHE_CENTERS, isValidTenantSlug } from '@/utilities/tenancy/avalancheCenters'

/**
* Invisible component that syncs the name field with the selected slug
* for new documents. Each time the slug dropdown changes, the name updates.
*/
export const AutoFillNameFromSlug = () => {
const { id } = useDocumentInfo()
const slugField = useFormFields(([fields]) => fields.slug)
const { setValue: setName } = useField<string>({ path: 'name' })

useEffect(() => {
if (id) return

const slugValue = typeof slugField?.value === 'string' ? slugField.value : null
if (slugValue && isValidTenantSlug(slugValue)) {
setName(AVALANCHE_CENTERS[slugValue].name)
}
}, [id, slugField?.value, setName])

return null
}
Loading
Loading