From 3ccff003f5cebf0bf7dd8e106d2e5493aea1e027 Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Thu, 11 Apr 2024 13:12:23 -1000 Subject: [PATCH] Add more tests, fix roiutes --- database/entities/Status.ts | 4 +- server/api/api/v1/accounts/[id]/index.test.ts | 110 +++++++ server/api/api/v1/accounts/[id]/index.ts | 17 +- .../api/api/v1/accounts/lookup/index.test.ts | 91 ++++++ server/api/api/v1/accounts/lookup/index.ts | 16 +- .../api/api/v1/accounts/search/index.test.ts | 114 +++++++ server/api/api/v1/accounts/search/index.ts | 72 +++-- .../v1/statuses/[id]/favourited_by.test.ts | 116 +++++++ .../api/v1/statuses/[id]/reblogged_by.test.ts | 116 +++++++ server/api/api/v1/statuses/index.test.ts | 297 ++++++++++++++++++ server/api/api/v1/statuses/index.ts | 50 +-- 11 files changed, 923 insertions(+), 80 deletions(-) create mode 100644 server/api/api/v1/accounts/[id]/index.test.ts create mode 100644 server/api/api/v1/accounts/lookup/index.test.ts create mode 100644 server/api/api/v1/accounts/search/index.test.ts create mode 100644 server/api/api/v1/statuses/[id]/favourited_by.test.ts create mode 100644 server/api/api/v1/statuses/[id]/reblogged_by.test.ts create mode 100644 server/api/api/v1/statuses/index.test.ts diff --git a/database/entities/Status.ts b/database/entities/Status.ts index 34059d0f..8ae2e860 100644 --- a/database/entities/Status.ts +++ b/database/entities/Status.ts @@ -847,7 +847,7 @@ export const createNewStatus = async ( content["text/markdown"]?.content || "", contentType: "text/html", - visibility: visibility, + visibility, sensitive: is_sensitive, spoilerText: spoiler_text, instanceId: author.instanceId || null, @@ -1148,7 +1148,7 @@ export const statusToAPI = async ( `/@${statusToConvert.author.username}/${statusToConvert.id}`, config.http.base_url, ).toString(), - visibility: "public", + visibility: statusToConvert.visibility as APIStatus["visibility"], url: statusToConvert.uri || new URL( diff --git a/server/api/api/v1/accounts/[id]/index.test.ts b/server/api/api/v1/accounts/[id]/index.test.ts new file mode 100644 index 00000000..9b5ad8fe --- /dev/null +++ b/server/api/api/v1/accounts/[id]/index.test.ts @@ -0,0 +1,110 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { + deleteOldTestUsers, + getTestStatuses, + getTestUsers, + sendTestRequest, +} from "~tests/utils"; +import { config } from "~index"; +import { meta } from "./index"; +import type { APIStatus } from "~types/entities/status"; +import type { APIAccount } from "~types/entities/account"; +import { getUserUri } from "~database/entities/User"; + +await deleteOldTestUsers(); + +const { users, tokens, deleteUsers } = await getTestUsers(5); +const timeline = (await getTestStatuses(40, users[0])).toReversed(); + +afterAll(async () => { + await deleteUsers(); +}); + +beforeAll(async () => { + for (const status of timeline) { + await fetch( + new URL( + `/api/v1/statuses/${status.id}/favourite`, + config.http.base_url, + ), + { + method: "POST", + headers: { + Authorization: `Bearer ${tokens[1].accessToken}`, + }, + }, + ); + } +}); + +// /api/v1/accounts/:id +describe(meta.route, () => { + test("should return 401 if not authenticated and trying to use following", async () => { + const response = await sendTestRequest( + new Request( + new URL( + `${meta.route.replace(":id", users[0].id)}?following=true`, + config.http.base_url, + ), + ), + ); + + expect(response.status).toBe(401); + }); + + test("should return 404 if ID is invalid", async () => { + const response = await sendTestRequest( + new Request( + new URL( + meta.route.replace(":id", "invalid"), + config.http.base_url, + ), + ), + ); + expect(response.status).toBe(404); + }); + + test("should return user", async () => { + const response = await sendTestRequest( + new Request( + new URL( + meta.route.replace(":id", users[0].id), + config.http.base_url, + ), + ), + ); + + expect(response.status).toBe(200); + + const data = (await response.json()) as APIAccount; + expect(data).toMatchObject({ + id: users[0].id, + username: users[0].username, + display_name: users[0].displayName, + avatar: expect.any(String), + header: expect.any(String), + locked: users[0].isLocked, + created_at: new Date(users[0].createdAt).toISOString(), + followers_count: 0, + following_count: 0, + statuses_count: 40, + note: users[0].note, + acct: users[0].username, + url: expect.any(String), + avatar_static: expect.any(String), + header_static: expect.any(String), + emojis: [], + moved: null, + fields: [], + bot: false, + group: false, + limited: false, + noindex: false, + suspended: false, + pleroma: { + is_admin: false, + is_moderator: false, + }, + } satisfies APIAccount); + }); +}); diff --git a/server/api/api/v1/accounts/[id]/index.ts b/server/api/api/v1/accounts/[id]/index.ts index 7bc86b2b..698e4634 100644 --- a/server/api/api/v1/accounts/[id]/index.ts +++ b/server/api/api/v1/accounts/[id]/index.ts @@ -1,9 +1,6 @@ import { apiRoute, applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; -import { client } from "~database/datasource"; -import type { UserWithRelations } from "~database/entities/User"; -import { userToAPI } from "~database/entities/User"; -import { userRelations } from "~database/entities/relations"; +import { findFirstUser, userToAPI } from "~database/entities/User"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -34,15 +31,9 @@ export default apiRoute(async (req, matchedRoute, extraData) => { const { user } = extraData.auth; - let foundUser: UserWithRelations | null; - try { - foundUser = await client.user.findUnique({ - where: { id }, - include: userRelations, - }); - } catch (e) { - return errorResponse("Invalid ID", 404); - } + const foundUser = await findFirstUser({ + where: (user, { eq }) => eq(user.id, id), + }).catch(() => null); if (!foundUser) return errorResponse("User not found", 404); diff --git a/server/api/api/v1/accounts/lookup/index.test.ts b/server/api/api/v1/accounts/lookup/index.test.ts new file mode 100644 index 00000000..e99f5eda --- /dev/null +++ b/server/api/api/v1/accounts/lookup/index.test.ts @@ -0,0 +1,91 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { + deleteOldTestUsers, + getTestStatuses, + getTestUsers, + sendTestRequest, +} from "~tests/utils"; +import { config } from "~index"; +import { meta } from "./index"; +import type { APIStatus } from "~types/entities/status"; +import type { APIAccount } from "~types/entities/account"; +import { getUserUri } from "~database/entities/User"; + +await deleteOldTestUsers(); + +const { users, tokens, deleteUsers } = await getTestUsers(5); + +afterAll(async () => { + await deleteUsers(); +}); + +// /api/v1/accounts/lookup +describe(meta.route, () => { + test("should return 400 if acct is missing", 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(400); + }); + + test("should return 400 if acct is empty", async () => { + const response = await sendTestRequest( + new Request(new URL(`${meta.route}?acct=`, config.http.base_url), { + headers: { + Authorization: `Bearer ${tokens[0].accessToken}`, + }, + }), + ); + + expect(response.status).toBe(400); + }); + + test("should return 404 if acct is invalid", async () => { + const response = await sendTestRequest( + new Request( + new URL(`${meta.route}?acct=invalid`, config.http.base_url), + { + headers: { + Authorization: `Bearer ${tokens[0].accessToken}`, + }, + }, + ), + ); + + expect(response.status).toBe(404); + }); + + test("should return 200 with users", async () => { + const response = await sendTestRequest( + new Request( + new URL( + `${meta.route}?acct=${users[0].username}`, + config.http.base_url, + ), + { + headers: { + Authorization: `Bearer ${tokens[0].accessToken}`, + }, + }, + ), + ); + + expect(response.status).toBe(200); + + const data = (await response.json()) as APIAccount[]; + expect(data).toEqual( + expect.objectContaining({ + id: users[0].id, + username: users[0].username, + display_name: users[0].displayName, + avatar: expect.any(String), + header: expect.any(String), + }), + ); + }); +}); diff --git a/server/api/api/v1/accounts/lookup/index.ts b/server/api/api/v1/accounts/lookup/index.ts index 21f709a1..87098ba2 100644 --- a/server/api/api/v1/accounts/lookup/index.ts +++ b/server/api/api/v1/accounts/lookup/index.ts @@ -1,9 +1,10 @@ import { apiRoute, applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; -import { client } from "~database/datasource"; -import type { UserWithRelations } from "~database/entities/User"; -import { resolveWebFinger, userToAPI } from "~database/entities/User"; -import { userRelations } from "~database/entities/relations"; +import { + findFirstUser, + resolveWebFinger, + userToAPI, +} from "~database/entities/User"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -57,11 +58,8 @@ export default apiRoute<{ username = username.slice(1); } - const account = await client.user.findFirst({ - where: { - username, - }, - include: userRelations, + const account = await findFirstUser({ + where: (user, { eq }) => eq(user.username, username), }); if (account) { diff --git a/server/api/api/v1/accounts/search/index.test.ts b/server/api/api/v1/accounts/search/index.test.ts new file mode 100644 index 00000000..9d081662 --- /dev/null +++ b/server/api/api/v1/accounts/search/index.test.ts @@ -0,0 +1,114 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { + deleteOldTestUsers, + getTestStatuses, + getTestUsers, + sendTestRequest, +} from "~tests/utils"; +import { config } from "~index"; +import { meta } from "./index"; +import type { APIStatus } from "~types/entities/status"; +import type { APIAccount } from "~types/entities/account"; +import { getUserUri } from "~database/entities/User"; + +await deleteOldTestUsers(); + +const { users, tokens, deleteUsers } = await getTestUsers(5); + +afterAll(async () => { + await deleteUsers(); +}); + +// /api/v1/accounts/search +describe(meta.route, () => { + test("should return 400 if q is missing", 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(400); + }); + + test("should return 400 if q is empty", async () => { + const response = await sendTestRequest( + new Request(new URL(`${meta.route}?q=`, config.http.base_url), { + headers: { + Authorization: `Bearer ${tokens[0].accessToken}`, + }, + }), + ); + + expect(response.status).toBe(400); + }); + + test("should return 400 if limit is less than 1", async () => { + const response = await sendTestRequest( + new Request( + new URL( + `${meta.route}?q=${users[0].username}&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}?q=${users[0].username}&limit=100`, + config.http.base_url, + ), + { + headers: { + Authorization: `Bearer ${tokens[0].accessToken}`, + }, + }, + ), + ); + + expect(response.status).toBe(400); + }); + + test("should return 200 with users", async () => { + const response = await sendTestRequest( + new Request( + new URL( + `${meta.route}?q=${users[0].username}`, + config.http.base_url, + ), + { + headers: { + Authorization: `Bearer ${tokens[0].accessToken}`, + }, + }, + ), + ); + + expect(response.status).toBe(200); + + const data = (await response.json()) as APIAccount[]; + expect(data).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: users[0].id, + username: users[0].username, + display_name: users[0].displayName, + avatar: expect.any(String), + header: expect.any(String), + }), + ]), + ); + }); +}); diff --git a/server/api/api/v1/accounts/search/index.ts b/server/api/api/v1/accounts/search/index.ts index 0f22a66d..26bc08b1 100644 --- a/server/api/api/v1/accounts/search/index.ts +++ b/server/api/api/v1/accounts/search/index.ts @@ -1,8 +1,13 @@ import { apiRoute, applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; -import { client } from "~database/datasource"; -import { userToAPI } from "~database/entities/User"; -import { userRelations } from "~database/entities/relations"; +import { sql } from "drizzle-orm"; +import { + findManyUsers, + resolveWebFinger, + userToAPI, + type UserWithRelations, +} from "~database/entities/User"; +import { user } from "~drizzle/schema"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -29,46 +34,47 @@ export default apiRoute<{ following = false, limit = 40, offset, + resolve, q, } = extraData.parsedRequest; - const { user } = extraData.auth; + const { user: self } = extraData.auth; - if (!user && following) return errorResponse("Unauthorized", 401); + if (!self && following) return errorResponse("Unauthorized", 401); if (limit < 1 || limit > 80) { return errorResponse("Limit must be between 1 and 80", 400); } - // TODO: Add WebFinger resolve + if (!q) { + return errorResponse("Query is required", 400); + } - const accounts = await client.user.findMany({ - where: { - OR: [ - { - displayName: { - contains: q, - }, - }, - { - username: { - contains: q, - }, - }, - ], - relationshipSubjects: following - ? { - some: { - ownerId: user?.id, - following, - }, - } - : undefined, - }, - take: Number(limit), - skip: Number(offset || 0), - include: userRelations, - }); + const [username, host] = q?.split("@") || []; + + const accounts: UserWithRelations[] = []; + + if (resolve && username && host) { + const resolvedUser = await resolveWebFinger(username, host); + + if (resolvedUser) { + accounts.push(resolvedUser); + } + } else { + accounts.push( + ...(await findManyUsers({ + where: (account, { or, like }) => + or( + like(account.displayName, `%${q}%`), + like(account.username, `%${q}%`), + following + ? sql`EXISTS (SELECT 1 FROM "Relationship" WHERE "Relationship"."subjectId" = ${user.id} AND "Relationship"."ownerId" = ${account.id} AND "Relationship"."following" = true)` + : undefined, + ), + offset: Number(offset), + })), + ); + } return jsonResponse(accounts.map((acct) => userToAPI(acct))); }); diff --git a/server/api/api/v1/statuses/[id]/favourited_by.test.ts b/server/api/api/v1/statuses/[id]/favourited_by.test.ts new file mode 100644 index 00000000..90e85048 --- /dev/null +++ b/server/api/api/v1/statuses/[id]/favourited_by.test.ts @@ -0,0 +1,116 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { + deleteOldTestUsers, + getTestStatuses, + getTestUsers, + sendTestRequest, +} from "~tests/utils"; +import { config } from "~index"; +import { meta } from "./favourited_by"; +import type { APIStatus } from "~types/entities/status"; +import type { APIAccount } from "~types/entities/account"; + +await deleteOldTestUsers(); + +const { users, tokens, deleteUsers } = await getTestUsers(5); +const timeline = (await getTestStatuses(40, users[0])).toReversed(); + +afterAll(async () => { + await deleteUsers(); +}); + +beforeAll(async () => { + for (const status of timeline) { + await fetch( + new URL( + `/api/v1/statuses/${status.id}/favourite`, + config.http.base_url, + ), + { + method: "POST", + headers: { + Authorization: `Bearer ${tokens[1].accessToken}`, + }, + }, + ); + } +}); + +// /api/v1/statuses/:id/favourited_by +describe(meta.route, () => { + test("should return 401 if not authenticated", async () => { + const response = await sendTestRequest( + new Request( + new URL( + meta.route.replace(":id", timeline[0].id), + 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.replace(":id", timeline[0].id)}?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.replace(":id", timeline[0].id)}?limit=100`, + config.http.base_url, + ), + { + headers: { + Authorization: `Bearer ${tokens[0].accessToken}`, + }, + }, + ), + ); + + expect(response.status).toBe(400); + }); + + test("should return 200 with users", async () => { + const response = await sendTestRequest( + new Request( + new URL( + meta.route.replace(":id", timeline[0].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 APIAccount[]; + + expect(objects.length).toBe(1); + for (const [index, status] of objects.entries()) { + expect(status.id).toBe(users[1].id); + expect(status.username).toBe(users[1].username); + } + }); +}); diff --git a/server/api/api/v1/statuses/[id]/reblogged_by.test.ts b/server/api/api/v1/statuses/[id]/reblogged_by.test.ts new file mode 100644 index 00000000..1e588a60 --- /dev/null +++ b/server/api/api/v1/statuses/[id]/reblogged_by.test.ts @@ -0,0 +1,116 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { + deleteOldTestUsers, + getTestStatuses, + getTestUsers, + sendTestRequest, +} from "~tests/utils"; +import { config } from "~index"; +import { meta } from "./reblogged_by"; +import type { APIStatus } from "~types/entities/status"; +import type { APIAccount } from "~types/entities/account"; + +await deleteOldTestUsers(); + +const { users, tokens, deleteUsers } = await getTestUsers(5); +const timeline = (await getTestStatuses(40, users[0])).toReversed(); + +afterAll(async () => { + await deleteUsers(); +}); + +beforeAll(async () => { + for (const status of timeline) { + await fetch( + new URL( + `/api/v1/statuses/${status.id}/reblog`, + config.http.base_url, + ), + { + method: "POST", + headers: { + Authorization: `Bearer ${tokens[1].accessToken}`, + }, + }, + ); + } +}); + +// /api/v1/statuses/:id/reblogged_by +describe(meta.route, () => { + test("should return 401 if not authenticated", async () => { + const response = await sendTestRequest( + new Request( + new URL( + meta.route.replace(":id", timeline[0].id), + 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.replace(":id", timeline[0].id)}?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.replace(":id", timeline[0].id)}?limit=100`, + config.http.base_url, + ), + { + headers: { + Authorization: `Bearer ${tokens[0].accessToken}`, + }, + }, + ), + ); + + expect(response.status).toBe(400); + }); + + test("should return 200 with users", async () => { + const response = await sendTestRequest( + new Request( + new URL( + meta.route.replace(":id", timeline[0].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 APIAccount[]; + + expect(objects.length).toBe(1); + for (const [index, status] of objects.entries()) { + expect(status.id).toBe(users[1].id); + expect(status.username).toBe(users[1].username); + } + }); +}); diff --git a/server/api/api/v1/statuses/index.test.ts b/server/api/api/v1/statuses/index.test.ts new file mode 100644 index 00000000..269d278a --- /dev/null +++ b/server/api/api/v1/statuses/index.test.ts @@ -0,0 +1,297 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { + deleteOldTestUsers, + getTestUsers, + sendTestRequest, +} from "~tests/utils"; +import { config } from "~index"; +import { meta } from "./index"; +import type { APIStatus } from "~types/entities/status"; + +await deleteOldTestUsers(); + +const { users, tokens, deleteUsers } = await getTestUsers(5); + +afterAll(async () => { + await deleteUsers(); +}); + +describe(meta.route, () => { + test("should return 405 if method is not allowed", async () => { + const response = await sendTestRequest( + new Request(new URL(meta.route, config.http.base_url), { + method: "GET", + }), + ); + + expect(response.status).toBe(405); + }); + + test("should return 401 if not authenticated", async () => { + const response = await sendTestRequest( + new Request(new URL(meta.route, config.http.base_url), { + method: "POST", + }), + ); + + expect(response.status).toBe(401); + }); + + test("should return 422 is status is empty", async () => { + const response = await sendTestRequest( + new Request(new URL(meta.route, config.http.base_url), { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${tokens[0].accessToken}`, + }, + body: JSON.stringify({}), + }), + ); + + expect(response.status).toBe(422); + }); + + test("should return 400 is status is too long", async () => { + const response = await sendTestRequest( + new Request(new URL(meta.route, config.http.base_url), { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${tokens[0].accessToken}`, + }, + body: JSON.stringify({ + status: "a".repeat(config.validation.max_note_size + 1), + federate: false, + }), + }), + ); + + expect(response.status).toBe(400); + }); + + test("should return 422 is visibility is invalid", async () => { + const response = await sendTestRequest( + new Request(new URL(meta.route, config.http.base_url), { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${tokens[0].accessToken}`, + }, + body: JSON.stringify({ + status: "Hello, world!", + visibility: "invalid", + federate: false, + }), + }), + ); + + expect(response.status).toBe(422); + }); + + test("should return 422 if scheduled_at is invalid", async () => { + const response = await sendTestRequest( + new Request(new URL(meta.route, config.http.base_url), { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${tokens[0].accessToken}`, + }, + body: JSON.stringify({ + status: "Hello, world!", + scheduled_at: "invalid", + federate: false, + }), + }), + ); + + expect(response.status).toBe(422); + }); + + test("should return 404 is in_reply_to_id is invalid", async () => { + const response = await sendTestRequest( + new Request(new URL(meta.route, config.http.base_url), { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${tokens[0].accessToken}`, + }, + body: JSON.stringify({ + status: "Hello, world!", + in_reply_to_id: "invalid", + federate: false, + }), + }), + ); + + expect(response.status).toBe(404); + }); + + test("should return 404 is quote_id is invalid", async () => { + const response = await sendTestRequest( + new Request(new URL(meta.route, config.http.base_url), { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${tokens[0].accessToken}`, + }, + body: JSON.stringify({ + status: "Hello, world!", + quote_id: "invalid", + federate: false, + }), + }), + ); + + expect(response.status).toBe(404); + }); + + test("should return 422 is media_ids is invalid", async () => { + const response = await sendTestRequest( + new Request(new URL(meta.route, config.http.base_url), { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${tokens[0].accessToken}`, + }, + body: JSON.stringify({ + status: "Hello, world!", + media_ids: ["invalid"], + federate: false, + }), + }), + ); + + expect(response.status).toBe(422); + }); + + test("should create a post", async () => { + const response = await sendTestRequest( + new Request(new URL(meta.route, config.http.base_url), { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${tokens[0].accessToken}`, + }, + body: JSON.stringify({ + status: "Hello, world!", + federate: false, + }), + }), + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("application/json"); + + const object = (await response.json()) as APIStatus; + + expect(object.content).toBe("

Hello, world!

"); + }); + + test("should create a post with visibility", async () => { + const response = await sendTestRequest( + new Request(new URL(meta.route, config.http.base_url), { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${tokens[0].accessToken}`, + }, + body: JSON.stringify({ + status: "Hello, world!", + visibility: "unlisted", + federate: false, + }), + }), + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("application/json"); + + const object = (await response.json()) as APIStatus; + + expect(object.content).toBe("

Hello, world!

"); + expect(object.visibility).toBe("unlisted"); + }); + + test("should create a post with a reply", async () => { + const response = await sendTestRequest( + new Request(new URL(meta.route, config.http.base_url), { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${tokens[0].accessToken}`, + }, + body: JSON.stringify({ + status: "Hello, world!", + federate: false, + }), + }), + ); + + const object = (await response.json()) as APIStatus; + + const response2 = await sendTestRequest( + new Request(new URL(meta.route, config.http.base_url), { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${tokens[0].accessToken}`, + }, + body: JSON.stringify({ + status: "Hello, world again!", + in_reply_to_id: object.id, + federate: false, + }), + }), + ); + + expect(response2.status).toBe(200); + expect(response2.headers.get("content-type")).toBe("application/json"); + + const object2 = (await response2.json()) as APIStatus; + + expect(object2.content).toBe("

Hello, world again!

"); + expect(object2.in_reply_to_id).toBe(object.id); + }); + + test("should create a post with a quote", async () => { + const response = await sendTestRequest( + new Request(new URL(meta.route, config.http.base_url), { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${tokens[0].accessToken}`, + }, + body: JSON.stringify({ + status: "Hello, world!", + federate: false, + }), + }), + ); + + const object = (await response.json()) as APIStatus; + + const response2 = await sendTestRequest( + new Request(new URL(meta.route, config.http.base_url), { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${tokens[0].accessToken}`, + }, + body: JSON.stringify({ + status: "Hello, world again!", + quote_id: object.id, + federate: false, + }), + }), + ); + + expect(response2.status).toBe(200); + expect(response2.headers.get("content-type")).toBe("application/json"); + + const object2 = (await response2.json()) as APIStatus; + + expect(object2.content).toBe("

Hello, world again!

"); + expect(object2.quote_id).toBe(object.id); + }); +}); diff --git a/server/api/api/v1/statuses/index.ts b/server/api/api/v1/statuses/index.ts index 26358d2d..121bdd94 100644 --- a/server/api/api/v1/statuses/index.ts +++ b/server/api/api/v1/statuses/index.ts @@ -14,6 +14,7 @@ import { } from "~database/entities/Status"; import type { UserWithRelations } from "~database/entities/User"; import { statusAndUserRelations } from "~database/entities/relations"; +import { db } from "~drizzle/db"; import type { APIStatus } from "~types/entities/status"; export const meta = applyConfig({ @@ -49,7 +50,7 @@ export default apiRoute<{ content_type?: string; federate?: boolean; }>(async (req, matchedRoute, extraData) => { - const { user, token } = extraData.auth; + const { user } = extraData.auth; if (!user) return errorResponse("Unauthorized", 401); @@ -138,11 +139,22 @@ export default apiRoute<{ } if (scheduled_at) { - if (new Date(scheduled_at).getTime() < Date.now()) { + if ( + Number.isNaN(new Date(scheduled_at).getTime()) || + new Date(scheduled_at).getTime() < Date.now() + ) { return errorResponse("Scheduled time must be in the future", 422); } } + // Validate visibility + if ( + visibility && + !["public", "unlisted", "private", "direct"].includes(visibility) + ) { + return errorResponse("Invalid visibility", 422); + } + let sanitizedStatus: string; if (content_type === "text/markdown") { @@ -162,14 +174,6 @@ export default apiRoute<{ ); } - // Validate visibility - if ( - visibility && - !["public", "unlisted", "private", "direct"].includes(visibility) - ) { - return errorResponse("Invalid visibility", 422); - } - // Get reply account and status if exists let replyStatus: StatusWithRelations | null = null; let quote: StatusWithRelations | null = null; @@ -177,7 +181,7 @@ export default apiRoute<{ if (in_reply_to_id) { replyStatus = await findFirstStatuses({ where: (status, { eq }) => eq(status.id, in_reply_to_id), - }); + }).catch(() => null); if (!replyStatus) { return errorResponse("Reply status not found", 404); @@ -187,7 +191,7 @@ export default apiRoute<{ if (quote_id) { quote = await findFirstStatuses({ where: (status, { eq }) => eq(status.id, quote_id), - }); + }).catch(() => null); if (!quote) { return errorResponse("Quote status not found", 404); @@ -200,17 +204,17 @@ export default apiRoute<{ } // Check if media attachments are all valid + if (media_ids && media_ids.length > 0) { + const foundAttachments = await db.query.attachment + .findMany({ + where: (attachment, { inArray }) => + inArray(attachment.id, media_ids), + }) + .catch(() => []); - const foundAttachments = await client.attachment.findMany({ - where: { - id: { - in: media_ids ?? [], - }, - }, - }); - - if (foundAttachments.length !== (media_ids ?? []).length) { - return errorResponse("Invalid media IDs", 422); + if (foundAttachments.length !== (media_ids ?? []).length) { + return errorResponse("Invalid media IDs", 422); + } } const mentions = await parseTextMentions(sanitizedStatus); @@ -222,7 +226,7 @@ export default apiRoute<{ content: sanitizedStatus ?? "", }, }, - visibility as APIStatus["visibility"], + visibility ?? "public", sensitive ?? false, spoiler_text ?? "", [],