You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@apisix.apache.org by ju...@apache.org on 2025/06/13 02:22:35 UTC

(apisix-dashboard) branch master updated: test: routes in services (#3112)

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

juzhiyuan 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 20cd07df3 test: routes in services (#3112)
20cd07df3 is described below

commit 20cd07df3c801f2992e8aa17eb95b5f87692c69d
Author: YYYoung <is...@outlook.com>
AuthorDate: Fri Jun 13 10:22:30 2025 +0800

    test: routes in services (#3112)
---
 e2e/pom/services.ts                    |  41 ++++++
 e2e/tests/services.routes.crud.spec.ts | 220 ++++++++++++++++++++++++++++++
 e2e/tests/services.routes.list.spec.ts | 239 +++++++++++++++++++++++++++++++++
 e2e/utils/ui/index.ts                  |  14 +-
 4 files changed, 512 insertions(+), 2 deletions(-)

diff --git a/e2e/pom/services.ts b/e2e/pom/services.ts
index b3b4685d1..7ac701cf6 100644
--- a/e2e/pom/services.ts
+++ b/e2e/pom/services.ts
@@ -24,6 +24,11 @@ const locator = {
     page.getByRole('button', { name: 'Add Service', exact: true }),
   getAddBtn: (page: Page) =>
     page.getByRole('button', { name: 'Add', exact: true }),
+  // Service routes locators
+  getServiceRoutesTab: (page: Page) =>
+    page.getByRole('tab', { name: 'Routes', exact: true }),
+  getAddRouteBtn: (page: Page) =>
+    page.getByRole('button', { name: 'Add Route', exact: true }),
 };
 
 const assert = {
@@ -46,11 +51,47 @@ const assert = {
     const title = page.getByRole('heading', { name: 'Service Detail' });
     await expect(title).toBeVisible();
   },
+  // Service routes assertions
+  isServiceRoutesPage: async (page: Page) => {
+    await expect(page).toHaveURL(
+      (url) =>
+        url.pathname.includes('/services/detail') &&
+        url.pathname.includes('/routes')
+    );
+    // Wait for page to load completely
+    await page.waitForLoadState('networkidle');
+    const title = page.getByRole('heading', { name: 'Routes' });
+    await expect(title).toBeVisible();
+  },
+  isServiceRouteAddPage: async (page: Page) => {
+    await expect(page).toHaveURL(
+      (url) =>
+        url.pathname.includes('/services/detail') &&
+        url.pathname.includes('/routes/add')
+    );
+    const title = page.getByRole('heading', { name: 'Add Route' });
+    await expect(title).toBeVisible();
+  },
+  isServiceRouteDetailPage: async (page: Page) => {
+    await expect(page).toHaveURL(
+      (url) =>
+        url.pathname.includes('/services/detail') &&
+        url.pathname.includes('/routes/detail')
+    );
+    const title = page.getByRole('heading', { name: 'Route Detail' });
+    await expect(title).toBeVisible();
+  },
 };
 
 const goto = {
   toIndex: (page: Page) => uiGoto(page, '/services'),
   toAdd: (page: Page) => uiGoto(page, '/services/add'),
+  toServiceRoutes: (page: Page, serviceId: string) =>
+    uiGoto(page, '/services/detail/$id/routes', { id: serviceId }),
+  toServiceRouteAdd: (page: Page, serviceId: string) =>
+    uiGoto(page, '/services/detail/$id/routes/add', {
+      id: serviceId,
+    }),
 };
 
 export const servicesPom = {
diff --git a/e2e/tests/services.routes.crud.spec.ts b/e2e/tests/services.routes.crud.spec.ts
new file mode 100644
index 000000000..16e6480db
--- /dev/null
+++ b/e2e/tests/services.routes.crud.spec.ts
@@ -0,0 +1,220 @@
+/**
+ * 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 { servicesPom } from '@e2e/pom/services';
+import { randomId } from '@e2e/utils/common';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { uiHasToastMsg } from '@e2e/utils/ui';
+import { uiFillUpstreamRequiredFields } from '@e2e/utils/ui/upstreams';
+import { expect } from '@playwright/test';
+
+import { deleteAllRoutes } from '@/apis/routes';
+import { deleteAllServices, postServiceReq } from '@/apis/services';
+import type { APISIXType } from '@/types/schema/apisix';
+
+const serviceName = randomId('test-service');
+const routeName = randomId('test-route');
+const routeUri = '/test-route';
+const nodes: APISIXType['UpstreamNode'][] = [
+  { host: 'test.com', port: 80, weight: 100 },
+  { host: 'test2.com', port: 80, weight: 100 },
+];
+
+let testServiceId: string;
+
+test.beforeAll(async () => {
+  await deleteAllRoutes(e2eReq);
+  await deleteAllServices(e2eReq);
+
+  // Create a test service for testing service routes
+  const serviceResponse = await postServiceReq(e2eReq, {
+    name: serviceName,
+    desc: 'Test service for route testing',
+  });
+
+  testServiceId = serviceResponse.data.value.id;
+});
+
+test.afterAll(async () => {
+  await deleteAllRoutes(e2eReq);
+  await deleteAllServices(e2eReq);
+});
+
+test('should CRUD route under service with required fields', async ({
+  page,
+}) => {
+  // Navigate to service detail page
+  await servicesPom.toIndex(page);
+  await servicesPom.isIndexPage(page);
+
+  // Click on the service to go to detail page
+  await page
+    .getByRole('row', { name: serviceName })
+    .getByRole('button', { name: 'View' })
+    .click();
+  await servicesPom.isDetailPage(page);
+
+  // Navigate to Routes tab
+  await servicesPom.getServiceRoutesTab(page).click();
+  await servicesPom.isServiceRoutesPage(page);
+
+  await servicesPom.getAddRouteBtn(page).click();
+  await servicesPom.isServiceRouteAddPage(page);
+
+  await test.step('cannot submit without required fields', async () => {
+    await servicesPom.getAddBtn(page).click();
+    await servicesPom.isServiceRouteAddPage(page);
+    await uiHasToastMsg(page, {
+      hasText: 'invalid configuration',
+    });
+  });
+
+  await test.step('submit with required fields', async () => {
+    // Fill in the Name field
+    await page.getByLabel('Name', { exact: true }).first().fill(routeName);
+    await page.getByLabel('URI', { exact: true }).fill(routeUri);
+
+    // Select HTTP method
+    await page.getByRole('textbox', { name: 'HTTP Methods' }).click();
+    await page.getByRole('option', { name: 'GET' }).click();
+
+    // Verify service_id is pre-filled and disabled (since it's read-only in service context)
+    const serviceIdField = page.getByLabel('Service ID', { exact: true });
+    await expect(serviceIdField).toHaveValue(testServiceId);
+    await expect(serviceIdField).toBeDisabled();
+
+    // Add upstream nodes
+    const upstreamSection = page.getByRole('group', {
+      name: 'Upstream',
+      exact: true,
+    });
+    await uiFillUpstreamRequiredFields(upstreamSection, {
+      nodes,
+      name: 'test-upstream',
+      desc: 'test',
+    });
+
+    // Submit the form
+    await servicesPom.getAddBtn(page).click();
+    await uiHasToastMsg(page, {
+      hasText: 'Add Route Successfully',
+    });
+  });
+
+  await test.step('auto navigate to route detail page', async () => {
+    await servicesPom.isServiceRouteDetailPage(page);
+
+    // Verify the route details
+    // Verify ID exists
+    const ID = page.getByRole('textbox', { name: 'ID', exact: true });
+    await expect(ID).toBeVisible();
+    await expect(ID).toBeDisabled();
+
+    // Verify the route name
+    const name = page.getByLabel('Name', { exact: true }).first();
+    await expect(name).toHaveValue(routeName);
+    await expect(name).toBeDisabled();
+
+    // Verify the route URI
+    const uri = page.getByLabel('URI', { exact: true });
+    await expect(uri).toHaveValue(routeUri);
+    await expect(uri).toBeDisabled();
+
+    // Verify service_id is still pre-filled and disabled
+    const serviceIdField = page.getByLabel('Service ID', { exact: true });
+    await expect(serviceIdField).toHaveValue(testServiceId);
+    await expect(serviceIdField).toBeDisabled();
+  });
+
+  await test.step('edit and update route in detail page', async () => {
+    // Click the Edit button in the detail page
+    await page.getByRole('button', { name: 'Edit' }).click();
+
+    // Verify we're in edit mode - fields should be editable now
+    const nameField = page.getByLabel('Name', { exact: true }).first();
+    await expect(nameField).toBeEnabled();
+
+    // Service ID should still be disabled even in edit mode
+    const serviceIdField = page.getByLabel('Service ID', { exact: true });
+    await expect(serviceIdField).toBeDisabled();
+
+    // Update the description field
+    const descriptionField = page.getByLabel('Description').first();
+    await descriptionField.fill('Updated description for testing');
+
+    // Update URI
+    const uriField = page.getByLabel('URI', { exact: true });
+    await uriField.fill(`${routeUri}-updated`);
+
+    // 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 servicesPom.isServiceRouteDetailPage(page);
+
+    // Verify the updated fields
+    // Verify description
+    await expect(page.getByLabel('Description').first()).toHaveValue(
+      'Updated description for testing'
+    );
+
+    // Check if the updated URI is visible
+    await expect(page.getByLabel('URI', { exact: true })).toHaveValue(
+      `${routeUri}-updated`
+    );
+  });
+
+  await test.step('route should exist in service routes list', async () => {
+    // Navigate back to service routes list
+    await servicesPom.toServiceRoutes(page, testServiceId);
+    await servicesPom.isServiceRoutesPage(page);
+
+    await expect(page.getByRole('cell', { name: routeName })).toBeVisible();
+
+    // Click on the route name to go to the detail page
+    await page
+      .getByRole('row', { name: routeName })
+      .getByRole('button', { name: 'View' })
+      .click();
+    await servicesPom.isServiceRouteDetailPage(page);
+  });
+
+  await test.step('delete route in detail page', async () => {
+    // We're already on the detail page from the previous step
+
+    // Delete the route
+    await page.getByRole('button', { name: 'Delete' }).click();
+
+    await page
+      .getByRole('dialog', { name: 'Delete Route' })
+      .getByRole('button', { name: 'Delete' })
+      .click();
+
+    // Will redirect to service routes page
+    await servicesPom.isServiceRoutesPage(page);
+    await uiHasToastMsg(page, {
+      hasText: 'Delete Route Successfully',
+    });
+    await expect(page.getByRole('cell', { name: routeName })).toBeHidden();
+  });
+});
diff --git a/e2e/tests/services.routes.list.spec.ts b/e2e/tests/services.routes.list.spec.ts
new file mode 100644
index 000000000..abb6b0679
--- /dev/null
+++ b/e2e/tests/services.routes.list.spec.ts
@@ -0,0 +1,239 @@
+/**
+ * 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 { routesPom } from '@e2e/pom/routes';
+import { servicesPom } from '@e2e/pom/services';
+import { randomId } from '@e2e/utils/common';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { expect } from '@playwright/test';
+
+import { deleteAllRoutes, postRouteReq } from '@/apis/routes';
+import { deleteAllServices, postServiceReq } from '@/apis/services';
+import type { APISIXType } from '@/types/schema/apisix';
+
+const serviceName = randomId('test-service');
+const anotherServiceName = randomId('another-service');
+const routes: APISIXType['Route'][] = [
+  {
+    name: randomId('route1'),
+    uri: '/api/v1/test1',
+    methods: ['GET'],
+  },
+  {
+    name: randomId('route2'),
+    uri: '/api/v1/test2',
+    methods: ['POST'],
+  },
+  {
+    name: randomId('route3'),
+    uri: '/api/v1/test3',
+    methods: ['PUT'],
+  },
+];
+
+// Route that uses upstream directly instead of service_id
+const upstreamRoute: APISIXType['Route'] = {
+  name: randomId('upstream-route'),
+  uri: '/api/v1/upstream-test',
+  methods: ['GET'],
+  upstream: {
+    nodes: [{ host: 'example.com', port: 80, weight: 100 }],
+  },
+};
+
+// Route that belongs to another service
+const anotherServiceRoute: APISIXType['Route'] = {
+  name: randomId('another-service-route'),
+  uri: '/api/v1/another-test',
+  methods: ['GET'],
+};
+
+let testServiceId: string;
+let anotherServiceId: string;
+const createdRoutes: string[] = [];
+
+test.beforeAll(async () => {
+  await deleteAllRoutes(e2eReq);
+  await deleteAllServices(e2eReq);
+
+  // Create a test service for testing service routes
+  const serviceResponse = await postServiceReq(e2eReq, {
+    name: serviceName,
+    desc: 'Test service for route listing',
+  });
+
+  testServiceId = serviceResponse.data.value.id;
+
+  // Create another service
+  const anotherServiceResponse = await postServiceReq(e2eReq, {
+    name: anotherServiceName,
+    desc: 'Another test service for route isolation testing',
+  });
+
+  anotherServiceId = anotherServiceResponse.data.value.id;
+
+  // Create test routes under the service
+  for (const route of routes) {
+    const routeResponse = await postRouteReq(e2eReq, {
+      ...route,
+      service_id: testServiceId,
+    });
+    createdRoutes.push(routeResponse.data.value.id);
+  }
+
+  // Create a route that uses upstream directly instead of service_id
+  await postRouteReq(e2eReq, upstreamRoute);
+
+  // Create a route under another service
+  await postRouteReq(e2eReq, {
+    ...anotherServiceRoute,
+    service_id: anotherServiceId,
+  });
+});
+
+test.afterAll(async () => {
+  await deleteAllRoutes(e2eReq);
+  await deleteAllServices(e2eReq);
+});
+
+test('should only show routes with current service_id', async ({ page }) => {
+  await test.step('should only show routes with current service_id', async () => {
+    await servicesPom.toIndex(page);
+    await servicesPom.isIndexPage(page);
+
+    await page
+      .getByRole('row', { name: serviceName })
+      .getByRole('button', { name: 'View' })
+      .click();
+    await servicesPom.isDetailPage(page);
+
+    await servicesPom.getServiceRoutesTab(page).click();
+    await servicesPom.isServiceRoutesPage(page);
+
+    // Routes from another service should not be visible
+    await expect(
+      page.getByRole('cell', { name: anotherServiceRoute.name })
+    ).toBeHidden();
+    // Upstream route (without service_id) should not be visible
+    await expect(
+      page.getByRole('cell', { name: upstreamRoute.name })
+    ).toBeHidden();
+    // Only routes belonging to current service should be visible
+    for (const route of routes) {
+      await expect(page.getByRole('cell', { name: route.name })).toBeVisible();
+    }
+  });
+
+  await test.step('without service_id routes should still exist in the routes list', async () => {
+    await routesPom.toIndex(page);
+    await routesPom.isIndexPage(page);
+
+    // All routes should be visible in the global routes list
+    await expect(
+      page.getByRole('cell', { name: upstreamRoute.name })
+    ).toBeVisible();
+    await expect(
+      page.getByRole('cell', { name: anotherServiceRoute.name })
+    ).toBeVisible();
+    for (const route of routes) {
+      await expect(page.getByRole('cell', { name: route.name })).toBeVisible();
+    }
+  });
+});
+
+test('should display routes list under service', async ({ page }) => {
+  // Navigate to service detail page
+  await servicesPom.toIndex(page);
+  await servicesPom.isIndexPage(page);
+
+  // Click on the service to go to detail page
+  await page
+    .getByRole('row', { name: serviceName })
+    .getByRole('button', { name: 'View' })
+    .click();
+  await servicesPom.isDetailPage(page);
+
+  // Navigate to Routes tab
+  await servicesPom.getServiceRoutesTab(page).click();
+  await servicesPom.isServiceRoutesPage(page);
+
+  await test.step('should display all routes under service', async () => {
+    // Verify all created routes are displayed
+    for (const route of routes) {
+      await expect(page.getByRole('cell', { name: route.name })).toBeVisible();
+      await expect(page.getByRole('cell', { name: route.uri })).toBeVisible();
+    }
+  });
+
+  await test.step('should have correct table headers', async () => {
+    await expect(page.getByRole('columnheader', { name: 'ID' })).toBeVisible();
+    await expect(
+      page.getByRole('columnheader', { name: 'Name' })
+    ).toBeVisible();
+    await expect(page.getByRole('columnheader', { name: 'URI' })).toBeVisible();
+    await expect(
+      page.getByRole('columnheader', { name: 'Actions' })
+    ).toBeVisible();
+  });
+
+  await test.step('should be able to navigate to route detail', async () => {
+    // Click on the first route's View button
+    await page
+      .getByRole('row', { name: routes[0].name })
+      .getByRole('button', { name: 'View' })
+      .click();
+
+    await servicesPom.isServiceRouteDetailPage(page);
+
+    // Verify we're on the correct route detail page
+    const nameField = page.getByLabel('Name', { exact: true }).first();
+    await expect(nameField).toHaveValue(routes[0].name);
+
+    // Verify service_id is correct
+    const serviceIdField = page.getByLabel('Service ID', { exact: true });
+    await expect(serviceIdField).toHaveValue(testServiceId);
+  });
+
+  await test.step('should have Add Route button', async () => {
+    // Navigate back to service routes list
+    await servicesPom.toServiceRoutes(page, testServiceId);
+    await servicesPom.isServiceRoutesPage(page);
+
+    // Verify Add Route button exists and is clickable
+    const addRouteBtn = servicesPom.getAddRouteBtn(page);
+    await expect(addRouteBtn).toBeVisible();
+
+    await addRouteBtn.click();
+    await servicesPom.isServiceRouteAddPage(page);
+
+    // Verify service_id is pre-filled
+    const serviceIdField = page.getByLabel('Service ID', { exact: true });
+    await expect(serviceIdField).toHaveValue(testServiceId);
+    await expect(serviceIdField).toBeDisabled();
+  });
+
+  await test.step('should show correct route count', async () => {
+    // Navigate back to service routes list
+    await servicesPom.toServiceRoutes(page, testServiceId);
+    await servicesPom.isServiceRoutesPage(page);
+
+    // Check that all 3 routes are displayed in the table
+    const tableRows = page.locator('tbody tr');
+    await expect(tableRows).toHaveCount(routes.length);
+  });
+});
+
diff --git a/e2e/utils/ui/index.ts b/e2e/utils/ui/index.ts
index 580ce2eaf..a16d619bf 100644
--- a/e2e/utils/ui/index.ts
+++ b/e2e/utils/ui/index.ts
@@ -22,8 +22,18 @@ import type { FileRouteTypes } from '@/routeTree.gen';
 
 import { env } from '../env';
 
-export const uiGoto = (page: Page, path: FileRouteTypes['to']) => {
-  return page.goto(`${env.E2E_TARGET_URL}${path.substring(1)}`);
+export const uiGoto = <T extends FileRouteTypes['to']>(
+  page: Page,
+  path: T,
+  params?: T extends `${string}$${string}` ? Record<string, string> : never
+) => {
+  let finalPath = path as string;
+  if (params) {
+    Object.entries(params).forEach(([key, value]) => {
+      finalPath = finalPath.replace(`$${key}`, value);
+    });
+  }
+  return page.goto(`${env.E2E_TARGET_URL}${finalPath.substring(1)}`);
 };
 
 export const uiHasToastMsg = async (