From 25b3fe202f7adfdd47b7202e9a4dbd593e972a5f Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Sat, 28 Oct 2023 10:21:04 -1000 Subject: [PATCH] Add likes, new endpoints --- database/entities/Like.ts | 32 + database/entities/Status.ts | 112 ++- server/api/api/v1/statuses/[id]/favourite.ts | 78 ++ .../api/api/v1/statuses/[id]/favourited_by.ts | 137 ++++ .../api/api/v1/statuses/[id]/reblogged_by.ts | 136 ++++ .../api/api/v1/statuses/[id]/unfavourite.ts | 64 ++ tests/api.test.ts | 717 ------------------ tests/api/accounts.test.ts | 585 ++++++++++++++ tests/api/statuses.test.ts | 388 ++++++++++ 9 files changed, 1490 insertions(+), 759 deletions(-) create mode 100644 database/entities/Like.ts create mode 100644 server/api/api/v1/statuses/[id]/favourite.ts create mode 100644 server/api/api/v1/statuses/[id]/favourited_by.ts create mode 100644 server/api/api/v1/statuses/[id]/reblogged_by.ts create mode 100644 server/api/api/v1/statuses/[id]/unfavourite.ts create mode 100644 tests/api/accounts.test.ts create mode 100644 tests/api/statuses.test.ts diff --git a/database/entities/Like.ts b/database/entities/Like.ts new file mode 100644 index 00000000..d28531c5 --- /dev/null +++ b/database/entities/Like.ts @@ -0,0 +1,32 @@ +import { + BaseEntity, + CreateDateColumn, + Entity, + ManyToOne, + PrimaryGeneratedColumn, +} from "typeorm"; +import { User } from "./User"; +import { Status } from "./Status"; + +/** + * Represents a Like entity in the database. + */ +@Entity({ + name: "likes", +}) +export class Like extends BaseEntity { + /** The unique identifier of the Like. */ + @PrimaryGeneratedColumn("uuid") + id!: string; + + /** The User who liked the Status. */ + @ManyToOne(() => User) + liker!: User; + + /** The Status that was liked. */ + @ManyToOne(() => Status) + liked!: Status; + + @CreateDateColumn() + created_at!: Date; +} diff --git a/database/entities/Status.ts b/database/entities/Status.ts index 14cf2fc0..fd82a7cf 100644 --- a/database/entities/Status.ts +++ b/database/entities/Status.ts @@ -9,6 +9,9 @@ import { ManyToOne, PrimaryGeneratedColumn, RemoveOptions, + Tree, + TreeChildren, + TreeParent, UpdateDateColumn, } from "typeorm"; import { APIStatus } from "~types/entities/status"; @@ -18,6 +21,8 @@ import { Emoji } from "./Emoji"; import { RawActivity } from "./RawActivity"; import { RawObject } from "./RawObject"; import { Instance } from "./Instance"; +import { Like } from "./Like"; +import { AppDataSource } from "~database/datasource"; const config = getConfig(); @@ -51,6 +56,7 @@ export const statusAndUserRelations = [ @Entity({ name: "statuses", }) +@Tree("closure-table") export class Status extends BaseEntity { /** * The unique identifier for this status. @@ -117,12 +123,14 @@ export class Status extends BaseEntity { /** * The raw object that this status is a reply to, if any. */ - @ManyToOne(() => Status, { - nullable: true, + @TreeParent({ onDelete: "SET NULL", }) in_reply_to_post!: Status | null; + @TreeChildren() + replies!: Status[]; + /** * The status' instance */ @@ -191,6 +199,13 @@ export class Status extends BaseEntity { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (this.object) await this.object.remove(options); + // Get all associated Likes and remove them as well + await Like.delete({ + liked: { + id: this.id, + }, + }); + return await super.remove(options); } @@ -268,46 +283,32 @@ export class Status extends BaseEntity { */ async getDescendants(fetcher: User | null) { const max = fetcher ? 4096 : 60; - // Go through all descendants in a tree-like manner - const descendants: Status[] = []; - return await Status._getDescendants(this, fetcher, max, descendants); - } - - /** - * Return all the descendants of a post, - * @param status The status to get the descendants of. - * @param isAuthenticated Whether the user is authenticated. - * @param max The maximum number of descendants to get. - * @param descendants The descendants to add to. - * @returns A promise that resolves with the descendants. - * @private - */ - private static async _getDescendants( - status: Status, - fetcher: User | null, - max: number, - descendants: Status[] - ) { - const currentStatus = await Status.find({ - where: { - in_reply_to_post: { - id: status.id, - }, - }, + const descendants = await AppDataSource.getTreeRepository( + Status + ).findDescendantsTree(this, { + depth: fetcher ? 20 : undefined, relations: statusAndUserRelations, }); - for (const status of currentStatus) { - if (status.isViewableByUser(fetcher)) { - descendants.push(status); - } - if (descendants.length < max) { - await this._getDescendants(status, fetcher, max, descendants); - } - } + // Go through .replies of each descendant recursively and add them to the list + const flatten = (descendants: Status): Status[] => { + const flattened = []; - return descendants; + for (const descendant of descendants.replies) { + if (descendant.isViewableByUser(fetcher)) { + flattened.push(descendant); + } + + flattened.push(...flatten(descendant)); + } + + return flattened; + }; + + const flattened = flatten(descendants); + + return flattened.slice(0, max); } /** @@ -398,11 +399,27 @@ export class Status extends BaseEntity { return newStatus; } + async isFavouritedBy(user: User) { + const like = await Like.findOne({ + where: { + liker: { + id: user.id, + }, + liked: { + id: this.id, + }, + }, + relations: ["liker"], + }); + + return !!like; + } + /** * Converts this status to an API status. * @returns A promise that resolves with the API status. */ - async toAPI(): Promise { + async toAPI(user?: User): Promise { const reblogCount = await Status.count({ where: { reblog: { @@ -421,6 +438,17 @@ export class Status extends BaseEntity { relations: ["in_reply_to_post"], }); + const favourited = user ? await this.isFavouritedBy(user) : false; + + const favourites_count = await Like.count({ + where: { + liked: { + id: this.id, + }, + }, + relations: ["liked"], + }); + return { id: this.id, in_reply_to_id: this.in_reply_to_post?.id || null, @@ -431,8 +459,8 @@ export class Status extends BaseEntity { card: null, content: this.content, emojis: await Promise.all(this.emojis.map(emoji => emoji.toAPI())), - favourited: false, - favourites_count: 0, + favourited, + favourites_count: favourites_count, media_attachments: [], mentions: await Promise.all( this.mentions.map(async m => await m.toAPI()) @@ -445,8 +473,8 @@ export class Status extends BaseEntity { reblogged: !!this.reblog, reblogs_count: reblogCount, replies_count: repliesCount, - sensitive: false, - spoiler_text: "", + sensitive: this.sensitive, + spoiler_text: this.spoiler_text, tags: [], uri: `${config.http.base_url}/users/${this.account.username}/statuses/${this.id}`, visibility: "public", diff --git a/server/api/api/v1/statuses/[id]/favourite.ts b/server/api/api/v1/statuses/[id]/favourite.ts new file mode 100644 index 00000000..c4d16847 --- /dev/null +++ b/server/api/api/v1/statuses/[id]/favourite.ts @@ -0,0 +1,78 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +import { applyConfig } from "@api"; +import { errorResponse, jsonResponse } from "@response"; +import { MatchedRoute } from "bun"; +import { Like } from "~database/entities/Like"; +import { Status, statusAndUserRelations } from "~database/entities/Status"; +import { User, userRelations } from "~database/entities/User"; +import { APIRouteMeta } from "~types/api"; + +export const meta: APIRouteMeta = applyConfig({ + allowedMethods: ["POST"], + ratelimits: { + max: 100, + duration: 60, + }, + route: "/api/v1/statuses/:id/favourite", + auth: { + required: true, + }, +}); + +/** + * Favourite a post + */ +export default async ( + req: Request, + matchedRoute: MatchedRoute +): Promise => { + const id = matchedRoute.params.id; + + const { user } = await User.getFromRequest(req); + + if (!user) return errorResponse("Unauthorized", 401); + + let foundStatus: Status | null; + try { + foundStatus = await Status.findOne({ + where: { + id, + }, + relations: statusAndUserRelations, + }); + } catch (e) { + return errorResponse("Invalid ID", 404); + } + + if (!foundStatus) return errorResponse("Record not found", 404); + + // Check if user is authorized to view this status (if it's private) + if (!foundStatus.isViewableByUser(user)) { + return errorResponse("Record not found", 404); + } + + // Check if user has already favourited this status + const existingLike = await Like.findOne({ + where: { + liked: { + id: foundStatus.id, + }, + liker: { + id: user.id, + }, + }, + relations: [ + ...userRelations.map(r => `liker.${r}`), + ...statusAndUserRelations.map(r => `liked.${r}`), + ], + }); + + if (!existingLike) { + const like = new Like(); + like.liker = user; + like.liked = foundStatus; + await like.save(); + } + + return jsonResponse(await foundStatus.toAPI()); +}; diff --git a/server/api/api/v1/statuses/[id]/favourited_by.ts b/server/api/api/v1/statuses/[id]/favourited_by.ts new file mode 100644 index 00000000..b6dd8187 --- /dev/null +++ b/server/api/api/v1/statuses/[id]/favourited_by.ts @@ -0,0 +1,137 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +import { applyConfig } from "@api"; +import { parseRequest } from "@request"; +import { errorResponse, jsonResponse } from "@response"; +import { MatchedRoute } from "bun"; +import { FindManyOptions } from "typeorm"; +import { Like } from "~database/entities/Like"; +import { Status, statusAndUserRelations } from "~database/entities/Status"; +import { User, userRelations } from "~database/entities/User"; +import { APIRouteMeta } from "~types/api"; + +export const meta: APIRouteMeta = applyConfig({ + allowedMethods: ["GET"], + ratelimits: { + max: 100, + duration: 60, + }, + route: "/api/v1/statuses/:id/favourited_by", + auth: { + required: true, + }, +}); + +/** + * Fetch users who favourited the post + */ +export default async ( + req: Request, + matchedRoute: MatchedRoute +): Promise => { + const id = matchedRoute.params.id; + + const { user } = await User.getFromRequest(req); + + let foundStatus: Status | null; + try { + foundStatus = await Status.findOne({ + where: { + id, + }, + relations: statusAndUserRelations, + }); + } catch (e) { + return errorResponse("Invalid ID", 404); + } + + if (!foundStatus) return errorResponse("Record not found", 404); + + // Check if user is authorized to view this status (if it's private) + if (!foundStatus.isViewableByUser(user)) { + return errorResponse("Record not found", 404); + } + + const { + max_id = null, + since_id = null, + limit = 40, + } = await parseRequest<{ + max_id?: string; + since_id?: string; + limit?: number; + }>(req); + + // Check for limit limits + if (limit > 80) return errorResponse("Invalid limit (maximum is 80)", 400); + if (limit < 1) return errorResponse("Invalid limit", 400); + + // Get list of boosts for this status + let query: FindManyOptions = { + where: { + liked: { + id, + }, + }, + relations: userRelations.map(r => `liker.${r}`), + take: limit, + order: { + id: "DESC", + }, + }; + + if (max_id) { + const maxLike = await Like.findOneBy({ id: max_id }); + if (maxLike) { + query = { + ...query, + where: { + ...query.where, + created_at: { + ...(query.where as any)?.created_at, + $lt: maxLike.created_at, + }, + }, + }; + } + } + + if (since_id) { + const sinceLike = await Like.findOneBy({ id: since_id }); + if (sinceLike) { + query = { + ...query, + where: { + ...query.where, + created_at: { + ...(query.where as any)?.created_at, + $gt: sinceLike.created_at, + }, + }, + }; + } + } + + const objects = await Like.find(query); + + // Constuct HTTP Link header (next and prev) + const linkHeader = []; + if (objects.length > 0) { + const urlWithoutQuery = req.url.split("?")[0]; + linkHeader.push( + `<${urlWithoutQuery}?max_id=${objects[0].id}&limit=${limit}>; rel="next"` + ); + linkHeader.push( + `<${urlWithoutQuery}?since_id=${ + objects[objects.length - 1].id + }&limit=${limit}>; rel="prev"` + ); + } + + return jsonResponse( + await Promise.all(objects.map(async like => await like.liker.toAPI())), + 200, + { + Link: linkHeader.join(", "), + } + ); +}; diff --git a/server/api/api/v1/statuses/[id]/reblogged_by.ts b/server/api/api/v1/statuses/[id]/reblogged_by.ts new file mode 100644 index 00000000..d3593ba6 --- /dev/null +++ b/server/api/api/v1/statuses/[id]/reblogged_by.ts @@ -0,0 +1,136 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +import { applyConfig } from "@api"; +import { parseRequest } from "@request"; +import { errorResponse, jsonResponse } from "@response"; +import { MatchedRoute } from "bun"; +import { FindManyOptions } from "typeorm"; +import { Status, statusAndUserRelations } from "~database/entities/Status"; +import { User } from "~database/entities/User"; +import { APIRouteMeta } from "~types/api"; + +export const meta: APIRouteMeta = applyConfig({ + allowedMethods: ["GET"], + ratelimits: { + max: 100, + duration: 60, + }, + route: "/api/v1/statuses/:id/reblogged_by", + auth: { + required: true, + }, +}); + +/** + * Fetch users who reblogged the post + */ +export default async ( + req: Request, + matchedRoute: MatchedRoute +): Promise => { + const id = matchedRoute.params.id; + + const { user } = await User.getFromRequest(req); + + let foundStatus: Status | null; + try { + foundStatus = await Status.findOne({ + where: { + id, + }, + relations: statusAndUserRelations, + }); + } catch (e) { + return errorResponse("Invalid ID", 404); + } + + if (!foundStatus) return errorResponse("Record not found", 404); + + // Check if user is authorized to view this status (if it's private) + if (!foundStatus.isViewableByUser(user)) { + return errorResponse("Record not found", 404); + } + + const { + max_id = null, + since_id = null, + limit = 40, + } = await parseRequest<{ + max_id?: string; + since_id?: string; + limit?: number; + }>(req); + + // Check for limit limits + if (limit > 80) return errorResponse("Invalid limit (maximum is 80)", 400); + if (limit < 1) return errorResponse("Invalid limit", 400); + + // Get list of boosts for this status + let query: FindManyOptions = { + where: { + reblog: { + id, + }, + }, + relations: statusAndUserRelations, + take: limit, + order: { + id: "DESC", + }, + }; + + if (max_id) { + const maxPost = await Status.findOneBy({ id: max_id }); + if (maxPost) { + query = { + ...query, + where: { + ...query.where, + created_at: { + ...(query.where as any)?.created_at, + $lt: maxPost.created_at, + }, + }, + }; + } + } + + if (since_id) { + const sincePost = await Status.findOneBy({ id: since_id }); + if (sincePost) { + query = { + ...query, + where: { + ...query.where, + created_at: { + ...(query.where as any)?.created_at, + $gt: sincePost.created_at, + }, + }, + }; + } + } + + const objects = await Status.find(query); + + // Constuct HTTP Link header (next and prev) + const linkHeader = []; + if (objects.length > 0) { + const urlWithoutQuery = req.url.split("?")[0]; + linkHeader.push( + `<${urlWithoutQuery}?max_id=${objects[0].id}&limit=${limit}>; rel="next"` + ); + linkHeader.push( + `<${urlWithoutQuery}?since_id=${ + objects[objects.length - 1].id + }&limit=${limit}>; rel="prev"` + ); + } + + return jsonResponse( + await Promise.all(objects.map(async object => await object.toAPI())), + 200, + { + Link: linkHeader.join(", "), + } + ); +}; diff --git a/server/api/api/v1/statuses/[id]/unfavourite.ts b/server/api/api/v1/statuses/[id]/unfavourite.ts new file mode 100644 index 00000000..52fdd85d --- /dev/null +++ b/server/api/api/v1/statuses/[id]/unfavourite.ts @@ -0,0 +1,64 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +import { applyConfig } from "@api"; +import { errorResponse, jsonResponse } from "@response"; +import { MatchedRoute } from "bun"; +import { Like } from "~database/entities/Like"; +import { Status, statusAndUserRelations } from "~database/entities/Status"; +import { User } from "~database/entities/User"; +import { APIRouteMeta } from "~types/api"; + +export const meta: APIRouteMeta = applyConfig({ + allowedMethods: ["POST"], + ratelimits: { + max: 100, + duration: 60, + }, + route: "/api/v1/statuses/:id/unfavourite", + auth: { + required: true, + }, +}); + +/** + * Unfavourite a post + */ +export default async ( + req: Request, + matchedRoute: MatchedRoute +): Promise => { + const id = matchedRoute.params.id; + + const { user } = await User.getFromRequest(req); + + if (!user) return errorResponse("Unauthorized", 401); + + let foundStatus: Status | null; + try { + foundStatus = await Status.findOne({ + where: { + id, + }, + relations: statusAndUserRelations, + }); + } catch (e) { + return errorResponse("Invalid ID", 404); + } + + if (!foundStatus) return errorResponse("Record not found", 404); + + // Check if user is authorized to view this status (if it's private) + if (!foundStatus.isViewableByUser(user)) { + return errorResponse("Record not found", 404); + } + + await Like.delete({ + liked: { + id: foundStatus.id, + }, + liker: { + id: user.id, + }, + }); + + return jsonResponse(await foundStatus.toAPI()); +}; diff --git a/tests/api.test.ts b/tests/api.test.ts index ed0d4b01..0cc98df1 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -8,20 +8,14 @@ import { Emoji } from "~database/entities/Emoji"; import { RawActivity } from "~database/entities/RawActivity"; import { Token, TokenType } from "~database/entities/Token"; import { User } from "~database/entities/User"; -import { APIAccount } from "~types/entities/account"; -import { APIContext } from "~types/entities/context"; import { APIEmoji } from "~types/entities/emoji"; import { APIInstance } from "~types/entities/instance"; -import { APIRelationship } from "~types/entities/relationship"; -import { APIStatus } from "~types/entities/status"; const config = getConfig(); let token: Token; let user: User; let user2: User; -let status: APIStatus | null = null; -let status2: APIStatus | null = null; describe("API Tests", () => { beforeAll(async () => { @@ -90,717 +84,6 @@ describe("API Tests", () => { await AppDataSource.destroy(); }); - describe("POST /api/v1/accounts/:id", () => { - test("should return a 404 error when trying to fetch a non-existent user", async () => { - const response = await fetch( - `${config.http.base_url}/api/v1/accounts/999999`, - { - method: "GET", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - } - ); - - expect(response.status).toBe(404); - expect(response.headers.get("content-type")).toBe( - "application/json" - ); - }); - }); - - describe("POST /api/v1/statuses", () => { - test("should create a new status and return an APIStatus object", async () => { - const response = await fetch( - `${config.http.base_url}/api/v1/statuses`, - { - method: "POST", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - status: "Hello, world!", - visibility: "public", - }), - } - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json" - ); - - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion - status = (await response.json()) as APIStatus; - expect(status.content).toBe("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).toEqual([]); - 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 fetch( - `${config.http.base_url}/api/v1/statuses`, - { - method: "POST", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - status: "This is a reply!", - visibility: "public", - in_reply_to_id: status?.id, - }), - } - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json" - ); - - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion - status2 = (await response.json()) as APIStatus; - expect(status2.content).toBe("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); - expect(status2.in_reply_to_account_id).toEqual(user.id); - }); - }); - - describe("GET /api/v1/timelines/public", () => { - test("should return an array of APIStatus objects that includes the created status", async () => { - const response = await fetch( - `${config.http.base_url}/api/v1/timelines/public`, - { - method: "GET", - headers: { - Authorization: `Bearer ${token.access_token}`, - }, - } - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json" - ); - - const statuses = (await response.json()) as APIStatus[]; - - expect(statuses.some(s => s.id === status?.id)).toBe(true); - }); - }); - - describe("PATCH /api/v1/accounts/update_credentials", () => { - test("should update the authenticated user's display name", async () => { - const response = await fetch( - `${config.http.base_url}/api/v1/accounts/update_credentials`, - { - method: "PATCH", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - display_name: "New Display Name", - }), - } - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json" - ); - - const user = (await response.json()) as APIAccount; - - 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 fetch( - `${config.http.base_url}/api/v1/accounts/verify_credentials`, - { - method: "GET", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - } - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json" - ); - - const account = (await response.json()) as APIAccount; - - expect(account.username).toBe(user.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(2); - expect(account.note).toBe(""); - expect(account.url).toBe( - `${config.http.base_url}/users/${user.username}` - ); - 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).toBeNull(); - expect(account.source?.note).toBe(""); - expect(account.source?.sensitive).toBe(false); - }); - }); - - describe("GET /api/v1/accounts/:id/statuses", () => { - test("should return the statuses of the specified user", async () => { - const response = await fetch( - `${config.http.base_url}/api/v1/accounts/${user.id}/statuses`, - { - method: "GET", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - } - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json" - ); - - const statuses = (await response.json()) as APIStatus[]; - - expect(statuses.length).toBe(2); - - const status1 = statuses[1]; - - // Basic validation - expect(status1.content).toBe("Hello, world!"); - expect(status1.visibility).toBe("public"); - expect(status1.account.id).toBe(user.id); - }); - }); - - describe("POST /api/v1/accounts/:id/follow", () => { - test("should follow the specified user and return an APIRelationship object", async () => { - const response = await fetch( - `${config.http.base_url}/api/v1/accounts/${user2.id}/follow`, - { - method: "POST", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({}), - } - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json" - ); - - const account = (await response.json()) as APIRelationship; - - expect(account.id).toBe(user2.id); - expect(account.following).toBe(true); - }); - }); - - describe("POST /api/v1/accounts/:id/unfollow", () => { - test("should unfollow the specified user and return an APIRelationship object", async () => { - const response = await fetch( - `${config.http.base_url}/api/v1/accounts/${user2.id}/unfollow`, - { - method: "POST", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({}), - } - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json" - ); - - const account = (await response.json()) as APIRelationship; - - expect(account.id).toBe(user2.id); - expect(account.following).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 fetch( - `${config.http.base_url}/api/v1/accounts/${user2.id}/remove_from_followers`, - { - method: "POST", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({}), - } - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json" - ); - - const account = (await response.json()) as APIRelationship; - - 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 fetch( - `${config.http.base_url}/api/v1/accounts/${user2.id}/block`, - { - method: "POST", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({}), - } - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json" - ); - - const account = (await response.json()) as APIRelationship; - - expect(account.id).toBe(user2.id); - expect(account.blocking).toBe(true); - }); - }); - - describe("POST /api/v1/accounts/:id/unblock", () => { - test("should unblock the specified user and return an APIRelationship object", async () => { - const response = await fetch( - `${config.http.base_url}/api/v1/accounts/${user2.id}/unblock`, - { - method: "POST", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({}), - } - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json" - ); - - const account = (await response.json()) as APIRelationship; - - expect(account.id).toBe(user2.id); - expect(account.blocking).toBe(false); - }); - }); - - describe("POST /api/v1/accounts/:id/mute with notifications parameter", () => { - test("should mute the specified user and return an APIRelationship object with notifications set to false", async () => { - const response = await fetch( - `${config.http.base_url}/api/v1/accounts/${user2.id}/mute`, - { - method: "POST", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ notifications: true }), - } - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json" - ); - - const account = (await response.json()) as APIRelationship; - - expect(account.id).toBe(user2.id); - expect(account.muting).toBe(true); - expect(account.muting_notifications).toBe(true); - }); - - test("should mute the specified user and return an APIRelationship object with notifications set to true", async () => { - const response = await fetch( - `${config.http.base_url}/api/v1/accounts/${user2.id}/mute`, - { - method: "POST", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ notifications: false }), - } - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json" - ); - - const account = (await response.json()) as APIRelationship; - - expect(account.id).toBe(user2.id); - expect(account.muting).toBe(true); - expect(account.muting_notifications).toBe(true); - }); - }); - - describe("POST /api/v1/accounts/:id/unmute", () => { - test("should unmute the specified user and return an APIRelationship object", async () => { - const response = await fetch( - `${config.http.base_url}/api/v1/accounts/${user2.id}/unmute`, - { - method: "POST", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({}), - } - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json" - ); - - const account = (await response.json()) as APIRelationship; - - expect(account.id).toBe(user2.id); - expect(account.muting).toBe(false); - }); - }); - - describe("POST /api/v1/accounts/:id/pin", () => { - test("should pin the specified user and return an APIRelationship object", async () => { - const response = await fetch( - `${config.http.base_url}/api/v1/accounts/${user2.id}/pin`, - { - method: "POST", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({}), - } - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json" - ); - - const account = (await response.json()) as APIRelationship; - - 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 fetch( - `${config.http.base_url}/api/v1/accounts/${user2.id}/unpin`, - { - method: "POST", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({}), - } - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json" - ); - - const account = (await response.json()) as APIRelationship; - - 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 fetch( - `${config.http.base_url}/api/v1/accounts/${user2.id}/note`, - { - method: "POST", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ comment: "This is a new note" }), - } - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json" - ); - - const account = (await response.json()) as APIAccount; - - 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 fetch( - `${config.http.base_url}/api/v1/accounts/relationships?id[]=${user2.id}`, - { - method: "GET", - headers: { - Authorization: `Bearer ${token.access_token}`, - }, - } - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json" - ); - - const relationships = (await response.json()) as APIRelationship[]; - - 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("GET /api/v1/accounts/familiar_followers", () => { - test("should return an array of objects with id and accounts properties, where id is a string and accounts is an array of APIAccount objects", async () => { - const response = await fetch( - `${config.http.base_url}/api/v1/accounts/familiar_followers?id[]=${user2.id}`, - { - method: "GET", - headers: { - Authorization: `Bearer ${token.access_token}`, - }, - } - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json" - ); - - const familiarFollowers = (await response.json()) as { - id: string; - accounts: APIAccount[]; - }[]; - - expect(Array.isArray(familiarFollowers)).toBe(true); - expect(familiarFollowers.length).toBeGreaterThan(0); - expect(typeof familiarFollowers[0].id).toBe("string"); - expect(Array.isArray(familiarFollowers[0].accounts)).toBe(true); - expect(familiarFollowers[0].accounts.length).toBeGreaterThanOrEqual( - 0 - ); - - if (familiarFollowers[0].accounts.length === 0) return; - expect(familiarFollowers[0].accounts[0].id).toBeDefined(); - expect(familiarFollowers[0].accounts[0].username).toBeDefined(); - expect(familiarFollowers[0].accounts[0].acct).toBeDefined(); - expect(familiarFollowers[0].accounts[0].display_name).toBeDefined(); - expect(familiarFollowers[0].accounts[0].locked).toBeDefined(); - expect(familiarFollowers[0].accounts[0].bot).toBeDefined(); - expect(familiarFollowers[0].accounts[0].discoverable).toBeDefined(); - expect(familiarFollowers[0].accounts[0].group).toBeDefined(); - expect(familiarFollowers[0].accounts[0].created_at).toBeDefined(); - expect(familiarFollowers[0].accounts[0].note).toBeDefined(); - expect(familiarFollowers[0].accounts[0].url).toBeDefined(); - expect(familiarFollowers[0].accounts[0].avatar).toBeDefined(); - expect( - familiarFollowers[0].accounts[0].avatar_static - ).toBeDefined(); - expect(familiarFollowers[0].accounts[0].header).toBeDefined(); - expect( - familiarFollowers[0].accounts[0].header_static - ).toBeDefined(); - expect( - familiarFollowers[0].accounts[0].followers_count - ).toBeDefined(); - expect( - familiarFollowers[0].accounts[0].following_count - ).toBeDefined(); - expect( - familiarFollowers[0].accounts[0].statuses_count - ).toBeDefined(); - expect(familiarFollowers[0].accounts[0].emojis).toBeDefined(); - expect(familiarFollowers[0].accounts[0].fields).toBeDefined(); - }); - }); - - describe("GET /api/v1/statuses/:id", () => { - test("should return the specified status object", async () => { - const response = await fetch( - `${config.http.base_url}/api/v1/statuses/${status?.id}`, - { - method: "GET", - headers: { - Authorization: `Bearer ${token.access_token}`, - }, - } - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json" - ); - - const statusJson = (await response.json()) as APIStatus; - - 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).toBeDefined(); - 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("GET /api/v1/statuses/:id/context", () => { - test("should return the context of the specified status", async () => { - const response = await fetch( - `${config.http.base_url}/api/v1/statuses/${status?.id}/context`, - { - method: "GET", - headers: { - Authorization: `Bearer ${token.access_token}`, - "Content-Type": "application/json", - }, - } - ); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe( - "application/json" - ); - - const context = (await response.json()) as APIContext; - - 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("DELETE /api/v1/statuses/:id", () => { - test("should delete the specified status object", async () => { - const response = await fetch( - `${config.http.base_url}/api/v1/statuses/${status?.id}`, - { - method: "DELETE", - headers: { - Authorization: `Bearer ${token.access_token}`, - }, - } - ); - - expect(response.status).toBe(200); - }); - }); - describe("GET /api/v1/instance", () => { test("should return an APIInstance object", async () => { const response = await fetch( diff --git a/tests/api/accounts.test.ts b/tests/api/accounts.test.ts new file mode 100644 index 00000000..47dfa1e3 --- /dev/null +++ b/tests/api/accounts.test.ts @@ -0,0 +1,585 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { getConfig } from "@config"; +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { AppDataSource } from "~database/datasource"; +import { Application } from "~database/entities/Application"; +import { RawActivity } from "~database/entities/RawActivity"; +import { Token, TokenType } from "~database/entities/Token"; +import { User } from "~database/entities/User"; +import { APIAccount } from "~types/entities/account"; +import { APIRelationship } from "~types/entities/relationship"; +import { APIStatus } from "~types/entities/status"; + +const config = getConfig(); + +let token: Token; +let user: User; +let user2: User; + +describe("API Tests", () => { + beforeAll(async () => { + if (!AppDataSource.isInitialized) await AppDataSource.initialize(); + + // Initialize test user + user = await User.createNewLocal({ + email: "test@test.com", + username: "test", + password: "test", + display_name: "", + }); + + // Initialize second test user + user2 = await User.createNewLocal({ + email: "test2@test.com", + username: "test2", + password: "test2", + display_name: "", + }); + + const app = new Application(); + + app.name = "Test Application"; + app.website = "https://example.com"; + app.client_id = "test"; + app.redirect_uris = "https://example.com"; + app.scopes = "read write"; + app.secret = "test"; + app.vapid_key = null; + + await app.save(); + + // Initialize test token + token = new Token(); + + token.access_token = "test"; + token.application = app; + token.code = "test"; + token.scope = "read write"; + token.token_type = TokenType.BEARER; + token.user = user; + + token = await token.save(); + }); + + afterAll(async () => { + const activities = await RawActivity.createQueryBuilder("activity") + .where("activity.data->>'actor' = :actor", { + actor: `${config.http.base_url}/users/test`, + }) + .leftJoinAndSelect("activity.objects", "objects") + .getMany(); + + // Delete all created objects and activities as part of testing + for (const activity of activities) { + for (const object of activity.objects) { + await object.remove(); + } + await activity.remove(); + } + + await user.remove(); + await user2.remove(); + + await AppDataSource.destroy(); + }); + + describe("POST /api/v1/accounts/:id", () => { + test("should return a 404 error when trying to fetch a non-existent user", async () => { + const response = await fetch( + `${config.http.base_url}/api/v1/accounts/999999`, + { + method: "GET", + headers: { + Authorization: `Bearer ${token.access_token}`, + "Content-Type": "application/json", + }, + } + ); + + expect(response.status).toBe(404); + expect(response.headers.get("content-type")).toBe( + "application/json" + ); + }); + }); + + describe("PATCH /api/v1/accounts/update_credentials", () => { + test("should update the authenticated user's display name", async () => { + const response = await fetch( + `${config.http.base_url}/api/v1/accounts/update_credentials`, + { + method: "PATCH", + headers: { + Authorization: `Bearer ${token.access_token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + display_name: "New Display Name", + }), + } + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json" + ); + + const user = (await response.json()) as APIAccount; + + 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 fetch( + `${config.http.base_url}/api/v1/accounts/verify_credentials`, + { + method: "GET", + headers: { + Authorization: `Bearer ${token.access_token}`, + "Content-Type": "application/json", + }, + } + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json" + ); + + const account = (await response.json()) as APIAccount; + + expect(account.username).toBe(user.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( + `${config.http.base_url}/users/${user.username}` + ); + 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).toBeNull(); + expect(account.source?.note).toBe(""); + expect(account.source?.sensitive).toBe(false); + }); + }); + + describe("GET /api/v1/accounts/:id/statuses", () => { + test("should return the statuses of the specified user", async () => { + const response = await fetch( + `${config.http.base_url}/api/v1/accounts/${user.id}/statuses`, + { + method: "GET", + headers: { + Authorization: `Bearer ${token.access_token}`, + "Content-Type": "application/json", + }, + } + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json" + ); + + const statuses = (await response.json()) as APIStatus[]; + + expect(statuses.length).toBe(0); + }); + }); + + describe("POST /api/v1/accounts/:id/follow", () => { + test("should follow the specified user and return an APIRelationship object", async () => { + const response = await fetch( + `${config.http.base_url}/api/v1/accounts/${user2.id}/follow`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token.access_token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({}), + } + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json" + ); + + const account = (await response.json()) as APIRelationship; + + expect(account.id).toBe(user2.id); + expect(account.following).toBe(true); + }); + }); + + describe("POST /api/v1/accounts/:id/unfollow", () => { + test("should unfollow the specified user and return an APIRelationship object", async () => { + const response = await fetch( + `${config.http.base_url}/api/v1/accounts/${user2.id}/unfollow`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token.access_token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({}), + } + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json" + ); + + const account = (await response.json()) as APIRelationship; + + expect(account.id).toBe(user2.id); + expect(account.following).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 fetch( + `${config.http.base_url}/api/v1/accounts/${user2.id}/remove_from_followers`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token.access_token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({}), + } + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json" + ); + + const account = (await response.json()) as APIRelationship; + + 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 fetch( + `${config.http.base_url}/api/v1/accounts/${user2.id}/block`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token.access_token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({}), + } + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json" + ); + + const account = (await response.json()) as APIRelationship; + + expect(account.id).toBe(user2.id); + expect(account.blocking).toBe(true); + }); + }); + + describe("POST /api/v1/accounts/:id/unblock", () => { + test("should unblock the specified user and return an APIRelationship object", async () => { + const response = await fetch( + `${config.http.base_url}/api/v1/accounts/${user2.id}/unblock`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token.access_token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({}), + } + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json" + ); + + const account = (await response.json()) as APIRelationship; + + expect(account.id).toBe(user2.id); + expect(account.blocking).toBe(false); + }); + }); + + describe("POST /api/v1/accounts/:id/mute with notifications parameter", () => { + test("should mute the specified user and return an APIRelationship object with notifications set to false", async () => { + const response = await fetch( + `${config.http.base_url}/api/v1/accounts/${user2.id}/mute`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token.access_token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ notifications: true }), + } + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json" + ); + + const account = (await response.json()) as APIRelationship; + + expect(account.id).toBe(user2.id); + expect(account.muting).toBe(true); + expect(account.muting_notifications).toBe(true); + }); + + test("should mute the specified user and return an APIRelationship object with notifications set to true", async () => { + const response = await fetch( + `${config.http.base_url}/api/v1/accounts/${user2.id}/mute`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token.access_token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ notifications: false }), + } + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json" + ); + + const account = (await response.json()) as APIRelationship; + + expect(account.id).toBe(user2.id); + expect(account.muting).toBe(true); + expect(account.muting_notifications).toBe(true); + }); + }); + + describe("POST /api/v1/accounts/:id/unmute", () => { + test("should unmute the specified user and return an APIRelationship object", async () => { + const response = await fetch( + `${config.http.base_url}/api/v1/accounts/${user2.id}/unmute`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token.access_token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({}), + } + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json" + ); + + const account = (await response.json()) as APIRelationship; + + expect(account.id).toBe(user2.id); + expect(account.muting).toBe(false); + }); + }); + + describe("POST /api/v1/accounts/:id/pin", () => { + test("should pin the specified user and return an APIRelationship object", async () => { + const response = await fetch( + `${config.http.base_url}/api/v1/accounts/${user2.id}/pin`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token.access_token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({}), + } + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json" + ); + + const account = (await response.json()) as APIRelationship; + + 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 fetch( + `${config.http.base_url}/api/v1/accounts/${user2.id}/unpin`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token.access_token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({}), + } + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json" + ); + + const account = (await response.json()) as APIRelationship; + + 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 fetch( + `${config.http.base_url}/api/v1/accounts/${user2.id}/note`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token.access_token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ comment: "This is a new note" }), + } + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json" + ); + + const account = (await response.json()) as APIAccount; + + 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 fetch( + `${config.http.base_url}/api/v1/accounts/relationships?id[]=${user2.id}`, + { + method: "GET", + headers: { + Authorization: `Bearer ${token.access_token}`, + }, + } + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json" + ); + + const relationships = (await response.json()) as APIRelationship[]; + + 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("GET /api/v1/accounts/familiar_followers", () => { + test("should return an array of objects with id and accounts properties, where id is a string and accounts is an array of APIAccount objects", async () => { + const response = await fetch( + `${config.http.base_url}/api/v1/accounts/familiar_followers?id[]=${user2.id}`, + { + method: "GET", + headers: { + Authorization: `Bearer ${token.access_token}`, + }, + } + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json" + ); + + const familiarFollowers = (await response.json()) as { + id: string; + accounts: APIAccount[]; + }[]; + + expect(Array.isArray(familiarFollowers)).toBe(true); + expect(familiarFollowers.length).toBeGreaterThan(0); + expect(typeof familiarFollowers[0].id).toBe("string"); + expect(Array.isArray(familiarFollowers[0].accounts)).toBe(true); + expect(familiarFollowers[0].accounts.length).toBeGreaterThanOrEqual( + 0 + ); + + if (familiarFollowers[0].accounts.length === 0) return; + expect(familiarFollowers[0].accounts[0].id).toBeDefined(); + expect(familiarFollowers[0].accounts[0].username).toBeDefined(); + expect(familiarFollowers[0].accounts[0].acct).toBeDefined(); + expect(familiarFollowers[0].accounts[0].display_name).toBeDefined(); + expect(familiarFollowers[0].accounts[0].locked).toBeDefined(); + expect(familiarFollowers[0].accounts[0].bot).toBeDefined(); + expect(familiarFollowers[0].accounts[0].discoverable).toBeDefined(); + expect(familiarFollowers[0].accounts[0].group).toBeDefined(); + expect(familiarFollowers[0].accounts[0].created_at).toBeDefined(); + expect(familiarFollowers[0].accounts[0].note).toBeDefined(); + expect(familiarFollowers[0].accounts[0].url).toBeDefined(); + expect(familiarFollowers[0].accounts[0].avatar).toBeDefined(); + expect( + familiarFollowers[0].accounts[0].avatar_static + ).toBeDefined(); + expect(familiarFollowers[0].accounts[0].header).toBeDefined(); + expect( + familiarFollowers[0].accounts[0].header_static + ).toBeDefined(); + expect( + familiarFollowers[0].accounts[0].followers_count + ).toBeDefined(); + expect( + familiarFollowers[0].accounts[0].following_count + ).toBeDefined(); + expect( + familiarFollowers[0].accounts[0].statuses_count + ).toBeDefined(); + expect(familiarFollowers[0].accounts[0].emojis).toBeDefined(); + expect(familiarFollowers[0].accounts[0].fields).toBeDefined(); + }); + }); +}); diff --git a/tests/api/statuses.test.ts b/tests/api/statuses.test.ts new file mode 100644 index 00000000..c4db675b --- /dev/null +++ b/tests/api/statuses.test.ts @@ -0,0 +1,388 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { getConfig } from "@config"; +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { AppDataSource } from "~database/datasource"; +import { Application } from "~database/entities/Application"; +import { RawActivity } from "~database/entities/RawActivity"; +import { Token, TokenType } from "~database/entities/Token"; +import { User } from "~database/entities/User"; +import { APIContext } from "~types/entities/context"; +import { APIStatus } from "~types/entities/status"; + +const config = getConfig(); + +let token: Token; +let user: User; +let user2: User; +let status: APIStatus | null = null; +let status2: APIStatus | null = null; + +describe("API Tests", () => { + beforeAll(async () => { + if (!AppDataSource.isInitialized) await AppDataSource.initialize(); + + // Initialize test user + user = await User.createNewLocal({ + email: "test@test.com", + username: "test", + password: "test", + display_name: "", + }); + + // Initialize second test user + user2 = await User.createNewLocal({ + email: "test2@test.com", + username: "test2", + password: "test2", + display_name: "", + }); + + const app = new Application(); + + app.name = "Test Application"; + app.website = "https://example.com"; + app.client_id = "test"; + app.redirect_uris = "https://example.com"; + app.scopes = "read write"; + app.secret = "test"; + app.vapid_key = null; + + await app.save(); + + // Initialize test token + token = new Token(); + + token.access_token = "test"; + token.application = app; + token.code = "test"; + token.scope = "read write"; + token.token_type = TokenType.BEARER; + token.user = user; + + token = await token.save(); + }); + + afterAll(async () => { + const activities = await RawActivity.createQueryBuilder("activity") + .where("activity.data->>'actor' = :actor", { + actor: `${config.http.base_url}/users/test`, + }) + .leftJoinAndSelect("activity.objects", "objects") + .getMany(); + + // Delete all created objects and activities as part of testing + for (const activity of activities) { + for (const object of activity.objects) { + await object.remove(); + } + await activity.remove(); + } + + await user.remove(); + await user2.remove(); + + await AppDataSource.destroy(); + }); + + describe("POST /api/v1/statuses", () => { + test("should create a new status and return an APIStatus object", async () => { + const response = await fetch( + `${config.http.base_url}/api/v1/statuses`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token.access_token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + status: "Hello, world!", + visibility: "public", + }), + } + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json" + ); + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + status = (await response.json()) as APIStatus; + expect(status.content).toBe("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).toEqual([]); + 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 fetch( + `${config.http.base_url}/api/v1/statuses`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token.access_token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + status: "This is a reply!", + visibility: "public", + in_reply_to_id: status?.id, + }), + } + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json" + ); + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + status2 = (await response.json()) as APIStatus; + expect(status2.content).toBe("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); + 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 fetch( + `${config.http.base_url}/api/v1/statuses/${status?.id}`, + { + method: "GET", + headers: { + Authorization: `Bearer ${token.access_token}`, + }, + } + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json" + ); + + const statusJson = (await response.json()) as APIStatus; + + 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).toBeDefined(); + 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("GET /api/v1/statuses/:id/context", () => { + test("should return the context of the specified status", async () => { + const response = await fetch( + `${config.http.base_url}/api/v1/statuses/${status?.id}/context`, + { + method: "GET", + headers: { + Authorization: `Bearer ${token.access_token}`, + "Content-Type": "application/json", + }, + } + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json" + ); + + const context = (await response.json()) as APIContext; + + 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/timelines/public", () => { + test("should return an array of APIStatus objects that includes the created status", async () => { + const response = await fetch( + `${config.http.base_url}/api/v1/timelines/public`, + { + method: "GET", + headers: { + Authorization: `Bearer ${token.access_token}`, + }, + } + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json" + ); + + const statuses = (await response.json()) as APIStatus[]; + + expect(statuses.some(s => s.id === status?.id)).toBe(true); + }); + }); + + describe("GET /api/v1/accounts/:id/statuses", () => { + test("should return the statuses of the specified user", async () => { + const response = await fetch( + `${config.http.base_url}/api/v1/accounts/${user.id}/statuses`, + { + method: "GET", + headers: { + Authorization: `Bearer ${token.access_token}`, + "Content-Type": "application/json", + }, + } + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json" + ); + + const statuses = (await response.json()) as APIStatus[]; + + expect(statuses.length).toBe(2); + + const status1 = statuses[1]; + + // Basic validation + expect(status1.content).toBe("Hello, world!"); + 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 fetch( + `${config.http.base_url}/api/v1/statuses/${status?.id}/favourite`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token.access_token}`, + }, + } + ); + + expect(response.status).toBe(200); + }); + }); + + describe("GET /api/v1/statuses/:id/favourited_by", () => { + test("should return an array of User objects who favourited the specified status", async () => { + const response = await fetch( + `${config.http.base_url}/api/v1/statuses/${status?.id}/favourited_by`, + { + method: "GET", + headers: { + Authorization: `Bearer ${token.access_token}`, + }, + } + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json" + ); + + const users = (await response.json()) as User[]; + + expect(users.length).toBe(1); + expect(users[0].id).toBe(user.id); + }); + }); + + describe("POST /api/v1/statuses/:id/unfavourite", () => { + test("should unfavourite the specified status object", async () => { + // Unfavourite the status + const response = await fetch( + `${config.http.base_url}/api/v1/statuses/${status?.id}/unfavourite`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token.access_token}`, + }, + } + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/json" + ); + + const updatedStatus = (await response.json()) as APIStatus; + + 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 fetch( + `${config.http.base_url}/api/v1/statuses/${status?.id}`, + { + method: "DELETE", + headers: { + Authorization: `Bearer ${token.access_token}`, + }, + } + ); + + expect(response.status).toBe(200); + }); + }); +});