feat: add tests for /api/document (#932)
This commit is contained in:
parent
5b4ad941d3
commit
020494f63b
6 changed files with 316 additions and 19 deletions
|
|
@ -16,7 +16,7 @@ export async function GET(request: Request) {
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
||||||
if (!session || !session.user) {
|
if (!session?.user?.id) {
|
||||||
return new Response('Unauthorized', { status: 401 });
|
return new Response('Unauthorized', { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -25,11 +25,11 @@ export async function GET(request: Request) {
|
||||||
const [document] = documents;
|
const [document] = documents;
|
||||||
|
|
||||||
if (!document) {
|
if (!document) {
|
||||||
return new Response('Not Found', { status: 404 });
|
return new Response('Not found', { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.userId !== session.user.id) {
|
if (document.userId !== session.user.id) {
|
||||||
return new Response('Unauthorized', { status: 401 });
|
return new Response('Forbidden', { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return Response.json(documents, { status: 200 });
|
return Response.json(documents, { status: 200 });
|
||||||
|
|
@ -56,7 +56,7 @@ export async function POST(request: Request) {
|
||||||
}: { content: string; title: string; kind: ArtifactKind } =
|
}: { content: string; title: string; kind: ArtifactKind } =
|
||||||
await request.json();
|
await request.json();
|
||||||
|
|
||||||
const documents = await getDocumentsById({ id: id });
|
const documents = await getDocumentsById({ id });
|
||||||
|
|
||||||
if (documents.length > 0) {
|
if (documents.length > 0) {
|
||||||
const [document] = documents;
|
const [document] = documents;
|
||||||
|
|
@ -104,10 +104,10 @@ export async function DELETE(request: Request) {
|
||||||
return new Response('Unauthorized', { status: 401 });
|
return new Response('Unauthorized', { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
await deleteDocumentsByIdAfterTimestamp({
|
const documentsDeleted = await deleteDocumentsByIdAfterTimestamp({
|
||||||
id,
|
id,
|
||||||
timestamp: new Date(timestamp),
|
timestamp: new Date(timestamp),
|
||||||
});
|
});
|
||||||
|
|
||||||
return new Response('Deleted', { status: 200 });
|
return Response.json(documentsDeleted, { status: 200 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,17 @@
|
||||||
import 'server-only';
|
import 'server-only';
|
||||||
|
|
||||||
import { genSaltSync, hashSync } from 'bcrypt-ts';
|
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 { drizzle } from 'drizzle-orm/postgres-js';
|
||||||
import postgres from 'postgres';
|
import postgres from 'postgres';
|
||||||
|
|
||||||
|
|
@ -15,9 +25,9 @@ import {
|
||||||
message,
|
message,
|
||||||
vote,
|
vote,
|
||||||
type DBMessage,
|
type DBMessage,
|
||||||
Chat,
|
type Chat,
|
||||||
} from './schema';
|
} from './schema';
|
||||||
import { ArtifactKind } from '@/components/artifact';
|
import type { ArtifactKind } from '@/components/artifact';
|
||||||
|
|
||||||
// Optionally, if not using email/pass login, you can
|
// Optionally, if not using email/pass login, you can
|
||||||
// use the Drizzle adapter for Auth.js / NextAuth
|
// use the Drizzle adapter for Auth.js / NextAuth
|
||||||
|
|
@ -241,14 +251,17 @@ export async function saveDocument({
|
||||||
userId: string;
|
userId: string;
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
return await db.insert(document).values({
|
return await db
|
||||||
id,
|
.insert(document)
|
||||||
title,
|
.values({
|
||||||
kind,
|
id,
|
||||||
content,
|
title,
|
||||||
userId,
|
kind,
|
||||||
createdAt: new Date(),
|
content,
|
||||||
});
|
userId,
|
||||||
|
createdAt: new Date(),
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save document in database');
|
console.error('Failed to save document in database');
|
||||||
throw error;
|
throw error;
|
||||||
|
|
@ -304,7 +317,8 @@ export async function deleteDocumentsByIdAfterTimestamp({
|
||||||
|
|
||||||
return await db
|
return await db
|
||||||
.delete(document)
|
.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) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
'Failed to delete documents by id after timestamp from database',
|
'Failed to delete documents by id after timestamp from database',
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "ai-chatbot",
|
"name": "ai-chatbot",
|
||||||
"version": "3.0.2",
|
"version": "3.0.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbo",
|
"dev": "next dev --turbo",
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,14 @@ export default defineConfig({
|
||||||
storageState: 'playwright/.auth/session.json',
|
storageState: 'playwright/.auth/session.json',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'routes',
|
||||||
|
testMatch: /routes\/.*.test.ts/,
|
||||||
|
dependencies: [],
|
||||||
|
use: {
|
||||||
|
...devices['Desktop Chrome'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
// {
|
// {
|
||||||
// name: 'firefox',
|
// name: 'firefox',
|
||||||
|
|
|
||||||
62
tests/auth-helper.ts
Normal file
62
tests/auth-helper.ts
Normal file
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
213
tests/routes/document.test.ts
Normal file
213
tests/routes/document.test.ts
Normal file
|
|
@ -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<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 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue