From 65ff53e90cb85d717410707fcbfc3049e942e33f Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Sun, 8 Oct 2023 10:20:42 -1000 Subject: [PATCH] Fix existing bugs in tests, refactor users --- benchmarks/posting.ts | 92 ++++++++++++++++++++ database/entities/Emoji.ts | 8 +- database/entities/RawActivity.ts | 4 +- database/entities/RawActor.ts | 53 ++---------- database/entities/RawObject.ts | 20 +++-- database/entities/Status.ts | 49 +++++------ database/entities/User.ts | 106 ++++++++++++++++++++--- server/api/api/v1/accounts/[id]/index.ts | 9 +- server/api/api/v1/accounts/index.ts | 2 +- server/api/api/v1/custom_emojis/index.ts | 17 ++++ server/api/api/v1/statuses/[id]/index.ts | 2 +- server/api/api/v1/statuses/index.ts | 9 +- server/api/api/v1/timelines/home.ts | 68 +++++++++++++++ server/api/auth/login/index.ts | 2 +- tests/actor.test.ts | 2 +- tests/api.test.ts | 46 +++++++++- tests/inbox.test.ts | 2 +- tests/oauth.test.ts | 9 +- utils/config.ts | 2 + 19 files changed, 385 insertions(+), 117 deletions(-) create mode 100644 benchmarks/posting.ts create mode 100644 server/api/api/v1/custom_emojis/index.ts create mode 100644 server/api/api/v1/timelines/home.ts diff --git a/benchmarks/posting.ts b/benchmarks/posting.ts new file mode 100644 index 00000000..9e80192a --- /dev/null +++ b/benchmarks/posting.ts @@ -0,0 +1,92 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { getConfig } from "@config"; +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"; + +const config = getConfig(); + +let token: Token; +if (!AppDataSource.isInitialized) await AppDataSource.initialize(); + +// Initialize test user +const user = await User.createNewLocal({ + email: "test@test.com", + username: "test", + password: "test", + 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(); + +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", + }), +}); + +const timeBefore = performance.now(); + +// Repeat 100 times +for (let i = 0; i < 100; i++) { + await fetch(`${config.http.base_url}/api/v1/timelines/public`, { + method: "GET", + headers: { + Authorization: `Bearer ${token.access_token}`, + }, + }); +} + +const timeAfter = performance.now(); + +const activities = await RawActivity.createQueryBuilder("activity") + .where("activity.data->>'actor' = :actor", { + actor: `${config.http.base_url}/@test`, + }) + .leftJoinAndSelect("activity.objects", "objects") + .getMany(); + +// Delete all created objects and activities as part of testing +await Promise.all( + activities.map(async activity => { + await Promise.all( + activity.objects.map(async object => await object.remove()) + ); + await activity.remove(); + }) +); + +await user.remove(); + +console.log(`Time taken: ${timeAfter - timeBefore}ms`); diff --git a/database/entities/Emoji.ts b/database/entities/Emoji.ts index 83833ec5..af8c5a50 100644 --- a/database/entities/Emoji.ts +++ b/database/entities/Emoji.ts @@ -34,7 +34,7 @@ export class Emoji extends BaseEntity { @ManyToOne(() => Instance, { nullable: true, }) - instance!: Instance; + instance!: Instance | null; /** * The URL for the emoji. @@ -56,9 +56,9 @@ export class Emoji extends BaseEntity { async toAPI(): Promise { return { shortcode: this.shortcode, - static_url: "", - url: "", - visible_in_picker: false, + static_url: this.url, // TODO: Add static version + url: this.url, + visible_in_picker: this.visible_in_picker, category: undefined, }; } diff --git a/database/entities/RawActivity.ts b/database/entities/RawActivity.ts index 5ea4a2ab..eb75f050 100644 --- a/database/entities/RawActivity.ts +++ b/database/entities/RawActivity.ts @@ -2,6 +2,7 @@ import { BaseEntity, Column, Entity, + Index, JoinTable, ManyToMany, PrimaryGeneratedColumn, @@ -23,6 +24,8 @@ export class RawActivity extends BaseEntity { id!: string; @Column("jsonb") + // Index ID for faster lookups + @Index({ unique: true, where: "(data->>'id') IS NOT NULL" }) data!: APActivity; @ManyToMany(() => RawObject) @@ -239,7 +242,6 @@ export class RawActivity extends BaseEntity { const rawActor = new RawActor(); rawActor.data = actor; - rawActor.followers = []; const config = getConfig(); diff --git a/database/entities/RawActor.ts b/database/entities/RawActor.ts index e90b7290..a1a5b0be 100644 --- a/database/entities/RawActor.ts +++ b/database/entities/RawActor.ts @@ -3,18 +3,15 @@ import { BaseEntity, Column, Entity, - JoinTable, - ManyToMany, + Index, PrimaryGeneratedColumn, } from "typeorm"; -import { APActor, APImage, APOrderedCollectionPage } from "activitypub-types"; +import { APActor, APImage } from "activitypub-types"; import { getConfig, getHost } from "@config"; import { appendFile } from "fs/promises"; import { errorResponse } from "@response"; import { APIAccount } from "~types/entities/account"; import { RawActivity } from "./RawActivity"; -import { RawObject } from "./RawObject"; - /** * Represents a raw actor entity in the database. */ @@ -30,21 +27,9 @@ export class RawActor extends BaseEntity { * The ActivityPub actor data associated with the actor. */ @Column("jsonb") + @Index({ unique: true, where: "(data->>'id') IS NOT NULL" }) data!: APActor; - /** - * The list of follower IDs associated with the actor. - */ - @Column("jsonb", { default: [] }) - followers!: string[]; - - /** - * The list of featured objects associated with the actor. - */ - @ManyToMany(() => RawObject, { nullable: true }) - @JoinTable() - featured!: RawObject[]; - /** * Retrieves a RawActor entity by actor ID. * @param id The ID of the actor to retrieve. @@ -62,13 +47,13 @@ export class RawActor extends BaseEntity { * @returns The newly created RawActor entity, or an error response if the actor already exists or is filtered. */ static async addIfNotExists(data: APActor) { + // TODO: Also add corresponding user if (await RawActor.exists(data.id ?? "")) { return errorResponse("Actor already exists", 409); } const actor = new RawActor(); actor.data = data; - actor.followers = []; const config = getConfig(); @@ -105,33 +90,6 @@ export class RawActor extends BaseEntity { return new URL(this.data.id ?? "").host; } - /** - * Fetches the list of followers associated with the actor and updates the `followers` property. - */ - async fetchFollowers() { - let followers: APOrderedCollectionPage = await fetch( - `${this.data.followers?.toString() ?? ""}?page=1`, - { - headers: { Accept: "application/activity+json" }, - } - ); - - let followersList = followers.orderedItems ?? []; - - while (followers.type === "OrderedCollectionPage" && followers.next) { - followers = await fetch((followers.next as string).toString(), { - headers: { Accept: "application/activity+json" }, - }).then(res => res.json()); - - followersList = { - ...followersList, - ...(followers.orderedItems ?? []), - }; - } - - this.followers = followersList as string[]; - } - /** * Converts the RawActor entity to an API account object. * @param isOwnAccount Whether the account is the user's own account. @@ -169,7 +127,7 @@ export class RawActor extends BaseEntity { config.defaults.header, locked: false, created_at: new Date(published ?? 0).toISOString(), - followers_count: this.followers.length, + followers_count: 0, following_count: 0, statuses_count: statusCount, emojis: [], @@ -249,7 +207,6 @@ export class RawActor extends BaseEntity { * @returns Whether an actor with the specified ID exists in the database. */ static async exists(id: string) { - console.log(!!(await RawActor.getByActorId(id))); return !!(await RawActor.getByActorId(id)); } } diff --git a/database/entities/RawObject.ts b/database/entities/RawObject.ts index 74e5cf22..53aafa48 100644 --- a/database/entities/RawObject.ts +++ b/database/entities/RawObject.ts @@ -1,4 +1,10 @@ -import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from "typeorm"; +import { + BaseEntity, + Column, + Entity, + Index, + PrimaryGeneratedColumn, +} from "typeorm"; import { APImage, APObject, DateTime } from "activitypub-types"; import { getConfig } from "@config"; import { appendFile } from "fs/promises"; @@ -6,6 +12,7 @@ import { APIStatus } from "~types/entities/status"; import { RawActor } from "./RawActor"; import { APIAccount } from "~types/entities/account"; import { APIEmoji } from "~types/entities/emoji"; +import { User } from "./User"; /** * Represents a raw ActivityPub object in the database. @@ -24,6 +31,11 @@ export class RawObject extends BaseEntity { * The data associated with the object. */ @Column("jsonb") + // Index ID, attributedTo, to and published for faster lookups + @Index({ unique: true, where: "(data->>'id') IS NOT NULL" }) + @Index({ where: "(data->>'attributedTo') IS NOT NULL" }) + @Index({ where: "(data->>'to') IS NOT NULL" }) + @Index({ where: "(data->>'published') IS NOT NULL" }) data!: APObject; /** @@ -82,10 +94,8 @@ export class RawObject extends BaseEntity { return { account: (await ( - await RawActor.getByActorId( - this.data.attributedTo as string - ) - )?.toAPIAccount()) ?? (null as unknown as APIAccount), + await User.getByActorId(this.data.attributedTo as string) + )?.toAPI()) ?? (null as unknown as APIAccount), created_at: new Date(this.data.published as DateTime).toISOString(), id: this.id, in_reply_to_id: null, diff --git a/database/entities/Status.ts b/database/entities/Status.ts index d25f3d4b..391b890d 100644 --- a/database/entities/Status.ts +++ b/database/entities/Status.ts @@ -17,7 +17,6 @@ import { Application } from "./Application"; import { Emoji } from "./Emoji"; import { RawActivity } from "./RawActivity"; import { RawObject } from "./RawObject"; -import { RawActor } from "./RawActor"; const config = getConfig(); @@ -100,10 +99,10 @@ export class Status extends BaseEntity { /** * The raw actor that this status is a reply to, if any. */ - @ManyToOne(() => RawActor, { + @ManyToOne(() => User, { nullable: true, }) - in_reply_to_account!: RawActor | null; + in_reply_to_account!: User | null; /** * Whether this status is sensitive. @@ -196,7 +195,7 @@ export class Status extends BaseEntity { emojis: Emoji[]; reply?: { object: RawObject; - actor: RawActor; + user: User; }; }) { const newStatus = new Status(); @@ -217,7 +216,7 @@ export class Status extends BaseEntity { if (data.reply) { newStatus.in_reply_to_post = data.reply.object; - newStatus.in_reply_to_account = data.reply.actor; + newStatus.in_reply_to_account = data.reply.user; } newStatus.object.data = { @@ -240,8 +239,8 @@ export class Status extends BaseEntity { return `${config.http.base_url}/@${match[1]}`; }); - // Map this to Actors - const mentionedActors = ( + // Map this to Users + const mentionedUsers = ( await Promise.all( mentionedPeople.map(async person => { // Check if post is in format @username or @username@instance.com @@ -252,23 +251,23 @@ export class Status extends BaseEntity { : null; if (instanceUrl) { - const actor = await RawActor.createQueryBuilder("actor") - .where("actor.data->>'id' = :id", { - // Where ID contains the instance URL - id: `%${instanceUrl}%`, - }) - // Where actor preferredUsername is the username - .andWhere( - "actor.data->>'preferredUsername' = :username", - { - username: person.split("@")[1], - } - ) - .getOne(); + const user = await User.findOne({ + where: { + username: person.split("@")[1], + // If contains instanceUrl + instance: { + base_url: instanceUrl, + }, + }, + relations: { + actor: true, + instance: true, + }, + }); - return actor?.data.id; + return user?.actor.data.id; } else { - const actor = await User.findOne({ + const user = await User.findOne({ where: { username: person.split("@")[1], }, @@ -277,13 +276,13 @@ export class Status extends BaseEntity { }, }); - return actor?.actor.data.id; + return user?.actor.data.id; } }) ) - ).map(actor => actor as string); + ).map(user => user as string); - newStatus.object.data.to = mentionedActors; + newStatus.object.data.to = mentionedUsers; if (data.visibility === "private") { newStatus.object.data.cc = [ diff --git a/database/entities/User.ts b/database/entities/User.ts index 38e6af36..2081962a 100644 --- a/database/entities/User.ts +++ b/database/entities/User.ts @@ -14,12 +14,13 @@ import { } from "typeorm"; import { APIAccount } from "~types/entities/account"; import { RawActor } from "./RawActor"; -import { APActor } from "activitypub-types"; +import { APActor, APOrderedCollectionPage } from "activitypub-types"; import { RawObject } from "./RawObject"; import { Token } from "./Token"; import { Status } from "./Status"; import { APISource } from "~types/entities/source"; import { Relationship } from "./Relationship"; +import { Instance } from "./Instance"; /** * Represents a user in the database. @@ -52,16 +53,19 @@ export class User extends BaseEntity { /** * The password for the user. */ - @Column("varchar") - password!: string; + @Column("varchar", { + nullable: true, + }) + password!: string | null; /** * The email address for the user. */ @Column("varchar", { unique: true, + nullable: true, }) - email!: string; + email!: string | null; /** * The note for the user. @@ -118,8 +122,10 @@ export class User extends BaseEntity { /** * The private key for the user. */ - @Column("varchar") - private_key!: string; + @Column("varchar", { + nullable: true, + }) + private_key!: string | null; /** * The relationships for the user. @@ -127,6 +133,16 @@ export class User extends BaseEntity { @OneToMany(() => Relationship, relationship => relationship.owner) relationships!: Relationship[]; + /** + * User's instance, null if local user + */ + @ManyToOne(() => Instance, { + nullable: true, + }) + instance!: Instance | null; + + /** */ + /** * The actor for the user. */ @@ -140,6 +156,50 @@ export class User extends BaseEntity { @JoinTable() pinned_notes!: RawObject[]; + /** + * Update this user data from its actor + * @returns The updated user. + */ + async updateFromActor() { + const actor = await this.actor.toAPIAccount(); + + this.username = actor.username; + this.display_name = actor.display_name; + this.note = actor.note; + this.avatar = actor.avatar; + this.header = actor.header; + this.avatar = actor.avatar; + + return await this.save(); + } + + /** + * Fetches the list of followers associated with the actor and updates the user's followers + */ + async fetchFollowers() { + let followers: APOrderedCollectionPage = await fetch( + `${this.actor.data.followers?.toString() ?? ""}?page=1`, + { + headers: { Accept: "application/activity+json" }, + } + ); + + let followersList = followers.orderedItems ?? []; + + while (followers.type === "OrderedCollectionPage" && followers.next) { + followers = await fetch((followers.next as string).toString(), { + headers: { Accept: "application/activity+json" }, + }).then(res => res.json()); + + followersList = { + ...followersList, + ...(followers.orderedItems ?? []), + }; + } + + // TODO: integrate followers + } + /** * Gets a user by actor ID. * @param id The actor ID to search for. @@ -159,11 +219,11 @@ export class User extends BaseEntity { } /** - * Creates a new user. + * Creates a new LOCAL user. * @param data The data for the new user. * @returns The newly created user. */ - static async createNew(data: { + static async createNewLocal(data: { username: string; display_name?: string; password: string; @@ -184,6 +244,7 @@ export class User extends BaseEntity { user.header = data.header ?? config.defaults.avatar; user.relationships = []; + user.instance = null; user.source = { language: null, @@ -401,7 +462,32 @@ export class User extends BaseEntity { } // eslint-disable-next-line @typescript-eslint/require-await - async toAPI(): Promise { - return await this.actor.toAPIAccount(); + async toAPI(isOwnAccount = false): Promise { + const follower_count = await Relationship.count({ + where: { + subject: { + id: this.id, + }, + following: true, + }, + relations: ["subject"], + }); + + const following_count = await Relationship.count({ + where: { + owner: { + id: this.id, + }, + following: true, + }, + relations: ["owner"], + }); + + return { + ...(await this.actor.toAPIAccount(isOwnAccount)), + id: this.id, + followers_count: follower_count, + following_count: following_count, + }; } } diff --git a/server/api/api/v1/accounts/[id]/index.ts b/server/api/api/v1/accounts/[id]/index.ts index b41e2ce2..79ca69c1 100644 --- a/server/api/api/v1/accounts/[id]/index.ts +++ b/server/api/api/v1/accounts/[id]/index.ts @@ -1,6 +1,5 @@ import { errorResponse, jsonResponse } from "@response"; import { MatchedRoute } from "bun"; -import { RawActor } from "~database/entities/RawActor"; import { User } from "~database/entities/User"; /** @@ -20,9 +19,9 @@ export default async ( const user = await User.retrieveFromToken(token); - let foundUser: RawActor | null; + let foundUser: User | null; try { - foundUser = await RawActor.findOneBy({ + foundUser = await User.findOneBy({ id, }); } catch (e) { @@ -31,7 +30,5 @@ export default async ( if (!foundUser) return errorResponse("User not found", 404); - return jsonResponse( - await foundUser.toAPIAccount(user?.id === foundUser.id) - ); + return jsonResponse(await foundUser.toAPI(user?.id === foundUser.id)); }; diff --git a/server/api/api/v1/accounts/index.ts b/server/api/api/v1/accounts/index.ts index a95dae0a..9e58a187 100644 --- a/server/api/api/v1/accounts/index.ts +++ b/server/api/api/v1/accounts/index.ts @@ -147,7 +147,7 @@ export default async (req: Request): Promise => { // TODO: Check if locale is valid - await User.createNew({ + await User.createNewLocal({ username: body.username ?? "", password: body.password ?? "", email: body.email ?? "", diff --git a/server/api/api/v1/custom_emojis/index.ts b/server/api/api/v1/custom_emojis/index.ts new file mode 100644 index 00000000..248a6baf --- /dev/null +++ b/server/api/api/v1/custom_emojis/index.ts @@ -0,0 +1,17 @@ +import { jsonResponse } from "@response"; +import { IsNull } from "typeorm"; +import { Emoji } from "~database/entities/Emoji"; + +/** + * Creates a new user + */ +// eslint-disable-next-line @typescript-eslint/require-await +export default async (): Promise => { + const emojis = await Emoji.findBy({ + instance: IsNull(), + }); + + return jsonResponse( + await Promise.all(emojis.map(async emoji => await emoji.toAPI())) + ); +}; diff --git a/server/api/api/v1/statuses/[id]/index.ts b/server/api/api/v1/statuses/[id]/index.ts index abc49cc5..2f623e35 100644 --- a/server/api/api/v1/statuses/[id]/index.ts +++ b/server/api/api/v1/statuses/[id]/index.ts @@ -37,7 +37,7 @@ export default async ( if (req.method === "GET") { return jsonResponse(await foundStatus.toAPI()); } else if (req.method === "DELETE") { - if ((await foundStatus.toAPI()).account.id !== user?.actor.id) { + if ((await foundStatus.toAPI()).account.id !== user?.id) { return errorResponse("Unauthorized", 401); } diff --git a/server/api/api/v1/statuses/index.ts b/server/api/api/v1/statuses/index.ts index cd36bc0f..5e4a9f50 100644 --- a/server/api/api/v1/statuses/index.ts +++ b/server/api/api/v1/statuses/index.ts @@ -6,7 +6,6 @@ import { parseRequest } from "@request"; import { errorResponse, jsonResponse } from "@response"; import { APActor } from "activitypub-types"; import { Application } from "~database/entities/Application"; -import { RawActor } from "~database/entities/RawActor"; import { RawObject } from "~database/entities/RawObject"; import { Status } from "~database/entities/Status"; import { User } from "~database/entities/User"; @@ -108,7 +107,7 @@ export default async (req: Request): Promise => { // Get reply account and status if exists let replyObject: RawObject | null = null; - let replyActor: RawActor | null = null; + let replyUser: User | null = null; if (in_reply_to_id) { replyObject = await RawObject.findOne({ @@ -117,7 +116,7 @@ export default async (req: Request): Promise => { }, }); - replyActor = await RawActor.getByActorId( + replyUser = await User.getByActorId( (replyObject?.data.attributedTo as APActor).id ?? "" ); } @@ -138,9 +137,9 @@ export default async (req: Request): Promise => { spoiler_text: spoiler_text || "", emojis: [], reply: - replyObject && replyActor + replyObject && replyUser ? { - actor: replyActor, + user: replyUser, object: replyObject, } : undefined, diff --git a/server/api/api/v1/timelines/home.ts b/server/api/api/v1/timelines/home.ts new file mode 100644 index 00000000..30788309 --- /dev/null +++ b/server/api/api/v1/timelines/home.ts @@ -0,0 +1,68 @@ +import { parseRequest } from "@request"; +import { errorResponse, jsonResponse } from "@response"; +import { RawObject } from "~database/entities/RawObject"; + +/** + * Fetch home timeline statuses + */ +export default async (req: Request): Promise => { + const { + limit = 20, + max_id, + min_id, + since_id, + } = await parseRequest<{ + max_id?: string; + since_id?: string; + min_id?: string; + limit?: number; + }>(req); + + if (limit < 1 || limit > 40) { + return errorResponse("Limit must be between 1 and 40", 400); + } + + let query = RawObject.createQueryBuilder("object") + .where("object.data->>'type' = 'Note'") + // From a user followed by the current user + .andWhere("CAST(object.data->>'to' AS jsonb) @> CAST(:to AS jsonb)", { + to: JSON.stringify([ + "https://www.w3.org/ns/activitystreams#Public", + ]), + }) + .orderBy("object.data->>'published'", "DESC") + .take(limit); + + if (max_id) { + const maxPost = await RawObject.findOneBy({ id: max_id }); + if (maxPost) { + query = query.andWhere("object.data->>'published' < :max_date", { + max_date: maxPost.data.published, + }); + } + } + + if (min_id) { + const minPost = await RawObject.findOneBy({ id: min_id }); + if (minPost) { + query = query.andWhere("object.data->>'published' > :min_date", { + min_date: minPost.data.published, + }); + } + } + + if (since_id) { + const sincePost = await RawObject.findOneBy({ id: since_id }); + if (sincePost) { + query = query.andWhere("object.data->>'published' >= :since_date", { + since_date: sincePost.data.published, + }); + } + } + + const objects = await query.getMany(); + + return jsonResponse( + await Promise.all(objects.map(async object => await object.toAPI())) + ); +}; diff --git a/server/api/auth/login/index.ts b/server/api/auth/login/index.ts index 064a842c..69629bf3 100644 --- a/server/api/auth/login/index.ts +++ b/server/api/auth/login/index.ts @@ -35,7 +35,7 @@ export default async ( email, }); - if (!user || !(await Bun.password.verify(password, user.password))) + if (!user || !(await Bun.password.verify(password, user.password || ""))) return errorResponse("Invalid username or password", 401); // Get application diff --git a/tests/actor.test.ts b/tests/actor.test.ts index 27cb037f..9df4dd32 100644 --- a/tests/actor.test.ts +++ b/tests/actor.test.ts @@ -13,7 +13,7 @@ beforeAll(async () => { if (!AppDataSource.isInitialized) await AppDataSource.initialize(); // Initialize test user - await User.createNew({ + await User.createNewLocal({ email: "test@test.com", username: "test", password: "test", diff --git a/tests/api.test.ts b/tests/api.test.ts index 6b6e8627..28be75a3 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -4,10 +4,12 @@ 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 { 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 { APIEmoji } from "~types/entities/emoji"; import { APIInstance } from "~types/entities/instance"; import { APIRelationship } from "~types/entities/relationship"; import { APIStatus } from "~types/entities/status"; @@ -23,7 +25,7 @@ beforeAll(async () => { if (!AppDataSource.isInitialized) await AppDataSource.initialize(); // Initialize test user - user = await User.createNew({ + user = await User.createNewLocal({ email: "test@test.com", username: "test", password: "test", @@ -31,7 +33,7 @@ beforeAll(async () => { }); // Initialize second test user - user2 = await User.createNew({ + user2 = await User.createNewLocal({ email: "test2@test.com", username: "test2", password: "test2", @@ -105,7 +107,7 @@ describe("POST /api/v1/statuses", () => { status = (await response.json()) as APIStatus; expect(status.content).toBe("Hello, world!"); expect(status.visibility).toBe("public"); - expect(status.account.id).toBe(user.actor.id); + expect(status.account.id).toBe(user.id); expect(status.replies_count).toBe(0); expect(status.favourites_count).toBe(0); expect(status.reblogged).toBe(false); @@ -238,7 +240,7 @@ describe("GET /api/v1/accounts/:id/statuses", () => { // Basic validation expect(status1.content).toBe("Hello, world!"); expect(status1.visibility).toBe("public"); - expect(status1.account.id).toBe(user.actor.id); + expect(status1.account.id).toBe(user.id); }); }); @@ -678,6 +680,42 @@ describe("GET /api/v1/instance", () => { }); }); +describe("GET /api/v1/custom_emojis", () => { + beforeAll(async () => { + const emoji = new Emoji(); + + emoji.instance = null; + emoji.url = "https://example.com"; + emoji.shortcode = "test"; + emoji.visible_in_picker = true; + + await emoji.save(); + }); + test("should return an array of at least one custom emoji", async () => { + const response = await fetch( + `${config.http.base_url}/api/v1/custom_emojis`, + { + method: "GET", + headers: { + Authorization: `Bearer ${token.access_token}`, + }, + } + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("application/json"); + + const emojis: APIEmoji[] = await response.json(); + + expect(emojis.length).toBeGreaterThan(0); + expect(emojis[0].shortcode).toBe("test"); + expect(emojis[0].url).toBe("https://example.com"); + }); + afterAll(async () => { + await Emoji.delete({ shortcode: "test" }); + }); +}); + afterAll(async () => { const activities = await RawActivity.createQueryBuilder("activity") .where("activity.data->>'actor' = :actor", { diff --git a/tests/inbox.test.ts b/tests/inbox.test.ts index e9f16f77..0f9e4437 100644 --- a/tests/inbox.test.ts +++ b/tests/inbox.test.ts @@ -11,7 +11,7 @@ beforeAll(async () => { if (!AppDataSource.isInitialized) await AppDataSource.initialize(); // Initialize test user - await User.createNew({ + await User.createNewLocal({ email: "test@test.com", username: "test", password: "test", diff --git a/tests/oauth.test.ts b/tests/oauth.test.ts index 62cecb44..f204953a 100644 --- a/tests/oauth.test.ts +++ b/tests/oauth.test.ts @@ -16,7 +16,7 @@ beforeAll(async () => { if (!AppDataSource.isInitialized) await AppDataSource.initialize(); // Initialize test user - await User.createNew({ + await User.createNewLocal({ email: "test@test.com", username: "test", password: "test", @@ -32,6 +32,7 @@ describe("POST /api/v1/apps/", () => { formData.append("website", "https://example.com"); formData.append("redirect_uris", "https://example.com"); formData.append("scopes", "read write"); + const response = await fetch(`${config.http.base_url}/api/v1/apps/`, { method: "POST", body: formData, @@ -64,10 +65,10 @@ describe("POST /auth/login/", () => { test("should get a code", async () => { const formData = new FormData(); - formData.append("username", "test"); + formData.append("email", "test@test.com"); formData.append("password", "test"); const response = await fetch( - `${config.http.base_url}/auth/login/?client_id=${client_id}&redirect_uri=https://example.com&response_type=code&scopes=read+write`, + `${config.http.base_url}/auth/login/?client_id=${client_id}&redirect_uri=https://example.com&response_type=code&scope=read+write`, { method: "POST", body: formData, @@ -93,7 +94,7 @@ describe("POST /oauth/token/", () => { formData.append("redirect_uri", "https://example.com"); formData.append("client_id", client_id); formData.append("client_secret", client_secret); - formData.append("scope", "read write"); + formData.append("scope", "read+write"); const response = await fetch(`${config.http.base_url}/oauth/token/`, { method: "POST", diff --git a/utils/config.ts b/utils/config.ts index dd0e19da..0a1efa5f 100644 --- a/utils/config.ts +++ b/utils/config.ts @@ -51,6 +51,7 @@ export interface ConfigType { discard_avatars: string[]; force_sensitive: string[]; remove_media: string[]; + fetch_all_colletion_members: boolean; }; filters: { @@ -163,6 +164,7 @@ export const configDefaults: ConfigType = { discard_avatars: [], force_sensitive: [], remove_media: [], + fetch_all_colletion_members: false, }, filters: { note_filters: [],