Skip to content

Commit 651f3ac

Browse files
authored
Merge pull request #427 from vigneshakaviki/fix/stub-trends-and-suggestions-endpoints
Add stub endpoints for trends and suggestions
2 parents 336f5c7 + ea924b7 commit 651f3ac

5 files changed

Lines changed: 224 additions & 1 deletion

File tree

CHANGES.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,11 @@ To be released.
144144
editor and is now returned from `GET /api/v1/preferences`, which helps
145145
clients like Phanpy honor each account's preferred CW behavior. [[#425]]
146146

147+
- Fixed Mastodon API compatibility for clients such as the official Mastodon
148+
iOS app by returning empty arrays for unimplemented trends and suggestions
149+
endpoints instead of `404 Not Found` responses. The suggestions endpoints
150+
still require an authenticated user token. [[#421], [#427] by Vignesh]
151+
147152
- Added a new dashboard page for thumbnail cleanup at `/thumbnail_cleanup`.
148153
Thumbnails from remote posts that have not been bookmarked, liked, reacted
149154
to, shared nor quoted by a local account before a given cut-off data can
@@ -159,8 +164,10 @@ To be released.
159164
[#357]: https://github.com/fedify-dev/hollo/issues/357
160165
[#409]: https://github.com/fedify-dev/hollo/issues/409
161166
[#420]: https://github.com/fedify-dev/hollo/issues/420
167+
[#421]: https://github.com/fedify-dev/hollo/issues/421
162168
[#424]: https://github.com/fedify-dev/hollo/issues/424
163169
[#425]: https://github.com/fedify-dev/hollo/issues/425
170+
[#427]: https://github.com/fedify-dev/hollo/pull/427
164171
[#435]: https://github.com/fedify-dev/hollo/issues/435
165172
[#436]: https://github.com/fedify-dev/hollo/pull/436
166173
[#445]: https://github.com/fedify-dev/hollo/issues/445

src/api/stub-endpoints.test.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { beforeEach, describe, expect, it } from "vitest";
2+
3+
import { cleanDatabase } from "../../tests/helpers";
4+
import {
5+
bearerAuthorization,
6+
createAccount,
7+
createOAuthApplication,
8+
getAccessToken,
9+
type Token,
10+
} from "../../tests/helpers/oauth";
11+
import app from "../index";
12+
13+
describe.sequential("Mastodon compatibility stub endpoints", () => {
14+
let readAccountsToken: Token;
15+
let readSearchToken: Token;
16+
17+
beforeEach(async () => {
18+
await cleanDatabase();
19+
20+
const account = await createAccount();
21+
const client = await createOAuthApplication({
22+
scopes: ["read:accounts", "read:search"],
23+
});
24+
readAccountsToken = await getAccessToken(client, account, [
25+
"read:accounts",
26+
]);
27+
readSearchToken = await getAccessToken(client, account, ["read:search"]);
28+
});
29+
30+
it.each([
31+
"/api/v1/trends",
32+
"/api/v1/trends/tags",
33+
"/api/v1/trends/statuses?offset=0",
34+
"/api/v1/trends/links",
35+
])("returns an empty array for GET %s", async (path) => {
36+
expect.assertions(3);
37+
38+
const response = await app.request(path);
39+
40+
expect(response.status).toBe(200);
41+
expect(response.headers.get("content-type")).toBe("application/json");
42+
expect(await response.json()).toEqual([]);
43+
});
44+
45+
it.each(["/api/v1/suggestions", "/api/v2/suggestions"])(
46+
"requires authentication for GET %s",
47+
async (path) => {
48+
expect.assertions(2);
49+
50+
const response = await app.request(path);
51+
52+
expect(response.status).toBe(401);
53+
expect(await response.json()).toEqual({ error: "unauthorized" });
54+
},
55+
);
56+
57+
it.each(["/api/v1/suggestions", "/api/v2/suggestions"])(
58+
"rejects insufficient scope for GET %s",
59+
async (path) => {
60+
expect.assertions(2);
61+
62+
const response = await app.request(path, {
63+
headers: {
64+
authorization: bearerAuthorization(readSearchToken),
65+
},
66+
});
67+
68+
expect(response.status).toBe(403);
69+
expect(await response.json()).toEqual({ error: "insufficient_scope" });
70+
},
71+
);
72+
73+
it.each(["/api/v1/suggestions", "/api/v2/suggestions"])(
74+
"returns an empty array for GET %s",
75+
async (path) => {
76+
expect.assertions(3);
77+
78+
const response = await app.request(path, {
79+
headers: {
80+
authorization: bearerAuthorization(readAccountsToken),
81+
},
82+
});
83+
84+
expect(response.status).toBe(200);
85+
expect(response.headers.get("content-type")).toBe("application/json");
86+
expect(await response.json()).toEqual([]);
87+
},
88+
);
89+
});

src/api/v1/index.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,33 @@ app.get("/announcements", (c) => {
9292
return c.json([]);
9393
});
9494

95+
app.get("/trends/tags", (c) => {
96+
return c.json([]);
97+
});
98+
99+
app.get("/trends/statuses", (c) => {
100+
return c.json([]);
101+
});
102+
103+
app.get("/trends/links", (c) => {
104+
return c.json([]);
105+
});
106+
107+
// Mastodon clients also request /trends without a subpath,
108+
// which is equivalent to /trends/tags:
109+
app.get("/trends", (c) => {
110+
return c.json([]);
111+
});
112+
113+
app.get(
114+
"/suggestions",
115+
tokenRequired,
116+
scopeRequired(["read:accounts"]),
117+
(c) => {
118+
return c.json([]);
119+
},
120+
);
121+
95122
app.get(
96123
"/favourites",
97124
tokenRequired,

src/api/v1/timelines.test.ts

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,6 @@ describe.sequential("/api/v1/timelines/list/:list_id", () => {
142142
expect(json[0].media_attachments[0].type).toBe("unknown");
143143
});
144144
});
145-
146145
describe.sequential("/api/v1/timelines/home", () => {
147146
let owner: Awaited<ReturnType<typeof createAccount>>;
148147
let approvedAuthor: Awaited<ReturnType<typeof createAccount>>;
@@ -222,3 +221,95 @@ describe.sequential("/api/v1/timelines/home", () => {
222221
expect(ids).toEqual([approvedPostId]);
223222
});
224223
});
224+
225+
describe.sequential("/api/v1/timelines/home", () => {
226+
let owner: Awaited<ReturnType<typeof createAccount>>;
227+
let client: Awaited<ReturnType<typeof createOAuthApplication>>;
228+
let accessToken: Awaited<ReturnType<typeof getAccessToken>>;
229+
230+
beforeEach(async () => {
231+
await cleanDatabase();
232+
233+
owner = await createAccount();
234+
client = await createOAuthApplication({
235+
scopes: ["read:statuses"],
236+
});
237+
accessToken = await getAccessToken(client, owner, ["read:statuses"]);
238+
});
239+
240+
it("serializes quotes using the Mastodon Quote entity format", async () => {
241+
expect.assertions(7);
242+
243+
const authorId = crypto.randomUUID() as Uuid;
244+
const quotedPostId = uuidv7();
245+
const quotePostId = uuidv7();
246+
247+
await db
248+
.insert(instances)
249+
.values({ host: "remote.test" })
250+
.onConflictDoNothing();
251+
252+
await db.insert(accounts).values({
253+
id: authorId,
254+
iri: "https://remote.test/users/author",
255+
instanceHost: "remote.test",
256+
type: "Person",
257+
name: "Remote author",
258+
emojis: {},
259+
handle: "@author@remote.test",
260+
bioHtml: "",
261+
url: "https://remote.test/@author",
262+
protected: false,
263+
inboxUrl: "https://remote.test/users/author/inbox",
264+
});
265+
266+
await db.insert(follows).values({
267+
iri: "https://hollo.test/follows/author",
268+
followingId: authorId,
269+
followerId: owner.id,
270+
approved: new Date(),
271+
});
272+
273+
await db.insert(posts).values([
274+
{
275+
id: quotedPostId,
276+
iri: `https://remote.test/notes/${quotedPostId}`,
277+
type: "Note",
278+
accountId: authorId,
279+
visibility: "public",
280+
content: "Quoted post",
281+
contentHtml: "<p>Quoted post</p>",
282+
published: new Date(),
283+
},
284+
{
285+
id: quotePostId,
286+
iri: `https://remote.test/notes/${quotePostId}`,
287+
type: "Note",
288+
accountId: authorId,
289+
quoteTargetId: quotedPostId,
290+
visibility: "public",
291+
content: "Quote post",
292+
contentHtml: "<p>Quote post</p>",
293+
published: new Date(),
294+
},
295+
]);
296+
297+
const response = await app.request("/api/v1/timelines/home", {
298+
method: "GET",
299+
headers: {
300+
authorization: bearerAuthorization(accessToken),
301+
},
302+
});
303+
304+
expect(response.status).toBe(200);
305+
expect(response.headers.get("content-type")).toBe("application/json");
306+
307+
const json = await response.json();
308+
309+
expect(Array.isArray(json)).toBe(true);
310+
expect(json[0].id).toBe(quotePostId);
311+
expect(json[0].quote_id).toBe(quotedPostId);
312+
expect(json[0].quote.state).toBe("accepted");
313+
expect(json[0].quote.quoted_status.id).toBe(quotedPostId);
314+
});
315+
});

src/api/v2/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,15 @@ app.route("/notifications", notificationsRoutes);
4747

4848
app.post("/media", tokenRequired, scopeRequired(["write:media"]), postMedia);
4949

50+
app.get(
51+
"/suggestions",
52+
tokenRequired,
53+
scopeRequired(["read:accounts"]),
54+
(c) => {
55+
return c.json([]);
56+
},
57+
);
58+
5059
app.get(
5160
"/search",
5261
tokenRequired,

0 commit comments

Comments
 (0)