Add new tests to server routes

This commit is contained in:
Jesse Wierzbinski 2024-04-11 03:52:44 -10:00
parent 6263c667e8
commit 6b3c604c33
No known key found for this signature in database
7 changed files with 524 additions and 17 deletions

View file

@ -1,6 +1,11 @@
import type { InferSelectModel } from "drizzle-orm";
import type { token } from "~drizzle/schema";
/**
* The type of token.
*/
export enum TokenType {
BEARER = "Bearer",
}
export type Token = InferSelectModel<typeof token>;

View file

@ -563,6 +563,7 @@ export const createNewLocalUser = async (data: {
avatar?: string;
header?: string;
admin?: boolean;
skipPasswordHash?: boolean;
}): Promise<UserWithRelations | null> => {
const keys = await generateUserKeys();
@ -572,7 +573,9 @@ export const createNewLocalUser = async (data: {
.values({
username: data.username,
displayName: data.display_name ?? data.username,
password: await Bun.password.hash(data.password),
password: data.skipPasswordHash
? data.password
: await Bun.password.hash(data.password),
email: data.email,
note: data.bio ?? "",
avatar: data.avatar ?? config.defaults.avatar,

View file

@ -0,0 +1,206 @@
import { afterAll, describe, expect, test } from "bun:test";
import {
deleteOldTestUsers,
getTestStatuses,
getTestUsers,
sendTestRequest,
} from "~tests/utils";
import { config } from "~index";
import { meta } from "./home";
import type { APIStatus } from "~types/entities/status";
await deleteOldTestUsers();
const { users, tokens, deleteUsers } = await getTestUsers(5);
const timeline = (await getTestStatuses(40, users[0])).toReversed();
afterAll(async () => {
await deleteUsers();
});
describe(meta.route, () => {
test("should return 401 if not authenticated", async () => {
const response = await sendTestRequest(
new Request(new URL(meta.route, config.http.base_url)),
);
expect(response.status).toBe(401);
});
test("should return 400 if limit is less than 1", async () => {
const response = await sendTestRequest(
new Request(
new URL(`${meta.route}?limit=0`, config.http.base_url),
{
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
},
),
);
expect(response.status).toBe(400);
});
test("should return 400 if limit is greater than 80", async () => {
const response = await sendTestRequest(
new Request(
new URL(`${meta.route}?limit=100`, config.http.base_url),
{
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
},
),
);
expect(response.status).toBe(400);
});
test("should correctly parse limit", async () => {
const response = await sendTestRequest(
new Request(
new URL(`${meta.route}?limit=5`, config.http.base_url),
{
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
},
),
);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe("application/json");
const objects = (await response.json()) as APIStatus[];
expect(objects.length).toBe(5);
});
test("should return 200 with statuses", async () => {
const response = await sendTestRequest(
new Request(new URL(meta.route, config.http.base_url), {
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
}),
);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe("application/json");
const objects = (await response.json()) as APIStatus[];
expect(objects.length).toBe(20);
for (const [index, status] of objects.entries()) {
expect(status.account).toBeDefined();
expect(status.account.id).toBe(users[0].id);
expect(status.content).toBeDefined();
expect(status.created_at).toBeDefined();
expect(status.id).toBe(timeline[index].id);
}
});
describe("should paginate properly", async () => {
test("should send correct Link header", async () => {
const response = await sendTestRequest(
new Request(
new URL(`${meta.route}?limit=20`, config.http.base_url),
{
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
},
),
);
expect(response.headers.get("link")).toBe(
`<${config.http.base_url}/api/v1/timelines/home?limit=20&max_id=${timeline[19].id}>; rel="next"`,
);
});
test("should correct statuses with max", async () => {
const response = await sendTestRequest(
new Request(
new URL(
`${meta.route}?limit=20&max_id=${timeline[19].id}`,
config.http.base_url,
),
{
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
},
),
);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe(
"application/json",
);
const objects = (await response.json()) as APIStatus[];
expect(objects.length).toBe(20);
for (const [index, status] of objects.entries()) {
expect(status.account).toBeDefined();
expect(status.account.id).toBe(users[0].id);
expect(status.content).toBeDefined();
expect(status.created_at).toBeDefined();
expect(status.id).toBe(timeline[index + 20].id);
}
});
test("should send correct Link prev header", async () => {
const response = await sendTestRequest(
new Request(
new URL(
`${meta.route}?limit=20&max_id=${timeline[19].id}`,
config.http.base_url,
),
{
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
},
),
);
expect(response.headers.get("link")).toInclude(
`${config.http.base_url}/api/v1/timelines/home?limit=20&min_id=${timeline[20].id}>; rel="prev"`,
);
});
test("should correct statuses with min_id", async () => {
const response = await sendTestRequest(
new Request(
new URL(
`${meta.route}?limit=20&min_id=${timeline[20].id}`,
config.http.base_url,
),
{
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
},
),
);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe(
"application/json",
);
const objects = (await response.json()) as APIStatus[];
expect(objects.length).toBe(20);
for (const [index, status] of objects.entries()) {
expect(status.account).toBeDefined();
expect(status.account.id).toBe(users[0].id);
expect(status.content).toBeDefined();
expect(status.created_at).toBeDefined();
expect(status.id).toBe(timeline[index].id);
}
});
});
});

View file

@ -1,13 +1,11 @@
import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { fetchTimeline } from "@timelines";
import { client } from "~database/datasource";
import {
type StatusWithRelations,
statusToAPI,
findManyStatuses,
} from "~database/entities/Status";
import { statusAndUserRelations } from "~database/entities/relations";
import { db } from "~drizzle/db";
export const meta = applyConfig({
@ -54,12 +52,13 @@ export default apiRoute<{
{
// @ts-expect-error Yes I KNOW the types are wrong
where: (status, { lt, gte, gt, and, or, eq, inArray, sql }) =>
or(
and(
and(
max_id ? lt(status.id, max_id) : undefined,
since_id ? gte(status.id, since_id) : undefined,
min_id ? gt(status.id, min_id) : undefined,
),
or(
eq(status.authorId, user.id),
/* inArray(
status.authorId,
@ -72,6 +71,7 @@ export default apiRoute<{
// WHERE format (... = ...)
sql`EXISTS (SELECT 1 FROM "Relationship" WHERE "Relationship"."subjectId" = ${status.authorId} AND "Relationship"."ownerId" = ${user.id} AND "Relationship"."following" = true)`,
),
),
limit: Number(limit),
// @ts-expect-error Yes I KNOW the types are wrong
orderBy: (status, { desc }) => desc(status.id),

View file

@ -0,0 +1,198 @@
import { afterAll, describe, expect, test } from "bun:test";
import {
deleteOldTestUsers,
getTestStatuses,
getTestUsers,
sendTestRequest,
} from "~tests/utils";
import { config } from "~index";
import { meta } from "./public";
import type { APIStatus } from "~types/entities/status";
await deleteOldTestUsers();
const { users, tokens, deleteUsers } = await getTestUsers(5);
const timeline = (await getTestStatuses(40, users[0])).toReversed();
afterAll(async () => {
await deleteUsers();
});
describe(meta.route, () => {
test("should return 400 if limit is less than 1", async () => {
const response = await sendTestRequest(
new Request(
new URL(`${meta.route}?limit=0`, config.http.base_url),
{
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
},
),
);
expect(response.status).toBe(400);
});
test("should return 400 if limit is greater than 80", async () => {
const response = await sendTestRequest(
new Request(
new URL(`${meta.route}?limit=100`, config.http.base_url),
{
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
},
),
);
expect(response.status).toBe(400);
});
test("should correctly parse limit", async () => {
const response = await sendTestRequest(
new Request(
new URL(`${meta.route}?limit=5`, config.http.base_url),
{
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
},
),
);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe("application/json");
const objects = (await response.json()) as APIStatus[];
expect(objects.length).toBe(5);
});
test("should return 200 with statuses", async () => {
const response = await sendTestRequest(
new Request(new URL(meta.route, config.http.base_url), {
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
}),
);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe("application/json");
const objects = (await response.json()) as APIStatus[];
expect(objects.length).toBe(20);
for (const [index, status] of objects.entries()) {
expect(status.account).toBeDefined();
expect(status.account.id).toBe(users[0].id);
expect(status.content).toBeDefined();
expect(status.created_at).toBeDefined();
expect(status.id).toBe(timeline[index].id);
}
});
describe("should paginate properly", async () => {
test("should send correct Link header", async () => {
const response = await sendTestRequest(
new Request(
new URL(`${meta.route}?limit=20`, config.http.base_url),
{
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
},
),
);
expect(response.headers.get("link")).toBe(
`<${config.http.base_url}/api/v1/timelines/public?limit=20&max_id=${timeline[19].id}>; rel="next"`,
);
});
test("should correct statuses with max", async () => {
const response = await sendTestRequest(
new Request(
new URL(
`${meta.route}?limit=20&max_id=${timeline[19].id}`,
config.http.base_url,
),
{
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
},
),
);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe(
"application/json",
);
const objects = (await response.json()) as APIStatus[];
expect(objects.length).toBe(20);
for (const [index, status] of objects.entries()) {
expect(status.account).toBeDefined();
expect(status.account.id).toBe(users[0].id);
expect(status.content).toBeDefined();
expect(status.created_at).toBeDefined();
expect(status.id).toBe(timeline[index + 20].id);
}
});
test("should send correct Link prev header", async () => {
const response = await sendTestRequest(
new Request(
new URL(
`${meta.route}?limit=20&max_id=${timeline[19].id}`,
config.http.base_url,
),
{
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
},
),
);
expect(response.headers.get("link")).toInclude(
`${config.http.base_url}/api/v1/timelines/public?limit=20&min_id=${timeline[20].id}>; rel="prev"`,
);
});
test("should correct statuses with min_id", async () => {
const response = await sendTestRequest(
new Request(
new URL(
`${meta.route}?limit=20&min_id=${timeline[20].id}`,
config.http.base_url,
),
{
headers: {
Authorization: `Bearer ${tokens[0].accessToken}`,
},
},
),
);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe(
"application/json",
);
const objects = (await response.json()) as APIStatus[];
expect(objects.length).toBe(20);
for (const [index, status] of objects.entries()) {
expect(status.account).toBeDefined();
expect(status.account.id).toBe(users[0].id);
expect(status.content).toBeDefined();
expect(status.created_at).toBeDefined();
expect(status.id).toBe(timeline[index].id);
}
});
});
});

View file

@ -1,4 +1,14 @@
import {
createNewLocalUser,
type User,
type UserWithRelations,
} from "~database/entities/User";
import { randomBytes } from "node:crypto";
import { server } from "~index";
import { db } from "~drizzle/db";
import { status, token, user } from "~drizzle/schema";
import { inArray, like } from "drizzle-orm";
import type { Status } from "~database/entities/Status";
/**
* This allows us to send a test request to the server even when it isnt running
@ -13,3 +23,86 @@ export async function sendTestRequest(req: Request) {
export function wrapRelativeUrl(url: string, base_url: string) {
return new URL(url, base_url);
}
export const deleteOldTestUsers = async () => {
// Deletes all users that match the test username (test-<32 random characters>)
await db.delete(user).where(like(user.username, "test-%"));
};
export const getTestUsers = async (count: number) => {
const users: UserWithRelations[] = [];
for (let i = 0; i < count; i++) {
const user = await createNewLocalUser({
username: `test-${randomBytes(32).toString("hex")}`,
email: `${randomBytes(32).toString("hex")}@test.com`,
password: randomBytes(32).toString("hex"),
skipPasswordHash: true,
});
if (!user) {
throw new Error("Failed to create test user");
}
users.push(user);
}
const tokens = await db
.insert(token)
.values(
users.map((u) => ({
accessToken: randomBytes(32).toString("hex"),
tokenType: "bearer",
userId: u.id,
applicationId: null,
code: randomBytes(32).toString("hex"),
scope: "read write follow push",
})),
)
.returning();
return {
users,
tokens,
deleteUsers: async () => {
await db.delete(user).where(
inArray(
user.id,
users.map((u) => u.id),
),
);
},
};
};
export const getTestStatuses = async (
count: number,
user: User,
partial?: Partial<Status>,
) => {
const statuses: Status[] = [];
for (let i = 0; i < count; i++) {
const newStatus = (
await db
.insert(status)
.values({
content: `${i} ${randomBytes(32).toString("hex")}`,
authorId: user.id,
sensitive: false,
updatedAt: new Date().toISOString(),
visibility: "public",
...partial,
})
.returning()
)[0];
if (!newStatus) {
throw new Error("Failed to create test status");
}
statuses.push(newStatus);
}
return statuses.toSorted((a, b) => a.id.localeCompare(b.id));
};

View file

@ -39,13 +39,13 @@ export async function fetchTimeline<T extends User | Status | Notification>(
const urlWithoutQuery = req.url.split("?")[0];
// Add prev link
linkHeader.push(
`<${urlWithoutQuery}?min_id=${objects[0].id}>; rel="prev"`,
`<${urlWithoutQuery}?limit=${args?.limit ?? 20}&min_id=${
objects[0].id
}>; rel="prev"`,
);
}
if (
objects.length < (Number(args?.limit) ?? Number.POSITIVE_INFINITY)
) {
if (objects.length >= (Number(args?.limit) ?? 20)) {
// Check if there are statuses after the last one
// @ts-expect-error hack again
const objectsAfter = await model({
@ -59,7 +59,9 @@ export async function fetchTimeline<T extends User | Status | Notification>(
const urlWithoutQuery = req.url.split("?")[0];
// Add next link
linkHeader.push(
`<${urlWithoutQuery}?max_id=${objectsAfter[0].id}>; rel="next"`,
`<${urlWithoutQuery}?limit=${args?.limit ?? 20}&max_id=${
objects.at(-1)?.id
}>; rel="next"`,
);
}
}