From ec506241f0c11c2524ae13de0d8ddfc3a0d555df Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Sun, 23 Mar 2025 03:34:17 +0100 Subject: [PATCH] test(api): :white_check_mark: Remove old tests and introduce new, better ones --- api/api/v1/accounts/:id/note.test.ts | 64 +++ api/api/v1/accounts/:id/pin.test.ts | 48 ++ .../:id/remove_from_followers.test.ts | 59 +++ api/api/v1/accounts/:id/unblock.test.ts | 55 +++ api/api/v1/accounts/:id/unpin.test.ts | 53 +++ .../v1/accounts/update_credentials/index.ts | 24 +- .../accounts/verify_credentials/index.test.ts | 60 +++ api/api/v1/blocks/index.test.ts | 52 +++ api/api/v1/profile/avatar.test.ts | 43 ++ api/api/v1/profile/avatar.ts | 3 +- api/api/v1/profile/header.test.ts | 41 ++ api/api/v1/profile/header.ts | 2 +- api/api/v1/statuses/:id/context.test.ts | 83 ++++ api/api/v1/statuses/:id/index.test.ts | 57 +++ api/api/v1/statuses/:id/reblog.test.ts | 49 +++ api/api/v1/statuses/:id/unreblog.test.ts | 51 +++ classes/database/user.ts | 4 +- packages/client/versia/client.ts | 82 +++- tests/api.test.ts | 55 --- tests/api/accounts.test.ts | 411 ------------------ tests/api/statuses.test.ts | 348 --------------- tests/oauth-scopes.test.ts | 121 ------ utils/oauth.ts | 55 --- 23 files changed, 819 insertions(+), 1001 deletions(-) create mode 100644 api/api/v1/accounts/:id/note.test.ts create mode 100644 api/api/v1/accounts/:id/pin.test.ts create mode 100644 api/api/v1/accounts/:id/remove_from_followers.test.ts create mode 100644 api/api/v1/accounts/:id/unblock.test.ts create mode 100644 api/api/v1/accounts/:id/unpin.test.ts create mode 100644 api/api/v1/accounts/verify_credentials/index.test.ts create mode 100644 api/api/v1/blocks/index.test.ts create mode 100644 api/api/v1/profile/avatar.test.ts create mode 100644 api/api/v1/profile/header.test.ts create mode 100644 api/api/v1/statuses/:id/context.test.ts create mode 100644 api/api/v1/statuses/:id/index.test.ts create mode 100644 api/api/v1/statuses/:id/reblog.test.ts create mode 100644 api/api/v1/statuses/:id/unreblog.test.ts delete mode 100644 tests/api.test.ts delete mode 100644 tests/api/accounts.test.ts delete mode 100644 tests/api/statuses.test.ts delete mode 100644 tests/oauth-scopes.test.ts delete mode 100644 utils/oauth.ts diff --git a/api/api/v1/accounts/:id/note.test.ts b/api/api/v1/accounts/:id/note.test.ts new file mode 100644 index 00000000..83f26c21 --- /dev/null +++ b/api/api/v1/accounts/:id/note.test.ts @@ -0,0 +1,64 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { generateClient, getTestUsers } from "~/tests/utils"; + +const { users, deleteUsers } = await getTestUsers(2); + +afterAll(async () => { + await deleteUsers(); +}); + +describe("/api/v1/accounts/:id/note", () => { + test("should return 401 if not authenticated", async () => { + await using client = await generateClient(); + + const { ok, raw } = await client.updateAccountNote(users[1].id, "test"); + + expect(ok).toBe(false); + expect(raw.status).toBe(401); + }); + + test("should return 404 if user not found", async () => { + await using client = await generateClient(users[0]); + + const { ok, raw } = await client.updateAccountNote( + "00000000-0000-0000-0000-000000000000", + "test", + ); + + expect(ok).toBe(false); + expect(raw.status).toBe(404); + }); + + test("should update note", async () => { + await using client = await generateClient(users[0]); + + const { ok, data } = await client.updateAccountNote( + users[1].id, + "test", + ); + + expect(ok).toBe(true); + expect(data.note).toBe("test"); + }); + + test("should return 200 if note is null", async () => { + await using client = await generateClient(users[0]); + + const { ok, data } = await client.updateAccountNote(users[1].id, null); + + expect(ok).toBe(true); + expect(data.note).toBe(""); + }); + + test("should return 422 if note is too long", async () => { + await using client = await generateClient(users[0]); + + const { ok, raw } = await client.updateAccountNote( + users[1].id, + "a".repeat(10_000), + ); + + expect(ok).toBe(false); + expect(raw.status).toBe(422); + }); +}); diff --git a/api/api/v1/accounts/:id/pin.test.ts b/api/api/v1/accounts/:id/pin.test.ts new file mode 100644 index 00000000..1cb446bd --- /dev/null +++ b/api/api/v1/accounts/:id/pin.test.ts @@ -0,0 +1,48 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { generateClient, getTestUsers } from "~/tests/utils"; + +const { users, deleteUsers } = await getTestUsers(2); + +afterAll(async () => { + await deleteUsers(); +}); + +describe("/api/v1/accounts/:id/pin", () => { + test("should return 401 if not authenticated", async () => { + await using client = await generateClient(); + + const { ok, raw } = await client.pinAccount(users[1].id); + + expect(ok).toBe(false); + expect(raw.status).toBe(401); + }); + + test("should return 404 if user not found", async () => { + await using client = await generateClient(users[0]); + + const { ok, raw } = await client.pinAccount( + "00000000-0000-0000-0000-000000000000", + ); + + expect(ok).toBe(false); + expect(raw.status).toBe(404); + }); + + test("should pin account", async () => { + await using client = await generateClient(users[0]); + + const { ok, data } = await client.pinAccount(users[1].id); + + expect(ok).toBe(true); + expect(data.endorsed).toBe(true); + }); + + test("should return 200 if account already pinned", async () => { + await using client = await generateClient(users[0]); + + const { ok, data } = await client.pinAccount(users[1].id); + + expect(ok).toBe(true); + expect(data.endorsed).toBe(true); + }); +}); diff --git a/api/api/v1/accounts/:id/remove_from_followers.test.ts b/api/api/v1/accounts/:id/remove_from_followers.test.ts new file mode 100644 index 00000000..434efc4e --- /dev/null +++ b/api/api/v1/accounts/:id/remove_from_followers.test.ts @@ -0,0 +1,59 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { generateClient, getTestUsers } from "~/tests/utils"; + +const { users, deleteUsers } = await getTestUsers(2); + +afterAll(async () => { + await deleteUsers(); +}); + +beforeAll(async () => { + // Make users[1] follow users[0] + await using client = await generateClient(users[1]); + + const { ok } = await client.followAccount(users[0].id); + + expect(ok).toBe(true); +}); + +describe("/api/v1/accounts/:id/remove_from_followers", () => { + test("should return 401 when not authenticated", async () => { + await using client = await generateClient(); + + const { ok, raw } = await client.removeFromFollowers(users[1].id); + + expect(ok).toBe(false); + expect(raw.status).toBe(401); + }); + + test("should return 404 when target account doesn't exist", async () => { + await using client = await generateClient(users[0]); + + const { ok, raw } = await client.removeFromFollowers("non-existent-id"); + + expect(ok).toBe(false); + expect(raw.status).toBe(422); + }); + + test("should remove follower and return relationship", async () => { + await using client = await generateClient(users[0]); + + const { ok, data } = await client.removeFromFollowers(users[1].id); + + expect(ok).toBe(true); + expect(data.id).toBe(users[1].id); + expect(data.following).toBe(false); + expect(data.followed_by).toBe(false); + }); + + test("should handle case when user is not following", async () => { + await using client = await generateClient(users[0]); + + const { ok, data } = await client.removeFromFollowers(users[1].id); + + expect(ok).toBe(true); + expect(data.id).toBe(users[1].id); + expect(data.following).toBe(false); + expect(data.followed_by).toBe(false); + }); +}); diff --git a/api/api/v1/accounts/:id/unblock.test.ts b/api/api/v1/accounts/:id/unblock.test.ts new file mode 100644 index 00000000..8515bfb9 --- /dev/null +++ b/api/api/v1/accounts/:id/unblock.test.ts @@ -0,0 +1,55 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { generateClient, getTestUsers } from "~/tests/utils"; + +const { users, deleteUsers } = await getTestUsers(2); + +afterAll(async () => { + await deleteUsers(); +}); + +describe("/api/v1/accounts/:id/unblock", () => { + test("should return 401 if not authenticated", async () => { + await using client = await generateClient(); + + const { ok, raw } = await client.unblockAccount(users[1].id); + + expect(ok).toBe(false); + expect(raw.status).toBe(401); + }); + + test("should return 404 if user not found", async () => { + await using client = await generateClient(users[0]); + + const { ok, raw } = await client.unblockAccount( + "00000000-0000-0000-0000-000000000000", + ); + + expect(ok).toBe(false); + expect(raw.status).toBe(404); + }); + + test("should unblock user", async () => { + await using client = await generateClient(users[0]); + + const { ok, data } = await client.blockAccount(users[1].id); + + expect(ok).toBe(true); + expect(data.blocking).toBe(true); + + const { ok: ok2, data: data2 } = await client.unblockAccount( + users[1].id, + ); + + expect(ok2).toBe(true); + expect(data2.blocking).toBe(false); + }); + + test("should return 200 if user already unblocked", async () => { + await using client = await generateClient(users[0]); + + const { ok, data } = await client.unblockAccount(users[1].id); + + expect(ok).toBe(true); + expect(data.blocking).toBe(false); + }); +}); diff --git a/api/api/v1/accounts/:id/unpin.test.ts b/api/api/v1/accounts/:id/unpin.test.ts new file mode 100644 index 00000000..eddcfd12 --- /dev/null +++ b/api/api/v1/accounts/:id/unpin.test.ts @@ -0,0 +1,53 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { generateClient, getTestUsers } from "~/tests/utils"; + +const { users, deleteUsers } = await getTestUsers(2); + +afterAll(async () => { + await deleteUsers(); +}); + +describe("/api/v1/accounts/:id/unpin", () => { + test("should return 401 if not authenticated", async () => { + await using client = await generateClient(); + + const { ok, raw } = await client.unpinAccount(users[1].id); + + expect(ok).toBe(false); + expect(raw.status).toBe(401); + }); + + test("should return 404 if user not found", async () => { + await using client = await generateClient(users[0]); + + const { ok, raw } = await client.unpinAccount( + "00000000-0000-0000-0000-000000000000", + ); + + expect(ok).toBe(false); + expect(raw.status).toBe(404); + }); + + test("should unpin account", async () => { + await using client = await generateClient(users[0]); + + const { ok, data } = await client.pinAccount(users[1].id); + + expect(ok).toBe(true); + expect(data.endorsed).toBe(true); + + const { ok: ok2, data: data2 } = await client.unpinAccount(users[1].id); + + expect(ok2).toBe(true); + expect(data2.endorsed).toBe(false); + }); + + test("should return 200 if account already unpinned", async () => { + await using client = await generateClient(users[0]); + + const { ok, data } = await client.unpinAccount(users[1].id); + + expect(ok).toBe(true); + expect(data.endorsed).toBe(false); + }); +}); diff --git a/api/api/v1/accounts/update_credentials/index.ts b/api/api/v1/accounts/update_credentials/index.ts index 3d97f1ee..c2af8cc1 100644 --- a/api/api/v1/accounts/update_credentials/index.ts +++ b/api/api/v1/accounts/update_credentials/index.ts @@ -4,7 +4,7 @@ import { sanitizedHtmlStrip } from "@/sanitization"; import { createRoute, z } from "@hono/zod-openapi"; import { Account as AccountSchema, zBoolean } from "@versia/client/schemas"; import { RolePermission } from "@versia/client/schemas"; -import { Emoji, User } from "@versia/kit/db"; +import { Emoji, Media, User } from "@versia/kit/db"; import { Users } from "@versia/kit/tables"; import { and, eq, isNull } from "drizzle-orm"; import { ApiError } from "~/classes/errors/api-error"; @@ -229,17 +229,29 @@ export default apiRoute((app) => if (avatar) { if (avatar instanceof File) { - await user.avatar?.updateFromFile(avatar); + if (user.avatar) { + await user.avatar.updateFromFile(avatar); + } else { + user.avatar = await Media.fromFile(avatar); + } + } else if (user.avatar) { + await user.avatar.updateFromUrl(avatar); } else { - await user.avatar?.updateFromUrl(avatar); + user.avatar = await Media.fromUrl(avatar); } } if (header) { if (header instanceof File) { - await user.header?.updateFromFile(header); + if (user.header) { + await user.header.updateFromFile(header); + } else { + user.header = await Media.fromFile(header); + } + } else if (user.header) { + await user.header.updateFromUrl(header); } else { - await user.header?.updateFromUrl(header); + user.header = await Media.fromUrl(header); } } @@ -333,7 +345,9 @@ export default apiRoute((app) => username: self.username, note: self.note, avatar: self.avatar, + avatarId: user.avatar?.id, header: self.header, + headerId: user.header?.id, fields: self.fields, isLocked: self.isLocked, isBot: self.isBot, diff --git a/api/api/v1/accounts/verify_credentials/index.test.ts b/api/api/v1/accounts/verify_credentials/index.test.ts new file mode 100644 index 00000000..f88d6cbe --- /dev/null +++ b/api/api/v1/accounts/verify_credentials/index.test.ts @@ -0,0 +1,60 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { config } from "~/config"; +import { generateClient, getTestUsers } from "~/tests/utils"; + +const { users, deleteUsers } = await getTestUsers(1); + +afterAll(async () => { + await deleteUsers(); +}); + +describe("/api/v1/accounts/verify_credentials", () => { + describe("Authentication", () => { + test("should return 401 when not authenticated", async () => { + await using client = await generateClient(); + + const { ok, raw } = await client.verifyAccountCredentials(); + + expect(ok).toBe(false); + expect(raw.status).toBe(401); + }); + + test("should return user data when authenticated", async () => { + await using client = await generateClient(users[0]); + + const { ok, data } = await client.verifyAccountCredentials(); + + expect(ok).toBe(true); + expect(data.id).toBe(users[0].id); + expect(data.username).toBe(users[0].data.username); + expect(data.acct).toBe(users[0].data.username); + expect(data.display_name).toBe(users[0].data.displayName); + expect(data.note).toBe(users[0].data.note); + expect(data.url).toBe( + new URL( + `/@${users[0].data.username}`, + config.http.base_url, + ).toString(), + ); + expect(data.avatar).toBeDefined(); + expect(data.avatar_static).toBeDefined(); + expect(data.header).toBeDefined(); + expect(data.header_static).toBeDefined(); + expect(data.locked).toBe(users[0].data.isLocked); + expect(data.bot).toBe(users[0].data.isBot); + expect(data.group).toBe(false); + expect(data.discoverable).toBe(users[0].data.isDiscoverable); + expect(data.noindex).toBe(false); + expect(data.moved).toBeNull(); + expect(data.suspended).toBe(false); + expect(data.limited).toBe(false); + expect(data.created_at).toBe( + new Date(users[0].data.createdAt).toISOString(), + ); + expect(data.last_status_at).toBeNull(); + expect(data.statuses_count).toBe(0); + expect(data.followers_count).toBe(0); + expect(data.following_count).toBe(0); + }); + }); +}); diff --git a/api/api/v1/blocks/index.test.ts b/api/api/v1/blocks/index.test.ts new file mode 100644 index 00000000..71c8f512 --- /dev/null +++ b/api/api/v1/blocks/index.test.ts @@ -0,0 +1,52 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { generateClient, getTestUsers } from "~/tests/utils"; + +const { users, deleteUsers } = await getTestUsers(3); + +afterAll(async () => { + await deleteUsers(); +}); + +describe("/api/v1/blocks", () => { + test("should return 401 when not authenticated", async () => { + await using client = await generateClient(); + + const { ok, raw } = await client.getBlocks(); + + expect(ok).toBe(false); + expect(raw.status).toBe(401); + }); + + test("should return empty array when no blocks", async () => { + await using client = await generateClient(users[0]); + + const { ok, data } = await client.getBlocks(); + + expect(ok).toBe(true); + expect(data).toBeArrayOfSize(0); + }); + + test("should return blocked users", async () => { + await using client = await generateClient(users[0]); + + // Block users[1] and users[2] + await client.blockAccount(users[1].id); + await client.blockAccount(users[2].id); + + const { ok, data } = await client.getBlocks(); + + expect(ok).toBe(true); + expect(data).toBeArrayOfSize(2); + expect(data.map((u) => u.id)).toContain(users[1].id); + expect(data.map((u) => u.id)).toContain(users[2].id); + }); + + test("should respect limit parameter", async () => { + await using client = await generateClient(users[0]); + + const { ok, data } = await client.getBlocks({ limit: 1 }); + + expect(ok).toBe(true); + expect(data).toBeArrayOfSize(1); + }); +}); diff --git a/api/api/v1/profile/avatar.test.ts b/api/api/v1/profile/avatar.test.ts new file mode 100644 index 00000000..65d0a282 --- /dev/null +++ b/api/api/v1/profile/avatar.test.ts @@ -0,0 +1,43 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { generateClient, getTestUsers } from "~/tests/utils"; + +const { users, deleteUsers } = await getTestUsers(2); + +let avatarUrl: string; + +beforeAll(async () => { + await using client = await generateClient(users[0]); + + const { ok, data } = await client.updateCredentials({ + avatar: new URL("https://placehold.co/100x100"), + }); + + expect(ok).toBe(true); + + avatarUrl = data.avatar; +}); + +afterAll(async () => { + await deleteUsers(); +}); + +describe("POST /api/v1/profile/avatar", () => { + test("should return 401 if not authenticated", async () => { + await using client = await generateClient(); + + const { ok, raw } = await client.deleteAvatar(); + + expect(ok).toBe(false); + expect(raw.status).toBe(401); + }); + + test("should delete avatar", async () => { + await using client = await generateClient(users[0]); + + const { ok, data } = await client.deleteAvatar(); + + expect(ok).toBe(true); + // Avatars are defaulted to a placeholder + expect(data.avatar).not.toBe(avatarUrl); + }); +}); diff --git a/api/api/v1/profile/avatar.ts b/api/api/v1/profile/avatar.ts index c36f033d..8a257b3b 100644 --- a/api/api/v1/profile/avatar.ts +++ b/api/api/v1/profile/avatar.ts @@ -38,7 +38,8 @@ export default apiRoute((app) => app.openapi(route, async (context) => { const { user } = context.get("auth"); - await user.header?.delete(); + await user.avatar?.delete(); + await user.reload(); return context.json(user.toApi(true), 200); }), diff --git a/api/api/v1/profile/header.test.ts b/api/api/v1/profile/header.test.ts new file mode 100644 index 00000000..3bb348fe --- /dev/null +++ b/api/api/v1/profile/header.test.ts @@ -0,0 +1,41 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { generateClient, getTestUsers } from "~/tests/utils"; + +const { users, deleteUsers } = await getTestUsers(3); + +let headerUrl: string; + +beforeAll(async () => { + await using client = await generateClient(users[0]); + + const { ok, data } = await client.updateCredentials({ + header: new URL("https://placehold.co/100x100"), + }); + + expect(ok).toBe(true); + + headerUrl = data.header; +}); + +afterAll(async () => { + await deleteUsers(); +}); +describe("POST /api/v1/profile/header", () => { + test("should return 401 if not authenticated", async () => { + await using client = await generateClient(); + + const { ok, raw } = await client.deleteHeader(); + + expect(ok).toBe(false); + expect(raw.status).toBe(401); + }); + + test("should delete header", async () => { + await using client = await generateClient(users[0]); + + const { ok, data } = await client.deleteHeader(); + + expect(ok).toBe(true); + expect(data.header).not.toBe(headerUrl); + }); +}); diff --git a/api/api/v1/profile/header.ts b/api/api/v1/profile/header.ts index ff97d3ec..fe13281e 100644 --- a/api/api/v1/profile/header.ts +++ b/api/api/v1/profile/header.ts @@ -38,7 +38,7 @@ export default apiRoute((app) => const { user } = context.get("auth"); await user.header?.delete(); - + await user.reload(); return context.json(user.toApi(true), 200); }), ); diff --git a/api/api/v1/statuses/:id/context.test.ts b/api/api/v1/statuses/:id/context.test.ts new file mode 100644 index 00000000..a7386c55 --- /dev/null +++ b/api/api/v1/statuses/:id/context.test.ts @@ -0,0 +1,83 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { generateClient, getTestStatuses, getTestUsers } from "~/tests/utils"; + +const { users, deleteUsers } = await getTestUsers(3); +const statuses = await getTestStatuses(1, users[0]); + +let replyId: string; +let replyToReplyId: string; + +beforeAll(async () => { + await using client = await generateClient(users[0]); + + const { ok, data } = await client.postStatus("Test reply", { + in_reply_to_id: statuses[0].id, + }); + + expect(ok).toBe(true); + + replyId = data.id; + + const { ok: ok2, data: data2 } = await client.postStatus( + "Test reply to reply", + { + in_reply_to_id: replyId, + }, + ); + + expect(ok2).toBe(true); + replyToReplyId = data2.id; +}); + +afterAll(async () => { + await deleteUsers(); +}); + +describe("GET /api/v1/statuses/:id/context", () => { + test("should return 404 if status is not found", async () => { + await using client = await generateClient(users[0]); + + const { ok, raw } = await client.getStatusContext( + "00000000-0000-0000-0000-000000000000", + ); + + expect(ok).toBe(false); + expect(raw.status).toBe(404); + }); + + test("should return context of status", async () => { + await using client = await generateClient(users[0]); + + const { ok, data } = await client.getStatusContext(statuses[0].id); + + expect(ok).toBe(true); + expect(data.ancestors).toBeArrayOfSize(0); + expect(data.descendants).toBeArrayOfSize(2); + expect(data.descendants[0].id).toBe(replyId); + expect(data.descendants[1].id).toBe(replyToReplyId); + }); + + test("should return context of reply", async () => { + await using client = await generateClient(users[0]); + + const { ok, data } = await client.getStatusContext(replyId); + + expect(ok).toBe(true); + expect(data.ancestors).toBeArrayOfSize(1); + expect(data.ancestors[0].id).toBe(statuses[0].id); + expect(data.descendants).toBeArrayOfSize(1); + expect(data.descendants[0].id).toBe(replyToReplyId); + }); + + test("should return context of reply to reply", async () => { + await using client = await generateClient(users[0]); + + const { ok, data } = await client.getStatusContext(replyToReplyId); + + expect(ok).toBe(true); + expect(data.ancestors).toBeArrayOfSize(2); + expect(data.ancestors[0].id).toBe(statuses[0].id); + expect(data.ancestors[1].id).toBe(replyId); + expect(data.descendants).toBeArrayOfSize(0); + }); +}); diff --git a/api/api/v1/statuses/:id/index.test.ts b/api/api/v1/statuses/:id/index.test.ts new file mode 100644 index 00000000..97cb177b --- /dev/null +++ b/api/api/v1/statuses/:id/index.test.ts @@ -0,0 +1,57 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { generateClient, getTestStatuses, getTestUsers } from "~/tests/utils"; + +const { users, deleteUsers } = await getTestUsers(2); +const statuses = await getTestStatuses(1, users[0]); + +afterAll(async () => { + await deleteUsers(); +}); + +describe("GET /api/v1/statuses/:id", () => { + test("should return status", async () => { + await using client = await generateClient(users[0]); + + const { ok, data } = await client.getStatus(statuses[0].id); + + expect(ok).toBe(true); + expect(data.id).toBe(statuses[0].id); + }); + + test("should return 404 if status is not found", async () => { + await using client = await generateClient(users[0]); + + const { ok, raw } = await client.getStatus( + "00000000-0000-0000-0000-000000000000", + ); + + expect(ok).toBe(false); + expect(raw.status).toBe(404); + }); + + test("should return 401 when trying to delete status that is not yours", async () => { + await using client = await generateClient(users[1]); + + const { ok, raw } = await client.deleteStatus(statuses[0].id); + + expect(ok).toBe(false); + expect(raw.status).toBe(401); + }); + + test("should delete status", async () => { + await using client = await generateClient(users[0]); + + const { ok } = await client.deleteStatus(statuses[0].id); + + expect(ok).toBe(true); + }); + + test("should return 404 if status is deleted", async () => { + await using client = await generateClient(users[0]); + + const { ok, raw } = await client.getStatus(statuses[0].id); + + expect(ok).toBe(false); + expect(raw.status).toBe(404); + }); +}); diff --git a/api/api/v1/statuses/:id/reblog.test.ts b/api/api/v1/statuses/:id/reblog.test.ts new file mode 100644 index 00000000..12f997f2 --- /dev/null +++ b/api/api/v1/statuses/:id/reblog.test.ts @@ -0,0 +1,49 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { generateClient, getTestStatuses, getTestUsers } from "~/tests/utils"; + +const { users, deleteUsers } = await getTestUsers(2); +const statuses = await getTestStatuses(1, users[0]); + +afterAll(async () => { + await deleteUsers(); +}); + +describe("POST /api/v1/statuses/:id/reblog", () => { + test("should return 401 if not authenticated", async () => { + await using client = await generateClient(); + + const { ok, raw } = await client.reblogStatus(statuses[0].id); + + expect(ok).toBe(false); + expect(raw.status).toBe(401); + }); + + test("should return 404 if status is not found", async () => { + await using client = await generateClient(users[0]); + + const { ok, raw } = await client.reblogStatus( + "00000000-0000-0000-0000-000000000000", + ); + + expect(ok).toBe(false); + expect(raw.status).toBe(404); + }); + + test("should reblog status", async () => { + await using client = await generateClient(users[0]); + + const { ok, data } = await client.reblogStatus(statuses[0].id); + + expect(ok).toBe(true); + expect(data.reblog?.id).toBe(statuses[0].id); + }); + + test("should not error when status is already reblogged", async () => { + await using client = await generateClient(users[0]); + + const { ok, data } = await client.reblogStatus(statuses[0].id); + + expect(ok).toBe(true); + expect(data.reblog?.id).toBe(statuses[0].id); + }); +}); diff --git a/api/api/v1/statuses/:id/unreblog.test.ts b/api/api/v1/statuses/:id/unreblog.test.ts new file mode 100644 index 00000000..2a98e3ae --- /dev/null +++ b/api/api/v1/statuses/:id/unreblog.test.ts @@ -0,0 +1,51 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { generateClient, getTestStatuses, getTestUsers } from "~/tests/utils"; + +const { users, deleteUsers } = await getTestUsers(2); +const statuses = await getTestStatuses(1, users[0]); + +afterAll(async () => { + await deleteUsers(); +}); + +describe("POST /api/v1/statuses/:id/unreblog", () => { + test("should return 401 if not authenticated", async () => { + await using client = await generateClient(); + + const { ok, raw } = await client.unreblogStatus(statuses[0].id); + + expect(ok).toBe(false); + expect(raw.status).toBe(401); + }); + + test("should return 404 if status is not found", async () => { + await using client = await generateClient(users[0]); + + const { ok, raw } = await client.unreblogStatus( + "00000000-0000-0000-0000-000000000000", + ); + + expect(ok).toBe(false); + expect(raw.status).toBe(404); + }); + + test("should unreblog status", async () => { + await using client = await generateClient(users[0]); + + const { ok, data } = await client.unreblogStatus(statuses[0].id); + + expect(ok).toBe(true); + expect(data.id).toBe(statuses[0].id); + expect(data.reblogged).toBe(false); + expect(data.reblog).toBeNull(); + }); + + test("should not error when status is not reblogged", async () => { + await using client = await generateClient(users[0]); + + const { ok, data } = await client.unreblogStatus(statuses[0].id); + + expect(ok).toBe(true); + expect(data.reblog).toBeNull(); + }); +}); diff --git a/classes/database/user.ts b/classes/database/user.ts index c282c20d..9619b981 100644 --- a/classes/database/user.ts +++ b/classes/database/user.ts @@ -102,6 +102,8 @@ export class User extends BaseInterface { } this.data = reloaded.data; + this.avatar = reloaded.avatar; + this.header = reloaded.header; } public static async fromId(id: string | null): Promise { @@ -1146,7 +1148,7 @@ export class User extends BaseInterface { moved: null, noindex: false, suspended: false, - discoverable: null, + discoverable: user.isDiscoverable, mute_expires_at: null, roles: user.roles .map((role) => new Role(role)) diff --git a/packages/client/versia/client.ts b/packages/client/versia/client.ts index 7acea723..61d54e32 100644 --- a/packages/client/versia/client.ts +++ b/packages/client/versia/client.ts @@ -2481,6 +2481,53 @@ export class Client extends BaseClient { ); } + /** + * DELETE /api/v1/profile/avatar + * + * @return Account. + */ + public deleteAvatar( + extra?: RequestInit, + ): Promise>> { + return this.delete>( + "/api/v1/profile/avatar", + undefined, + extra, + ); + } + + /** + * DELETE /api/v1/profile/header + * + * @return Account. + */ + public deleteHeader( + extra?: RequestInit, + ): Promise>> { + return this.delete>( + "/api/v1/profile/header", + undefined, + extra, + ); + } + + /** + * POST /api/v1/accounts/:id/remove_from_followers + * + * @param id The account ID. + * @return Relationship. + */ + public removeFromFollowers( + id: string, + extra?: RequestInit, + ): Promise>> { + return this.post>( + `/api/v1/accounts/${id}/remove_from_followers`, + undefined, + extra, + ); + } + // FIXME: Announcement schema is not defined. /** * DELETE /api/v1/announcements/:id/reactions/:name @@ -2971,6 +3018,25 @@ export class Client extends BaseClient { ); } + /** + * POST /api/v1/accounts/:id/note + * + * @param id The account ID. + * @param note The note to set. + * @return Account. + */ + public updateAccountNote( + id: string, + note: string | null, + extra?: RequestInit, + ): Promise>> { + return this.post>( + `/api/v1/accounts/${id}/note`, + { comment: note ?? undefined }, + extra, + ); + } + /** * PATCH /api/v1/accounts/update_credentials * @@ -2987,7 +3053,7 @@ export class Client extends BaseClient { */ public updateCredentials( options: Partial<{ - avatar: File; + avatar: File | URL; bot: boolean; discoverable: boolean; display_name: string; @@ -2995,7 +3061,7 @@ export class Client extends BaseClient { name: string; value: string; }[]; - header: File; + header: File | URL; locked: boolean; note: string; source: Partial<{ @@ -3008,7 +3074,17 @@ export class Client extends BaseClient { ): Promise>> { return this.patchForm>( "/api/v1/accounts/update_credentials", - options, + { + ...options, + avatar: + options.avatar instanceof File + ? options.avatar + : options.avatar?.toString(), + header: + options.header instanceof File + ? options.header + : options.header?.toString(), + }, extra, ); } diff --git a/tests/api.test.ts b/tests/api.test.ts deleted file mode 100644 index 799d7bbe..00000000 --- a/tests/api.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { afterAll, describe, expect, test } from "bun:test"; -import { fakeRequest, getTestUsers } from "./utils.ts"; - -const { tokens, deleteUsers } = await getTestUsers(1); - -describe("API Tests", () => { - afterAll(async () => { - await deleteUsers(); - }); - - test("Try sending FormData without a boundary", async () => { - const formData = new FormData(); - formData.append("test", "test"); - - const response = await fakeRequest("/api/v1/statuses", { - method: "POST", - headers: { - Authorization: `Bearer ${tokens[0].data.accessToken}`, - "Content-Type": "multipart/form-data", - }, - body: formData, - }); - - expect(response.status).toBe(400); - const data = await response.json(); - - expect(data.error).toBeString(); - expect(data.details).toContain("https://stackoverflow.com"); - }); - - // Now automatically mitigated by the server - /* test("try sending a request with a different origin", async () => { - if (config.http.base_url.protocol === "http:") { - return; - } - - const response = await fakeRequest( - - "/api/v1/instance", - base_url.replace("https://", "http://"), - ), - { - method: "GET", - headers: { - Authorization: `Bearer ${tokens[0].data.accessToken}`, - }, - }, - ), - ); - - expect(response.status).toBe(400); - const data = await response.json(); - expect(data.error).toContain("does not match base URL"); - }); */ -}); diff --git a/tests/api/accounts.test.ts b/tests/api/accounts.test.ts deleted file mode 100644 index 84b4894a..00000000 --- a/tests/api/accounts.test.ts +++ /dev/null @@ -1,411 +0,0 @@ -/** - * @deprecated - */ -import { afterAll, describe, expect, test } from "bun:test"; -import type { z } from "@hono/zod-openapi"; -import type { Account, Relationship } from "@versia/client/schemas"; -import { config } from "~/config.ts"; -import { fakeRequest, getTestUsers } from "~/tests/utils"; - -const { users, tokens, deleteUsers } = await getTestUsers(2); -const user = users[0]; -const user2 = users[1]; -const token = tokens[0]; - -afterAll(async () => { - await deleteUsers(); -}); - -const getFormData = ( - object: Record, -): FormData => - Object.keys(object).reduce((formData, key) => { - formData.append(key, String(object[key])); - return formData; - }, new FormData()); - -describe("API Tests", () => { - describe("PATCH /api/v1/accounts/update_credentials", () => { - test("should update the authenticated user's display name", async () => { - const response = await fakeRequest( - "/api/v1/accounts/update_credentials", - { - method: "PATCH", - headers: { - Authorization: `Bearer ${token.data.accessToken}`, - }, - body: getFormData({ - display_name: "New Display Name", - }), - }, - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toContain( - "application/json", - ); - - const user = (await response.json()) as z.infer; - - expect(user.display_name).toBe("New Display Name"); - }); - }); - - describe("GET /api/v1/accounts/verify_credentials", () => { - test("should return the authenticated user's account information", async () => { - const response = await fakeRequest( - "/api/v1/accounts/verify_credentials", - { - headers: { - Authorization: `Bearer ${token.data.accessToken}`, - }, - }, - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toContain( - "application/json", - ); - - const account = (await response.json()) as z.infer; - - expect(account.username).toBe(user.data.username); - expect(account.bot).toBe(false); - expect(account.locked).toBe(false); - expect(account.created_at).toBeDefined(); - expect(account.followers_count).toBe(0); - expect(account.following_count).toBe(0); - expect(account.statuses_count).toBe(0); - expect(account.note).toBe(""); - expect(account.url).toBe( - new URL( - `/@${user.data.username}`, - config.http.base_url, - ).toString(), - ); - expect(account.avatar).toBeDefined(); - expect(account.avatar_static).toBeDefined(); - expect(account.header).toBeDefined(); - expect(account.header_static).toBeDefined(); - expect(account.emojis).toEqual([]); - expect(account.fields).toEqual([]); - expect(account.source?.fields).toEqual([]); - expect(account.source?.privacy).toBe("public"); - expect(account.source?.language).toBe("en"); - expect(account.source?.note).toBe(""); - expect(account.source?.sensitive).toBe(false); - }); - }); - - describe("POST /api/v1/accounts/:id/remove_from_followers", () => { - test("should remove the specified user from the authenticated user's followers and return an APIRelationship object", async () => { - const response = await fakeRequest( - `/api/v1/accounts/${user2.id}/remove_from_followers`, - { - method: "POST", - headers: { - Authorization: `Bearer ${token.data.accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({}), - }, - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toContain( - "application/json", - ); - - const account = (await response.json()) as z.infer< - typeof Relationship - >; - - expect(account.id).toBe(user2.id); - expect(account.followed_by).toBe(false); - }); - }); - - describe("POST /api/v1/accounts/:id/block", () => { - test("should block the specified user and return an APIRelationship object", async () => { - const response = await fakeRequest( - `/api/v1/accounts/${user2.id}/block`, - { - method: "POST", - headers: { - Authorization: `Bearer ${token.data.accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({}), - }, - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toContain( - "application/json", - ); - - const account = (await response.json()) as z.infer< - typeof Relationship - >; - - expect(account.id).toBe(user2.id); - expect(account.blocking).toBe(true); - }); - }); - - describe("GET /api/v1/blocks", () => { - test("should return an array of APIAccount objects for the user's blocked accounts", async () => { - const response = await fakeRequest("/api/v1/blocks", { - method: "GET", - headers: { - Authorization: `Bearer ${token.data.accessToken}`, - }, - }); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toContain( - "application/json", - ); - const body = (await response.json()) as z.infer[]; - - expect(Array.isArray(body)).toBe(true); - expect(body.length).toBe(1); - expect(body[0].id).toBe(user2.id); - }); - }); - - describe("POST /api/v1/accounts/:id/unblock", () => { - test("should unblock the specified user and return an APIRelationship object", async () => { - const response = await fakeRequest( - `/api/v1/accounts/${user2.id}/unblock`, - { - method: "POST", - headers: { - Authorization: `Bearer ${token.data.accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({}), - }, - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toContain( - "application/json", - ); - - const account = (await response.json()) as z.infer< - typeof Relationship - >; - - expect(account.id).toBe(user2.id); - expect(account.blocking).toBe(false); - }); - }); - - describe("POST /api/v1/accounts/:id/pin", () => { - test("should pin the specified user and return an APIRelationship object", async () => { - const response = await fakeRequest( - `/api/v1/accounts/${user2.id}/pin`, - { - method: "POST", - headers: { - Authorization: `Bearer ${token.data.accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({}), - }, - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toContain( - "application/json", - ); - - const account = (await response.json()) as z.infer< - typeof Relationship - >; - - expect(account.id).toBe(user2.id); - expect(account.endorsed).toBe(true); - }); - }); - - describe("POST /api/v1/accounts/:id/unpin", () => { - test("should unpin the specified user and return an APIRelationship object", async () => { - const response = await fakeRequest( - `/api/v1/accounts/${user2.id}/unpin`, - { - method: "POST", - headers: { - Authorization: `Bearer ${token.data.accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({}), - }, - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toContain( - "application/json", - ); - - const account = (await response.json()) as z.infer< - typeof Relationship - >; - - expect(account.id).toBe(user2.id); - expect(account.endorsed).toBe(false); - }); - }); - - describe("POST /api/v1/accounts/:id/note", () => { - test("should update the specified account's note and return the updated account object", async () => { - const response = await fakeRequest( - `/api/v1/accounts/${user2.id}/note`, - { - method: "POST", - headers: { - Authorization: `Bearer ${token.data.accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ comment: "This is a new note" }), - }, - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toContain( - "application/json", - ); - - const account = (await response.json()) as z.infer; - - expect(account.id).toBe(user2.id); - expect(account.note).toBe("This is a new note"); - }); - }); - - describe("GET /api/v1/accounts/relationships", () => { - test("should return an array of APIRelationship objects for the authenticated user's relationships", async () => { - const response = await fakeRequest( - `/api/v1/accounts/relationships?id[]=${user2.id}`, - { - method: "GET", - headers: { - Authorization: `Bearer ${token.data.accessToken}`, - }, - }, - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toContain( - "application/json", - ); - - const relationships = (await response.json()) as z.infer< - typeof Relationship - >[]; - - expect(Array.isArray(relationships)).toBe(true); - expect(relationships.length).toBeGreaterThan(0); - expect(relationships[0].id).toBeDefined(); - expect(relationships[0].following).toBeDefined(); - expect(relationships[0].followed_by).toBeDefined(); - expect(relationships[0].blocking).toBeDefined(); - expect(relationships[0].muting).toBeDefined(); - expect(relationships[0].muting_notifications).toBeDefined(); - expect(relationships[0].requested).toBeDefined(); - expect(relationships[0].domain_blocking).toBeDefined(); - expect(relationships[0].notifying).toBeDefined(); - }); - }); - - describe("DELETE /api/v1/profile/avatar", () => { - test("should delete the avatar of the authenticated user and return the updated account object", async () => { - const response = await fakeRequest("/api/v1/profile/avatar", { - method: "DELETE", - headers: { - Authorization: `Bearer ${token.data.accessToken}`, - }, - }); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toContain( - "application/json", - ); - - const account = (await response.json()) as z.infer; - - expect(account.id).toBeDefined(); - expect(account.avatar).toBeDefined(); - }); - }); - - describe("DELETE /api/v1/profile/header", () => { - test("should delete the header of the authenticated user and return the updated account object", async () => { - const response = await fakeRequest("/api/v1/profile/header", { - method: "DELETE", - headers: { - Authorization: `Bearer ${token.data.accessToken}`, - }, - }); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toContain( - "application/json", - ); - - const account = (await response.json()) as z.infer; - - expect(account.id).toBeDefined(); - expect(account.header).toBe(""); - }); - }); - - describe("GET /api/v1/accounts/familiar_followers", () => { - test("should follow the user", async () => { - const response = await fakeRequest( - `/api/v1/accounts/${user2.id}/follow`, - { - method: "POST", - headers: { - Authorization: `Bearer ${token.data.accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({}), - }, - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toContain( - "application/json", - ); - }); - - test("should return no familiar followers", async () => { - const response = await fakeRequest( - `/api/v1/accounts/familiar_followers?id[]=${user2.id}`, - { - method: "GET", - headers: { - Authorization: `Bearer ${token.data.accessToken}`, - }, - }, - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toContain( - "application/json", - ); - - const familiarFollowers = (await response.json()) as { - id: string; - accounts: z.infer[]; - }[]; - - expect(Array.isArray(familiarFollowers)).toBe(true); - expect(familiarFollowers.length).toBe(1); - expect(familiarFollowers[0].id).toBe(user2.id); - expect(familiarFollowers[0].accounts).toBeArrayOfSize(0); - }); - }); -}); diff --git a/tests/api/statuses.test.ts b/tests/api/statuses.test.ts deleted file mode 100644 index 68be9fd7..00000000 --- a/tests/api/statuses.test.ts +++ /dev/null @@ -1,348 +0,0 @@ -/** - * @deprecated - */ -import { afterAll, describe, expect, test } from "bun:test"; -import type { z } from "@hono/zod-openapi"; -import type { Attachment, Context, Status } from "@versia/client/schemas"; -import { fakeRequest, getTestUsers } from "~/tests/utils"; - -const { users, tokens, deleteUsers } = await getTestUsers(1); -const user = users[0]; -const token = tokens[0]; -let status: z.infer | null = null; -let status2: z.infer | null = null; -let media1: z.infer | null = null; - -describe("API Tests", () => { - afterAll(async () => { - await deleteUsers(); - }); - - describe("POST /api/v2/media", () => { - test("should upload a file and return a MediaAttachment object", async () => { - const formData = new FormData(); - formData.append("file", new Blob(["test"], { type: "text/plain" })); - - const response = await fakeRequest("/api/v2/media", { - method: "POST", - headers: { - Authorization: `Bearer ${token.data.accessToken}`, - }, - body: formData, - }); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toContain( - "application/json", - ); - - media1 = (await response.json()) as z.infer; - - expect(media1.id).toBeDefined(); - expect(media1.type).toBe("unknown"); - expect(media1.url).toBeDefined(); - }); - }); - - describe("POST /api/v1/statuses", () => { - test("should create a new status and return an APIStatus object", async () => { - const response = await fakeRequest("/api/v1/statuses", { - method: "POST", - headers: { - Authorization: `Bearer ${token.data.accessToken}`, - }, - body: new URLSearchParams({ - status: "Hello, world!", - visibility: "public", - "media_ids[]": media1?.id ?? "", - local_only: "true", - }), - }); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toContain( - "application/json", - ); - - status = (await response.json()) as z.infer; - expect(status.content).toContain("Hello, world!"); - expect(status.visibility).toBe("public"); - expect(status.account.id).toBe(user.id); - expect(status.replies_count).toBe(0); - expect(status.favourites_count).toBe(0); - expect(status.reblogged).toBe(false); - expect(status.favourited).toBe(false); - expect(status.media_attachments).toBeArrayOfSize(1); - expect(status.mentions).toEqual([]); - expect(status.tags).toEqual([]); - expect(status.sensitive).toBe(false); - expect(status.spoiler_text).toBe(""); - expect(status.language).toBeNull(); - expect(status.pinned).toBe(false); - expect(status.visibility).toBe("public"); - expect(status.card).toBeNull(); - expect(status.poll).toBeNull(); - expect(status.emojis).toEqual([]); - expect(status.in_reply_to_id).toBeNull(); - expect(status.in_reply_to_account_id).toBeNull(); - }); - - test("should create a new status in reply to the previous one", async () => { - const response = await fakeRequest("/api/v1/statuses", { - method: "POST", - headers: { - Authorization: `Bearer ${token.data.accessToken}`, - }, - body: new URLSearchParams({ - status: "This is a reply!", - visibility: "public", - in_reply_to_id: status?.id ?? "", - local_only: "true", - }), - }); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toContain( - "application/json", - ); - - status2 = (await response.json()) as z.infer; - expect(status2.content).toContain("This is a reply!"); - expect(status2.visibility).toBe("public"); - expect(status2.account.id).toBe(user.id); - expect(status2.replies_count).toBe(0); - expect(status2.favourites_count).toBe(0); - expect(status2.reblogged).toBe(false); - expect(status2.favourited).toBe(false); - expect(status2.media_attachments).toEqual([]); - expect(status2.mentions).toEqual([]); - expect(status2.tags).toEqual([]); - expect(status2.sensitive).toBe(false); - expect(status2.spoiler_text).toBe(""); - expect(status2.language).toBeNull(); - expect(status2.pinned).toBe(false); - expect(status2.visibility).toBe("public"); - expect(status2.card).toBeNull(); - expect(status2.poll).toBeNull(); - expect(status2.emojis).toEqual([]); - expect(status2.in_reply_to_id).toEqual(status?.id || null); - expect(status2.in_reply_to_account_id).toEqual(user.id); - }); - }); - - describe("GET /api/v1/statuses/:id", () => { - test("should return the specified status object", async () => { - const response = await fakeRequest( - `/api/v1/statuses/${status?.id}`, - { - headers: { - Authorization: `Bearer ${token.data.accessToken}`, - }, - }, - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toContain( - "application/json", - ); - - const statusJson = (await response.json()) as z.infer< - typeof Status - >; - - expect(statusJson.id).toBe(status?.id || ""); - expect(statusJson.content).toBeDefined(); - expect(statusJson.created_at).toBeDefined(); - expect(statusJson.account).toBeDefined(); - expect(statusJson.reblog).toBeDefined(); - expect(statusJson.application).toBeUndefined(); - expect(statusJson.emojis).toBeDefined(); - expect(statusJson.media_attachments).toBeDefined(); - expect(statusJson.poll).toBeDefined(); - expect(statusJson.card).toBeDefined(); - expect(statusJson.visibility).toBeDefined(); - expect(statusJson.sensitive).toBeDefined(); - expect(statusJson.spoiler_text).toBeDefined(); - expect(statusJson.uri).toBeDefined(); - expect(statusJson.url).toBeDefined(); - expect(statusJson.replies_count).toBeDefined(); - expect(statusJson.reblogs_count).toBeDefined(); - expect(statusJson.favourites_count).toBeDefined(); - expect(statusJson.favourited).toBeDefined(); - expect(statusJson.reblogged).toBeDefined(); - expect(statusJson.muted).toBeDefined(); - expect(statusJson.bookmarked).toBeDefined(); - expect(statusJson.pinned).toBeDefined(); - }); - }); - - describe("POST /api/v1/statuses/:id/reblog", () => { - test("should reblog the specified status and return the reblogged status object", async () => { - const response = await fakeRequest( - `/api/v1/statuses/${status?.id}/reblog`, - { - method: "POST", - headers: { - Authorization: `Bearer ${token.data.accessToken}`, - }, - }, - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toContain( - "application/json", - ); - - const rebloggedStatus = (await response.json()) as z.infer< - typeof Status - >; - - expect(rebloggedStatus.id).toBeDefined(); - expect(rebloggedStatus.reblog?.id).toEqual(status?.id ?? ""); - expect(rebloggedStatus.reblog?.reblogged).toBe(true); - }); - }); - - describe("POST /api/v1/statuses/:id/unreblog", () => { - test("should unreblog the specified status and return the original status object", async () => { - const response = await fakeRequest( - `/api/v1/statuses/${status?.id}/unreblog`, - { - method: "POST", - headers: { - Authorization: `Bearer ${token.data.accessToken}`, - }, - }, - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toContain( - "application/json", - ); - - const unrebloggedStatus = (await response.json()) as z.infer< - typeof Status - >; - - expect(unrebloggedStatus.id).toBeDefined(); - expect(unrebloggedStatus.reblogged).toBe(false); - }); - }); - - describe("GET /api/v1/statuses/:id/context", () => { - test("should return the context of the specified status", async () => { - const response = await fakeRequest( - `/api/v1/statuses/${status?.id}/context`, - { - headers: { - Authorization: `Bearer ${token.data.accessToken}`, - }, - }, - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toContain( - "application/json", - ); - - const context = (await response.json()) as z.infer; - - expect(context.ancestors.length).toBe(0); - expect(context.descendants.length).toBe(1); - - // First descendant should be status2 - expect(context.descendants[0].id).toBe(status2?.id || ""); - }); - }); - - describe("GET /api/v1/accounts/:id/statuses", () => { - test("should return the statuses of the specified user", async () => { - const response = await fakeRequest( - `/api/v1/accounts/${user.id}/statuses`, - { - method: "GET", - headers: { - Authorization: `Bearer ${token.data.accessToken}`, - }, - }, - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toContain( - "application/json", - ); - - const statuses = (await response.json()) as z.infer< - typeof Status - >[]; - - expect(statuses.length).toBe(2); - - const status1 = statuses[0]; - - // Basic validation - expect(status1.content).toContain("This is a reply!"); - expect(status1.visibility).toBe("public"); - expect(status1.account.id).toBe(user.id); - }); - }); - - describe("POST /api/v1/statuses/:id/favourite", () => { - test("should favourite the specified status object", async () => { - const response = await fakeRequest( - `/api/v1/statuses/${status?.id}/favourite`, - { - method: "POST", - headers: { - Authorization: `Bearer ${token.data.accessToken}`, - }, - }, - ); - - expect(response.status).toBe(200); - }); - }); - - describe("POST /api/v1/statuses/:id/unfavourite", () => { - test("should unfavourite the specified status object", async () => { - // Unfavourite the status - const response = await fakeRequest( - `/api/v1/statuses/${status?.id}/unfavourite`, - { - method: "POST", - headers: { - Authorization: `Bearer ${token.data.accessToken}`, - }, - }, - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toContain( - "application/json", - ); - - const updatedStatus = (await response.json()) as z.infer< - typeof Status - >; - - expect(updatedStatus.favourited).toBe(false); - expect(updatedStatus.favourites_count).toBe(0); - }); - }); - - describe("DELETE /api/v1/statuses/:id", () => { - test("should delete the specified status object", async () => { - const response = await fakeRequest( - `/api/v1/statuses/${status?.id}`, - { - method: "DELETE", - headers: { - Authorization: `Bearer ${token.data.accessToken}`, - }, - }, - ); - - expect(response.status).toBe(200); - }); - }); -}); diff --git a/tests/oauth-scopes.test.ts b/tests/oauth-scopes.test.ts deleted file mode 100644 index c80bd649..00000000 --- a/tests/oauth-scopes.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { checkIfOauthIsValid } from "@/oauth"; -import { Application } from "@versia/kit/db"; -describe("checkIfOauthIsValid", () => { - it("should return true when routeScopes and application.scopes are empty", () => { - const application = new Application({ - scopes: "", - } as typeof Application.$type); - const routeScopes: string[] = []; - const result = checkIfOauthIsValid(application, routeScopes); - expect(result).toBe(true); - }); - - it("should return true when routeScopes is empty and application.scopes contains write:* or write", () => { - const application = new Application({ - scopes: "write:*", - } as typeof Application.$type); - const routeScopes: string[] = []; - const result = checkIfOauthIsValid(application, routeScopes); - expect(result).toBe(true); - }); - - it("should return true when routeScopes is empty and application.scopes contains read:* or read", () => { - const application = new Application({ - scopes: "read:*", - } as typeof Application.$type); - const routeScopes: string[] = []; - const result = checkIfOauthIsValid(application, routeScopes); - expect(result).toBe(true); - }); - - it("should return true when routeScopes contains only write: permissions and application.scopes contains write:* or write", () => { - const application = new Application({ - scopes: "write:*", - } as typeof Application.$type); - const routeScopes = ["write:users", "write:posts"]; - const result = checkIfOauthIsValid(application, routeScopes); - expect(result).toBe(true); - }); - - it("should return true when routeScopes contains only read: permissions and application.scopes contains read:* or read", () => { - const application = new Application({ - scopes: "read:*", - } as typeof Application.$type); - const routeScopes = ["read:users", "read:posts"]; - const result = checkIfOauthIsValid(application, routeScopes); - expect(result).toBe(true); - }); - - it("should return true when routeScopes contains both write: and read: permissions and application.scopes contains write:* or write and read:* or read", () => { - const application = new Application({ - scopes: "write:* read:*", - } as typeof Application.$type); - const routeScopes = ["write:users", "read:posts"]; - const result = checkIfOauthIsValid(application, routeScopes); - expect(result).toBe(true); - }); - - it("should return false when routeScopes contains write: permissions but application.scopes does not contain write:* or write", () => { - const application = new Application({ - scopes: "read:*", - } as typeof Application.$type); - const routeScopes = ["write:users", "write:posts"]; - const result = checkIfOauthIsValid(application, routeScopes); - expect(result).toBe(false); - }); - - it("should return false when routeScopes contains read: permissions but application.scopes does not contain read:* or read", () => { - const application = new Application({ - scopes: "write:*", - } as typeof Application.$type); - const routeScopes = ["read:users", "read:posts"]; - const result = checkIfOauthIsValid(application, routeScopes); - expect(result).toBe(false); - }); - - it("should return false when routeScopes contains both write: and read: permissions but application.scopes does not contain write:* or write and read:* or read", () => { - const application = new Application({ - scopes: "", - } as typeof Application.$type); - const routeScopes = ["write:users", "read:posts"]; - const result = checkIfOauthIsValid(application, routeScopes); - expect(result).toBe(false); - }); - - it("should return true when routeScopes contains a mix of valid and invalid permissions and application.scopes contains all the required permissions", () => { - const application = new Application({ - scopes: "write:* read:*", - } as typeof Application.$type); - const routeScopes = ["write:users", "invalid:permission", "read:posts"]; - const result = checkIfOauthIsValid(application, routeScopes); - expect(result).toBe(true); - }); - - it("should return false when routeScopes contains a mix of valid and invalid permissions but application.scopes does not contain all the required permissions", () => { - const application = new Application({ - scopes: "write:*", - } as typeof Application.$type); - const routeScopes = ["write:users", "invalid:permission", "read:posts"]; - const result = checkIfOauthIsValid(application, routeScopes); - expect(result).toBe(false); - }); - - it("should return true when routeScopes contains a mix of valid write and read permissions and application.scopes contains all the required permissions", () => { - const application = new Application({ - scopes: "write:* read:posts", - } as typeof Application.$type); - const routeScopes = ["write:users", "read:posts"]; - const result = checkIfOauthIsValid(application, routeScopes); - expect(result).toBe(true); - }); - - it("should return false when routeScopes contains a mix of valid write and read permissions but application.scopes does not contain all the required permissions", () => { - const application = new Application({ - scopes: "write:*", - } as typeof Application.$type); - const routeScopes = ["write:users", "read:posts"]; - const result = checkIfOauthIsValid(application, routeScopes); - expect(result).toBe(false); - }); -}); diff --git a/utils/oauth.ts b/utils/oauth.ts deleted file mode 100644 index b8148dc4..00000000 --- a/utils/oauth.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { Application } from "@versia/kit/db"; - -/** - * Check if an OAuth application is valid for a route - * @param application The OAuth application - * @param routeScopes The scopes required for the route - * @returns Whether the OAuth application is valid for the route - */ -export const checkIfOauthIsValid = ( - application: Application, - routeScopes: string[], -): boolean => { - if (routeScopes.length === 0) { - return true; - } - - const hasAllWriteScopes = - application.data.scopes.split(" ").includes("write:*") || - application.data.scopes.split(" ").includes("write"); - - const hasAllReadScopes = - application.data.scopes.split(" ").includes("read:*") || - application.data.scopes.split(" ").includes("read"); - - if (hasAllWriteScopes && hasAllReadScopes) { - return true; - } - - let nonMatchedScopes = routeScopes; - - if (hasAllWriteScopes) { - // Filter out all write scopes as valid - nonMatchedScopes = routeScopes.filter( - (scope) => !scope.startsWith("write:"), - ); - } - - if (hasAllReadScopes) { - // Filter out all read scopes as valid - nonMatchedScopes = routeScopes.filter( - (scope) => !scope.startsWith("read:"), - ); - } - - // If there are still scopes left, check if they match - // If there are no scopes left, return true - if (nonMatchedScopes.length === 0) { - return true; - } - - // If there are scopes left, check if they match - return nonMatchedScopes.every((scope) => - application.data.scopes.split(" ").includes(scope), - ); -};