Skip to content

Commit

Permalink
dev: api integration tests (#907)
Browse files Browse the repository at this point in the history
* feat: api tests

* fix: types and tests

* fix: add common 400 response type

* fix: add more common responses

---------

Co-authored-by: Eric McDaniel <[email protected]>
  • Loading branch information
tefkah and 3mcd authored Jan 22, 2025
1 parent 39daaac commit 404bf17
Show file tree
Hide file tree
Showing 9 changed files with 309 additions and 9 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,7 @@ const handler = createNextHandler(
}

return {
status: 200,
status: 204,
};
},
relations: {
Expand Down
15 changes: 13 additions & 2 deletions core/app/c/[communitySlug]/settings/tokens/CreateTokenForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ export type CreateTokenForm = ReturnType<typeof useForm<CreateTokenFormSchema>>;
export const CreateTokenForm = ({ context }: { context: CreateTokenFormContext }) => {
const form = useForm<CreateTokenFormSchema>({
resolver: zodResolver(createTokenFormSchema),
defaultValues: {
// default to 1 day from now, mostly to make testing easier
expiration: new Date(Date.now() + 1000 * 60 * 60 * 24),
},
});

const createToken = useServerAction(actions.createToken);
Expand Down Expand Up @@ -158,7 +162,12 @@ export const CreateTokenForm = ({ context }: { context: CreateTokenFormContext }
}}
/>

<Button type="submit" className="justify-self-end">
<Button
type="submit"
className="justify-self-end"
disabled={!form.formState.isValid}
data-testid="create-token-button"
>
Create Token
</Button>
</CardContent>
Expand All @@ -170,7 +179,9 @@ export const CreateTokenForm = ({ context }: { context: CreateTokenFormContext }
<DialogTitle>Token created!</DialogTitle>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-x-4">
<span className="text-lg font-semibold">{token}</span>
<span className="text-lg font-semibold" data-testid="token-value">
{token}
</span>
<CopyButton value={token} />
</div>
<p>
Expand Down
27 changes: 23 additions & 4 deletions core/app/c/[communitySlug]/settings/tokens/PermissionField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ const permissionContraintMap: PermissionContraintMap = {
);
}}
animation={0}
data-testid={`pub-${ApiAccessType.write}-stages-select`}
/>
</div>
);
Expand Down Expand Up @@ -199,6 +200,7 @@ const permissionContraintMap: PermissionContraintMap = {
onChange(value.length > 0 ? value : true);
}}
animation={0}
data-testid={`stage-${ApiAccessType.read}-stages-select`}
/>
</div>
);
Expand Down Expand Up @@ -247,19 +249,21 @@ export const PermissionField = ({
function FormItemWrapper({
children,
checked,

onChange,
type,
dataTestId,
}: {
children?: React.ReactNode;
checked: boolean;
onChange: (change: boolean) => void;
type: ApiAccessType;
dataTestId: string;
}) {
return (
<FormItem className="flex items-center gap-x-2 space-y-0">
<FormControl>
<Checkbox
data-testid={dataTestId}
checked={checked}
onCheckedChange={(change) => {
if (typeof change === "boolean") {
Expand Down Expand Up @@ -312,15 +316,30 @@ export const ConstraintFormFieldRender = ({

if (!ExtraContrainstsFormItem) {
return (
<FormItemWrapper checked={Boolean(field.value)} onChange={field.onChange} type={type} />
<FormItemWrapper
dataTestId={`${scope}-${type}-checkbox`}
checked={Boolean(field.value)}
onChange={field.onChange}
type={type}
/>
);
}

return (
<FormItemWrapper checked={Boolean(field.value)} onChange={field.onChange} type={type}>
<FormItemWrapper
dataTestId={`${scope}-${type}-checkbox`}
checked={Boolean(field.value)}
onChange={field.onChange}
type={type}
>
<Popover>
<PopoverTrigger asChild>
<Button variant="secondary" type="button" size="sm">
<Button
variant="secondary"
type="button"
size="sm"
data-testid={`${scope}-${type}-options`}
>
Options
</Button>
</PopoverTrigger>
Expand Down
44 changes: 44 additions & 0 deletions core/playwright/api/site.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { Page } from "@playwright/test";

import { expect, test } from "@playwright/test";

import { ApiTokenPage } from "../fixtures/api-token-page";
import { LoginPage } from "../fixtures/login-page";
import { createCommunity } from "../helpers";

const now = new Date().getTime();
const COMMUNITY_SLUG = `playwright-test-community-${now}`;

test.describe.configure({ mode: "serial" });

let page: Page;

test.beforeAll(async ({ browser }) => {
page = await browser.newPage();
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.loginAndWaitForNavigation();

await createCommunity({
page,
community: { name: `test community ${now}`, slug: COMMUNITY_SLUG },
});

const tokenPage = new ApiTokenPage(page, COMMUNITY_SLUG);
await tokenPage.goto();
await tokenPage.createToken({
// expiration: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30),
name: "test token",
permissions: {
community: { read: true, write: true, archive: true },
pub: { read: true, write: true, archive: true },
stage: { read: true, write: true, archive: true },
pubType: { read: true, write: true, archive: true },
member: { read: true, write: true, archive: true },
},
});
});

test("token should exist", async () => {
await expect(page.getByText("test token")).toBeVisible();
});
90 changes: 90 additions & 0 deletions core/playwright/fixtures/api-token-page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import type { Locator, Page } from "@playwright/test";
import type { z } from "zod";

import { expect } from "@playwright/test";

import { ApiAccessScope, ApiAccessType } from "db/public";

import type { createTokenFormSchema } from "~/app/c/[communitySlug]/settings/tokens/CreateTokenForm";

export class ApiTokenPage {
private readonly newTokenNameBox: Locator;
private readonly newTokenDescriptionBox: Locator;
// private readonly newTokenExpiryDatePicker: Locator;
private readonly newTokenCreateButton: Locator;
private readonly communitySlug: string;

constructor(
public readonly page: Page,
communitySlug: string
) {
this.communitySlug = communitySlug;
this.newTokenNameBox = this.page.getByRole("textbox", { name: "name" });
this.newTokenDescriptionBox = this.page.getByRole("textbox", { name: "description" });
// this.newTokenExpiryDatePicker = this.page.getByLabel("Expiry date");
this.newTokenCreateButton = this.page.getByTestId("create-token-button");
}

async goto() {
await this.page.goto(`/c/${this.communitySlug}/settings/tokens`);
}

async togglePermission(scope: ApiAccessScope, type: ApiAccessType) {
await this.page.getByTestId(`${scope}-${type}-checkbox`).click();
}

async createToken(
input: Omit<
z.infer<typeof createTokenFormSchema>,
"permissions" | "issuedById" | "expiration"
> & {
permissions: Partial<z.infer<typeof createTokenFormSchema>["permissions"]> | true;
}
) {
await this.newTokenNameBox.fill(input.name);
await this.newTokenDescriptionBox.fill(input.description ?? "");
// await this.newTokenExpiryDatePicker.fill(input.expiration.toISOString());

for (const scope of Object.values(ApiAccessScope)) {
for (const type of Object.values(ApiAccessType)) {
const value = input.permissions === true ? true : input.permissions[scope]?.[type];

if (typeof value === "boolean") {
if (!value) {
continue;
}
await this.togglePermission(scope as ApiAccessScope, type as ApiAccessType);
continue;
}

if (value && "stage" in value) {
await this.page.getByTestId(`${scope}-${type}-options`).click();
await this.page.getByTestId(`${scope}-${type}-stages-select`).click();
for (const stage of value.stages) {
await this.page.getByLabel("Suggestions").getByText(stage).click();
}
continue;
}
}
}

await this.newTokenCreateButton.click();

const token = await this.page.getByTestId("token-value").textContent();

// close modal
await this.page.keyboard.press("Escape");
return token;
}

// TODO: delete token

// TODO: get token permissions
}

export function expectStatus<T extends { status: number }, S extends T["status"]>(
response: T,
status: S
): asserts response is Extract<T, { status: S }> {
expect(response.status).toBe(status);
}
107 changes: 107 additions & 0 deletions core/playwright/site-api.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import type { APIRequestContext, Page } from "@playwright/test";

import { expect, test } from "@playwright/test";
import { initClient } from "@ts-rest/core";

import type { PubsId } from "db/public";
import { siteApi } from "contracts";

import { ApiTokenPage, expectStatus } from "./fixtures/api-token-page";
import { LoginPage } from "./fixtures/login-page";
import { createCommunity } from "./helpers";

const now = new Date().getTime();
const COMMUNITY_SLUG = `playwright-test-community-${now}`;

let page: Page;

let client: ReturnType<typeof initClient<typeof siteApi, any>>;

test.beforeAll(async ({ browser }) => {
page = await browser.newPage();

const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.loginAndWaitForNavigation();

await createCommunity({
page,
community: { name: `test community ${now}`, slug: COMMUNITY_SLUG },
});

const apiTokenPage = new ApiTokenPage(page, COMMUNITY_SLUG);
await apiTokenPage.goto();
const token = await apiTokenPage.createToken({
name: "test token",
description: "test description",
permissions: true,
});

client = initClient(siteApi, {
baseUrl: `http://localhost:3000/`,
baseHeaders: {
Authorization: `Bearer ${token}`,
},
});
});

test.describe("Site API", () => {
test.describe("pubs", () => {
let newPubId: PubsId;
test("should be able to create a pub", async () => {
const pubTypesResponse = await client.pubTypes.getMany({
params: {
communitySlug: COMMUNITY_SLUG,
},
});

expectStatus(pubTypesResponse, 200);
expect(pubTypesResponse.body).toHaveLength(1);

const pubType = pubTypesResponse.body[0];

const pubResponse = await client.pubs.create({
headers: {
prefer: "return=representation",
},
params: {
communitySlug: COMMUNITY_SLUG,
},
body: {
pubTypeId: pubType.id,
values: {
[`${COMMUNITY_SLUG}:title`]: "Hello world",
},
},
});

expectStatus(pubResponse, 201);

expect(pubResponse.body.values).toEqual(
expect.arrayContaining([
expect.objectContaining({
fieldSlug: `${COMMUNITY_SLUG}:title`,
value: "Hello world",
}),
])
);

newPubId = pubResponse.body.id;
});

test("should be able to retrieve a specific pub", async () => {
expect(newPubId).toBeDefined();

const response = await client.pubs.get({
params: {
communitySlug: COMMUNITY_SLUG,
pubId: newPubId,
},
query: {},
});

expectStatus(response, 200);
expect(response.body.id).toBe(newPubId);
});
});
});
1 change: 1 addition & 0 deletions packages/contracts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"utils": "workspace:*"
},
"peerDependencies": {
"@types/node": "catalog:",
"@ts-rest/core": "catalog:",
"zod": "catalog:"
},
Expand Down
Loading

0 comments on commit 404bf17

Please sign in to comment.