Skip to content

Commit

Permalink
feat(portal): check duplicate name when changing a new seller API (#86)
Browse files Browse the repository at this point in the history
* feat(improvement): check duplicate name when changing a new seller API name

* fix(portal): add products hook service tests

* fix(portal): add validator tests

* build(misc): add sonarcloud exlusion for utils in kraken-app-portal

* correct the sonar coverage config

* correct the sonar.coverage.exclusions

* fix(portal): adjust Promise rejection message as error

---------

Co-authored-by: Dave Xiong <[email protected]>
  • Loading branch information
KsiBart and DaveXiong authored Nov 8, 2024
1 parent f1ec234 commit c0234f3
Show file tree
Hide file tree
Showing 8 changed files with 254 additions and 12 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { describe, it, expect, vi } from "vitest";
import * as service from "../../services/products";
import request from "@/utils/helpers/request";
import { PRODUCT } from "@/utils/constants/api";

// Mock request function
vi.mock("@/utils/helpers/request");

describe("Service Tests", () => {
afterEach(() => {
vi.clearAllMocks();
});

it("should call getListComponents with correct parameters", async () => {
const productId = "testProduct";
const params = { key: "value" };
await service.getListComponents(productId, params);

expect(request).toHaveBeenCalledWith(`${PRODUCT}/${productId}/components`, {
params,
});
});

it("should call getListComponentsV2 with correct parameters", async () => {
const productId = "testProduct";
const targetMapperKey = "testKey";
await service.getListComponentsV2(productId, targetMapperKey);

expect(request).toHaveBeenCalledWith(`${PRODUCT}/${productId}/components/${targetMapperKey}`);
});

it("should call createNewComponent with correct parameters", async () => {
const productId = "testProduct";
const data = { name: "testComponent" };
await service.createNewComponent(productId, data);

expect(request).toHaveBeenCalledWith(`${PRODUCT}/${productId}/components`, {
method: "POST",
data,
});
});

it("should call getListEnvs with correct parameters", async () => {
const productId = "testProduct";
await service.getListEnvs(productId);

expect(request).toHaveBeenCalledWith(`${PRODUCT}/${productId}/envs`);
});

it("should call getComponentDetail with correct parameters", async () => {
const productId = "testProduct";
const componentId = "testComponent";
await service.getComponentDetail(productId, componentId);

expect(request).toHaveBeenCalledWith(`${PRODUCT}/${productId}/components/${componentId}`);
});

it("should call editComponentDetail with correct parameters", async () => {
const productId = "testProduct";
const componentId = "testComponent";
const data = { description: "updated description" };
await service.editComponentDetail(productId, componentId, data);

expect(request).toHaveBeenCalledWith(`${PRODUCT}/${productId}/components/${componentId}`, {
method: "PATCH",
data,
});
});

it("should call deployProduct with correct parameters", async () => {
const productId = "testProduct";
const envId = "testEnv";
const data = { status: "deploy" };
await service.deployProduct(productId, envId, data);

expect(request).toHaveBeenCalledWith(`${PRODUCT}/${productId}/envs/${envId}/deployment`, {
method: "POST",
data,
});
});

it("should call getAllApiKeyList with correct parameters", async () => {
const productId = "testProduct";
const params = { page: 1, limit: 10, size: 10 };
await service.getAllApiKeyList(productId, params);

expect(request).toHaveBeenCalledWith(`${PRODUCT}/${productId}/env-api-tokens`, {
method: "GET",
params,
});
});
});
16 changes: 16 additions & 0 deletions kraken-app/kraken-app-portal/src/hooks/product/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
deleteAPIServer,
getAPIServers,
getComponentDetailV2,
getValidateServerName,
} from "@/services/products";
import { STALE_TIME } from "@/utils/constants/common";
import {
Expand Down Expand Up @@ -75,6 +76,7 @@ import { IEnvComponent } from "@/utils/types/envComponent.type";
import {
IApiMapperDeployment,
IDeploymentHistory,
IProductIdAndNameParams,
} from "@/utils/types/product.type";
import { useMutation, useQuery, useQueries } from "@tanstack/react-query";
import { AxiosResponse } from "axios";
Expand Down Expand Up @@ -127,6 +129,7 @@ export const PRODUCT_CACHE_KEYS = {
update_target_mapper: "update_target_mapper",
upgrade_mapping_template_production: "upgrade_mapping_template_production",
upgrade_mapping_template_stage: "upgrade_mapping_template_stage",
get_validate_api_server_name: "get_validate_api_server_name",
verify_product: "verify_product",
};

Expand Down Expand Up @@ -731,3 +734,16 @@ export const useRegenToken = () => {
},
});
};

export const useGetValidateServerName = () => {
return useMutation<any, Error, IProductIdAndNameParams>({
mutationKey: [PRODUCT_CACHE_KEYS.get_validate_api_server_name],
mutationFn: ({ productId, name }: IProductIdAndNameParams) =>
getValidateServerName(productId, name),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [PRODUCT_CACHE_KEYS.get_validate_api_server_name],
});
}
});
};
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
import { render } from "@testing-library/react";
import { fireEvent, getAllByTestId, render } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { queryClient } from "@/utils/helpers/reactQuery";
import { BrowserRouter } from "react-router-dom";
import NewAPIServer from "..";
import SelectAPIServer from '../components/SelectAPIServer';
import { isURL } from '@/utils/helpers/url';
import { validateServerName, validateURL } from '@/utils/helpers/validators';
import { Mock } from 'vitest';

vi.mock('@/utils/helpers/url', () => ({
isURL: vi.fn(),
}));

test("test API new", () => {
const { container } = render(
<QueryClientProvider client={queryClient}>
Expand All @@ -13,3 +22,87 @@ test("test API new", () => {
);
expect(container).toBeInTheDocument();
});


test("test SelectApiServer", async () => {
vi.mock("@/hooks/product", async () => {
const actual = await vi.importActual("@/hooks/product");
return {
...actual,
useGetValidateServerName: vi.fn().mockResolvedValue({
mutateAsync: vi.fn().mockReturnValue({
data: false
}),
isLoading: false,
}),
};
});

const { container } = render(
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<SelectAPIServer />
</BrowserRouter>
</QueryClientProvider>
);
expect(container).toBeInTheDocument();
const input = getAllByTestId(container, "api-seller-name-input")[0];
const formContainer = getAllByTestId(container, "api-seller-name-container")[0];
fireEvent.change(input, { target: { value: "test" } });
expect(formContainer).toBeInTheDocument();
});

test("test SelectApiServer", async () => {
vi.mock("@/hooks/product", async () => {
const actual = await vi.importActual("@/hooks/product");
return {
...actual,
useGetValidateServerName: vi.fn().mockResolvedValue({
mutateAsync: vi.fn().mockReturnValue({
data: false
}),
isLoading: false,
}),
};
});

const { container } = render(
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<SelectAPIServer />
</BrowserRouter>
</QueryClientProvider>
);
expect(container).toBeInTheDocument();
const input = getAllByTestId(container, "api-seller-name-input")[0];
const formContainer = getAllByTestId(container, "api-seller-name-container")[0];
fireEvent.change(input, { target: { value: "test" } });
expect(formContainer).toBeInTheDocument();
});

it('should resolve if the URL is valid', async () => {
(isURL as Mock).mockReturnValue(true);
const result = await validateURL({}, 'http://valid-url.com');
expect(result).toBeUndefined();
expect(isURL).toHaveBeenCalledWith('http://valid-url.com');
});


it('should reject with an error if the URL is invalid', async () => {
(isURL as Mock).mockReturnValue(false);
await expect(validateURL({}, 'invalid-url')).rejects.toThrow('Please enter a valid URL');
expect(isURL).toHaveBeenCalledWith('invalid-url');
});

it('should resolve if the server name is valid', async () => {
const validateNameMock = vi.fn().mockResolvedValue({ data: true });
const result = await validateServerName(validateNameMock, 'product-1', 'validName');
expect(result).toBeUndefined(); // Promise resolves without rejection
expect(validateNameMock).toHaveBeenCalledWith({ productId: 'product-1', name: 'validName' });
});

it('should reject with an error message if the server name is taken', async () => {
const validateNameMock = vi.fn().mockResolvedValue({ data: false });
await expect(validateServerName(validateNameMock, 'product-1', 'takenName')).rejects.toThrow('The name takenName is already taken');
expect(validateNameMock).toHaveBeenCalledWith({ productId: 'product-1', name: 'takenName' });
});
Original file line number Diff line number Diff line change
@@ -1,29 +1,38 @@
import TitleIcon from "@/assets/title-icon.svg";
import Flex from "@/components/Flex";
import { Text } from "@/components/Text";
import { isURL } from "@/utils/helpers/url";
import { useGetValidateServerName } from '@/hooks/product';
import { useAppStore } from '@/stores/app.store';
import { validateServerName, validateURL } from '@/utils/helpers/validators';
import { Form, Input } from "antd";
import { isEmpty } from "lodash";

const SelectAPIServer = () => {
const { currentProduct } = useAppStore();
const { mutateAsync: validateName } = useGetValidateServerName();
return (
<>
<Flex gap={8} justifyContent="flex-start">
<TitleIcon />
<Text.NormalLarge>Seller API Server basics</Text.NormalLarge>
</Flex>
<Form.Item
data-testid="api-seller-name-container"
label="Seller API Server Name"
name="name"
rules={[
{
required: true,
message: "Please complete this field.",
},
{
validator: (_, name) => validateServerName(validateName, currentProduct, name)
}
]}
validateDebounce={1000}
labelCol={{ span: 24 }}

>
<Input placeholder="Add API Server Name" style={{ width: "100%" }} />
<Input data-testid="api-seller-name-input" placeholder="Add API Server Name" style={{ width: "100%" }} />
</Form.Item>
<Form.Item label="Description" name="description" labelCol={{ span: 24 }}>
<Input placeholder="Add description" style={{ width: "100%" }} />
Expand All @@ -36,12 +45,7 @@ const SelectAPIServer = () => {
required: false,
},
{
validator: (_, value) => {
if (isURL(value) || isEmpty(value)) {
return Promise.resolve();
}
return Promise.reject(new Error("Please enter a valid URL"));
},
validator: validateURL
},
]}
labelCol={{ span: 24 }}
Expand Down
12 changes: 12 additions & 0 deletions kraken-app/kraken-app-portal/src/services/products.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,3 +370,15 @@ export const regenerateBuyerAccessToken = (productId: string, id: string) => {
method: "POST",
});
};

export const getValidateServerName = (
productId: string,
name: string
) => {
return request(
`/v2${PRODUCT}/${productId}/components/${productId}/api-servers/${name}`,
{
method: "GET",
}
);
};
20 changes: 20 additions & 0 deletions kraken-app/kraken-app-portal/src/utils/helpers/validators.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { UseMutateAsyncFunction } from '@tanstack/react-query';
import { isEmpty } from 'lodash';
import { IProductIdAndNameParams } from '../types/product.type';
import { isURL } from './url';

export const validateServerName = async (validateName: UseMutateAsyncFunction<any, Error, IProductIdAndNameParams, unknown>, currentProduct: string, name: string) => {
const { data: isValid } = await validateName({ productId: currentProduct, name });
if (isValid) {
return Promise.resolve();
} else {
return Promise.reject(new Error(`The name ${name} is already taken`));
}
};

export const validateURL = (_: unknown, value: string) => {
if (isURL(value) || isEmpty(value)) {
return Promise.resolve();
}
return Promise.reject(new Error("Please enter a valid URL"));
};
5 changes: 5 additions & 0 deletions kraken-app/kraken-app-portal/src/utils/types/product.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,8 @@ export interface IApiUseCase {
componentName: string;
details: IRunningMapping[];
}

export interface IProductIdAndNameParams {
productId: string;
name: string;
}
4 changes: 2 additions & 2 deletions sonar-project.properties
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,6 @@ sonar.sources=./kraken-app/kraken-app-portal
# Encoding of the source code. Default is default system encoding
sonar.sourceEncoding=UTF-8

sonar.coverage.exclusions= ./kraken-app/kraken-app-portal/src/utils/**, ./kraken-app/kraken-app-portal/src/services/**, ./kraken-app/kraken-app-portal/src/hooks/**, ./kraken-app/kraken-app-portal/src/constants/**, ./kraken-app/kraken-app-portal/src/store/**, ./kraken-app/kraken-app-portal/src/store/**, **/*.constant.tsx, **/*.constant.ts, ./kraken-app/kraken-app-portal/src/libs/**
sonar.exclusions=./kraken-app/kraken-app-portal/src/__mocks__/**,./kraken-app/kraken-app-portal/src/__tests__/**,**/*.test.ts, **/*.test.tsx, ./kraken-app/kraken-app-portal/src/setupTests.tsx, **/*.d.ts, **/*.type.ts, **/*.type.tsx, ./kraken-app/kraken-app-portal/htmlTemplates/**
sonar.coverage.exclusions= **/src/utils/**, **/src/services/**, **/src/hooks/**, **/src/constants/**, **/src/store/**, **/src/store/**, **/*.constant.tsx, **/*.constant.ts, src/libs/**
sonar.exclusions=**/src/__mocks__/**,**/src/__tests__/**,**/*.test.ts, **/*.test.tsx, **/src/setupTests.tsx, **/*.d.ts, **/*.type.ts, **/*.type.tsx, htmlTemplates/**
sonar.javascript.lcov.reportPaths=./kraken-app/kraken-app-portal/coverage/lcov.info

0 comments on commit c0234f3

Please sign in to comment.