From fa1dd69e2db3d26189e5818e3a3035df312bd7b1 Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Wed, 28 May 2025 17:07:24 +0200 Subject: [PATCH] feat(api): :sparkles: Make Reactions API correctly output whether a reaction is remote --- .../v1/statuses/[id]/reactions/[name].test.ts | 3 + .../v1/statuses/[id]/reactions/index.test.ts | 4 + api/inbox/index.test.ts | 123 +++++++++++++++++- classes/database/note.ts | 7 +- packages/client/schemas/versia.ts | 5 + 5 files changed, 139 insertions(+), 3 deletions(-) diff --git a/api/api/v1/statuses/[id]/reactions/[name].test.ts b/api/api/v1/statuses/[id]/reactions/[name].test.ts index ccf0546a..e2042e10 100644 --- a/api/api/v1/statuses/[id]/reactions/[name].test.ts +++ b/api/api/v1/statuses/[id]/reactions/[name].test.ts @@ -106,6 +106,7 @@ describe("/api/v1/statuses/:id/reactions/:name", () => { name: "❤️", count: 1, me: false, + remote: false, }), ); expect(data.reactions).toContainEqual( @@ -113,6 +114,7 @@ describe("/api/v1/statuses/:id/reactions/:name", () => { name: "😂", count: 1, me: true, + remote: false, }), ); }); @@ -133,6 +135,7 @@ describe("/api/v1/statuses/:id/reactions/:name", () => { name: "👍", count: 1, me: true, + remote: false, }); }); diff --git a/api/api/v1/statuses/[id]/reactions/index.test.ts b/api/api/v1/statuses/[id]/reactions/index.test.ts index 9c5d43ca..c214f8d5 100644 --- a/api/api/v1/statuses/[id]/reactions/index.test.ts +++ b/api/api/v1/statuses/[id]/reactions/index.test.ts @@ -39,6 +39,7 @@ describe("/api/v1/statuses/:id/reactions", () => { count: 1, me: true, account_ids: [users[1].id], + remote: false, }); // Check for ❤️ reaction @@ -48,6 +49,7 @@ describe("/api/v1/statuses/:id/reactions", () => { count: 1, me: false, account_ids: [users[2].id], + remote: false, }); // Check for 😂 reaction @@ -57,6 +59,7 @@ describe("/api/v1/statuses/:id/reactions", () => { count: 1, me: true, account_ids: [users[1].id], + remote: false, }); }); @@ -71,6 +74,7 @@ describe("/api/v1/statuses/:id/reactions", () => { // All reactions should have me: false when not authenticated for (const reaction of data) { expect(reaction.me).toBe(false); + expect(reaction.remote).toBe(false); } }); }); diff --git a/api/inbox/index.test.ts b/api/inbox/index.test.ts index 9a571bed..329b8334 100644 --- a/api/inbox/index.test.ts +++ b/api/inbox/index.test.ts @@ -6,7 +6,7 @@ import { enableRealRequests, mock, } from "bun-bagel"; -import { and, eq } from "drizzle-orm"; +import { and, eq, isNull } from "drizzle-orm"; import { Instance } from "~/classes/database/instance"; import { Note } from "~/classes/database/note"; import { Reaction } from "~/classes/database/reaction"; @@ -15,13 +15,14 @@ import { config } from "~/config"; import { Notes, Reactions, Users } from "~/drizzle/schema"; import { sign } from "~/packages/sdk/crypto"; import * as VersiaEntities from "~/packages/sdk/entities"; -import { fakeRequest } from "~/tests/utils"; +import { fakeRequest, generateClient, getTestUsers } from "~/tests/utils"; const instanceUrl = new URL("https://versia.example.com"); const noteId = randomUUIDv7(); const userId = randomUUIDv7(); const shareId = randomUUIDv7(); const reactionId = randomUUIDv7(); +const reaction2Id = randomUUIDv7(); const userKeys = await User.generateKeys(); const privateKey = await crypto.subtle.importKey( "pkcs8", @@ -32,6 +33,7 @@ const privateKey = await crypto.subtle.importKey( ); const instanceKeys = await User.generateKeys(); const inboxUrl = new URL("/inbox", config.http.base_url); +const { users, deleteUsers } = await getTestUsers(1); disableRealRequests(); @@ -101,6 +103,7 @@ afterAll(async () => { } await instance.delete(); + await deleteUsers(); clearMocks(); enableRealRequests(); }); @@ -286,6 +289,122 @@ describe("Inbox Tests", () => { ); expect(reaction).not.toBeNull(); + + // Check if API returns the reaction correctly + await using client = await generateClient(users[1]); + + const { data, ok } = await client.getStatusReactions(dbNote.id); + + expect(ok).toBe(true); + expect(data).toContainEqual( + expect.objectContaining({ + name: "👍", + count: 1, + me: false, + remote: false, + }), + ); + }); + + test("should correctly process Reaction with custom emoji", async () => { + const exampleRequest = new VersiaEntities.Reaction({ + id: reaction2Id, + created_at: "2025-04-18T10:32:01.427Z", + uri: new URL(`/reactions/${reaction2Id}`, instanceUrl).href, + type: "pub.versia:reactions/Reaction", + author: new URL(`/users/${userId}`, instanceUrl).href, + object: new URL(`/notes/${noteId}`, instanceUrl).href, + content: ":neocat:", + extensions: { + "pub.versia:custom_emojis": { + emojis: [ + { + name: ":neocat:", + url: { + "image/webp": { + hash: { + sha256: "e06240155d2cb90e8dc05327d023585ab9d47216ff547ad72aaf75c485fe9649", + }, + size: 4664, + width: 256, + height: 256, + remote: true, + content: + "https://cdn.cpluspatch.com/versia-cpp/e06240155d2cb90e8dc05327d023585ab9d47216ff547ad72aaf75c485fe9649/neocat.webp", + }, + }, + }, + ], + }, + }, + }); + + const signedRequest = await sign( + privateKey, + new URL(exampleRequest.data.author), + new Request(inboxUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + "User-Agent": "Versia/1.0.0", + }, + body: JSON.stringify(exampleRequest.toJSON()), + }), + ); + + const response = await fakeRequest(inboxUrl, { + method: "POST", + headers: signedRequest.headers, + body: signedRequest.body, + }); + + expect(response.status).toBe(200); + + await sleep(500); + + const dbNote = await Note.fromSql( + eq(Notes.uri, new URL(`/notes/${noteId}`, instanceUrl).href), + ); + + if (!dbNote) { + throw new Error("DBNote not found"); + } + + // Find the remote user who reacted by URI + const remoteUser = await User.fromSql( + eq(Users.uri, new URL(`/users/${userId}`, instanceUrl).href), + ); + + if (!remoteUser) { + throw new Error("Remote user not found"); + } + + // Check if reaction was created in the database + const reaction = await Reaction.fromSql( + and( + eq(Reactions.noteId, dbNote.id), + eq(Reactions.authorId, remoteUser.id), + isNull(Reactions.emojiText), // Custom emoji reactions have emojiText as NULL + ), + ); + + expect(reaction).not.toBeNull(); + + // Check if API returns the reaction correctly + await using client = await generateClient(users[1]); + + const { data, ok } = await client.getStatusReactions(dbNote.id); + + expect(ok).toBe(true); + expect(data).toContainEqual( + expect.objectContaining({ + name: ":neocat@versia.example.com:", + count: 1, + me: false, + remote: true, + }), + ); }); test("should correctly process Delete", async () => { diff --git a/classes/database/note.ts b/classes/database/note.ts index a11cb1a5..25fa185b 100644 --- a/classes/database/note.ts +++ b/classes/database/note.ts @@ -948,6 +948,7 @@ export class Note extends BaseInterface { { count: number; me: boolean; + instance: typeof Instance.$type | null; account_ids: string[]; } >(); @@ -958,8 +959,10 @@ export class Note extends BaseInterface { // Determine emoji name based on type if (reaction.emojiText) { emojiName = reaction.emojiText; - } else if (reaction.emoji) { + } else if (reaction.emoji?.instance === null) { emojiName = `:${reaction.emoji.shortcode}:`; + } else if (reaction.emoji?.instance) { + emojiName = `:${reaction.emoji.shortcode}@${reaction.emoji.instance.baseUrl}:`; } else { continue; // Skip invalid reactions } @@ -970,6 +973,7 @@ export class Note extends BaseInterface { count: 0, me: false, account_ids: [], + instance: reaction.emoji?.instance ?? null, }); } @@ -994,6 +998,7 @@ export class Note extends BaseInterface { count: data.count, me: data.me, account_ids: data.account_ids, + remote: data.instance !== null, })); } } diff --git a/packages/client/schemas/versia.ts b/packages/client/schemas/versia.ts index 89a7f6c7..4606bfe8 100644 --- a/packages/client/schemas/versia.ts +++ b/packages/client/schemas/versia.ts @@ -62,6 +62,11 @@ export const NoteReaction = z description: "Number of users who reacted with this emoji.", example: 5, }), + remote: z.boolean().openapi({ + description: + "Whether this reaction is from a remote instance (federated).", + example: false, + }), me: z.boolean().optional().openapi({ description: "Whether the current authenticated user reacted with this emoji.",