Skip to content

Commit 127391c

Browse files
authored
fix(routes, stream_routes): clear upstream when has upstream_id (#3151)
* fix(routes, stream_routes): clear upstream when has upstream_id * test: add related cases
1 parent fde2c5f commit 127391c

File tree

8 files changed

+327
-22
lines changed

8 files changed

+327
-22
lines changed
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
/**
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
import { routesPom } from '@e2e/pom/routes';
18+
import { randomId } from '@e2e/utils/common';
19+
import { e2eReq } from '@e2e/utils/req';
20+
import { test } from '@e2e/utils/test';
21+
import { uiHasToastMsg } from '@e2e/utils/ui';
22+
import { uiDeleteRoute } from '@e2e/utils/ui/routes';
23+
import { uiFillUpstreamRequiredFields } from '@e2e/utils/ui/upstreams';
24+
import { expect, type Page } from '@playwright/test';
25+
26+
import { deleteAllRoutes, getRouteReq } from '@/apis/routes';
27+
import { deleteAllServices, postServiceReq } from '@/apis/services';
28+
import { deleteAllUpstreams, postUpstreamReq } from '@/apis/upstreams';
29+
import type { APISIXType } from '@/types/schema/apisix';
30+
31+
const upstreamName = randomId('test-upstream');
32+
const serviceName = randomId('test-service');
33+
const routeNameForUpstreamId = randomId('test-route-upstream-id');
34+
const routeNameForServiceId = randomId('test-route-service-id');
35+
const routeUri1 = '/test-route-upstream-id';
36+
const routeUri2 = '/test-route-service-id';
37+
38+
const upstreamNodes: APISIXType['UpstreamNode'][] = [
39+
{ host: 'test.com', port: 80, weight: 100 },
40+
{ host: 'test2.com', port: 80, weight: 100 },
41+
];
42+
43+
let testUpstreamId: string;
44+
let testServiceId: string;
45+
46+
// Common helper functions
47+
async function fillBasicRouteFields(
48+
page: Page,
49+
routeName: string,
50+
routeUri: string,
51+
method: string
52+
) {
53+
await page.getByLabel('Name', { exact: true }).first().fill(routeName);
54+
await page.getByLabel('URI', { exact: true }).fill(routeUri);
55+
56+
// Select HTTP method
57+
await page.getByRole('textbox', { name: 'HTTP Methods' }).click();
58+
await page.getByRole('option', { name: method }).click();
59+
}
60+
61+
async function fillUpstreamFields(
62+
page: Page,
63+
upstreamName: string,
64+
upstreamDesc: string
65+
) {
66+
const upstreamSection = page.getByRole('group', {
67+
name: 'Upstream',
68+
exact: true,
69+
});
70+
71+
await uiFillUpstreamRequiredFields(upstreamSection, {
72+
nodes: upstreamNodes,
73+
name: upstreamName,
74+
desc: upstreamDesc,
75+
});
76+
77+
return upstreamSection;
78+
}
79+
80+
async function verifyRouteData(
81+
page: Page,
82+
expectedIdField: 'upstream_id' | 'service_id',
83+
expectedIdValue: string
84+
) {
85+
await routesPom.isDetailPage(page);
86+
87+
// Get the route ID from URL
88+
const url = page.url();
89+
const routeId = url.split('/').pop();
90+
expect(routeId).toBeDefined();
91+
92+
// Fetch route data via API to verify the upstream field was cleared
93+
const routeResponse = await getRouteReq(e2eReq, routeId!);
94+
const routeData = routeResponse.value;
95+
96+
// Verify the expected ID field is preserved
97+
expect(routeData[expectedIdField]).toBe(expectedIdValue);
98+
99+
// Verify upstream field is cleared (should be undefined or empty)
100+
expect(routeData.upstream).toBeUndefined();
101+
102+
// Verify in UI - the ID field should have the value and be disabled
103+
const idField = page.locator(`input[name="${expectedIdField}"]`);
104+
await expect(idField).toHaveValue(expectedIdValue);
105+
await expect(idField).toBeDisabled();
106+
107+
return routeId!;
108+
}
109+
110+
async function editRouteAndAddUpstream(
111+
page: Page,
112+
upstreamName: string,
113+
upstreamDesc: string
114+
) {
115+
// Click Edit button to enter edit mode
116+
await page.getByRole('button', { name: 'Edit' }).click();
117+
118+
// Verify we're in edit mode
119+
const nameField = page.getByLabel('Name', { exact: true }).first();
120+
await expect(nameField).toBeEnabled();
121+
122+
// Add upstream configuration
123+
await fillUpstreamFields(page, upstreamName, upstreamDesc);
124+
125+
// Submit the changes
126+
await page.getByRole('button', { name: 'Save' }).click();
127+
await uiHasToastMsg(page, {
128+
hasText: 'success',
129+
});
130+
}
131+
132+
test.beforeAll(async () => {
133+
// Clean up existing resources
134+
await deleteAllRoutes(e2eReq);
135+
await deleteAllServices(e2eReq);
136+
await deleteAllUpstreams(e2eReq);
137+
138+
// Create a test upstream for testing upstream_id scenario
139+
const upstreamResponse = await postUpstreamReq(e2eReq, {
140+
name: upstreamName,
141+
nodes: upstreamNodes,
142+
});
143+
testUpstreamId = upstreamResponse.data.value.id;
144+
145+
// Create a test service for testing service_id scenario
146+
const serviceResponse = await postServiceReq(e2eReq, {
147+
name: serviceName,
148+
desc: 'Test service for route upstream field clearing',
149+
});
150+
testServiceId = serviceResponse.data.value.id;
151+
});
152+
153+
test.afterAll(async () => {
154+
await deleteAllRoutes(e2eReq);
155+
await deleteAllServices(e2eReq);
156+
await deleteAllUpstreams(e2eReq);
157+
});
158+
159+
test('should clear upstream field when upstream_id exists (create and edit)', async ({
160+
page,
161+
}) => {
162+
await routesPom.toAdd(page);
163+
164+
await test.step('create route with both upstream and upstream_id', async () => {
165+
// Fill basic route fields
166+
await fillBasicRouteFields(page, routeNameForUpstreamId, routeUri1, 'GET');
167+
168+
// Fill upstream fields
169+
const upstreamSection = await fillUpstreamFields(
170+
page,
171+
'test-upstream-inline',
172+
'test inline upstream'
173+
);
174+
175+
// Set upstream_id (this should cause upstream field to be cleared)
176+
const upstreamIdInput = upstreamSection.locator(
177+
'input[name="upstream_id"]'
178+
);
179+
await upstreamIdInput.fill(testUpstreamId);
180+
181+
// verify upstream_id has value
182+
await expect(upstreamIdInput).toHaveValue(testUpstreamId);
183+
184+
// Submit the form
185+
await routesPom.getAddBtn(page).click();
186+
await uiHasToastMsg(page, {
187+
hasText: 'Add Route Successfully',
188+
});
189+
});
190+
191+
await test.step('verify upstream field is cleared after creation', async () => {
192+
await verifyRouteData(page, 'upstream_id', testUpstreamId);
193+
});
194+
195+
await test.step('edit route and add upstream configuration again', async () => {
196+
await editRouteAndAddUpstream(
197+
page,
198+
'test-upstream-edit-1',
199+
'test upstream for editing'
200+
);
201+
});
202+
203+
await test.step('verify upstream field is still cleared after editing', async () => {
204+
await verifyRouteData(page, 'upstream_id', testUpstreamId);
205+
await uiDeleteRoute(page);
206+
});
207+
});
208+
209+
test('should clear upstream field when service_id exists (create and edit)', async ({
210+
page,
211+
}) => {
212+
await routesPom.toAdd(page);
213+
214+
await test.step('create route with both upstream and service_id', async () => {
215+
// Fill basic route fields
216+
await fillBasicRouteFields(page, routeNameForServiceId, routeUri2, 'POST');
217+
218+
// Fill upstream fields
219+
await fillUpstreamFields(
220+
page,
221+
'test-upstream-inline-2',
222+
'test inline upstream 2'
223+
);
224+
225+
// Set service_id (this should cause upstream field to be cleared)
226+
const serviceSection = page.getByRole('group', { name: 'Service' });
227+
await serviceSection
228+
.locator('input[name="service_id"]')
229+
.fill(testServiceId);
230+
// verify service_id has value
231+
await expect(page.getByLabel('Service ID', { exact: true })).toHaveValue(
232+
testServiceId
233+
);
234+
235+
// Submit the form
236+
await routesPom.getAddBtn(page).click();
237+
await uiHasToastMsg(page, {
238+
hasText: 'Add Route Successfully',
239+
});
240+
});
241+
242+
await test.step('verify upstream field is cleared after creation', async () => {
243+
await verifyRouteData(page, 'service_id', testServiceId);
244+
});
245+
246+
await test.step('edit route and add upstream configuration again', async () => {
247+
await editRouteAndAddUpstream(
248+
page,
249+
'test-upstream-edit-2',
250+
'test upstream for editing 2'
251+
);
252+
});
253+
254+
await test.step('verify upstream field is still cleared after editing', async () => {
255+
await verifyRouteData(page, 'service_id', testServiceId);
256+
await uiDeleteRoute(page);
257+
});
258+
});

e2e/utils/ui/routes.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/**
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
import { routesPom } from '@e2e/pom/routes';
18+
import type { Page } from '@playwright/test';
19+
20+
import { uiHasToastMsg } from '.';
21+
22+
export async function uiDeleteRoute(page: Page) {
23+
// Delete the route for cleanup
24+
await page.getByRole('button', { name: 'Delete' }).click();
25+
await page
26+
.getByRole('dialog', { name: 'Delete Route' })
27+
.getByRole('button', { name: 'Delete' })
28+
.click();
29+
await uiHasToastMsg(page, {
30+
hasText: 'Delete Route Successfully',
31+
});
32+
await routesPom.isIndexPage(page);
33+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/**
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
import { produceRmUpstreamWhenHas } from '@/utils/form-producer';
18+
import { pipeProduce } from '@/utils/producer';
19+
20+
export const produceRoute = pipeProduce(
21+
produceRmUpstreamWhenHas('service_id', 'upstream_id')
22+
);

src/routes/routes/add.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,11 @@ import {
2828
RoutePostSchema,
2929
type RoutePostType,
3030
} from '@/components/form-slice/FormPartRoute/schema';
31+
import { produceRoute } from '@/components/form-slice/FormPartRoute/util';
3132
import { FormTOCBox } from '@/components/form-slice/FormSection';
3233
import PageHeader from '@/components/page/PageHeader';
3334
import { req } from '@/config/req';
3435
import type { APISIXType } from '@/types/schema/apisix';
35-
import { produceRmUpstreamWhenHas } from '@/utils/form-producer';
36-
import { pipeProduce } from '@/utils/producer';
3736

3837
type Props = {
3938
navigate: (res: APISIXType['RespRouteDetail']) => Promise<void>;
@@ -45,8 +44,7 @@ export const RouteAddForm = (props: Props) => {
4544
const { t } = useTranslation();
4645

4746
const postRoute = useMutation({
48-
mutationFn: (d: RoutePostType) =>
49-
postRouteReq(req, pipeProduce(produceRmUpstreamWhenHas('service_id'))(d)),
47+
mutationFn: (d: RoutePostType) => postRouteReq(req, produceRoute(d)),
5048
async onSuccess(res) {
5149
notifications.show({
5250
message: t('info.add.success', { name: t('routes.singular') }),

src/routes/routes/detail.$id.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { getRouteQueryOptions } from '@/apis/hooks';
3232
import { putRouteReq } from '@/apis/routes';
3333
import { FormSubmitBtn } from '@/components/form/Btn';
3434
import { FormPartRoute } from '@/components/form-slice/FormPartRoute';
35+
import { produceRoute } from '@/components/form-slice/FormPartRoute/util';
3536
import { produceToUpstreamForm } from '@/components/form-slice/FormPartUpstream/util';
3637
import { FormTOCBox } from '@/components/form-slice/FormSection';
3738
import { FormSectionGeneral } from '@/components/form-slice/FormSectionGeneral';
@@ -40,8 +41,6 @@ import PageHeader from '@/components/page/PageHeader';
4041
import { API_ROUTES } from '@/config/constant';
4142
import { req } from '@/config/req';
4243
import { APISIX, type APISIXType } from '@/types/schema/apisix';
43-
import { produceRmUpstreamWhenHas } from '@/utils/form-producer';
44-
import { pipeProduce } from '@/utils/producer';
4544

4645
type Props = {
4746
readOnly: boolean;
@@ -73,8 +72,7 @@ const RouteDetailForm = (props: Props) => {
7372
}, [routeData, form, isLoading]);
7473

7574
const putRoute = useMutation({
76-
mutationFn: (d: APISIXType['Route']) =>
77-
putRouteReq(req, pipeProduce(produceRmUpstreamWhenHas('service_id'))(d)),
75+
mutationFn: (d: APISIXType['Route']) => putRouteReq(req, produceRoute(d)),
7876
async onSuccess() {
7977
notifications.show({
8078
message: t('info.edit.success', { name: t('routes.singular') }),

src/routes/stream_routes/add.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { useTranslation } from 'react-i18next';
2323

2424
import { postStreamRouteReq } from '@/apis/stream_routes';
2525
import { FormSubmitBtn } from '@/components/form/Btn';
26+
import { produceRoute } from '@/components/form-slice/FormPartRoute/util';
2627
import { FormPartStreamRoute } from '@/components/form-slice/FormPartStreamRoute';
2728
import {
2829
StreamRoutePostSchema,
@@ -33,8 +34,6 @@ import PageHeader from '@/components/page/PageHeader';
3334
import { StreamRoutesErrorComponent } from '@/components/page-slice/stream_routes/ErrorComponent';
3435
import { req } from '@/config/req';
3536
import type { APISIXType } from '@/types/schema/apisix';
36-
import { produceRmUpstreamWhenHas } from '@/utils/form-producer';
37-
import { pipeProduce } from '@/utils/producer';
3837

3938
type Props = {
4039
navigate: (res: APISIXType['RespStreamRouteDetail']) => Promise<void>;
@@ -47,10 +46,7 @@ export const StreamRouteAddForm = (props: Props) => {
4746

4847
const postStreamRoute = useMutation({
4948
mutationFn: (d: StreamRoutePostType) =>
50-
postStreamRouteReq(
51-
req,
52-
pipeProduce(produceRmUpstreamWhenHas('service_id'))(d)
53-
),
49+
postStreamRouteReq(req, produceRoute(d)),
5450
async onSuccess(res) {
5551
notifications.show({
5652
message: t('info.add.success', { name: t('streamRoutes.singular') }),

0 commit comments

Comments
 (0)