From 020494f63be953adccdbfa3b8af1f27dd146f849 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Mon, 14 Apr 2025 10:34:11 -0700 Subject: [PATCH] feat: add tests for /api/document (#932) --- app/(chat)/api/document/route.ts | 12 +- lib/db/queries.ts | 38 ++++-- package.json | 2 +- playwright.config.ts | 8 ++ tests/auth-helper.ts | 62 +++++++++ tests/routes/document.test.ts | 213 +++++++++++++++++++++++++++++++ 6 files changed, 316 insertions(+), 19 deletions(-) create mode 100644 tests/auth-helper.ts create mode 100644 tests/routes/document.test.ts diff --git a/app/(chat)/api/document/route.ts b/app/(chat)/api/document/route.ts index 862d49f..a1c0884 100644 --- a/app/(chat)/api/document/route.ts +++ b/app/(chat)/api/document/route.ts @@ -16,7 +16,7 @@ export async function GET(request: Request) { const session = await auth(); - if (!session || !session.user) { + if (!session?.user?.id) { return new Response('Unauthorized', { status: 401 }); } @@ -25,11 +25,11 @@ export async function GET(request: Request) { const [document] = documents; if (!document) { - return new Response('Not Found', { status: 404 }); + return new Response('Not found', { status: 404 }); } if (document.userId !== session.user.id) { - return new Response('Unauthorized', { status: 401 }); + return new Response('Forbidden', { status: 403 }); } return Response.json(documents, { status: 200 }); @@ -56,7 +56,7 @@ export async function POST(request: Request) { }: { content: string; title: string; kind: ArtifactKind } = await request.json(); - const documents = await getDocumentsById({ id: id }); + const documents = await getDocumentsById({ id }); if (documents.length > 0) { const [document] = documents; @@ -104,10 +104,10 @@ export async function DELETE(request: Request) { return new Response('Unauthorized', { status: 401 }); } - await deleteDocumentsByIdAfterTimestamp({ + const documentsDeleted = await deleteDocumentsByIdAfterTimestamp({ id, timestamp: new Date(timestamp), }); - return new Response('Deleted', { status: 200 }); + return Response.json(documentsDeleted, { status: 200 }); } diff --git a/lib/db/queries.ts b/lib/db/queries.ts index d51c5ae..f83c50b 100644 --- a/lib/db/queries.ts +++ b/lib/db/queries.ts @@ -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', diff --git a/package.json b/package.json index 7eb8d94..3138fe4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ai-chatbot", - "version": "3.0.2", + "version": "3.0.3", "private": true, "scripts": { "dev": "next dev --turbo", diff --git a/playwright.config.ts b/playwright.config.ts index bc855c9..80feac0 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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', diff --git a/tests/auth-helper.ts b/tests/auth-helper.ts new file mode 100644 index 0000000..7b0882a --- /dev/null +++ b/tests/auth-helper.ts @@ -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 { + 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, + }; +} diff --git a/tests/routes/document.test.ts b/tests/routes/document.test.ts new file mode 100644 index 0000000..e1887b9 --- /dev/null +++ b/tests/routes/document.test.ts @@ -0,0 +1,213 @@ +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 = []; + +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 text = await response.text(); + expect(text).toEqual('Missing id'); + }); + + 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 text = await response.text(); + expect(text).toEqual('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 [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 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 [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 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 text = await response.text(); + expect(text).toEqual('Missing id'); + }); + + 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 text = await response.text(); + expect(text).toEqual('Missing timestamp'); + }); + + 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}×tamp=${firstDocument.createdAt}`, + ); + expect(response.status()).toBe(200); + + const 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 retrievedDocuments = await response.json(); + expect(retrievedDocuments).toHaveLength(1); + + const [firstRetrievedDocument] = retrievedDocuments; + expect(firstRetrievedDocument).toMatchObject(firstDocument); + }); + + 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 text = await response.text(); + expect(text).toEqual('Forbidden'); + }); + + 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 documentsRetrieved = await response.json(); + expect(documentsRetrieved).toHaveLength(1); + }); + });