test: playkit v0.3.1 adoption, Zod widening, PROD smoke, NextAuth write gates (#68)
This commit was merged in pull request #68.
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
import { type Page, type Locator } from '@playwright/test';
|
||||
import { BasePage, waitForVisible, waitForHidden } from '@levkin/playkit';
|
||||
|
||||
export interface NewUserInput {
|
||||
email: string;
|
||||
password: string;
|
||||
name: string;
|
||||
isAdmin?: boolean;
|
||||
hasWriteAccess?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Page object for the "Manage Users" overlay content
|
||||
* (`viewer-frontend/app/admin/users/ManageUsersContent.tsx`).
|
||||
*
|
||||
* Talks to the NextAuth-backed `/api/users` store — a separate account
|
||||
* database from the FastAPI `/api/v1/users` store covered by
|
||||
* `api.role-permissions.spec.ts`.
|
||||
*/
|
||||
export class ManageUsersPanel extends BasePage {
|
||||
constructor(page: Page, baseUrl: string) {
|
||||
super(page, baseUrl);
|
||||
}
|
||||
|
||||
row(email: string): Locator {
|
||||
return this.page.locator('tbody tr').filter({ hasText: email });
|
||||
}
|
||||
|
||||
/** The row's action buttons (Edit is always first; Deactivate/Delete is
|
||||
* second and only rendered for active users). Positional rather than
|
||||
* aria-label-based — proved more reliable against the live table than
|
||||
* exact accessible-name matching. */
|
||||
private actionButton(email: string, position: 'first' | 'last'): Locator {
|
||||
const buttons = this.row(email).getByRole('button');
|
||||
return position === 'first' ? buttons.first() : buttons.last();
|
||||
}
|
||||
|
||||
/** Scopes to the currently-open dialog matching a heading, to avoid
|
||||
* ambiguous matches against hidden/portalled sibling dialogs. */
|
||||
private dialog(headingText: string): Locator {
|
||||
return this.page
|
||||
.getByRole('dialog')
|
||||
.filter({ has: this.page.getByRole('heading', { name: headingText, exact: true }) });
|
||||
}
|
||||
|
||||
async createUser(input: NewUserInput): Promise<void> {
|
||||
await this.click(this.page.getByRole('button', { name: 'Add User', exact: true }));
|
||||
const dialog = this.dialog('Add New User');
|
||||
await waitForVisible(dialog);
|
||||
await this.fill(dialog.locator('#add-email'), input.email);
|
||||
await this.fill(dialog.locator('#add-password'), input.password);
|
||||
await this.fill(dialog.locator('#add-name'), input.name);
|
||||
if (input.isAdmin) {
|
||||
await this.click(dialog.locator('#add-role'));
|
||||
await this.click(this.page.getByRole('option', { name: 'Admin', exact: true }));
|
||||
}
|
||||
if (input.hasWriteAccess) {
|
||||
await this.click(dialog.locator('#add-write-access'));
|
||||
}
|
||||
await this.click(dialog.getByRole('button', { name: 'Create User', exact: true }));
|
||||
await waitForVisible(this.row(input.email), { timeout: 15_000 });
|
||||
}
|
||||
|
||||
async openEditDialog(email: string): Promise<void> {
|
||||
await this.click(this.actionButton(email, 'first'));
|
||||
await waitForVisible(this.dialog('Edit User'));
|
||||
}
|
||||
|
||||
private async setCheckbox(locator: Locator, checked: boolean): Promise<void> {
|
||||
const isChecked = await locator.isChecked();
|
||||
if (isChecked !== checked) {
|
||||
await this.click(locator);
|
||||
}
|
||||
}
|
||||
|
||||
async setActive(active: boolean): Promise<void> {
|
||||
await this.setCheckbox(this.dialog('Edit User').locator('#edit-active'), active);
|
||||
}
|
||||
|
||||
async setWriteAccess(enabled: boolean): Promise<void> {
|
||||
await this.setCheckbox(this.dialog('Edit User').locator('#edit-write-access'), enabled);
|
||||
}
|
||||
|
||||
async saveEdit(): Promise<void> {
|
||||
const dialog = this.dialog('Edit User');
|
||||
await this.click(dialog.getByRole('button', { name: 'Save Changes', exact: true }));
|
||||
await waitForHidden(dialog);
|
||||
}
|
||||
|
||||
/** Clicks the row's delete/deactivate icon and confirms in the dialog.
|
||||
* Hard-deletes if the account has no related records, otherwise the
|
||||
* backend soft-deactivates it — either way `isActive` ends up false. */
|
||||
async deleteUser(email: string): Promise<void> {
|
||||
await this.click(this.actionButton(email, 'last'));
|
||||
const dialog = this.dialog('Delete User');
|
||||
await waitForVisible(dialog);
|
||||
await this.click(dialog.getByRole('button', { name: 'Delete', exact: true }));
|
||||
await waitForHidden(dialog);
|
||||
}
|
||||
|
||||
async statusBadgeText(email: string): Promise<string> {
|
||||
return (await this.row(email).locator('td').nth(2).innerText()).trim();
|
||||
}
|
||||
|
||||
async roleBadgeText(email: string): Promise<string> {
|
||||
return (await this.row(email).locator('td').nth(3).innerText()).trim();
|
||||
}
|
||||
|
||||
async writeAccessText(email: string): Promise<string> {
|
||||
return (await this.row(email).locator('td').nth(4).innerText()).trim();
|
||||
}
|
||||
|
||||
async waitForRowGone(email: string, timeout = 15_000): Promise<void> {
|
||||
await waitForHidden(this.row(email), { timeout });
|
||||
}
|
||||
|
||||
/** The table defaults to the "Active only" filter and refetches with it on
|
||||
* every save (`ManageUsersContent.handleEditUser` → `fetchUsers()`), so a
|
||||
* just-deactivated row silently drops out of view. Switch to "All" before
|
||||
* reading a status badge you expect to still be visible post-deactivation. */
|
||||
async showAllUsers(): Promise<void> {
|
||||
await this.click(this.page.locator('#status-filter'));
|
||||
await this.click(this.page.getByRole('option', { name: 'All', exact: true }));
|
||||
await waitForHidden(this.page.getByRole('option', { name: 'All', exact: true }));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user