Comments on plays

- Anyone who can see a play can read its comments; the player and the people they follow can write.
  The author, the player and admins can delete; comments can be reported (admins see the text).
- Notifications: "commented on" for the player, "replied on" for earlier commenters, each with its
  own setting. Reaction and comment notifications now open the play.
- A 💬 chip with the count next to reactions on the feed and recent plays, opening the thread on
  the app (/play/[id]) and the website. Threads refresh every few seconds while open.
- Blocking removes comments between the two people. Plays with comments survive history trimming.
- Privacy page updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Y2iUsKEWDuRGjK5FPmcUP
This commit is contained in:
2026-10-15 13:32:14 -04:00
co-authored by Claude Opus 5.5
parent 31aed8dec4
commit 4402171f46
33 changed files with 2850 additions and 36 deletions
+16
View File
@@ -0,0 +1,16 @@
CREATE TABLE "comments" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"listen_id" uuid NOT NULL,
"user_id" uuid NOT NULL,
"body" varchar(500) NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "notifications" ADD COLUMN "comment_id" uuid;--> statement-breakpoint
ALTER TABLE "reports" ADD COLUMN "comment_id" uuid;--> statement-breakpoint
ALTER TABLE "reports" ADD COLUMN "comment_body" varchar(500);--> statement-breakpoint
ALTER TABLE "comments" ADD CONSTRAINT "comments_listen_id_listens_id_fk" FOREIGN KEY ("listen_id") REFERENCES "public"."listens"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "comments" ADD CONSTRAINT "comments_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "comments_listen_created_idx" ON "comments" USING btree ("listen_id","created_at");--> statement-breakpoint
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_comment_id_comments_id_fk" FOREIGN KEY ("comment_id") REFERENCES "public"."comments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "reports" ADD CONSTRAINT "reports_comment_id_comments_id_fk" FOREIGN KEY ("comment_id") REFERENCES "public"."comments"("id") ON DELETE set null ON UPDATE no action;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -120,6 +120,13 @@
"when": 1792028149643,
"tag": "0016_notifications",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1792084480262,
"tag": "0017_comments",
"breakpoints": true
}
]
}
+2
View File
@@ -12,6 +12,7 @@ import { HttpError } from './lib/http';
import { redis } from './lib/redis';
import { adminRoutes } from './routes/admin';
import { appUpdateRoutes } from './routes/appUpdates';
import { commentRoutes } from './routes/comments';
import { accountRoutes } from './routes/account';
import { authRoutes } from './routes/auth';
import { avatarRoutes } from './routes/avatars';
@@ -78,6 +79,7 @@ export async function buildApp({ logger = true, rateLimits = true }: { logger?:
await app.register(adminRoutes);
await app.register(trackRoutes);
await app.register(reactionRoutes);
await app.register(commentRoutes);
await app.register(notificationRoutes);
await app.register(connectionRoutes);
await app.register(deviceRoutes);
+25 -3
View File
@@ -105,6 +105,9 @@ export const reports = pgTable(
.references(() => users.id, { onDelete: 'cascade' }),
reason: text('reason').$type<ReportReason>().notNull(),
note: varchar('note', { length: 500 }),
/** When a comment was reported: which, and what it said then (kept if the comment is deleted). */
commentId: uuid('comment_id').references(() => comments.id, { onDelete: 'set null' }),
commentBody: varchar('comment_body', { length: 500 }),
status: text('status').$type<'open' | 'resolved'>().notNull().default('open'),
createdAt: createdAt(),
},
@@ -262,6 +265,23 @@ export const reactions = pgTable(
(t) => [primaryKey({ columns: [t.listenId, t.userId] })],
);
/** Comments on a play. They go with the play (plays with comments are kept when history is trimmed). */
export const comments = pgTable(
'comments',
{
id: uuid('id').primaryKey().defaultRandom(),
listenId: uuid('listen_id')
.notNull()
.references(() => listens.id, { onDelete: 'cascade' }),
userId: uuid('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
body: varchar('body', { length: 500 }).notNull(),
createdAt: createdAt(),
},
(t) => [index('comments_listen_created_idx').on(t.listenId, t.createdAt)],
);
/** Email confirmation links (sign-up and email changes). Only a hash of the token is stored. */
export const emailVerifications = pgTable(
'email_verifications',
@@ -306,7 +326,7 @@ export const lyrics = pgTable('lyrics', {
fetchedAt: timestamp('fetched_at', { withTimezone: true }).notNull().defaultNow(),
});
/** Things that happened to a user: a reaction, a new follower, a song sent to them. */
/** Things that happened to a user: a reaction or comment, a new follower, a song sent to them. */
export const notifications = pgTable(
'notifications',
{
@@ -319,10 +339,12 @@ export const notifications = pgTable(
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
trackId: uuid('track_id').references(() => tracks.id, { onDelete: 'cascade' }),
/** The play a reaction was on (gone once the play is trimmed from history). */
/** The play a reaction or comment was on (gone once the play is trimmed from history). */
listenId: uuid('listen_id').references(() => listens.id, { onDelete: 'set null' }),
/** The comment (or reply); deleting the comment takes its notifications with it. */
commentId: uuid('comment_id').references(() => comments.id, { onDelete: 'cascade' }),
emoji: varchar('emoji', { length: 32 }),
/** A note sent with a song. */
/** A note sent with a song, or the start of a comment. */
message: varchar('message', { length: 280 }),
readAt: timestamp('read_at', { withTimezone: true }),
createdAt: createdAt(),
+1
View File
@@ -40,6 +40,7 @@ export async function adminRoutes(app: FastifyInstance) {
createdAt: report.createdAt.toISOString(),
reporter: by ? toUserSummary(by) : null,
reported: { ...toUserSummary(who), suspended: Boolean(who.suspendedAt) },
comment: report.commentBody ? { id: report.commentId, body: report.commentBody } : null,
}));
});
+163
View File
@@ -0,0 +1,163 @@
import { and, asc, eq, inArray, isNull, ne, or } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { commentSchema, reportSchema, type CommentDTO, type CommentThreadDTO } from '@musicshare/shared';
import { db, schema } from '../db';
import type { User } from '../db/schema';
import { requireUser } from '../lib/auth';
import { HttpError } from '../lib/http';
import { blockStatus, hiddenUserIds } from '../services/blocks';
import { notify } from '../services/notifications';
import { getReactions, publishNowPlaying, toUserSummary } from '../services/nowPlaying';
import { getTrackDTOs } from '../services/tracks';
const params = z.object({ id: z.uuid() });
/** Who may write on a play: the person who played it, and the people they follow. */
async function canCommentOn(viewerId: string, ownerId: string) {
if (viewerId === ownerId) return true;
const [row] = await db
.select({ one: schema.follows.followerId })
.from(schema.follows)
.where(and(eq(schema.follows.followerId, ownerId), eq(schema.follows.followeeId, viewerId)));
return Boolean(row);
}
/** The play and its owner, if the viewer may see it: the owner isn't suspended, and no block either way. */
async function visiblePlay(viewerId: string, listenId: string) {
const [row] = await db
.select({ listen: schema.listens, owner: schema.users })
.from(schema.listens)
.innerJoin(schema.users, eq(schema.users.id, schema.listens.userId))
.where(eq(schema.listens.id, listenId));
if (!row || row.owner.suspendedAt) throw new HttpError(404, 'That play is gone');
const { viewerBlocked, blockedByThem } = await blockStatus(viewerId, row.owner.id);
if (viewerBlocked || blockedByThem) throw new HttpError(404, 'That play is gone');
return row;
}
const toCommentDTO = (c: typeof schema.comments.$inferSelect, author: User, viewer: User, ownerId: string): CommentDTO => ({
id: c.id,
user: toUserSummary(author),
body: c.body,
createdAt: c.createdAt.toISOString(),
canDelete: c.userId === viewer.id || ownerId === viewer.id || viewer.role === 'admin',
});
export async function commentRoutes(app: FastifyInstance) {
app.get('/listens/:id/comments', async (req): Promise<CommentThreadDTO> => {
const viewer = requireUser(req);
const { id } = params.parse(req.params);
const { listen, owner } = await visiblePlay(viewer.id, id);
const hidden = await hiddenUserIds(viewer.id);
const rows = await db
.select({ comment: schema.comments, author: schema.users })
.from(schema.comments)
.innerJoin(schema.users, eq(schema.users.id, schema.comments.userId))
.where(and(eq(schema.comments.listenId, id), isNull(schema.users.suspendedAt)))
.orderBy(asc(schema.comments.createdAt));
const [tracks, reactions, canComment] = await Promise.all([
getTrackDTOs([listen.trackId]),
getReactions([id]),
canCommentOn(viewer.id, owner.id),
]);
return {
listenId: id,
owner: toUserSummary(owner),
track: tracks.get(listen.trackId)!,
playedAt: listen.startedAt.toISOString(),
reactions: reactions.get(id) ?? [],
comments: rows.filter((r) => !hidden.has(r.author.id)).map((r) => toCommentDTO(r.comment, r.author, viewer, owner.id)),
canComment,
};
});
app.post(
'/listens/:id/comments',
{ config: { rateLimit: { max: 20, timeWindow: '1 minute' } } },
async (req): Promise<CommentDTO> => {
const viewer = requireUser(req);
const { id } = params.parse(req.params);
const { body } = commentSchema.parse(req.body);
const { listen, owner } = await visiblePlay(viewer.id, id);
if (!(await canCommentOn(viewer.id, owner.id))) {
throw new HttpError(403, `Only people @${owner.username} follows can comment on their songs`);
}
const [comment] = await db.insert(schema.comments).values({ listenId: id, userId: viewer.id, body }).returning();
const excerpt = body.length > 280 ? `${body.slice(0, 279)}…` : body;
const about = { actorId: viewer.id, listenId: id, trackId: listen.trackId, commentId: comment!.id, message: excerpt };
await notify({ ...about, userId: owner.id, type: 'comment' });
// Everyone else who has commented here hears about replies (unless either side has blocked the other).
const others = await db
.selectDistinct({ userId: schema.comments.userId })
.from(schema.comments)
.where(and(eq(schema.comments.listenId, id), ne(schema.comments.userId, viewer.id), ne(schema.comments.userId, owner.id)));
const hidden = await hiddenUserIds(viewer.id);
for (const { userId } of others) {
if (!hidden.has(userId)) await notify({ ...about, userId, type: 'reply' });
}
await publishNowPlaying(owner.id); // the comment count on the feed
return toCommentDTO(comment!, viewer, viewer, owner.id);
},
);
async function findComment(commentId: string) {
const [row] = await db
.select({ comment: schema.comments, ownerId: schema.listens.userId })
.from(schema.comments)
.innerJoin(schema.listens, eq(schema.listens.id, schema.comments.listenId))
.where(eq(schema.comments.id, commentId));
if (!row) throw new HttpError(404, 'That comment is gone');
return row;
}
/** The author, the play's owner, or an admin. Its notifications go with it. */
app.delete('/comments/:id', async (req) => {
const viewer = requireUser(req);
const { id } = params.parse(req.params);
const { comment, ownerId } = await findComment(id);
if (comment.userId !== viewer.id && ownerId !== viewer.id && viewer.role !== 'admin') {
throw new HttpError(403, "You can't delete that comment");
}
await db.delete(schema.comments).where(eq(schema.comments.id, id));
await publishNowPlaying(ownerId);
return { ok: true };
});
app.post(
'/comments/:id/report',
{ config: { rateLimit: { max: 10, timeWindow: '1 hour' } } },
async (req) => {
const viewer = requireUser(req);
const { id } = params.parse(req.params);
const { comment } = await findComment(id);
await visiblePlay(viewer.id, comment.listenId);
if (comment.userId === viewer.id) throw new HttpError(400, "You can't report your own comment");
const input = reportSchema.parse(req.body);
await db.insert(schema.reports).values({
reporterId: viewer.id,
reportedId: comment.userId,
reason: input.reason,
note: input.note || null,
commentId: comment.id,
commentBody: comment.body,
});
return { ok: true };
},
);
}
/** When someone's blocked, their comments on each other's plays go too. */
export async function removeCommentsBetween(a: string, b: string) {
const playsOf = (userId: string) => db.select({ id: schema.listens.id }).from(schema.listens).where(eq(schema.listens.userId, userId));
await db
.delete(schema.comments)
.where(
or(
and(eq(schema.comments.userId, a), inArray(schema.comments.listenId, playsOf(b))),
and(eq(schema.comments.userId, b), inArray(schema.comments.listenId, playsOf(a))),
),
);
}
+2
View File
@@ -11,6 +11,7 @@ import { HttpError } from '../lib/http';
import { FOLLOWS_CHANGED_CHANNEL, redis } from '../lib/redis';
import { removeNotificationsBetween } from '../services/notifications';
import { toUserSummary } from '../services/nowPlaying';
import { removeCommentsBetween } from './comments';
import { removeReactionsBetween } from './reactions';
async function findUser(username: string) {
@@ -49,6 +50,7 @@ export async function safetyRoutes(app: FastifyInstance) {
);
});
await removeReactionsBetween(me.id, them.id);
await removeCommentsBetween(me.id, them.id);
await removeNotificationsBetween(me.id, them.id);
await followsChanged(me.id, them.id);
return { ok: true };
+2
View File
@@ -34,6 +34,7 @@ export async function notify(input: {
listenId?: string | null;
emoji?: string | null;
message?: string | null;
commentId?: string | null;
}): Promise<string | null> {
if (input.userId === input.actorId) return null;
const n = schema.notifications;
@@ -91,6 +92,7 @@ export async function toNotificationDTOs(rows: Row[]): Promise<NotificationDTO[]
type: r.type,
actor,
track: r.trackId ? (tracks.get(r.trackId) ?? null) : null,
listenId: r.listenId,
emoji: r.emoji,
message: r.message,
read: Boolean(r.readAt),
+28 -4
View File
@@ -1,4 +1,4 @@
import { and, desc, eq, inArray, isNull, lt } from 'drizzle-orm';
import { and, count, desc, eq, inArray, isNull, lt, notExists } from 'drizzle-orm';
import type { NowPlayingDTO, NowPlayingProvider, ReactionDTO, RecentPlayDTO, UserSummaryDTO } from '@musicshare/shared';
import { db, schema } from '../db';
import type { User } from '../db/schema';
@@ -40,6 +40,18 @@ export async function getReactions(listenIds: string[]): Promise<Map<string, Rea
return result;
}
/** How many comments each play has (from people who aren't suspended). */
export async function getCommentCounts(listenIds: string[]): Promise<Map<string, number>> {
if (listenIds.length === 0) return new Map();
const rows = await db
.select({ listenId: schema.comments.listenId, n: count() })
.from(schema.comments)
.innerJoin(schema.users, eq(schema.users.id, schema.comments.userId))
.where(and(inArray(schema.comments.listenId, listenIds), isNull(schema.users.suspendedAt)))
.groupBy(schema.comments.listenId);
return new Map(rows.map((r) => [r.listenId, r.n]));
}
/** Phones report changes and a heartbeat every few minutes; if they go quiet (app killed, no signal), stop showing "listening now". */
export const DEVICE_STALE_MS = 9 * 60_000;
const PUSH_SOURCES = new Set<NowPlayingProvider>(['android_media_session']);
@@ -63,7 +75,8 @@ export async function getNowPlaying(userIds: string[]): Promise<NowPlayingDTO[]>
const l = listenIds.get(np.userId);
return l && l.trackId === np.trackId ? l.id : null;
};
const reactions = await getReactions(rows.map((r) => listenFor(r.np)).filter((id): id is string => Boolean(id)));
const shownListens = rows.map((r) => listenFor(r.np)).filter((id): id is string => Boolean(id));
const [reactions, commentCounts] = await Promise.all([getReactions(shownListens), getCommentCounts(shownListens)]);
const now = Date.now();
return rows
.map(({ np, user }) => ({ np, user, track: tracks.get(np.trackId)! }))
@@ -78,6 +91,7 @@ export async function getNowPlaying(userIds: string[]): Promise<NowPlayingDTO[]>
updatedAt: np.updatedAt.toISOString(),
listenId: listenFor(np),
reactions: reactions.get(listenFor(np) ?? '') ?? [],
commentCount: commentCounts.get(listenFor(np) ?? '') ?? 0,
}))
.sort((a, b) => Number(b.isPlaying) - Number(a.isPlaying) || b.updatedAt.localeCompare(a.updatedAt));
}
@@ -108,7 +122,11 @@ async function recordListen(userId: string, trackId: string, source: NowPlayingP
.offset(LISTEN_HISTORY_LIMIT - 1)
.limit(1);
if (cutoff) {
await db.delete(schema.listens).where(and(eq(schema.listens.userId, userId), lt(schema.listens.startedAt, cutoff.startedAt)));
// Plays with comments stay, so conversations don't vanish as new songs come in.
const commented = db.select({ one: schema.comments.id }).from(schema.comments).where(eq(schema.comments.listenId, schema.listens.id));
await db
.delete(schema.listens)
.where(and(eq(schema.listens.userId, userId), lt(schema.listens.startedAt, cutoff.startedAt), notExists(commented)));
}
}
@@ -122,13 +140,19 @@ export async function getRecentPlays(userId: string, limit = 10): Promise<Recent
// Don't repeat the track that's playing right now.
const [current] = await db.select().from(schema.nowPlaying).where(eq(schema.nowPlaying.userId, userId));
const plays = current?.isPlaying && rows[0]?.trackId === current.trackId ? rows.slice(1) : rows.slice(0, limit);
const [tracks, reactions] = await Promise.all([getTrackDTOs(plays.map((p) => p.trackId)), getReactions(plays.map((p) => p.id))]);
const ids = plays.map((p) => p.id);
const [tracks, reactions, commentCounts] = await Promise.all([
getTrackDTOs(plays.map((p) => p.trackId)),
getReactions(ids),
getCommentCounts(ids),
]);
return plays.map((p) => ({
listenId: p.id,
track: tracks.get(p.trackId)!,
source: p.source,
playedAt: p.startedAt.toISOString(),
reactions: reactions.get(p.id) ?? [],
commentCount: commentCounts.get(p.id) ?? 0,
}));
}
+116
View File
@@ -0,0 +1,116 @@
import { describe, expect, it } from 'vitest';
import { applyPollResult, LISTEN_HISTORY_LIMIT } from '../src/services/nowPlaying';
import { bearer, createUser, follow, useTestApp } from './helpers';
const app = useTestApp();
const song = (title: string) => ({ track: { title, artist: 'Massive Attack' }, isPlaying: true, progressMs: null });
/** Alice plays a song. Bob follows her and she follows him back; Carol follows her, but she doesn't follow Carol. */
async function setup() {
const alice = await createUser(app(), 'alice');
const bob = await createUser(app(), 'bob');
const carol = await createUser(app(), 'carol');
await follow(app(), bob.token, 'alice');
await follow(app(), alice.token, 'bob');
await follow(app(), carol.token, 'alice');
await applyPollResult(alice.user.id, 'lastfm', song('Teardrop'));
const feed = (await app().inject({ url: '/feed', headers: bearer(bob.token) })).json();
return { alice, bob, carol, listenId: feed[0].listenId as string };
}
const comment = (token: string, listenId: string, body: string) =>
app().inject({ method: 'POST', url: `/listens/${listenId}/comments`, headers: bearer(token), payload: { body } });
const thread = (token: string, listenId: string) => app().inject({ url: `/listens/${listenId}/comments`, headers: bearer(token) });
/** Comment and reply notifications (setup's follows make some too). */
const notifications = async (token: string) =>
((await app().inject({ url: '/notifications', headers: bearer(token) })).json().notifications as { type: string; message: string }[]).filter(
(n) => n.type === 'comment' || n.type === 'reply',
);
describe('comments', () => {
it('lets the player and people they follow comment, oldest first, with a count on the feed', async () => {
const { alice, bob, carol, listenId } = await setup();
expect((await comment(bob.token, listenId, ' what a tune ')).json()).toMatchObject({ body: 'what a tune', canDelete: true });
await comment(alice.token, listenId, 'right?');
const res = await comment(carol.token, listenId, 'hi');
expect(res.statusCode).toBe(403);
expect(res.json().error).toBe('Only people @alice follows can comment on their songs');
const forCarol = (await thread(carol.token, listenId)).json();
expect(forCarol).toMatchObject({ canComment: false, owner: { username: 'alice' }, track: { title: 'Teardrop' } });
expect(forCarol.comments.map((c: { body: string; canDelete: boolean }) => [c.body, c.canDelete])).toEqual([
['what a tune', false],
['right?', false],
]);
expect((await thread(alice.token, listenId)).json().comments.every((c: { canDelete: boolean }) => c.canDelete)).toBe(true);
const feed = (await app().inject({ url: '/feed', headers: bearer(carol.token) })).json();
expect(feed[0].commentCount).toBe(2);
});
it('rejects empty and overlong comments', async () => {
const { bob, listenId } = await setup();
expect((await comment(bob.token, listenId, ' ')).statusCode).toBe(400);
expect((await comment(bob.token, listenId, 'x'.repeat(501))).statusCode).toBe(400);
});
it('notifies the player, and earlier commenters about replies', async () => {
const { alice, bob, listenId } = await setup();
const dave = await createUser(app(), 'dave');
await follow(app(), alice.token, 'dave');
await comment(bob.token, listenId, 'what a tune');
await comment(dave.token, listenId, 'agreed');
await comment(alice.token, listenId, 'thanks both');
expect((await notifications(alice.token)).map((n) => [n.type, n.message])).toEqual([
['comment', 'agreed'],
['comment', 'what a tune'],
]);
expect((await notifications(bob.token)).map((n) => [n.type, n.message])).toEqual([
['reply', 'thanks both'],
['reply', 'agreed'],
]);
const [latest] = (await app().inject({ url: '/notifications', headers: bearer(bob.token) })).json().notifications;
expect(latest).toMatchObject({ type: 'reply', listenId });
});
it('can be deleted by the author, the player or an admin (taking its notifications along), not anyone else', async () => {
const { alice, bob, carol, listenId } = await setup();
const { id } = (await comment(bob.token, listenId, 'first')).json();
const del = (token: string) => app().inject({ method: 'DELETE', url: `/comments/${id}`, headers: bearer(token) });
expect((await del(carol.token)).statusCode).toBe(403);
expect(await notifications(alice.token)).toHaveLength(1);
expect((await del(alice.token)).statusCode).toBe(200);
expect((await thread(bob.token, listenId)).json().comments).toEqual([]);
expect(await notifications(alice.token)).toEqual([]);
});
it('can be reported, keeping what it said for the admins', async () => {
const { bob, carol, listenId } = await setup();
const { id } = (await comment(bob.token, listenId, 'rude words')).json();
const report = (token: string) =>
app().inject({ method: 'POST', url: `/comments/${id}/report`, headers: bearer(token), payload: { reason: 'harassment' } });
expect((await report(bob.token)).statusCode).toBe(400);
expect((await report(carol.token)).statusCode).toBe(200);
});
it('goes away with a block, both ways', async () => {
const { alice, bob, listenId } = await setup();
await comment(bob.token, listenId, 'hello');
await app().inject({ method: 'POST', url: '/users/bob/block', headers: bearer(alice.token), payload: {} });
expect((await thread(bob.token, listenId)).statusCode).toBe(404);
expect((await thread(alice.token, listenId)).json().comments).toEqual([]);
});
it('keeps plays with comments when old history is trimmed', async () => {
const { alice, bob, listenId } = await setup();
await comment(bob.token, listenId, 'keep this');
for (let i = 0; i < LISTEN_HISTORY_LIMIT + 2; i++) {
await applyPollResult(alice.user.id, 'lastfm', song(`Song ${i}`));
}
const res = await thread(bob.token, listenId);
expect(res.statusCode).toBe(200);
expect(res.json().comments).toHaveLength(1);
});
});
+2 -2
View File
@@ -76,8 +76,8 @@ describe('notifications', () => {
it('saves notification settings', async () => {
const { alice } = await setup();
const me = (await app().inject({ method: 'PATCH', url: '/me', headers: bearer(alice.token), payload: { notifyPrefs: { follow: false } } })).json();
expect(me.notifyPrefs).toEqual({ reaction: true, follow: false, share: true });
const me = (await app().inject({ method: 'PATCH', url: '/me', headers: bearer(alice.token), payload: { notifyPrefs: { follow: false, reply: false } } })).json();
expect(me.notifyPrefs).toEqual({ reaction: true, comment: true, reply: false, follow: false, share: true });
});
});