diff --git a/database/entities/Token.ts b/database/entities/Token.ts index 3a07a0de..bb553bd6 100644 --- a/database/entities/Token.ts +++ b/database/entities/Token.ts @@ -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; diff --git a/database/entities/User.ts b/database/entities/User.ts index b35e4b61..6f85e201 100644 --- a/database/entities/User.ts +++ b/database/entities/User.ts @@ -563,6 +563,7 @@ export const createNewLocalUser = async (data: { avatar?: string; header?: string; admin?: boolean; + skipPasswordHash?: boolean; }): Promise => { 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, diff --git a/server/api/api/v1/timelines/home.test.ts b/server/api/api/v1/timelines/home.test.ts new file mode 100644 index 00000000..dcbac52f --- /dev/null +++ b/server/api/api/v1/timelines/home.test.ts @@ -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); + } + }); + }); +}); diff --git a/server/api/api/v1/timelines/home.ts b/server/api/api/v1/timelines/home.ts index e383fee6..cb73ab8f 100644 --- a/server/api/api/v1/timelines/home.ts +++ b/server/api/api/v1/timelines/home.ts @@ -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 diff --git a/server/api/api/v1/timelines/public.test.ts b/server/api/api/v1/timelines/public.test.ts new file mode 100644 index 00000000..15798631 --- /dev/null +++ b/server/api/api/v1/timelines/public.test.ts @@ -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); + } + }); + }); +}); diff --git a/tests/utils.ts b/tests/utils.ts index 53d23be2..ed21dc38 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -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, +) => { + 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)); +}; diff --git a/utils/timelines.ts b/utils/timelines.ts index 12f0e619..66cc2f82 100644 --- a/utils/timelines.ts +++ b/utils/timelines.ts @@ -39,13 +39,13 @@ export async function fetchTimeline( 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( 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"`, ); } }