Skip to content

feat: add tests for routes #1

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/playwright.yml
Original file line number Diff line number Diff line change
@@ -13,6 +13,8 @@ jobs:
AUTH_SECRET: ${{ secrets.AUTH_SECRET }}
POSTGRES_URL: ${{ secrets.POSTGRES_URL }}
BLOB_READ_WRITE_TOKEN: ${{ secrets.BLOB_READ_WRITE_TOKEN }}
NEON_API_KEY: ${{ secrets.NEON_API_KEY }}
NEON_PROJECT_ID: ${{ secrets.NEON_PROJECT_ID }}

steps:
- uses: actions/checkout@v4
@@ -70,3 +72,12 @@ jobs:
name: playwright-report
path: playwright-report/
retention-days: 7

- name: Process test result
if: always()
run: |
if [[ "${{ steps.run-tests.outputs.tests-passed }}" == "true" ]]; then
echo "All tests passed!"
else
echo "Some tests failed"
fi
70 changes: 42 additions & 28 deletions app/(chat)/api/document/route.ts
Original file line number Diff line number Diff line change
@@ -1,52 +1,53 @@
import { auth } from '@/app/(auth)/auth';
import { ArtifactKind } from '@/components/artifact';
import type { ArtifactKind } from '@/components/artifact';
import {
deleteDocumentsByIdAfterTimestamp,
getDocumentsById,
saveDocument,
} from '@/lib/db/queries';
import { apiErrors, successResponse } from '@/lib/responses';

export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');

if (!id) {
return new Response('Missing id', { status: 400 });
return apiErrors.missingParameter();
}

const session = await auth();

if (!session || !session.user) {
return new Response('Unauthorized', { status: 401 });
if (!session?.user?.id) {
return apiErrors.unauthorized();
}

const documents = await getDocumentsById({ id });

const [document] = documents;

if (!document) {
return new Response('Not Found', { status: 404 });
return apiErrors.documentNotFound();
}

if (document.userId !== session.user.id) {
return new Response('Unauthorized', { status: 401 });
return apiErrors.documentForbidden();
}

return Response.json(documents, { status: 200 });
return successResponse(documents);
}

export async function POST(request: Request) {
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');

if (!id) {
return new Response('Missing id', { status: 400 });
return apiErrors.missingParameter();
}

const session = await auth();

if (!session) {
return new Response('Unauthorized', { status: 401 });
return apiErrors.unauthorized();
}

const {
@@ -56,49 +57,62 @@ export async function POST(request: Request) {
}: { content: string; title: string; kind: ArtifactKind } =
await request.json();

if (session.user?.id) {
const document = await saveDocument({
id,
content,
title,
kind,
userId: session.user.id,
});
if (!session?.user?.id) {
return apiErrors.unauthorized();
}

const documents = await getDocumentsById({ id: id });

if (documents.length > 0) {
const [document] = documents;

return Response.json(document, { status: 200 });
if (document.userId !== session.user.id) {
return apiErrors.documentForbidden();
}
}

return new Response('Unauthorized', { status: 401 });
const [createdDocument] = await saveDocument({
id,
content,
title,
kind,
userId: session.user.id,
});

return successResponse(createdDocument);
}

export async function PATCH(request: Request) {
export async function DELETE(request: Request) {
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');

const { timestamp }: { timestamp: string } = await request.json();
const timestamp = searchParams.get('timestamp');

if (!id) {
return new Response('Missing id', { status: 400 });
return apiErrors.missingParameter();
}

if (!timestamp) {
return apiErrors.missingParameter();
}

const session = await auth();

if (!session || !session.user) {
return new Response('Unauthorized', { status: 401 });
if (!session?.user?.id) {
return apiErrors.unauthorized();
}

const documents = await getDocumentsById({ id });

const [document] = documents;

if (document.userId !== session.user.id) {
return new Response('Unauthorized', { status: 401 });
return apiErrors.documentForbidden();
}

await deleteDocumentsByIdAfterTimestamp({
const deletedDocuments = await deleteDocumentsByIdAfterTimestamp({
id,
timestamp: new Date(timestamp),
});

return new Response('Deleted', { status: 200 });
return successResponse(deletedDocuments);
}
2 changes: 1 addition & 1 deletion biome.jsonc
Original file line number Diff line number Diff line change
@@ -116,7 +116,7 @@
"formatter": { "enabled": false },
"linter": { "enabled": false }
},
"organizeImports": { "enabled": false },
"organizeImports": { "enabled": true },
"overrides": [
// Playwright requires an object destructure, even if empty
// https://github.com/microsoft/playwright/issues/30007
18 changes: 9 additions & 9 deletions components/version-footer.tsx
Original file line number Diff line number Diff line change
@@ -57,15 +57,15 @@ export const VersionFooter = ({

mutate(
`/api/document?id=${artifact.documentId}`,
await fetch(`/api/document?id=${artifact.documentId}`, {
method: 'PATCH',
body: JSON.stringify({
timestamp: getDocumentTimestampByIndex(
documents,
currentVersionIndex,
),
}),
}),
await fetch(
`/api/document?id=${artifact.documentId}&timestamp=${getDocumentTimestampByIndex(
documents,
currentVersionIndex,
)}`,
{
method: 'DELETE',
},
),
{
optimisticData: documents
? [
38 changes: 26 additions & 12 deletions lib/db/queries.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
import 'server-only';

import { genSaltSync, hashSync } from 'bcrypt-ts';
import { and, asc, desc, eq, gt, gte, inArray, lt, SQL } from 'drizzle-orm';
import {
and,
asc,
desc,
eq,
gt,
gte,
inArray,
lt,
type SQL,
} from 'drizzle-orm';
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';

@@ -15,9 +25,9 @@ import {
message,
vote,
type DBMessage,
Chat,
type Chat,
} from './schema';
import { ArtifactKind } from '@/components/artifact';
import type { ArtifactKind } from '@/components/artifact';

// Optionally, if not using email/pass login, you can
// use the Drizzle adapter for Auth.js / NextAuth
@@ -241,14 +251,17 @@ export async function saveDocument({
userId: string;
}) {
try {
return await db.insert(document).values({
id,
title,
kind,
content,
userId,
createdAt: new Date(),
});
return await db
.insert(document)
.values({
id,
title,
kind,
content,
userId,
createdAt: new Date(),
})
.returning();
} catch (error) {
console.error('Failed to save document in database');
throw error;
@@ -304,7 +317,8 @@ export async function deleteDocumentsByIdAfterTimestamp({

return await db
.delete(document)
.where(and(eq(document.id, id), gt(document.createdAt, timestamp)));
.where(and(eq(document.id, id), gt(document.createdAt, timestamp)))
.returning();
} catch (error) {
console.error(
'Failed to delete documents by id after timestamp from database',
19 changes: 19 additions & 0 deletions lib/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export const ERRORS = {
MISSING_PARAMETER: {
type: 'missing_parameter',
message: 'Missing parameter',
},
UNAUTHORIZED: {
type: 'unauthorized',
message: 'Unauthorized',
},
DOCUMENT_NOT_FOUND: {
type: 'document_not_found',
message: 'Document not found',
},
DOCUMENT_FORBIDDEN: {
type: 'document_forbidden',
message:
'Access to this document is forbidden. You may not have the required permissions.',
},
};
19 changes: 19 additions & 0 deletions lib/responses.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { ERRORS } from './errors';

export function successResponse(data: any) {
return Response.json({ data, error: null }, { status: 200 });
}

export function errorResponse(
error: { type: string; message: string },
status: number,
) {
return Response.json({ data: null, error }, { status });
}

export const apiErrors = {
missingParameter: () => errorResponse(ERRORS.MISSING_PARAMETER, 400),
unauthorized: () => errorResponse(ERRORS.UNAUTHORIZED, 401),
documentNotFound: () => errorResponse(ERRORS.DOCUMENT_NOT_FOUND, 404),
documentForbidden: () => errorResponse(ERRORS.DOCUMENT_FORBIDDEN, 403),
};
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -16,7 +16,7 @@
"db:pull": "drizzle-kit pull",
"db:check": "drizzle-kit check",
"db:up": "drizzle-kit up",
"test": "export PLAYWRIGHT=True && pnpm exec playwright test --workers=4"
"test": "pnpm tsx tests/run-tests"
},
"dependencies": {
"@ai-sdk/react": "^1.2.8",
@@ -92,6 +92,7 @@
"@types/pdf-parse": "^1.1.4",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/wait-on": "^5.3.4",
"drizzle-kit": "^0.25.0",
"eslint": "^8.57.0",
"eslint-config-next": "14.2.5",
@@ -101,7 +102,8 @@
"postcss": "^8",
"tailwindcss": "^3.4.1",
"tsx": "^4.19.1",
"typescript": "^5.6.3"
"typescript": "^5.6.3",
"wait-on": "^8.0.3"
},
"packageManager": "[email protected]"
}
18 changes: 9 additions & 9 deletions playwright.config.ts
Original file line number Diff line number Diff line change
@@ -33,7 +33,7 @@ export default defineConfig({
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
reporter: [['line']],
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
@@ -91,6 +91,14 @@ export default defineConfig({
storageState: 'playwright/.auth/session.json',
},
},
{
name: 'routes',
testMatch: /routes\/.*\.test.ts/,
dependencies: [],
use: {
...devices['Desktop Chrome'],
},
},

// {
// name: 'firefox',
@@ -122,12 +130,4 @@ export default defineConfig({
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
// },
],

/* Run your local dev server before starting the tests */
webServer: {
command: 'pnpm dev',
url: baseURL,
timeout: 120 * 1000,
reuseExistingServer: !process.env.CI,
},
});
151 changes: 151 additions & 0 deletions pnpm-lock.yaml
62 changes: 62 additions & 0 deletions tests/auth-helper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import fs from 'node:fs';
import path from 'node:path';
import {
type APIRequestContext,
type Browser,
type BrowserContext,
expect,
type Page,
} from '@playwright/test';
import { generateId } from 'ai';
import { getUnixTime } from 'date-fns';

export type UserContext = {
context: BrowserContext;
page: Page;
request: APIRequestContext;
};

export async function createAuthenticatedContext({
browser,
name,
}: {
browser: Browser;
name: string;
}): Promise<UserContext> {
const authDir = path.join(__dirname, '../playwright/.auth');

if (!fs.existsSync(authDir)) {
fs.mkdirSync(authDir, { recursive: true });
}

const storageFile = path.join(authDir, `${name}.json`);

const context = await browser.newContext();
const page = await context.newPage();

const email = `test-${name}-${getUnixTime(new Date())}@playwright.com`;
const password = generateId(16);

await page.goto('http://localhost:3000/register');
await page.getByPlaceholder('user@acme.com').click();
await page.getByPlaceholder('user@acme.com').fill(email);
await page.getByLabel('Password').click();
await page.getByLabel('Password').fill(password);
await page.getByRole('button', { name: 'Sign Up' }).click();

await expect(page.getByTestId('toast')).toContainText(
'Account created successfully!',
);

await context.storageState({ path: storageFile });
await page.close();

const newContext = await browser.newContext({ storageState: storageFile });
const newPage = await newContext.newPage();

return {
context: newContext,
page: newPage,
request: newContext.request,
};
}
2 changes: 1 addition & 1 deletion tests/auth.setup.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import path from 'path';
import path from 'node:path';
import { generateId } from 'ai';
import { getUnixTime } from 'date-fns';
import { expect, test as setup } from '@playwright/test';
2 changes: 1 addition & 1 deletion tests/auth.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { generateId } from 'ai';
import { getUnixTime } from 'date-fns';
import { test, expect, Page } from '@playwright/test';
import { test, expect, type Page } from '@playwright/test';

test.use({ storageState: { cookies: [], origins: [] } });

245 changes: 245 additions & 0 deletions tests/routes/document.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
import type { Document } from '@/lib/db/schema';
import { generateUUID } from '@/lib/utils';
import {
createAuthenticatedContext,
type UserContext,
} from '@/tests/auth-helper';
import { expect, test } from '@playwright/test';

let adaContext: UserContext;
let babbageContext: UserContext;

const documentsCreatedByAda: Array<Document> = [];

test.beforeAll(async ({ browser }) => {
adaContext = await createAuthenticatedContext({
browser,
name: 'ada',
});

babbageContext = await createAuthenticatedContext({
browser,
name: 'babbage',
});
});

test.afterAll(async () => {
await adaContext.context.close();
await babbageContext.context.close();
});

test.describe
.serial('/api/document', () => {
test('Ada cannot retrieve a document without specifying an id', async () => {
const response = await adaContext.request.get('/api/document');
expect(response.status()).toBe(400);

const { error } = await response.json();
expect(error).toEqual({
type: 'missing_parameter',
message: 'Missing parameter',
});
});

test('Ada cannot retrieve a document that does not exist', async () => {
const documentId = generateUUID();

const response = await adaContext.request.get(
`/api/document?id=${documentId}`,
);
expect(response.status()).toBe(404);

const { error } = await response.json();
expect(error).toEqual({
type: 'document_not_found',
message: 'Document not found',
});
});

test('Ada can create a document', async () => {
const documentId = generateUUID();

const draftDocument = {
title: "Ada's Document",
kind: 'text',
content: 'Created by Ada',
};

const response = await adaContext.request.post(
`/api/document?id=${documentId}`,
{
data: draftDocument,
},
);
expect(response.status()).toBe(200);

const { data: createdDocument } = await response.json();
expect(createdDocument).toMatchObject(draftDocument);

documentsCreatedByAda.push(createdDocument);
});

test('Ada can retrieve a created document', async () => {
const [document] = documentsCreatedByAda;

const response = await adaContext.request.get(
`/api/document?id=${document.id}`,
);
expect(response.status()).toBe(200);

const { data: retrievedDocuments } = await response.json();
expect(retrievedDocuments).toHaveLength(1);

const [retrievedDocument] = retrievedDocuments;
expect(retrievedDocument).toMatchObject(document);
});

test('Ada can save a new version of the document', async () => {
const [firstDocument] = documentsCreatedByAda;

const draftDocument = {
title: "Ada's Document",
kind: 'text',
content: 'Updated by Ada',
};

const response = await adaContext.request.post(
`/api/document?id=${firstDocument.id}`,
{
data: draftDocument,
},
);
expect(response.status()).toBe(200);

const { data: createdDocument } = await response.json();
expect(createdDocument).toMatchObject(draftDocument);

documentsCreatedByAda.push(createdDocument);
});

test('Ada can retrieve all versions of her documents', async () => {
const [firstDocument, secondDocument] = documentsCreatedByAda;

const response = await adaContext.request.get(
`/api/document?id=${firstDocument.id}`,
);
expect(response.status()).toBe(200);

const { data: retrievedDocuments } = await response.json();
expect(retrievedDocuments).toHaveLength(2);

const [firstRetrievedDocument, secondRetrievedDocument] =
retrievedDocuments;
expect(firstRetrievedDocument).toMatchObject(firstDocument);
expect(secondRetrievedDocument).toMatchObject(secondDocument);
});

test('Ada cannot delete a document without specifying an id', async () => {
const response = await adaContext.request.delete(`/api/document`);
expect(response.status()).toBe(400);

const { error } = await response.json();
expect(error).toEqual({
type: 'missing_parameter',
message: 'Missing parameter',
});
});

test('Ada cannot delete a document without specifying a timestamp', async () => {
const [firstDocument] = documentsCreatedByAda;

const response = await adaContext.request.delete(
`/api/document?id=${firstDocument.id}`,
);
expect(response.status()).toBe(400);

const { error } = await response.json();
expect(error).toEqual({
type: 'missing_parameter',
message: 'Missing parameter',
});
});

test('Ada can delete a document by specifying id and timestamp', async () => {
const [firstDocument, secondDocument] = documentsCreatedByAda;

const response = await adaContext.request.delete(
`/api/document?id=${firstDocument.id}&timestamp=${firstDocument.createdAt}`,
);
expect(response.status()).toBe(200);

const { data: deletedDocuments } = await response.json();
expect(deletedDocuments).toHaveLength(1);

const [deletedDocument] = deletedDocuments;
expect(deletedDocument).toMatchObject(secondDocument);
});

test('Ada can retrieve documents without deleted versions', async () => {
const [firstDocument] = documentsCreatedByAda;

const response = await adaContext.request.get(
`/api/document?id=${firstDocument.id}`,
);
expect(response.status()).toBe(200);

const { data: retrievedDocuments } = await response.json();
expect(retrievedDocuments).toHaveLength(1);

const [firstRetrievedDocument] = retrievedDocuments;
expect(firstRetrievedDocument).toMatchObject(firstDocument);
});

test("Babbage cannot retrieve Ada's document", async () => {
const [firstDocument] = documentsCreatedByAda;

const response = await babbageContext.request.get(
`/api/document?id=${firstDocument.id}`,
);
expect(response.status()).toBe(403);

const { error } = await response.json();
expect(error).toEqual({
type: 'document_forbidden',
message:
'Access to this document is forbidden. You may not have the required permissions.',
});
});

test("Babbage cannot update Ada's document", async () => {
const [firstDocument] = documentsCreatedByAda;

const draftDocument = {
title: "Babbage's Document",
kind: 'text',
content: 'Created by Babbage',
};

const response = await babbageContext.request.post(
`/api/document?id=${firstDocument.id}`,
{
data: draftDocument,
},
);
expect(response.status()).toBe(403);

const { error } = await response.json();
expect(error).toEqual({
type: 'document_forbidden',
message:
'Access to this document is forbidden. You may not have the required permissions.',
});
});

test("Ada's documents did not get updated", async () => {
const [firstDocument] = documentsCreatedByAda;

const response = await adaContext.request.get(
`/api/document?id=${firstDocument.id}`,
);
expect(response.status()).toBe(200);

const { data: documentsRetrieved } = await response.json();
expect(documentsRetrieved).toHaveLength(1);
});
});
158 changes: 158 additions & 0 deletions tests/run-tests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { type ChildProcess, execSync, spawn } from 'node:child_process';
import fs from 'node:fs';
import { config } from 'dotenv';
import waitOn from 'wait-on';

config({
path: '.env.local',
});

const neonProjectId = process.env.NEON_PROJECT_ID;
const neonApiKey = process.env.NEON_API_KEY;

async function createBranch(): Promise<{
branchId: string;
branchConnectionUri: string;
}> {
const createBranchResponse = await fetch(
`https://console.neon.tech/api/v2/projects/${neonProjectId}/branches`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${neonApiKey}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
endpoints: [{ type: 'read_write' }],
branch: {
init_source: 'schema-only',
},
}),
},
);

const { branch, connection_uris } = await createBranchResponse.json();

if (!branch) {
throw new Error('Unabled to create branch');
}

if (connection_uris.length === 0) {
throw new Error('No connection URIs found');
}

const [connection] = connection_uris;

if (!connection.connection_uri) {
throw new Error('Connection URI is missing');
}

return {
branchId: branch.id,
branchConnectionUri: connection.connection_uri,
};
}

async function deleteBranch(branchId: string) {
await fetch(
`https://console.neon.tech/api/v2/projects/${neonProjectId}/branches/${branchId}`,
{
method: 'DELETE',
headers: {
Authorization: `Bearer ${process.env.NEON_API_KEY}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
},
);
}

async function main(): Promise<void> {
let createdBranchId: string | null = null;
let serverProcess: ChildProcess | null = null;
let testFailed = false;

try {
console.log(`Creating database branch...`);
const { branchId, branchConnectionUri } = await createBranch();
createdBranchId = branchId;

console.log(`Branch created: ${branchId}`);

console.log('Starting Next.js server...');
serverProcess = spawn('pnpm', ['dev'], {
env: {
...process.env,
POSTGRES_URL: branchConnectionUri,
PORT: '3000',
PLAYWRIGHT: 'True',
},
stdio: ['ignore', 'ignore', 'pipe'],
});

if (serverProcess.stderr) {
serverProcess.stderr.on('data', (data: Buffer) =>
console.error(`Server error: ${data}`),
);
}

console.log('Waiting for server to be ready...');
await waitOn({ resources: ['http://localhost:3000'] });

console.log('Running Playwright tests...');
try {
if (!fs.existsSync('playwright-report')) {
fs.mkdirSync('playwright-report', { recursive: true });
}

execSync('pnpm playwright test --reporter=line', {
stdio: 'inherit',
env: { ...process.env, POSTGRES_URL: branchConnectionUri },
});

console.log('All tests passed!');
} catch (testError) {
testFailed = true;
const exitCode = (testError as any).status || 1;
console.error(`Tests failed with exit code: ${exitCode}`);

if (process.env.GITHUB_ACTIONS === 'true') {
console.log(
'::error::Playwright tests failed. See report for details.',
);
}
}
} catch (error) {
console.error('Error during test setup or execution:', error);
testFailed = true;
} finally {
if (serverProcess) {
console.log('Shutting down server...');
serverProcess.kill();
}

try {
if (!createdBranchId) {
console.log('No branch created');
} else {
console.log(`Cleaning up: deleting branch ${createdBranchId}`);
await deleteBranch(createdBranchId);
console.log('Branch deleted successfully');
}
} catch (cleanupError) {
console.error('Error during cleanup:', cleanupError);
}

if (testFailed) {
process.exit(1);
} else {
process.exit(0);
}
}
}

main().catch((error) => {
console.error('Unhandled error:', error);
process.exit(1);
});