This is an automated email from the ASF dual-hosted git repository.

young pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix-dashboard.git


The following commit(s) were added to refs/heads/master by this push:
     new bb5dddb62 test(e2e): Add E2E tests for consumer and credentials (#3243)
bb5dddb62 is described below

commit bb5dddb62a53af851354dbd9927abcd5f4d7c622
Author: Deep Shekhar Singh <[email protected]>
AuthorDate: Fri Nov 21 07:33:13 2025 +0530

    test(e2e): Add E2E tests for consumer and credentials (#3243)
    
    * feat(e2e): add E2E tests for Consumers resource
    
    - Add Page Object Model for Consumers (e2e/pom/consumers.ts)
    - Add list and pagination tests (consumers.list.spec.ts)
    - Add CRUD tests with required fields 
(consumers.crud-required-fields.spec.ts)
    - Add CRUD tests with all fields (consumers.crud-all-fields.spec.ts)
    - Add deleteAllConsumers() helper function in src/apis/consumers.ts
    
    All tests follow the established pattern:
    - *.list.spec.ts for list page and pagination
    - *.crud-required-fields.spec.ts for basic CRUD operations
    - *.crud-all-fields.spec.ts for comprehensive CRUD with all fields
    
    Tests cover:
    - Navigation and page assertions
    - Form validation
    - Create, Read, Update, Delete operations
    - Labels management with tags input
    - Pagination with table controls and URL params
    
    Fixes username validation by using only allowed characters [a-zA-Z0-9_-]
    
    * feat: add e2e tests for consumer credentials
    
    - Add credentialsPom for page object model
    - Add comprehensive test suite for credentials CRUD operations
    - Test credential isolation between consumers
    - Test empty state handling
    - Configure serial mode to avoid race conditions
    
    All 8 tests passing
---
 e2e/pom/consumers.ts                             |  58 ++++
 e2e/pom/credentials.ts                           |  70 ++++
 e2e/tests/consumers.credentials.list.spec.ts     | 413 +++++++++++++++++++++++
 e2e/tests/consumers.crud-all-fields.spec.ts      | 145 ++++++++
 e2e/tests/consumers.crud-required-fields.spec.ts | 127 +++++++
 e2e/tests/consumers.list.spec.ts                 |  81 +++++
 src/apis/consumers.ts                            |  20 +-
 7 files changed, 913 insertions(+), 1 deletion(-)

diff --git a/e2e/pom/consumers.ts b/e2e/pom/consumers.ts
new file mode 100644
index 000000000..9b811f1a7
--- /dev/null
+++ b/e2e/pom/consumers.ts
@@ -0,0 +1,58 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { uiGoto } from '@e2e/utils/ui';
+import { expect, type Page } from '@playwright/test';
+
+const locator = {
+  getConsumerNavBtn: (page: Page) =>
+    page.getByRole('link', { name: 'Consumers', exact: true }),
+  getAddConsumerBtn: (page: Page) =>
+    page.getByRole('button', { name: 'Add Consumer', exact: true }),
+  getAddBtn: (page: Page) =>
+    page.getByRole('button', { name: 'Add', exact: true }),
+};
+
+const assert = {
+  isIndexPage: async (page: Page) => {
+    await expect(page).toHaveURL((url) => url.pathname.endsWith('/consumers'));
+    const title = page.getByRole('heading', { name: 'Consumers' });
+    await expect(title).toBeVisible();
+  },
+  isAddPage: async (page: Page) => {
+    await expect(page).toHaveURL((url) => 
url.pathname.endsWith('/consumers/add'));
+    const title = page.getByRole('heading', { name: 'Add Consumer' });
+    await expect(title).toBeVisible();
+  },
+  isDetailPage: async (page: Page) => {
+    await expect(page).toHaveURL((url) =>
+      url.pathname.includes('/consumers/detail')
+    );
+    const title = page.getByRole('heading', { name: 'Consumer Detail' });
+    await expect(title).toBeVisible();
+  },
+};
+
+const goto = {
+  toIndex: (page: Page) => uiGoto(page, '/consumers'),
+  toAdd: (page: Page) => uiGoto(page, '/consumers/add'),
+};
+
+export const consumersPom = {
+  ...locator,
+  ...assert,
+  ...goto,
+};
diff --git a/e2e/pom/credentials.ts b/e2e/pom/credentials.ts
new file mode 100644
index 000000000..94b37a10c
--- /dev/null
+++ b/e2e/pom/credentials.ts
@@ -0,0 +1,70 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { uiGoto } from '@e2e/utils/ui';
+import { expect, type Page } from '@playwright/test';
+
+const locator = {
+  getCredentialsTab: (page: Page) =>
+    page.getByRole('tab', { name: 'Credentials' }),
+  getAddCredentialBtn: (page: Page) =>
+    page.getByRole('button', { name: 'Add Credential', exact: true }),
+  getAddBtn: (page: Page) =>
+    page.getByRole('button', { name: 'Add', exact: true }),
+};
+
+const assert = {
+  isCredentialsIndexPage: async (page: Page, username: string) => {
+    await expect(page).toHaveURL((url) =>
+      url.pathname.includes(`/consumers/detail/${username}/credentials`)
+    );
+    const title = page.getByRole('heading', { name: 'Credentials' });
+    await expect(title).toBeVisible();
+  },
+  isCredentialAddPage: async (page: Page, username: string) => {
+    await expect(page).toHaveURL((url) =>
+      url.pathname.endsWith(`/consumers/detail/${username}/credentials/add`)
+    );
+    const title = page.getByRole('heading', { name: 'Add Credential' });
+    await expect(title).toBeVisible();
+  },
+  isCredentialDetailPage: async (page: Page) => {
+    await expect(page).toHaveURL((url) =>
+      url.pathname.includes('/consumers/detail/') &&
+      url.pathname.includes('/credentials/detail/')
+    );
+    const title = page.getByRole('heading', { name: 'Credential Detail' });
+    await expect(title).toBeVisible();
+  },
+};
+
+const goto = {
+  toCredentialsIndex: (page: Page, username: string) =>
+    uiGoto(page, '/consumers/detail/$username/credentials', { username }),
+  toCredentialAdd: (page: Page, username: string) =>
+    uiGoto(page, '/consumers/detail/$username/credentials/add', { username }),
+  toCredentialDetail: (page: Page, username: string, id: string) =>
+    uiGoto(page, '/consumers/detail/$username/credentials/detail/$id', {
+      username,
+      id,
+    }),
+};
+
+export const credentialsPom = {
+  ...locator,
+  ...assert,
+  ...goto,
+};
diff --git a/e2e/tests/consumers.credentials.list.spec.ts 
b/e2e/tests/consumers.credentials.list.spec.ts
new file mode 100644
index 000000000..462370a3c
--- /dev/null
+++ b/e2e/tests/consumers.credentials.list.spec.ts
@@ -0,0 +1,413 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { consumersPom } from '@e2e/pom/consumers';
+import { credentialsPom } from '@e2e/pom/credentials';
+import { randomId } from '@e2e/utils/common';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { expect } from '@playwright/test';
+
+import { deleteAllConsumers, putConsumerReq } from '@/apis/consumers';
+import { putCredentialReq } from '@/apis/credentials';
+import { API_CONSUMERS } from '@/config/constant';
+import type { APISIXType } from '@/types/schema/apisix';
+
+const testConsumerUsername = randomId('test-consumer');
+const anotherConsumerUsername = randomId('another-consumer');
+
+const credentials: (APISIXType['CredentialPut'] & { username: string })[] = [
+  {
+    username: testConsumerUsername,
+    id: randomId('cred-1'),
+    desc: 'Test credential 1',
+    plugins: {
+      'key-auth': {
+        key: randomId('key-1'),
+      },
+    },
+  },
+  {
+    username: testConsumerUsername,
+    id: randomId('cred-2'),
+    desc: 'Test credential 2',
+    plugins: {
+      'key-auth': {
+        key: randomId('key-2'),
+      },
+    },
+  },
+  {
+    username: testConsumerUsername,
+    id: randomId('cred-3'),
+    desc: 'Test credential 3',
+    plugins: {
+      'basic-auth': {
+        username: 'testuser',
+        password: 'testpass',
+      },
+    },
+  },
+];
+
+// Credential that belongs to another consumer
+const anotherConsumerCredential: APISIXType['CredentialPut'] & {
+  username: string;
+} = {
+  username: anotherConsumerUsername,
+  id: randomId('another-cred'),
+  desc: 'Another consumer credential',
+  plugins: {
+    'key-auth': {
+      key: randomId('another-key'),
+    },
+  },
+};
+
+// Configure tests to run serially to avoid race conditions
+test.describe.configure({ mode: 'serial' });
+
+test.beforeAll(async () => {
+  // Clean up any existing consumers first
+  await deleteAllConsumers(e2eReq);
+
+  // Create test consumer first
+  await putConsumerReq(e2eReq, {
+    username: testConsumerUsername,
+    desc: 'Test consumer for credential testing',
+  });
+
+  // Create another consumer
+  await putConsumerReq(e2eReq, {
+    username: anotherConsumerUsername,
+    desc: 'Another test consumer for credential isolation testing',
+  });
+
+  // Wait a bit to ensure consumers are created
+  await new Promise((resolve) => setTimeout(resolve, 100));
+
+  // Create credentials for test consumer - now that consumer exists
+  for (const credential of credentials) {
+    await putCredentialReq(e2eReq, credential);
+  }
+
+  // Create credential for another consumer - now that consumer exists
+  await putCredentialReq(e2eReq, anotherConsumerCredential);
+});
+
+test.afterAll(async () => {
+  await deleteAllConsumers(e2eReq);
+});
+
+test('should navigate to consumer credentials page', async ({ page }) => {
+  await test.step('navigate to consumer detail page', async () => {
+    await consumersPom.toIndex(page);
+    await consumersPom.isIndexPage(page);
+
+    await page
+      .getByRole('row', { name: testConsumerUsername })
+      .getByRole('button', { name: 'View' })
+      .click();
+    await consumersPom.isDetailPage(page);
+  });
+
+  await test.step('navigate to credentials tab', async () => {
+    // Directly navigate to credentials page instead of clicking tab
+    await credentialsPom.toCredentialsIndex(page, testConsumerUsername);
+    await credentialsPom.isCredentialsIndexPage(page, testConsumerUsername);
+  });
+
+  await test.step('verify credentials page components', async () => {
+    await expect(credentialsPom.getAddCredentialBtn(page)).toBeVisible();
+
+    // list table exists
+    const table = page.getByRole('table');
+    await expect(table).toBeVisible();
+    await expect(table.getByText('ID', { exact: true })).toBeVisible();
+    await expect(table.getByText('Actions', { exact: true })).toBeVisible();
+  });
+});
+
+test('should only show credentials for current consumer', async ({ page }) => {
+  await test.step('should only show credentials for current consumer', async 
() => {
+    await credentialsPom.toCredentialsIndex(page, testConsumerUsername);
+    await credentialsPom.isCredentialsIndexPage(page, testConsumerUsername);
+
+    // Credentials from another consumer should not be visible
+    await expect(
+      page.getByRole('cell', { name: anotherConsumerCredential.id })
+    ).toBeHidden();
+
+    // Only credentials belonging to current consumer should be visible
+    for (const credential of credentials) {
+      await expect(
+        page.getByRole('cell', { name: credential.id })
+      ).toBeVisible();
+    }
+  });
+
+  await test.step('verify credential isolation', async () => {
+    // Navigate to another consumer's credentials
+    await credentialsPom.toCredentialsIndex(page, anotherConsumerUsername);
+    await credentialsPom.isCredentialsIndexPage(page, anotherConsumerUsername);
+
+    // Should only see the other consumer's credential
+    await expect(
+      page.getByRole('cell', { name: anotherConsumerCredential.id })
+    ).toBeVisible();
+
+    // Should not see test consumer's credentials
+    for (const credential of credentials) {
+      await expect(
+        page.getByRole('cell', { name: credential.id })
+      ).toBeHidden();
+    }
+  });
+});
+
+test('should display credentials list under consumer', async ({ page }) => {
+  await test.step('navigate to consumer credentials page', async () => {
+    // Directly navigate to credentials page
+    await credentialsPom.toCredentialsIndex(page, testConsumerUsername);
+    await credentialsPom.isCredentialsIndexPage(page, testConsumerUsername);
+  });
+
+  await test.step('should display all credentials for consumer', async () => {
+    // Verify all created credentials are displayed
+    for (const credential of credentials) {
+      await expect(
+        page.getByRole('cell', { name: credential.id })
+      ).toBeVisible();
+      await expect(
+        page.getByRole('cell', { name: credential.desc || '' })
+      ).toBeVisible();
+    }
+  });
+
+  await test.step('should have correct table headers', async () => {
+    await expect(page.getByRole('columnheader', { name: 'ID' })).toBeVisible();
+    await expect(
+      page.getByRole('columnheader', { name: 'Description' })
+    ).toBeVisible();
+    await expect(
+      page.getByRole('columnheader', { name: 'Actions' })
+    ).toBeVisible();
+  });
+
+  await test.step('should show correct credential count', async () => {
+    // Check that all 3 credentials are displayed in the table
+    const tableRows = page.locator('tbody tr');
+    await expect(tableRows).toHaveCount(credentials.length);
+  });
+});
+
+test('should be able to navigate to credential detail', async ({ page }) => {
+  await test.step('navigate to credentials list', async () => {
+    await credentialsPom.toCredentialsIndex(page, testConsumerUsername);
+    await credentialsPom.isCredentialsIndexPage(page, testConsumerUsername);
+  });
+
+  await test.step('click on credential view button', async () => {
+    // Click on the first credential's View button
+    await page
+      .getByRole('row', { name: credentials[0].id })
+      .getByRole('button', { name: 'View' })
+      .click();
+
+    await credentialsPom.isCredentialDetailPage(page);
+  });
+
+  await test.step('verify credential detail page', async () => {
+    // Verify we're on the correct credential detail page
+    const idField = page.getByLabel('ID', { exact: true }).first();
+    await expect(idField).toHaveValue(credentials[0].id);
+
+    const descField = page.getByLabel('Description', { exact: true });
+    await expect(descField).toHaveValue(credentials[0].desc || '');
+  });
+});
+
+test('should have Add Credential button', async ({ page }) => {
+  await test.step('navigate to credentials list', async () => {
+    await credentialsPom.toCredentialsIndex(page, testConsumerUsername);
+    await credentialsPom.isCredentialsIndexPage(page, testConsumerUsername);
+  });
+
+  await test.step('verify Add Credential button exists and works', async () => 
{
+    const addCredentialBtn = credentialsPom.getAddCredentialBtn(page);
+    await expect(addCredentialBtn).toBeVisible();
+
+    await addCredentialBtn.click();
+    await credentialsPom.isCredentialAddPage(page, testConsumerUsername);
+  });
+
+  await test.step('verify add page has required fields', async () => {
+    // Verify ID field exists
+    const idField = page.getByLabel('ID', { exact: true }).first();
+    await expect(idField).toBeVisible();
+
+    // Verify Description field exists
+    const descField = page.getByLabel('Description', { exact: true });
+    await expect(descField).toBeVisible();
+  });
+});
+
+test('should be able to delete credential', async ({ page }) => {
+  // Create a temporary credential for deletion test
+  const tempCredential: APISIXType['CredentialPut'] & { username: string } = {
+    username: testConsumerUsername,
+    id: randomId('temp-cred'),
+    desc: 'Temporary credential for deletion',
+    plugins: {
+      'key-auth': {
+        key: randomId('temp-key'),
+      },
+    },
+  };
+
+  await test.step('create temporary credential', async () => {
+    await putCredentialReq(e2eReq, tempCredential);
+  });
+
+  await test.step('navigate to credentials list', async () => {
+    await credentialsPom.toCredentialsIndex(page, testConsumerUsername);
+    await credentialsPom.isCredentialsIndexPage(page, testConsumerUsername);
+  });
+
+  await test.step('verify temporary credential exists', async () => {
+    await expect(
+      page.getByRole('cell', { name: tempCredential.id })
+    ).toBeVisible();
+  });
+
+  await test.step('delete the credential', async () => {
+    // Click delete button on the temporary credential
+    await page
+      .getByRole('row', { name: tempCredential.id })
+      .getByRole('button', { name: 'Delete' })
+      .click();
+
+    // Confirm deletion in modal
+    const deleteDialog = page.getByRole('dialog', { name: 'Delete Credential' 
});
+    await expect(deleteDialog).toBeVisible();
+    await deleteDialog.getByRole('button', { name: 'Delete' }).click();
+
+    // Wait for success notification
+    await expect(page.getByRole('alert')).toBeVisible();
+  });
+
+  await test.step('verify credential is deleted', async () => {
+    // Reload the page to ensure the credential is gone
+    await page.reload();
+    await credentialsPom.isCredentialsIndexPage(page, testConsumerUsername);
+
+    // Verify the credential no longer appears
+    await expect(
+      page.getByRole('cell', { name: tempCredential.id })
+    ).toBeHidden();
+  });
+});
+
+test('should be able to edit credential', async ({ page }) => {
+  const credentialToEdit = credentials[0];
+  const updatedDesc = randomId('updated-desc');
+
+  await test.step('navigate to credential detail', async () => {
+    await credentialsPom.toCredentialDetail(
+      page,
+      testConsumerUsername,
+      credentialToEdit.id
+    );
+    await credentialsPom.isCredentialDetailPage(page);
+  });
+
+  await test.step('enable edit mode', async () => {
+    const editBtn = page.getByRole('button', { name: 'Edit' });
+    await expect(editBtn).toBeVisible();
+    await editBtn.click();
+  });
+
+  await test.step('update credential description', async () => {
+    const descField = page.getByLabel('Description', { exact: true });
+    await expect(descField).toBeEnabled();
+    await descField.clear();
+    await descField.fill(updatedDesc);
+  });
+
+  await test.step('save changes', async () => {
+    const saveBtn = page.getByRole('button', { name: 'Save' });
+    await expect(saveBtn).toBeVisible();
+    await saveBtn.click();
+
+    // Wait for success notification
+    await expect(page.getByRole('alert')).toBeVisible();
+  });
+
+  await test.step('verify changes are saved', async () => {
+    // Reload the page to ensure changes persisted
+    await page.reload();
+    await credentialsPom.isCredentialDetailPage(page);
+
+    const descField = page.getByLabel('Description', { exact: true });
+    await expect(descField).toHaveValue(updatedDesc);
+  });
+
+  await test.step('restore original description', async () => {
+    // Edit again to restore original value
+    const editBtn = page.getByRole('button', { name: 'Edit' });
+    await editBtn.click();
+
+    const descField = page.getByLabel('Description', { exact: true });
+    await descField.clear();
+    await descField.fill(credentialToEdit.desc || '');
+
+    const saveBtn = page.getByRole('button', { name: 'Save' });
+    await saveBtn.click();
+
+    await expect(page.getByRole('alert')).toBeVisible();
+  });
+});
+
+test('should handle empty credentials list', async ({ page }) => {
+  const emptyConsumerUsername = randomId('empty-consumer');
+
+  await test.step('create consumer without credentials', async () => {
+    await putConsumerReq(e2eReq, {
+      username: emptyConsumerUsername,
+      desc: 'Consumer without credentials',
+    });
+  });
+
+  await test.step('navigate to empty credentials list', async () => {
+    await credentialsPom.toCredentialsIndex(page, emptyConsumerUsername);
+    await credentialsPom.isCredentialsIndexPage(page, emptyConsumerUsername);
+  });
+
+  await test.step('verify empty state', async () => {
+    // Table should exist but be empty or show empty message
+    const table = page.getByRole('table');
+    await expect(table).toBeVisible();
+
+    // Check that no actual credential data rows exist (excluding empty state 
row)
+    const credentialCells = page.getByRole('cell').filter({ hasText: /cred-/ 
});
+    await expect(credentialCells).toHaveCount(0);
+  });
+
+  await test.step('cleanup empty consumer', async () => {
+    await e2eReq.delete(`${API_CONSUMERS}/${emptyConsumerUsername}`);
+  });
+});
diff --git a/e2e/tests/consumers.crud-all-fields.spec.ts 
b/e2e/tests/consumers.crud-all-fields.spec.ts
new file mode 100644
index 000000000..b13eb93ac
--- /dev/null
+++ b/e2e/tests/consumers.crud-all-fields.spec.ts
@@ -0,0 +1,145 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { consumersPom } from '@e2e/pom/consumers';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { uiHasToastMsg } from '@e2e/utils/ui';
+import { expect } from '@playwright/test';
+import { customAlphabet } from 'nanoid';
+
+import { deleteAllConsumers } from '@/apis/consumers';
+
+// Consumer usernames can only contain: a-zA-Z0-9_-
+const nanoid = 
customAlphabet('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789',
 10);
+const consumerUsername = `testconsumer${nanoid()}`;
+const description = 'Test consumer with all fields filled';
+
+test.beforeAll(async () => {
+  await deleteAllConsumers(e2eReq);
+});
+
+test('should CRUD consumer with all fields', async ({ page }) => {
+  test.slow();
+
+  await consumersPom.toIndex(page);
+  await consumersPom.isIndexPage(page);
+
+  await consumersPom.getAddConsumerBtn(page).click();
+  await consumersPom.isAddPage(page);
+
+  await test.step('submit with all fields', async () => {
+    // Fill username (required)
+    await page.getByRole('textbox', { name: 'Username' 
}).fill(consumerUsername);
+    
+    // Fill description (optional)
+    await page.getByRole('textbox', { name: 'Description' }).fill(description);
+
+    // Add labels using tags input
+    const labelsInput = page.getByPlaceholder('Input text like `key:value`, 
then enter or blur');
+    await labelsInput.fill('version:v1');
+    await labelsInput.press('Enter');
+    await labelsInput.fill('env:test');
+    await labelsInput.press('Enter');
+    await labelsInput.fill('team:engineering');
+    await labelsInput.press('Enter');
+
+    // Submit the form
+    await consumersPom.getAddBtn(page).click();
+    await uiHasToastMsg(page, {
+      hasText: 'Add Consumer Successfully',
+    });
+  });
+
+  await test.step('auto navigate to consumer detail page', async () => {
+    await consumersPom.isDetailPage(page);
+
+    // Verify the consumer username
+    await expect(page.getByRole('textbox', { name: 'Username' }))
+      .toHaveValue(consumerUsername);
+  });
+
+  await test.step('edit and update all fields', async () => {
+    // Enter edit mode
+    await page.getByRole('button', { name: 'Edit' }).click();
+
+    // Update description
+    await page.getByRole('textbox', { name: 'Description' }).fill('Updated: ' 
+ description);
+
+    // Update labels - remove old ones and add new ones
+    // First, remove existing labels by clicking the X button
+    const labelsSection = page.getByRole('group', { name: 'Basic Infomation' 
});
+    const removeButtons = 
labelsSection.locator('button[aria-label^="Remove"]');
+    const count = await removeButtons.count();
+    for (let i = 0; i < count; i++) {
+      await removeButtons.first().click();
+    }
+    
+    // Add new labels
+    const labelsInput = page.getByPlaceholder('Input text like `key:value`, 
then enter or blur');
+    await labelsInput.fill('version:v2');
+    await labelsInput.press('Enter');
+    await labelsInput.fill('env:production');
+    await labelsInput.press('Enter');
+    await labelsInput.fill('team:platform');
+    await labelsInput.press('Enter');
+
+    // Save changes
+    await page.getByRole('button', { name: 'Save' }).click();
+    await uiHasToastMsg(page, {
+      hasText: 'success',
+    });
+
+    // Verify updates
+    await expect(page.getByRole('textbox', { name: 'Description' }))
+      .toHaveValue('Updated: ' + description);
+  });
+
+  await test.step('verify consumer in list page', async () => {
+    await consumersPom.getConsumerNavBtn(page).click();
+    await consumersPom.isIndexPage(page);
+
+    // Find the consumer in the list
+    const row = page.getByRole('row', { name: consumerUsername });
+    await expect(row).toBeVisible();
+  });
+
+  await test.step('delete consumer', async () => {
+    // Navigate to detail page
+    await page
+      .getByRole('row', { name: consumerUsername })
+      .getByRole('button', { name: 'View' })
+      .click();
+    await consumersPom.isDetailPage(page);
+
+    // Delete
+    await page.getByRole('button', { name: 'Delete' }).click();
+    await page
+      .getByRole('dialog', { name: 'Delete Consumer' })
+      .getByRole('button', { name: 'Delete' })
+      .click();
+
+    // Verify deletion
+    await uiHasToastMsg(page, {
+      hasText: 'Delete Consumer Successfully',
+    });
+    
+    // Navigate to consumers list to verify consumer is gone
+    await consumersPom.toIndex(page);
+    await consumersPom.isIndexPage(page);
+    await expect(page.getByRole('cell', { name: consumerUsername 
})).toBeHidden();
+  });
+});
diff --git a/e2e/tests/consumers.crud-required-fields.spec.ts 
b/e2e/tests/consumers.crud-required-fields.spec.ts
new file mode 100644
index 000000000..0354abc73
--- /dev/null
+++ b/e2e/tests/consumers.crud-required-fields.spec.ts
@@ -0,0 +1,127 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { consumersPom } from '@e2e/pom/consumers';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { uiHasToastMsg } from '@e2e/utils/ui';
+import { expect } from '@playwright/test';
+import { customAlphabet } from 'nanoid';
+
+import { deleteAllConsumers } from '@/apis/consumers';
+
+// Consumer usernames can only contain: a-zA-Z0-9_-
+const nanoid = 
customAlphabet('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789',
 10);
+const consumerUsername = `testconsumer${nanoid()}`;
+
+test.beforeAll(async () => {
+  await deleteAllConsumers(e2eReq);
+});
+
+test('should CRUD consumer with required fields', async ({ page }) => {
+  await consumersPom.toIndex(page);
+  await consumersPom.isIndexPage(page);
+
+  await consumersPom.getAddConsumerBtn(page).click();
+  await consumersPom.isAddPage(page);
+
+  await test.step('cannot submit without required fields', async () => {
+    await consumersPom.getAddBtn(page).click();
+    // Should stay on add page - form validation prevents submission
+    await consumersPom.isAddPage(page);
+  });
+
+  await test.step('submit with required fields', async () => {
+    // Fill in the Username field (only required field for consumers)
+    await page.getByRole('textbox', { name: 'Username' 
}).fill(consumerUsername);
+
+    // Submit the form
+    await consumersPom.getAddBtn(page).click();
+    await uiHasToastMsg(page, {
+      hasText: 'Add Consumer Successfully',
+    });
+  });
+
+  await test.step('auto navigate to consumer detail page', async () => {
+    await consumersPom.isDetailPage(page);
+
+    // Verify the consumer username
+    const username = page.getByRole('textbox', { name: 'Username' });
+    await expect(username).toHaveValue(consumerUsername);
+    await expect(username).toBeDisabled();
+  });
+
+  await test.step('edit and update consumer in detail page', async () => {
+    // Click the Edit button in the detail page
+    await page.getByRole('button', { name: 'Edit' }).click();
+
+    // Update the description field
+    const descriptionField = page.getByRole('textbox', { name: 'Description' 
});
+    await descriptionField.fill('Updated description for testing');
+
+    // Click the Save button to save changes
+    const saveBtn = page.getByRole('button', { name: 'Save' });
+    await saveBtn.click();
+
+    // Verify the update was successful
+    await uiHasToastMsg(page, {
+      hasText: 'success',
+    });
+
+    // Verify we're back in detail view mode
+    await consumersPom.isDetailPage(page);
+
+    // Verify the updated fields
+    await expect(page.getByRole('textbox', { name: 'Description' 
})).toHaveValue(
+      'Updated description for testing'
+    );
+  });
+
+  await test.step('consumer should exist in list page', async () => {
+    await consumersPom.getConsumerNavBtn(page).click();
+    await consumersPom.isIndexPage(page);
+    await expect(page.getByRole('cell', { name: consumerUsername 
})).toBeVisible();
+
+    // Click on the view button to go to the detail page
+    await page
+      .getByRole('row', { name: consumerUsername })
+      .getByRole('button', { name: 'View' })
+      .click();
+    await consumersPom.isDetailPage(page);
+  });
+
+  await test.step('delete consumer in detail page', async () => {
+    // We're already on the detail page from the previous step
+
+    // Delete the consumer
+    await page.getByRole('button', { name: 'Delete' }).click();
+
+    await page
+      .getByRole('dialog', { name: 'Delete Consumer' })
+      .getByRole('button', { name: 'Delete' })
+      .click();
+
+    // Verify deletion was successful with toast
+    await uiHasToastMsg(page, {
+      hasText: 'Delete Consumer Successfully',
+    });
+    
+    // Navigate to consumers index to verify consumer is gone
+    await consumersPom.toIndex(page);
+    await consumersPom.isIndexPage(page);
+    await expect(page.getByRole('cell', { name: consumerUsername 
})).toBeHidden();
+  });
+});
diff --git a/e2e/tests/consumers.list.spec.ts b/e2e/tests/consumers.list.spec.ts
new file mode 100644
index 000000000..43ff5ce9b
--- /dev/null
+++ b/e2e/tests/consumers.list.spec.ts
@@ -0,0 +1,81 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { consumersPom } from '@e2e/pom/consumers';
+import { setupPaginationTests } from '@e2e/utils/pagination-test-helper';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { expect, type Page } from '@playwright/test';
+
+import { deleteAllConsumers, putConsumerReq } from '@/apis/consumers';
+import { API_CONSUMERS } from '@/config/constant';
+import type { APISIXType } from '@/types/schema/apisix';
+
+test('should navigate to consumers page', async ({ page }) => {
+  await test.step('navigate to consumers page', async () => {
+    await consumersPom.getConsumerNavBtn(page).click();
+    await consumersPom.isIndexPage(page);
+  });
+
+  await test.step('verify consumers page components', async () => {
+    await expect(consumersPom.getAddConsumerBtn(page)).toBeVisible();
+
+    // list table exists
+    const table = page.getByRole('table');
+    await expect(table).toBeVisible();
+    await expect(table.getByText('Username', { exact: true })).toBeVisible();
+    await expect(table.getByText('Actions', { exact: true })).toBeVisible();
+  });
+});
+
+const consumers: APISIXType['ConsumerPut'][] = Array.from({ length: 11 }, (_, 
i) => ({
+  username: `test_consumer_${i + 1}`,
+  desc: `Description for consumer ${i + 1}`,
+}));
+
+test.describe('page and page_size should work correctly', () => {
+  test.describe.configure({ mode: 'serial' });
+  test.beforeAll(async () => {
+    await deleteAllConsumers(e2eReq);
+    await Promise.all(consumers.map((d) => putConsumerReq(e2eReq, d)));
+  });
+
+  test.afterAll(async () => {
+    await Promise.all(
+      consumers.map((d) => e2eReq.delete(`${API_CONSUMERS}/${d.username}`))
+    );
+  });
+
+  // Setup pagination tests with consumer-specific configurations
+  const filterItemsNotInPage = async (page: Page) => {
+    // filter the item which not in the current page
+    // it should be random, so we need get all items in the table
+    const itemsInPage = await page
+      .getByRole('cell', { name: /test_consumer_/ })
+      .all();
+    const names = await Promise.all(itemsInPage.map((v) => v.textContent()));
+    return consumers.filter((d) => !names.includes(d.username));
+  };
+
+  setupPaginationTests(test, {
+    pom: consumersPom,
+    items: consumers,
+    filterItemsNotInPage,
+    getCell: (page, item) =>
+      page.getByRole('cell', { name: item.username }).first(),
+  });
+});
diff --git a/src/apis/consumers.ts b/src/apis/consumers.ts
index c4779df1c..7b7751290 100644
--- a/src/apis/consumers.ts
+++ b/src/apis/consumers.ts
@@ -16,7 +16,7 @@
  */
 import type { AxiosInstance } from 'axios';
 
-import { API_CONSUMERS } from '@/config/constant';
+import { API_CONSUMERS, PAGE_SIZE_MAX, PAGE_SIZE_MIN } from 
'@/config/constant';
 import type { APISIXType } from '@/types/schema/apisix';
 import type { PageSearchType } from '@/types/schema/pageSearch';
 
@@ -43,3 +43,21 @@ export const putConsumerReq = (
     data
   );
 };
+
+export const deleteAllConsumers = async (req: AxiosInstance) => {
+  const totalRes = await getConsumerListReq(req, {
+    page: 1,
+    page_size: PAGE_SIZE_MIN,
+  });
+  const total = totalRes.total;
+  if (total === 0) return;
+  for (let times = Math.ceil(total / PAGE_SIZE_MAX); times > 0; times--) {
+    const res = await getConsumerListReq(req, {
+      page: 1,
+      page_size: PAGE_SIZE_MAX,
+    });
+    await Promise.all(
+      res.list.map((d) => req.delete(`${API_CONSUMERS}/${d.value.username}`))
+    );
+  }
+};


Reply via email to