Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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