From 404bf17f629105536ca6907fef236868b9fe8b72 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Wed, 22 Jan 2025 11:25:08 +0100 Subject: [PATCH] dev: api integration tests (#907) * feat: api tests * fix: types and tests * fix: add common 400 response type * fix: add more common responses --------- Co-authored-by: Eric McDaniel --- .../site/[...ts-rest]/route.ts | 2 +- .../settings/tokens/CreateTokenForm.tsx | 15 ++- .../settings/tokens/PermissionField.tsx | 27 ++++- core/playwright/api/site.spec.ts | 44 +++++++ core/playwright/fixtures/api-token-page.ts | 90 +++++++++++++++ core/playwright/site-api.spec.ts | 107 ++++++++++++++++++ packages/contracts/package.json | 1 + packages/contracts/src/resources/site.ts | 27 ++++- pnpm-lock.yaml | 5 +- 9 files changed, 309 insertions(+), 9 deletions(-) create mode 100644 core/playwright/api/site.spec.ts create mode 100644 core/playwright/fixtures/api-token-page.ts create mode 100644 core/playwright/site-api.spec.ts diff --git a/core/app/api/v0/c/[communitySlug]/site/[...ts-rest]/route.ts b/core/app/api/v0/c/[communitySlug]/site/[...ts-rest]/route.ts index e5851ce95..90b3d92ba 100644 --- a/core/app/api/v0/c/[communitySlug]/site/[...ts-rest]/route.ts +++ b/core/app/api/v0/c/[communitySlug]/site/[...ts-rest]/route.ts @@ -371,7 +371,7 @@ const handler = createNextHandler( } return { - status: 200, + status: 204, }; }, relations: { diff --git a/core/app/c/[communitySlug]/settings/tokens/CreateTokenForm.tsx b/core/app/c/[communitySlug]/settings/tokens/CreateTokenForm.tsx index 46f2df04c..0f018f663 100644 --- a/core/app/c/[communitySlug]/settings/tokens/CreateTokenForm.tsx +++ b/core/app/c/[communitySlug]/settings/tokens/CreateTokenForm.tsx @@ -61,6 +61,10 @@ export type CreateTokenForm = ReturnType>; export const CreateTokenForm = ({ context }: { context: CreateTokenFormContext }) => { const form = useForm({ 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); @@ -158,7 +162,12 @@ export const CreateTokenForm = ({ context }: { context: CreateTokenFormContext } }} /> - @@ -170,7 +179,9 @@ export const CreateTokenForm = ({ context }: { context: CreateTokenFormContext } Token created!
- {token} + + {token} +

diff --git a/core/app/c/[communitySlug]/settings/tokens/PermissionField.tsx b/core/app/c/[communitySlug]/settings/tokens/PermissionField.tsx index 407425501..36972ddd4 100644 --- a/core/app/c/[communitySlug]/settings/tokens/PermissionField.tsx +++ b/core/app/c/[communitySlug]/settings/tokens/PermissionField.tsx @@ -168,6 +168,7 @@ const permissionContraintMap: PermissionContraintMap = { ); }} animation={0} + data-testid={`pub-${ApiAccessType.write}-stages-select`} />

); @@ -199,6 +200,7 @@ const permissionContraintMap: PermissionContraintMap = { onChange(value.length > 0 ? value : true); }} animation={0} + data-testid={`stage-${ApiAccessType.read}-stages-select`} /> ); @@ -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 ( { if (typeof change === "boolean") { @@ -312,15 +316,30 @@ export const ConstraintFormFieldRender = ({ if (!ExtraContrainstsFormItem) { return ( - + ); } return ( - + - diff --git a/core/playwright/api/site.spec.ts b/core/playwright/api/site.spec.ts new file mode 100644 index 000000000..c7c1d56a1 --- /dev/null +++ b/core/playwright/api/site.spec.ts @@ -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(); +}); diff --git a/core/playwright/fixtures/api-token-page.ts b/core/playwright/fixtures/api-token-page.ts new file mode 100644 index 000000000..eb8ea11c7 --- /dev/null +++ b/core/playwright/fixtures/api-token-page.ts @@ -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, + "permissions" | "issuedById" | "expiration" + > & { + permissions: Partial["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( + response: T, + status: S +): asserts response is Extract { + expect(response.status).toBe(status); +} diff --git a/core/playwright/site-api.spec.ts b/core/playwright/site-api.spec.ts new file mode 100644 index 000000000..0225dc327 --- /dev/null +++ b/core/playwright/site-api.spec.ts @@ -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>; + +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); + }); + }); +}); diff --git a/packages/contracts/package.json b/packages/contracts/package.json index a807b0c4e..989bed7d2 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -25,6 +25,7 @@ "utils": "workspace:*" }, "peerDependencies": { + "@types/node": "catalog:", "@ts-rest/core": "catalog:", "zod": "catalog:" }, diff --git a/packages/contracts/src/resources/site.ts b/packages/contracts/src/resources/site.ts index 1dc59e5db..f67951584 100644 --- a/packages/contracts/src/resources/site.ts +++ b/packages/contracts/src/resources/site.ts @@ -1,5 +1,7 @@ +import type { AppRouteResponse, ContractOtherResponse, Opaque } from "@ts-rest/core"; + import { initContract } from "@ts-rest/core"; -import { z } from "zod"; +import { z, ZodNull } from "zod"; import type { CommunitiesId, @@ -334,6 +336,19 @@ const getPubQuerySchema = z }) .passthrough(); +export const zodErrorSchema = z.object({ + name: z.string(), + issues: z.array( + z.object({ + code: z.string(), + expected: z.string(), + received: z.string(), + path: z.array(z.string()), + message: z.string(), + }) + ), +}); + export const siteApi = contract.router( { pubs: { @@ -416,6 +431,7 @@ export const siteApi = contract.router( responses: { 200: processedPubSchema, 204: z.never().optional(), + 400: zodErrorSchema.or(z.string()), }, }, replace: { @@ -429,6 +445,7 @@ export const siteApi = contract.router( responses: { 200: processedPubSchema, 204: z.never().optional(), + 400: zodErrorSchema.or(z.string()), }, }, remove: { @@ -442,6 +459,7 @@ export const siteApi = contract.router( responses: { 200: processedPubSchema, 204: z.never().optional(), + 400: zodErrorSchema.or(z.string()), }, }, }, @@ -530,6 +548,7 @@ export const siteApi = contract.router( }, }, { + strictStatusCodes: true, pathPrefix: "/api/v0/c/:communitySlug/site", baseHeaders: z.object({ authorization: z @@ -537,5 +556,11 @@ export const siteApi = contract.router( .regex(/^Bearer /) .optional(), }), + commonResponses: { + // this makes sure that 400 is always a valid response code + 400: zodErrorSchema, + 403: z.string(), + 404: z.string(), + }, } ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2af38a38e..9a8bd9ca0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -811,6 +811,9 @@ importers: packages/contracts: dependencies: + '@types/node': + specifier: 'catalog:' + version: 20.16.5 db: specifier: workspace:* version: link:../db @@ -823,7 +826,7 @@ importers: version: link:../../config/prettier '@ts-rest/core': specifier: 'catalog:' - version: 3.51.0(@types/node@22.7.5)(zod@3.23.8) + version: 3.51.0(@types/node@20.16.5)(zod@3.23.8) tsconfig: specifier: workspace:* version: link:../../config/tsconfig