mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 16:38:19 +01:00
Add new tests to server routes
This commit is contained in:
parent
6263c667e8
commit
6b3c604c33
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
206
server/api/api/v1/timelines/home.test.ts
Normal file
206
server/api/api/v1/timelines/home.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,23 +52,25 @@ 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,
|
||||
),
|
||||
eq(status.authorId, user.id),
|
||||
/* inArray(
|
||||
or(
|
||||
eq(status.authorId, user.id),
|
||||
/* inArray(
|
||||
status.authorId,
|
||||
followers.map((f) => f.ownerId),
|
||||
), */
|
||||
// All statuses where the user is mentioned, using table _StatusToUser which has a: status.id and b: user.id
|
||||
// WHERE format (... = ...)
|
||||
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
|
||||
// WHERE format (... = ...)
|
||||
sql`EXISTS (SELECT 1 FROM "Relationship" WHERE "Relationship"."subjectId" = ${status.authorId} AND "Relationship"."ownerId" = ${user.id} AND "Relationship"."following" = true)`,
|
||||
// All statuses where the user is mentioned, using table _StatusToUser which has a: status.id and b: user.id
|
||||
// WHERE format (... = ...)
|
||||
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
|
||||
// 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
|
||||
|
|
|
|||
198
server/api/api/v1/timelines/public.test.ts
Normal file
198
server/api/api/v1/timelines/public.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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));
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue