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,
},
});
Loading