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. * The type of token.
*/ */
export enum TokenType { export enum TokenType {
BEARER = "Bearer", BEARER = "Bearer",
} }
export type Token = InferSelectModel<typeof token>;

View file

@ -563,6 +563,7 @@ export const createNewLocalUser = async (data: {
avatar?: string; avatar?: string;
header?: string; header?: string;
admin?: boolean; admin?: boolean;
skipPasswordHash?: boolean;
}): Promise<UserWithRelations | null> => { }): Promise<UserWithRelations | null> => {
const keys = await generateUserKeys(); const keys = await generateUserKeys();
@ -572,7 +573,9 @@ export const createNewLocalUser = async (data: {
.values({ .values({
username: data.username, username: data.username,
displayName: data.display_name ?? 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, email: data.email,
note: data.bio ?? "", note: data.bio ?? "",
avatar: data.avatar ?? config.defaults.avatar, 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 { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { errorResponse, jsonResponse } from "@response";
import { fetchTimeline } from "@timelines"; import { fetchTimeline } from "@timelines";
import { client } from "~database/datasource";
import { import {
type StatusWithRelations, type StatusWithRelations,
statusToAPI, statusToAPI,
findManyStatuses, findManyStatuses,
} from "~database/entities/Status"; } from "~database/entities/Status";
import { statusAndUserRelations } from "~database/entities/relations";
import { db } from "~drizzle/db"; import { db } from "~drizzle/db";
export const meta = applyConfig({ export const meta = applyConfig({
@ -54,23 +52,25 @@ export default apiRoute<{
{ {
// @ts-expect-error Yes I KNOW the types are wrong // @ts-expect-error Yes I KNOW the types are wrong
where: (status, { lt, gte, gt, and, or, eq, inArray, sql }) => where: (status, { lt, gte, gt, and, or, eq, inArray, sql }) =>
or( and(
and( and(
max_id ? lt(status.id, max_id) : undefined, max_id ? lt(status.id, max_id) : undefined,
since_id ? gte(status.id, since_id) : undefined, since_id ? gte(status.id, since_id) : undefined,
min_id ? gt(status.id, min_id) : undefined, min_id ? gt(status.id, min_id) : undefined,
), ),
eq(status.authorId, user.id), or(
/* inArray( eq(status.authorId, user.id),
/* inArray(
status.authorId, status.authorId,
followers.map((f) => f.ownerId), followers.map((f) => f.ownerId),
), */ ), */
// All statuses where the user is mentioned, using table _StatusToUser which has a: status.id and b: user.id // All statuses where the user is mentioned, using table _StatusToUser which has a: status.id and b: user.id
// WHERE format (... = ...) // WHERE format (... = ...)
sql`EXISTS (SELECT 1 FROM "_StatusToUser" WHERE "_StatusToUser"."A" = ${status.id} AND "_StatusToUser"."B" = ${user.id})`, sql`EXISTS (SELECT 1 FROM "_StatusToUser" WHERE "_StatusToUser"."A" = ${status.id} AND "_StatusToUser"."B" = ${user.id})`,
// All statuses from users that the user is following // All statuses from users that the user is following
// WHERE format (... = ...) // WHERE format (... = ...)
sql`EXISTS (SELECT 1 FROM "Relationship" WHERE "Relationship"."subjectId" = ${status.authorId} AND "Relationship"."ownerId" = ${user.id} AND "Relationship"."following" = true)`, sql`EXISTS (SELECT 1 FROM "Relationship" WHERE "Relationship"."subjectId" = ${status.authorId} AND "Relationship"."ownerId" = ${user.id} AND "Relationship"."following" = true)`,
),
), ),
limit: Number(limit), limit: Number(limit),
// @ts-expect-error Yes I KNOW the types are wrong // @ts-expect-error Yes I KNOW the types are wrong

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