From 02ac7d002938c595db3ecba88750d697ba989e09 Mon Sep 17 00:00:00 2001 From: tamaina Date: Sun, 13 Jul 2025 20:24:31 +0900 Subject: [PATCH 01/25] wip --- .../backend/src/core/NoteCreateService.ts | 2 + .../backend/src/core/UserFollowingService.ts | 7 ++-- .../backend/src/core/UserSuspendService.ts | 40 ++++++++++--------- .../activitypub/ApDeliverManagerService.ts | 1 + packages/backend/src/models/Following.ts | 7 ++++ .../api/endpoints/federation/followers.ts | 3 +- .../api/endpoints/federation/following.ts | 3 +- .../server/api/endpoints/federation/stats.ts | 2 + .../server/api/endpoints/users/followers.ts | 2 + .../server/api/endpoints/users/following.ts | 2 + .../api/endpoints/users/recommendation.ts | 3 +- 11 files changed, 48 insertions(+), 24 deletions(-) diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 469426f87e..3f045efd03 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -545,6 +545,7 @@ export class NoteCreateService implements OnApplicationShutdown { // TODO: キャッシュ this.followingsRepository.findBy({ followeeId: user.id, + isFollowerSuspended: false, notify: 'normal', }).then(async followings => { if (note.visibility !== 'specified') { @@ -850,6 +851,7 @@ export class NoteCreateService implements OnApplicationShutdown { where: { followeeId: user.id, followerHost: IsNull(), + isFollowerSuspended: false, isFollowerHibernated: false, }, select: ['followerId', 'withReplies'], diff --git a/packages/backend/src/core/UserFollowingService.ts b/packages/backend/src/core/UserFollowingService.ts index e7a6be99fb..f5d8222a8e 100644 --- a/packages/backend/src/core/UserFollowingService.ts +++ b/packages/backend/src/core/UserFollowingService.ts @@ -229,9 +229,7 @@ export class UserFollowingService implements OnModuleInit { followee: { id: MiUser['id']; host: MiUser['host']; uri: MiUser['host']; inbox: MiUser['inbox']; sharedInbox: MiUser['sharedInbox'] }, - follower: { - id: MiUser['id']; host: MiUser['host']; uri: MiUser['host']; inbox: MiUser['inbox']; sharedInbox: MiUser['sharedInbox'] - }, + follower: MiUser, silent = false, withReplies?: boolean, ): Promise { @@ -244,6 +242,7 @@ export class UserFollowingService implements OnModuleInit { followerId: follower.id, followeeId: followee.id, withReplies: withReplies, + isFollowerSuspended: follower.isSuspended, // 非正規化 followerHost: follower.host, @@ -734,6 +733,7 @@ export class UserFollowingService implements OnModuleInit { return this.followingsRepository.createQueryBuilder('following') .select('following.followeeId') .where('following.followerId = :followerId', { followerId: userId }) + .andWhere('following.isFollowerSuspended = false') .getMany(); } @@ -743,6 +743,7 @@ export class UserFollowingService implements OnModuleInit { where: { followerId, followeeId, + isFollowerSuspended: false, }, }); } diff --git a/packages/backend/src/core/UserSuspendService.ts b/packages/backend/src/core/UserSuspendService.ts index 7920e58e36..601859990a 100644 --- a/packages/backend/src/core/UserSuspendService.ts +++ b/packages/backend/src/core/UserSuspendService.ts @@ -49,8 +49,8 @@ export class UserSuspendService { }); (async () => { - await this.postSuspend(user).catch(e => {}); - await this.unFollowAll(user).catch(e => {}); + await this.postSuspend(user).catch((e: any) => {}); + await this.unFollowAll(user).catch((e: any) => {}); })(); } @@ -67,7 +67,8 @@ export class UserSuspendService { }); (async () => { - await this.postUnsuspend(user).catch(e => {}); + await this.postUnsuspend(user).catch((e: any) => {}); + await this.restoreFollowings(user).catch((e: any) => {}); })(); } @@ -140,23 +141,26 @@ export class UserSuspendService { @bindThis private async unFollowAll(follower: MiUser) { - const followings = await this.followingsRepository.find({ - where: { + await this.followingsRepository.update( + { followerId: follower.id, - followeeId: Not(IsNull()), }, - }); - - const jobs: RelationshipJobData[] = []; - for (const following of followings) { - if (following.followeeId && following.followerId) { - jobs.push({ - from: { id: following.followerId }, - to: { id: following.followeeId }, - silent: true, - }); + { + isFollowerSuspended: true, } - } - this.queueService.createUnfollowJob(jobs); + ); + } + + @bindThis + private async restoreFollowings(follower: MiUser) { + // フォロー関係を復元(isFollowerSuspended: false)に変更 + await this.followingsRepository.update( + { + followerId: follower.id, + }, + { + isFollowerSuspended: false, + } + ); } } diff --git a/packages/backend/src/core/activitypub/ApDeliverManagerService.ts b/packages/backend/src/core/activitypub/ApDeliverManagerService.ts index 0140ce9fd6..c0d667253f 100644 --- a/packages/backend/src/core/activitypub/ApDeliverManagerService.ts +++ b/packages/backend/src/core/activitypub/ApDeliverManagerService.ts @@ -119,6 +119,7 @@ class DeliverManager { where: { followeeId: this.actor.id, followerHost: Not(IsNull()), + isFollowerSuspended: false, }, select: { followerSharedInbox: true, diff --git a/packages/backend/src/models/Following.ts b/packages/backend/src/models/Following.ts index 62cbc29f26..c30a7d42d6 100644 --- a/packages/backend/src/models/Following.ts +++ b/packages/backend/src/models/Following.ts @@ -10,6 +10,8 @@ import { MiUser } from './User.js'; @Entity('following') @Index(['followerId', 'followeeId'], { unique: true }) @Index(['followeeId', 'followerHost', 'isFollowerHibernated']) +@Index(['followeeId', 'followerHost', 'isFollowerSuspended']) +@Index(['followerId', 'isFollowerSuspended']) export class MiFollowing { @PrimaryColumn(id()) public id: string; @@ -45,6 +47,11 @@ export class MiFollowing { }) public isFollowerHibernated: boolean; + @Column('boolean', { + default: false, + }) + public isFollowerSuspended: boolean; + // タイムラインにその人のリプライまで含めるかどうか @Column('boolean', { default: false, diff --git a/packages/backend/src/server/api/endpoints/federation/followers.ts b/packages/backend/src/server/api/endpoints/federation/followers.ts index 296bc7c5a8..f4d4caf988 100644 --- a/packages/backend/src/server/api/endpoints/federation/followers.ts +++ b/packages/backend/src/server/api/endpoints/federation/followers.ts @@ -50,7 +50,8 @@ export default class extends Endpoint { // eslint- ) { super(meta, paramDef, async (ps, me) => { const query = this.queryService.makePaginationQuery(this.followingsRepository.createQueryBuilder('following'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) - .andWhere('following.followeeHost = :host', { host: ps.host }); + .andWhere('following.followeeHost = :host', { host: ps.host }) + .andWhere('following.isFollowerSuspended = false'); const followings = await query .limit(ps.limit) diff --git a/packages/backend/src/server/api/endpoints/federation/following.ts b/packages/backend/src/server/api/endpoints/federation/following.ts index 091bf442af..fa8af37c1c 100644 --- a/packages/backend/src/server/api/endpoints/federation/following.ts +++ b/packages/backend/src/server/api/endpoints/federation/following.ts @@ -50,7 +50,8 @@ export default class extends Endpoint { // eslint- ) { super(meta, paramDef, async (ps, me) => { const query = this.queryService.makePaginationQuery(this.followingsRepository.createQueryBuilder('following'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) - .andWhere('following.followerHost = :host', { host: ps.host }); + .andWhere('following.followerHost = :host', { host: ps.host }) + .andWhere('following.isFollowerSuspended = false'); const followings = await query .limit(ps.limit) diff --git a/packages/backend/src/server/api/endpoints/federation/stats.ts b/packages/backend/src/server/api/endpoints/federation/stats.ts index 69900bff9a..3093aa1e86 100644 --- a/packages/backend/src/server/api/endpoints/federation/stats.ts +++ b/packages/backend/src/server/api/endpoints/federation/stats.ts @@ -94,11 +94,13 @@ export default class extends Endpoint { // eslint- this.followingsRepository.count({ where: { followeeHost: Not(IsNull()), + isFollowerSuspended: false, }, }), this.followingsRepository.count({ where: { followerHost: Not(IsNull()), + isFollowerSuspended: false, }, }), ]); diff --git a/packages/backend/src/server/api/endpoints/users/followers.ts b/packages/backend/src/server/api/endpoints/users/followers.ts index 84c4c80d01..3afba603a2 100644 --- a/packages/backend/src/server/api/endpoints/users/followers.ts +++ b/packages/backend/src/server/api/endpoints/users/followers.ts @@ -125,6 +125,7 @@ export default class extends Endpoint { // eslint- where: { followeeId: user.id, followerId: me.id, + isFollowerSuspended: false, }, }); if (!isFollowing) { @@ -136,6 +137,7 @@ export default class extends Endpoint { // eslint- const query = this.queryService.makePaginationQuery(this.followingsRepository.createQueryBuilder('following'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .andWhere('following.followeeId = :userId', { userId: user.id }) + .andWhere('following.isFollowerSuspended = false') .innerJoinAndSelect('following.follower', 'follower'); const followings = await query diff --git a/packages/backend/src/server/api/endpoints/users/following.ts b/packages/backend/src/server/api/endpoints/users/following.ts index 047f9a053b..fb85106e11 100644 --- a/packages/backend/src/server/api/endpoints/users/following.ts +++ b/packages/backend/src/server/api/endpoints/users/following.ts @@ -133,6 +133,7 @@ export default class extends Endpoint { // eslint- where: { followeeId: user.id, followerId: me.id, + isFollowerSuspended: false, }, }); if (!isFollowing) { @@ -144,6 +145,7 @@ export default class extends Endpoint { // eslint- const query = this.queryService.makePaginationQuery(this.followingsRepository.createQueryBuilder('following'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .andWhere('following.followerId = :userId', { userId: user.id }) + .andWhere('following.isFollowerSuspended = false') .innerJoinAndSelect('following.followee', 'followee'); if (ps.birthday) { diff --git a/packages/backend/src/server/api/endpoints/users/recommendation.ts b/packages/backend/src/server/api/endpoints/users/recommendation.ts index 769a72d7a1..3ef7848ebb 100644 --- a/packages/backend/src/server/api/endpoints/users/recommendation.ts +++ b/packages/backend/src/server/api/endpoints/users/recommendation.ts @@ -68,7 +68,8 @@ export default class extends Endpoint { // eslint- const followingQuery = this.followingsRepository.createQueryBuilder('following') .select('following.followeeId') - .where('following.followerId = :followerId', { followerId: me.id }); + .where('following.followerId = :followerId', { followerId: me.id }) + .andWhere('following.isFollowerSuspended = false'); query .andWhere(`user.id NOT IN (${ followingQuery.getQuery() })`); From e5a0d2775a04c4bca587c64c7f6a36e66308e016 Mon Sep 17 00:00:00 2001 From: tamaina Date: Sun, 13 Jul 2025 21:52:33 +0900 Subject: [PATCH 02/25] add migration --- ...2410859370-FollowingIsFollowerSuspended.js | 21 +++++++++++++++++++ packages/backend/src/models/Following.ts | 5 ++--- 2 files changed, 23 insertions(+), 3 deletions(-) create mode 100644 packages/backend/migration/1752410859370-FollowingIsFollowerSuspended.js diff --git a/packages/backend/migration/1752410859370-FollowingIsFollowerSuspended.js b/packages/backend/migration/1752410859370-FollowingIsFollowerSuspended.js new file mode 100644 index 0000000000..8542bcdd8b --- /dev/null +++ b/packages/backend/migration/1752410859370-FollowingIsFollowerSuspended.js @@ -0,0 +1,21 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ +module.exports = class FollowingIsFollowerSuspended1752410859370 { + name = 'FollowingIsFollowerSuspended1752410859370' + + async up(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDX_ce62b50d882d4e9dee10ad0d2f"`); + await queryRunner.query(`ALTER TABLE "following" ADD "isFollowerSuspended" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`CREATE INDEX "IDX_1896254b78a41a50e0396fdabd" ON "following" ("followeeId", "followerHost", "isFollowerSuspended", "isFollowerHibernated") `); + await queryRunner.query(`CREATE INDEX "IDX_d2b8dbf0b772042f4fe241a29d" ON "following" ("followerId", "followeeId", "isFollowerSuspended") `); + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDX_d2b8dbf0b772042f4fe241a29d"`); + await queryRunner.query(`DROP INDEX "public"."IDX_1896254b78a41a50e0396fdabd"`); + await queryRunner.query(`ALTER TABLE "following" DROP COLUMN "isFollowerSuspended"`); + await queryRunner.query(`CREATE INDEX "IDX_ce62b50d882d4e9dee10ad0d2f" ON "following" ("followeeId", "followerHost", "isFollowerHibernated") `); + } +} diff --git a/packages/backend/src/models/Following.ts b/packages/backend/src/models/Following.ts index c30a7d42d6..b9ac2e5005 100644 --- a/packages/backend/src/models/Following.ts +++ b/packages/backend/src/models/Following.ts @@ -9,9 +9,8 @@ import { MiUser } from './User.js'; @Entity('following') @Index(['followerId', 'followeeId'], { unique: true }) -@Index(['followeeId', 'followerHost', 'isFollowerHibernated']) -@Index(['followeeId', 'followerHost', 'isFollowerSuspended']) -@Index(['followerId', 'isFollowerSuspended']) +@Index(['followerId', 'followeeId', 'isFollowerSuspended']) +@Index(['followeeId', 'followerHost', 'isFollowerSuspended', 'isFollowerHibernated']) export class MiFollowing { @PrimaryColumn(id()) public id: string; From 77a11e369f2d8bbc5bc9b26c0e00b317a8555885 Mon Sep 17 00:00:00 2001 From: tamaina Date: Sun, 13 Jul 2025 23:48:39 +0900 Subject: [PATCH 03/25] add test --- .../backend/test/unit/UserSuspendService.ts | 366 ++++++++++++++++++ 1 file changed, 366 insertions(+) create mode 100644 packages/backend/test/unit/UserSuspendService.ts diff --git a/packages/backend/test/unit/UserSuspendService.ts b/packages/backend/test/unit/UserSuspendService.ts new file mode 100644 index 0000000000..89edc6d116 --- /dev/null +++ b/packages/backend/test/unit/UserSuspendService.ts @@ -0,0 +1,366 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +process.env.NODE_ENV = 'test'; + +import { jest } from '@jest/globals'; +import { Test } from '@nestjs/testing'; +import { DataSource } from 'typeorm'; +import type { TestingModule } from '@nestjs/testing'; +import { GlobalModule } from '@/GlobalModule.js'; +import { UserSuspendService } from '@/core/UserSuspendService.js'; +import { + MiFollowing, + MiUser, + FollowingsRepository, + FollowRequestsRepository, + UsersRepository, +} from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { QueueService } from '@/core/QueueService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { secureRndstr } from '@/misc/secure-rndstr.js'; + +export async function sleep(ms = 250): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +describe('UserSuspendService', () => { + let app: TestingModule; + let userSuspendService: UserSuspendService; + let usersRepository: UsersRepository; + let followingsRepository: FollowingsRepository; + let followRequestsRepository: FollowRequestsRepository; + let userEntityService: jest.Mocked; + let queueService: jest.Mocked; + let globalEventService: jest.Mocked; + let apRendererService: jest.Mocked; + let moderationLogService: jest.Mocked; + + async function createUser(data: Partial = {}): Promise { + const user = { + id: secureRndstr(16), + username: secureRndstr(16), + usernameLower: secureRndstr(16).toLowerCase(), + host: null, + isSuspended: false, + ...data, + } as MiUser; + + await usersRepository.insert(user); + return user; + } + + async function createFollowing(follower: MiUser, followee: MiUser, data: Partial = {}): Promise { + const following = { + id: secureRndstr(16), + followerId: follower.id, + followeeId: followee.id, + isFollowerSuspended: false, + isFollowerHibernated: false, + withReplies: false, + notify: null, + followerHost: follower.host, + followerInbox: null, + followerSharedInbox: null, + followeeHost: followee.host, + followeeInbox: null, + followeeSharedInbox: null, + ...data, + } as MiFollowing; + + await followingsRepository.insert(following); + return following; + } + + beforeEach(async () => { + app = await Test.createTestingModule({ + imports: [GlobalModule], + providers: [ + UserSuspendService, + { + provide: UserEntityService, + useFactory: () => ({ + isLocalUser: jest.fn(), + genLocalUserUri: jest.fn(), + }), + }, + { + provide: QueueService, + useFactory: () => ({ + deliver: jest.fn(), + }), + }, + { + provide: GlobalEventService, + useFactory: () => ({ + publishInternalEvent: jest.fn(), + }), + }, + { + provide: ApRendererService, + useFactory: () => ({ + addContext: jest.fn(), + renderDelete: jest.fn(), + renderUndo: jest.fn(), + }), + }, + { + provide: ModerationLogService, + useFactory: () => ({ + log: jest.fn(), + }), + }, + ], + }).compile(); + + app.enableShutdownHooks(); + + userSuspendService = app.get(UserSuspendService); + usersRepository = app.get(DI.usersRepository); + followingsRepository = app.get(DI.followingsRepository); + followRequestsRepository = app.get(DI.followRequestsRepository); + userEntityService = app.get(UserEntityService) as jest.Mocked; + queueService = app.get(QueueService) as jest.Mocked; + globalEventService = app.get(GlobalEventService) as jest.Mocked; + apRendererService = app.get(ApRendererService) as jest.Mocked; + moderationLogService = app.get(ModerationLogService) as jest.Mocked; + + // Reset mocks + jest.clearAllMocks(); + }); + + afterEach(async () => { + await app.close(); + }); + + describe('suspend', () => { + test('should suspend user and update database', async () => { + const user = await createUser(); + const moderator = await createUser(); + + await userSuspendService.suspend(user, moderator); + + // ユーザーが凍結されているかチェック + const suspendedUser = await usersRepository.findOneBy({ id: user.id }); + expect(suspendedUser?.isSuspended).toBe(true); + + // モデレーションログが記録されているかチェック + expect(moderationLogService.log).toHaveBeenCalledWith(moderator, 'suspend', { + userId: user.id, + userUsername: user.username, + userHost: user.host, + }); + }); + + test('should mark follower relationships as suspended', async () => { + const user = await createUser(); + const followee1 = await createUser(); + const followee2 = await createUser(); + const moderator = await createUser(); + + // ユーザーがフォローしている関係を作成 + await createFollowing(user, followee1); + await createFollowing(user, followee2); + + await userSuspendService.suspend(user, moderator); + await sleep(); + + // フォロー関係が論理削除されているかチェック + const followings = await followingsRepository.find({ + where: { followerId: user.id }, + }); + + expect(followings).toHaveLength(2); + followings.forEach(following => { + expect(following.isFollowerSuspended).toBe(true); + }); + }); + + test('should publish internal event for suspension', async () => { + const user = await createUser(); + const moderator = await createUser(); + + await userSuspendService.suspend(user, moderator); + await sleep(); + + // 内部イベントが発行されているかチェック(非同期処理のため少し待つ) + await new Promise(resolve => setTimeout(resolve, 100)); + + expect(globalEventService.publishInternalEvent).toHaveBeenCalledWith( + 'userChangeSuspendedState', + { id: user.id, isSuspended: true }, + ); + }); + }); + + describe('unsuspend', () => { + test('should unsuspend user and update database', async () => { + const user = await createUser({ isSuspended: true }); + const moderator = await createUser(); + + await userSuspendService.unsuspend(user, moderator); + await sleep(); + + // ユーザーの凍結が解除されているかチェック + const unsuspendedUser = await usersRepository.findOneBy({ id: user.id }); + expect(unsuspendedUser?.isSuspended).toBe(false); + + // モデレーションログが記録されているかチェック + expect(moderationLogService.log).toHaveBeenCalledWith(moderator, 'unsuspend', { + userId: user.id, + userUsername: user.username, + userHost: user.host, + }); + }); + + test('should restore follower relationships', async () => { + const user = await createUser({ isSuspended: true }); + const followee1 = await createUser(); + const followee2 = await createUser(); + const moderator = await createUser(); + + // 凍結状態のフォロー関係を作成 + await createFollowing(user, followee1, { isFollowerSuspended: true }); + await createFollowing(user, followee2, { isFollowerSuspended: true }); + + await userSuspendService.unsuspend(user, moderator); + await sleep(); + + // フォロー関係が復元されているかチェック + const followings = await followingsRepository.find({ + where: { followerId: user.id }, + }); + + expect(followings).toHaveLength(2); + followings.forEach(following => { + expect(following.isFollowerSuspended).toBe(false); + }); + }); + + test('should publish internal event for unsuspension', async () => { + const user = await createUser({ isSuspended: true }); + const moderator = await createUser(); + + await userSuspendService.unsuspend(user, moderator); + await sleep(); + + // 内部イベントが発行されているかチェック(非同期処理のため少し待つ) + await new Promise(resolve => setTimeout(resolve, 100)); + + expect(globalEventService.publishInternalEvent).toHaveBeenCalledWith( + 'userChangeSuspendedState', + { id: user.id, isSuspended: false }, + ); + }); + }); + + describe('integration test: suspend and unsuspend cycle', () => { + test('should preserve follow relationships through suspend/unsuspend cycle', async () => { + const user = await createUser(); + const followee1 = await createUser(); + const followee2 = await createUser(); + const moderator = await createUser(); + + // 初期のフォロー関係を作成 + await createFollowing(user, followee1); + await createFollowing(user, followee2); + + // 初期状態の確認 + let followings = await followingsRepository.find({ + where: { followerId: user.id }, + }); + expect(followings).toHaveLength(2); + followings.forEach(following => { + expect(following.isFollowerSuspended).toBe(false); + }); + + // 凍結 + await userSuspendService.suspend(user, moderator); + await sleep(); + + // 凍結後の状態確認 + followings = await followingsRepository.find({ + where: { followerId: user.id }, + }); + expect(followings).toHaveLength(2); + followings.forEach(following => { + expect(following.isFollowerSuspended).toBe(true); + }); + + // 凍結解除 + const suspendedUser = await usersRepository.findOneByOrFail({ id: user.id }); + await userSuspendService.unsuspend(suspendedUser, moderator); + await sleep(); + + // 凍結解除後の状態確認 + followings = await followingsRepository.find({ + where: { followerId: user.id }, + }); + expect(followings).toHaveLength(2); + followings.forEach(following => { + expect(following.isFollowerSuspended).toBe(false); + }); + }); + }); + + describe('ActivityPub delivery', () => { + test('should deliver Delete activity on suspend of local user', async () => { + const localUser = await createUser({ host: null }); + const moderator = await createUser(); + + userEntityService.isLocalUser.mockReturnValue(true); + userEntityService.genLocalUserUri.mockReturnValue(`https://example.com/users/${localUser.id}`); + apRendererService.renderDelete.mockReturnValue({ type: 'Delete' } as any); + apRendererService.addContext.mockReturnValue({ '@context': '...', type: 'Delete' } as any); + + await userSuspendService.suspend(localUser, moderator); + await sleep(); + + // ActivityPub配信が呼ばれているかチェック + expect(userEntityService.isLocalUser).toHaveBeenCalledWith(localUser); + expect(apRendererService.renderDelete).toHaveBeenCalled(); + expect(apRendererService.addContext).toHaveBeenCalled(); + }); + + test('should deliver Undo Delete activity on unsuspend of local user', async () => { + const localUser = await createUser({ host: null, isSuspended: true }); + const moderator = await createUser(); + + userEntityService.isLocalUser.mockReturnValue(true); + userEntityService.genLocalUserUri.mockReturnValue(`https://example.com/users/${localUser.id}`); + apRendererService.renderDelete.mockReturnValue({ type: 'Delete' } as any); + apRendererService.renderUndo.mockReturnValue({ type: 'Undo' } as any); + apRendererService.addContext.mockReturnValue({ '@context': '...', type: 'Undo' } as any); + + await userSuspendService.unsuspend(localUser, moderator); + await sleep(); + + // ActivityPub配信が呼ばれているかチェック + expect(userEntityService.isLocalUser).toHaveBeenCalledWith(localUser); + expect(apRendererService.renderDelete).toHaveBeenCalled(); + expect(apRendererService.renderUndo).toHaveBeenCalled(); + expect(apRendererService.addContext).toHaveBeenCalled(); + }); + + test('should not deliver any activity on suspend of remote user', async () => { + const remoteUser = await createUser({ host: 'remote.example.com' }); + const moderator = await createUser(); + + userEntityService.isLocalUser.mockReturnValue(false); + + await userSuspendService.suspend(remoteUser, moderator); + await sleep(); + + // ActivityPub配信が呼ばれていないことをチェック + expect(userEntityService.isLocalUser).toHaveBeenCalledWith(remoteUser); + expect(apRendererService.renderDelete).not.toHaveBeenCalled(); + expect(queueService.deliver).not.toHaveBeenCalled(); + }); + }); +}); From 3a5870b9a537f4aefae44e6f53e45dc1c44eb937 Mon Sep 17 00:00:00 2001 From: tamaina Date: Sun, 13 Jul 2025 23:54:16 +0900 Subject: [PATCH 04/25] add migration; copy suspended state from user table --- ...ngIsFollowerSuspendedCopySuspendedState.js | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 packages/backend/migration/1752410900000-FollowingIsFollowerSuspendedCopySuspendedState.js diff --git a/packages/backend/migration/1752410900000-FollowingIsFollowerSuspendedCopySuspendedState.js b/packages/backend/migration/1752410900000-FollowingIsFollowerSuspendedCopySuspendedState.js new file mode 100644 index 0000000000..4620442a13 --- /dev/null +++ b/packages/backend/migration/1752410900000-FollowingIsFollowerSuspendedCopySuspendedState.js @@ -0,0 +1,20 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ +module.exports = class FollowingIsFollowerSuspendedCopySuspendedState1752410900000 { + name = 'FollowingIsFollowerSuspendedCopySuspendedState1752410900000' + + async up(queryRunner) { + // Update existing records based on user suspension status + await queryRunner.query(` + UPDATE "following" + SET "isFollowerSuspended" = "user"."isSuspended" + FROM "user" + WHERE "following"."followerId" = "user"."id" + `); + } + + async down(queryRunner) { + } +} From cbc85eef3e5bc8032783e31da25e27a2ce1c20e7 Mon Sep 17 00:00:00 2001 From: tamaina Date: Mon, 14 Jul 2025 14:15:29 +0900 Subject: [PATCH 05/25] wip --- packages/backend/src/core/FanoutTimelineService.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/backend/src/core/FanoutTimelineService.ts b/packages/backend/src/core/FanoutTimelineService.ts index 24999bf4da..3d1f571066 100644 --- a/packages/backend/src/core/FanoutTimelineService.ts +++ b/packages/backend/src/core/FanoutTimelineService.ts @@ -8,6 +8,7 @@ import * as Redis from 'ioredis'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; import { IdService } from '@/core/IdService.js'; +import { MiUser } from '@/models/User.js'; export type FanoutTimelineName = ( // home timeline @@ -112,4 +113,17 @@ export class FanoutTimelineService { public purge(name: FanoutTimelineName) { return this.redisForTimelines.del('list:' + name); } + + @bindThis + public purgeByUserIds(userIds: MiUser['id'][]) { + return Promise.all(userIds.flatMap(userId => [ + this.purge(`homeTimeline:${userId}`), + this.purge(`homeTimelineWithFiles:${userId}`), + this.purge(`localTimelineWithReplyTo:${userId}`), + this.purge(`userTimeline:${userId}`), + this.purge(`userTimelineWithFiles:${userId}`), + this.purge(`userTimelineWithReplies:${userId}`), + this.purge(`userTimelineWithChannel:${userId}`), + ])); + } } From 4da98fe3946720f8e19f2e57d69057517fa99f4d Mon Sep 17 00:00:00 2001 From: tamaina Date: Mon, 14 Jul 2025 14:20:44 +0900 Subject: [PATCH 06/25] =?UTF-8?q?test(backend):=20=E9=9D=9EFTT=E6=99=82?= =?UTF-8?q?=E3=81=AE=E3=83=86=E3=82=B9=E3=83=88=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/test/e2e/timelines.ts | 3196 ++++++++++++------------ 1 file changed, 1650 insertions(+), 1546 deletions(-) diff --git a/packages/backend/test/e2e/timelines.ts b/packages/backend/test/e2e/timelines.ts index e53c3d8f34..237b5885c5 100644 --- a/packages/backend/test/e2e/timelines.ts +++ b/packages/backend/test/e2e/timelines.ts @@ -9,1561 +9,1665 @@ import * as assert from 'assert'; import { setTimeout } from 'node:timers/promises'; import { Redis } from 'ioredis'; -import { api, post, randomString, sendEnvUpdateRequest, signup, uploadUrl } from '../utils.js'; +import { api, post, randomString, sendEnvUpdateRequest, signup, uploadUrl, role } from '../utils.js'; import { loadConfig } from '@/config.js'; +import { SignupResponse, Role } from 'misskey-js/entities.js'; function genHost() { return randomString() + '.example.com'; } -function waitForPushToTl() { - return setTimeout(500); -} - let redisForTimelines: Redis; +let root: SignupResponse; +let roleAdmin: Role; describe('Timelines', () => { - beforeAll(() => { + beforeAll(async () => { redisForTimelines = new Redis(loadConfig().redisForTimelines); + root = await signup({ username: 'root' }); + //console.log(await api('admin/update-meta', { enableFanoutTimeline }, root)); + }, 1000 * 60 * 2); + + describe.each([ + { enableFanoutTimeline: true }, + { enableFanoutTimeline: false }, + ])('Timelines (enableFanoutTimeline: $enableFanoutTimeline)', ({ enableFanoutTimeline }) => { + function waitForPushToTl() { + return setTimeout(250); + } + + beforeAll(async () => { + console.log(await api('admin/update-meta', { enableFanoutTimeline }, root)); + }, 1000 * 60 * 2); + + describe('Home TL', () => { + test('自分の visibility: followers なノートが含まれる', async () => { + const [alice] = await Promise.all([signup()]); + + const aliceNote = await post(alice, { text: 'hi', visibility: 'followers' }); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + assert.strictEqual(res.body.find(note => note.id === aliceNote.id)?.text, 'hi'); + }); + + test('フォローしているユーザーのノートが含まれる', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + await api('following/create', { userId: bob.id }, alice); + await setTimeout(250); + const bobNote = await post(bob, { text: 'hi' }); + const carolNote = await post(carol, { text: 'hi' }); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + }); + + test('フォローしているユーザーの visibility: followers なノートが含まれる', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + await api('following/create', { userId: bob.id }, alice); + await setTimeout(250); + const bobNote = await post(bob, { text: 'hi', visibility: 'followers' }); + const carolNote = await post(carol, { text: 'hi' }); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.find(note => note.id === bobNote.id)?.text, 'hi'); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + }); + + test('withReplies: false でフォローしているユーザーの他人への返信が含まれない', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + await api('following/create', { userId: bob.id }, alice); + await setTimeout(250); + const carolNote = await post(carol, { text: 'hi' }); + const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + }); + + test('withReplies: true でフォローしているユーザーの他人への返信が含まれる', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + await api('following/create', { userId: bob.id }, alice); + await api('following/update', { userId: bob.id, withReplies: true }, alice); + await setTimeout(250); + const carolNote = await post(carol, { text: 'hi' }); + const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + }); + + test('withReplies: true でフォローしているユーザーの他人へのDM返信が含まれない', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + await api('following/create', { userId: bob.id }, alice); + await api('following/update', { userId: bob.id, withReplies: true }, alice); + await setTimeout(250); + const carolNote = await post(carol, { text: 'hi' }); + const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id, visibility: 'specified', visibleUserIds: [carolNote.id] }); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + }); + + test('withReplies: true でフォローしているユーザーの他人の visibility: followers な投稿への返信が含まれない', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + await api('following/create', { userId: carol.id }, bob); + await api('following/create', { userId: bob.id }, alice); + await api('following/update', { userId: bob.id, withReplies: true }, alice); + await setTimeout(250); + const carolNote = await post(carol, { text: 'hi', visibility: 'followers' }); + const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + }); + + test('withReplies: true でフォローしているユーザーの行った別のフォローしているユーザーの visibility: followers な投稿への返信が含まれる', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + await api('following/create', { userId: bob.id }, alice); + await api('following/create', { userId: carol.id }, alice); + await api('following/create', { userId: carol.id }, bob); + await api('following/update', { userId: bob.id, withReplies: true }, alice); + await setTimeout(250); + const carolNote = await post(carol, { text: 'hi', visibility: 'followers' }); + const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), true); + assert.strictEqual(res.body.find(note => note.id === carolNote.id)?.text, 'hi'); + }); + + test('withReplies: true でフォローしているユーザーの自分の visibility: followers な投稿への返信が含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + await api('following/create', { userId: bob.id }, alice); + await api('following/create', { userId: alice.id }, bob); + await api('following/update', { userId: bob.id, withReplies: true }, alice); + await setTimeout(250); + const aliceNote = await post(alice, { text: 'hi', visibility: 'followers' }); + const bobNote = await post(bob, { text: 'hi', replyId: aliceNote.id }); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); + }); + + test('withReplies: true でフォローしているユーザーの行った別のフォローしているユーザーの投稿への visibility: specified な返信が含まれない', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + await api('following/create', { userId: bob.id }, alice); + await api('following/create', { userId: carol.id }, alice); + await api('following/update', { userId: bob.id, withReplies: true }, alice); + await setTimeout(250); + const carolNote = await post(carol, { text: 'hi' }); + const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id, visibility: 'specified', visibleUserIds: [carolNote.id] }); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), true); + }); + + test('withReplies: false でフォローしているユーザーのそのユーザー自身への返信が含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + await api('following/create', { userId: bob.id }, alice); + await setTimeout(250); + const bobNote1 = await post(bob, { text: 'hi' }); + const bobNote2 = await post(bob, { text: 'hi', replyId: bobNote1.id }); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote1.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); + }); + + test('withReplies: false でフォローしているユーザーからの自分への返信が含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + await api('following/create', { userId: bob.id }, alice); + await setTimeout(250); + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'hi', replyId: aliceNote.id }); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + }); + + test('自分の他人への返信が含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const bobNote = await post(bob, { text: 'hi' }); + const aliceNote = await post(alice, { text: 'hi', replyId: bobNote.id }); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + }); + + test('フォローしているユーザーの他人の投稿のリノートが含まれる', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + await api('following/create', { userId: bob.id }, alice); + await setTimeout(250); + const carolNote = await post(carol, { text: 'hi' }); + const bobNote = await post(bob, { renoteId: carolNote.id }); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + }); + + test('[withRenotes: false] フォローしているユーザーの他人の投稿のリノートが含まれない', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + await api('following/create', { userId: bob.id }, alice); + await setTimeout(250); + const carolNote = await post(carol, { text: 'hi' }); + const bobNote = await post(bob, { renoteId: carolNote.id }); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { + withRenotes: false, + }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + }); + + test('[withRenotes: false] フォローしているユーザーの他人の投稿の引用が含まれる', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + await api('following/create', { userId: bob.id }, alice); + await setTimeout(250); + const carolNote = await post(carol, { text: 'hi' }); + const bobNote = await post(bob, { text: 'hi', renoteId: carolNote.id }); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { + withRenotes: false, + }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + }); + + test('フォローしているユーザーの他人への visibility: specified なノートが含まれない', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + await api('following/create', { userId: bob.id }, alice); + await setTimeout(250); + const bobNote = await post(bob, { text: 'hi', visibility: 'specified', visibleUserIds: [carol.id] }); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + }); + + test('フォローしているユーザーが行ったミュートしているユーザーのリノートが含まれない', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + await api('following/create', { userId: bob.id }, alice); + await api('mute/create', { userId: carol.id }, alice); + await setTimeout(250); + const carolNote = await post(carol, { text: 'hi' }); + const bobNote = await post(bob, { text: 'hi', renoteId: carolNote.id }); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + }); + + test('withReplies: true でフォローしているユーザーが行ったミュートしているユーザーの投稿への返信が含まれない', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + await api('following/create', { userId: bob.id }, alice); + await api('following/update', { userId: bob.id, withReplies: true }, alice); + await api('mute/create', { userId: carol.id }, alice); + await setTimeout(250); + const carolNote = await post(carol, { text: 'hi' }); + const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + }); + + test('ミュートしているユーザーのノートの、関係のないユーザによる引用ノートの、フォローしているユーザーによるリノートが含まれない', async () => { + const [alice, bob, carol, dave] = await Promise.all([signup(), signup(), signup(), signup()]); + + await api('following/create', { userId: bob.id }, alice); + await api('mute/create', { userId: carol.id }, alice); + await setTimeout(250); + const carolNote = await post(carol, { text: 'hi' }); + const daveNote = await post(dave, { text: 'quote hi', renoteId: carolNote.id }); + const bobNote = await post(bob, { renoteId: daveNote.id }); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + assert.strictEqual(res.body.some(note => note.id === daveNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + }); + + test('ミュートしているユーザーのノートの、関係のないユーザによるリプライの、フォローしているユーザーによるリノートが含まれない', async () => { + const [alice, bob, carol, dave] = await Promise.all([signup(), signup(), signup(), signup()]); + + await api('following/create', { userId: bob.id }, alice); + await api('mute/create', { userId: carol.id }, alice); + await setTimeout(250); + const carolNote = await post(carol, { text: 'hi' }); + const daveNote = await post(dave, { text: 'quote hi', replyId: carolNote.id }); + const bobNote = await post(bob, { renoteId: daveNote.id }); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + assert.strictEqual(res.body.some(note => note.id === daveNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + }); + + test('フォローしているリモートユーザーのノートが含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup({ host: genHost() })]); + + await sendEnvUpdateRequest({ key: 'FORCE_FOLLOW_REMOTE_USER_FOR_TESTING', value: 'true' }); + await api('following/create', { userId: bob.id }, alice); + + const bobNote = await post(bob, { text: 'hi' }); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + }); + + test('フォローしているリモートユーザーの visibility: home なノートが含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup({ host: genHost() })]); + + await sendEnvUpdateRequest({ key: 'FORCE_FOLLOW_REMOTE_USER_FOR_TESTING', value: 'true' }); + await api('following/create', { userId: bob.id }, alice); + + const bobNote = await post(bob, { text: 'hi', visibility: 'home' }); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + }); + + test('[withFiles: true] フォローしているユーザーのファイル付きノートのみ含まれる', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + await api('following/create', { userId: bob.id }, alice); + await setTimeout(250); + const [bobFile, carolFile] = await Promise.all([ + uploadUrl(bob, 'https://raw.githubusercontent.com/misskey-dev/assets/main/public/icon.png'), + uploadUrl(carol, 'https://raw.githubusercontent.com/misskey-dev/assets/main/public/icon.png'), + ]); + const bobNote1 = await post(bob, { text: 'hi' }); + const bobNote2 = await post(bob, { fileIds: [bobFile.id] }); + const carolNote1 = await post(carol, { text: 'hi' }); + const carolNote2 = await post(carol, { fileIds: [carolFile.id] }); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100, withFiles: true }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote1.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote1.id), false); + assert.strictEqual(res.body.some(note => note.id === carolNote2.id), false); + }, 1000 * 30); + + test('フォローしているユーザーのチャンネル投稿が含まれない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await api('channels/create', { name: 'channel' }, bob).then(x => x.body); + await api('following/create', { userId: bob.id }, alice); + await setTimeout(250); + const bobNote = await post(bob, { text: 'hi', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + }); + + test('自分の visibility: specified なノートが含まれる', async () => { + const [alice] = await Promise.all([signup()]); + + const aliceNote = await post(alice, { text: 'hi', visibility: 'specified' }); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + assert.strictEqual(res.body.find(note => note.id === aliceNote.id)?.text, 'hi'); + }); + + test('フォローしているユーザーの自身を visibleUserIds に指定した visibility: specified なノートが含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + await api('following/create', { userId: bob.id }, alice); + await setTimeout(250); + const bobNote = await post(bob, { text: 'hi', visibility: 'specified', visibleUserIds: [alice.id] }); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.find(note => note.id === bobNote.id)?.text, 'hi'); + }); + + test('フォローしていないユーザーの自身を visibleUserIds に指定した visibility: specified なノートが含まれない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const bobNote = await post(bob, { text: 'hi', visibility: 'specified', visibleUserIds: [alice.id] }); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + }); + + test('フォローしているユーザーの自身を visibleUserIds に指定していない visibility: specified なノートが含まれない', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + await api('following/create', { userId: bob.id }, alice); + await setTimeout(250); + const bobNote = await post(bob, { text: 'hi', visibility: 'specified', visibleUserIds: [carol.id] }); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + }); + + test('フォローしていないユーザーからの visibility: specified なノートに返信したときの自身のノートが含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const bobNote = await post(bob, { text: 'hi', visibility: 'specified', visibleUserIds: [alice.id] }); + const aliceNote = await post(alice, { text: 'ok', visibility: 'specified', visibleUserIds: [bob.id], replyId: bobNote.id }); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + assert.strictEqual(res.body.find(note => note.id === aliceNote.id)?.text, 'ok'); + }); + + /* TODO + test('自身の visibility: specified なノートへのフォローしていないユーザーからの返信が含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const aliceNote = await post(alice, { text: 'hi', visibility: 'specified', visibleUserIds: [bob.id] }); + const bobNote = await post(bob, { text: 'ok', visibility: 'specified', visibleUserIds: [alice.id], replyId: aliceNote.id }); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.find(note => note.id === bobNote.id).text, 'ok'); + }); + */ + + // ↑の挙動が理想だけど実装が面倒かも + test('自身の visibility: specified なノートへのフォローしていないユーザーからの返信が含まれない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const aliceNote = await post(alice, { text: 'hi', visibility: 'specified', visibleUserIds: [bob.id] }); + const bobNote = await post(bob, { text: 'ok', visibility: 'specified', visibleUserIds: [alice.id], replyId: aliceNote.id }); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + }); + + test('FTT: ローカルユーザーの HTL にはプッシュされる', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + await api('following/create', { + userId: alice.id, + }, bob); + + const aliceNote = await post(alice, { text: 'I\'m Alice.' }); + const bobNote = await post(bob, { text: 'I\'m Bob.' }); + const carolNote = await post(carol, { text: 'I\'m Carol.' }); + + await waitForPushToTl(); + + if (enableFanoutTimeline) { + // NOTE: notes/timeline だと DB へのフォールバックが効くので Redis を直接見て確かめる + assert.strictEqual(await redisForTimelines.exists(`list:homeTimeline:${bob.id}`), 1); + + const bobHTL = await redisForTimelines.lrange(`list:homeTimeline:${bob.id}`, 0, -1); + assert.strictEqual(bobHTL.includes(aliceNote.id), true); + assert.strictEqual(bobHTL.includes(bobNote.id), true); + assert.strictEqual(bobHTL.includes(carolNote.id), false); + } else { + assert.strictEqual(await redisForTimelines.exists(`list:homeTimeline:${bob.id}`), 0); + } + }); + + test('FTT: リモートユーザーの HTL にはプッシュされない', async () => { + const [alice, bob] = await Promise.all([signup(), signup({ host: genHost() })]); + + await api('following/create', { + userId: alice.id, + }, bob); + + await post(alice, { text: 'I\'m Alice.' }); + await post(bob, { text: 'I\'m Bob.' }); + + await waitForPushToTl(); + + // NOTE: notes/timeline だと DB へのフォールバックが効くので Redis を直接見て確かめる + assert.strictEqual(await redisForTimelines.exists(`list:homeTimeline:${bob.id}`), 0); + }); + + test('凍結: 凍結後に凍結されたユーザーのノートは見えなくなる', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + await api('following/create', { userId: bob.id }, alice); + await api('following/create', { userId: carol.id }, alice); + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'yo' }); + const carolNote = await post(carol, { text: 'kon\'nichiwa' }); + + await waitForPushToTl(); + + await api('admin/suspend-user', { userId: carol.id }, root); + + await setTimeout(250); + + const res = await api('notes/timeline', { limit: 100 }, alice); + assert.strictEqual(res.body.length, 2); + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + }); + }); + + describe('Local TL', () => { + test('visibility: home なノートが含まれない', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + const carolNote = await post(carol, { text: 'hi', visibility: 'home' }); + const bobNote = await post(bob, { text: 'hi' }); + + await waitForPushToTl(); + + const res = await api('notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + }); + + test('他人の他人への返信が含まれない', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + const carolNote = await post(carol, { text: 'hi' }); + const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); + + await waitForPushToTl(); + + const res = await api('notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), true); + }); + + test('他人のその人自身への返信が含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const bobNote1 = await post(bob, { text: 'hi' }); + const bobNote2 = await post(bob, { text: 'hi', replyId: bobNote1.id }); + + await waitForPushToTl(); + + const res = await api('notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote1.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); + }); + + test('チャンネル投稿が含まれない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await api('channels/create', { name: 'channel' }, bob).then(x => x.body); + const bobNote = await post(bob, { text: 'hi', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + }); + + test('リモートユーザーのノートが含まれない', async () => { + const [alice, bob] = await Promise.all([signup(), signup({ host: genHost() })]); + + const bobNote = await post(bob, { text: 'hi' }); + + await waitForPushToTl(); + + const res = await api('notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + }); + + // 含まれても良いと思うけど実装が面倒なので含まれない + test('フォローしているユーザーの visibility: home なノートが含まれない', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + await api('following/create', { userId: carol.id }, alice); + await setTimeout(250); + const carolNote = await post(carol, { text: 'hi', visibility: 'home' }); + const bobNote = await post(bob, { text: 'hi' }); + + await waitForPushToTl(); + + const res = await api('notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + }); + + test('ミュートしているユーザーのノートが含まれない', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + await api('mute/create', { userId: carol.id }, alice); + await setTimeout(250); + const carolNote = await post(carol, { text: 'hi' }); + const bobNote = await post(bob, { text: 'hi' }); + + await waitForPushToTl(); + + const res = await api('notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + }); + + test('フォローしているユーザーが行ったミュートしているユーザーのリノートが含まれない', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + await api('following/create', { userId: bob.id }, alice); + await api('mute/create', { userId: carol.id }, alice); + await setTimeout(250); + const carolNote = await post(carol, { text: 'hi' }); + const bobNote = await post(bob, { text: 'hi', renoteId: carolNote.id }); + + await waitForPushToTl(); + + const res = await api('notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + }); + + test('withReplies: true でフォローしているユーザーが行ったミュートしているユーザーの投稿への返信が含まれない', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + await api('following/create', { userId: bob.id }, alice); + await api('following/update', { userId: bob.id, withReplies: true }, alice); + await api('mute/create', { userId: carol.id }, alice); + await setTimeout(250); + const carolNote = await post(carol, { text: 'hi' }); + const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); + + await waitForPushToTl(); + + const res = await api('notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + }); + + test('ミュートしているユーザーのノートの、関係のないユーザによる引用ノートの、リノートが含まれない', async () => { + const [alice, bob, carol, dave] = await Promise.all([signup(), signup(), signup(), signup()]); + + await api('mute/create', { userId: carol.id }, alice); + await setTimeout(250); + const carolNote = await post(carol, { text: 'hi' }); + const daveNote = await post(dave, { text: 'quote hi', renoteId: carolNote.id }); + const bobNote = await post(bob, { renoteId: daveNote.id }); + + await waitForPushToTl(); + + const res = await api('notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + assert.strictEqual(res.body.some(note => note.id === daveNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + }); + + test('ミュートしているユーザーのノートの、関係のないユーザによるリプライの、リノートが含まれない', async () => { + const [alice, bob, carol, dave] = await Promise.all([signup(), signup(), signup(), signup()]); + + await api('mute/create', { userId: carol.id }, alice); + await setTimeout(250); + const carolNote = await post(carol, { text: 'hi' }); + const daveNote = await post(dave, { text: 'quote hi', replyId: carolNote.id }); + const bobNote = await post(bob, { renoteId: daveNote.id }); + + await waitForPushToTl(); + + const res = await api('notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + assert.strictEqual(res.body.some(note => note.id === daveNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + }); + + test('withReplies: false でフォローしているユーザーからの自分への返信が含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + await api('following/create', { userId: bob.id }, alice); + await setTimeout(250); + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'hi', replyId: aliceNote.id }); + + await waitForPushToTl(); + + const res = await api('notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + }); + + test('withReplies: false でフォローしていないユーザーからの自分への返信が含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + await setTimeout(250); + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'hi', replyId: aliceNote.id }); + + await waitForPushToTl(); + + const res = await api('notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + }); + + test('[withReplies: true] 他人の他人への返信が含まれる', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + const carolNote = await post(carol, { text: 'hi' }); + const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); + + await waitForPushToTl(); + + const res = await api('notes/local-timeline', { limit: 100, withReplies: true }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + }); + + test('[withFiles: true] ファイル付きノートのみ含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const file = await uploadUrl(bob, 'https://raw.githubusercontent.com/misskey-dev/assets/main/public/icon.png'); + const bobNote1 = await post(bob, { text: 'hi' }); + const bobNote2 = await post(bob, { fileIds: [file.id] }); + + await waitForPushToTl(); + + const res = await api('notes/local-timeline', { limit: 100, withFiles: true }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote1.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); + }, 1000 * 10); + + test('凍結: 凍結後に凍結されたユーザーのノートは見えなくなる', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + await api('following/create', { userId: bob.id }, alice); + await api('following/create', { userId: carol.id }, alice); + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'yo' }); + const carolNote = await post(carol, { text: 'kon\'nichiwa' }); + + await waitForPushToTl(); + + await api('admin/suspend-user', { userId: carol.id }, root); + + await setTimeout(250); + + const res = await api('notes/local-timeline', { limit: 100 }, alice); + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + }); + }); + + describe('Social TL', () => { + test('ローカルユーザーのノートが含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const bobNote = await post(bob, { text: 'hi' }); + + await waitForPushToTl(); + + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + }); + + test('ローカルユーザーの visibility: home なノートが含まれない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const bobNote = await post(bob, { text: 'hi', visibility: 'home' }); + + await waitForPushToTl(); + + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + }); + + test('フォローしているローカルユーザーの visibility: home なノートが含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + await api('following/create', { userId: bob.id }, alice); + await setTimeout(250); + const bobNote = await post(bob, { text: 'hi', visibility: 'home' }); + + await waitForPushToTl(); + + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + }); + + test('withReplies: false でフォローしているユーザーからの自分への返信が含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + await api('following/create', { userId: bob.id }, alice); + await setTimeout(250); + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'hi', replyId: aliceNote.id }); + + await waitForPushToTl(); + + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + }); + + test('withReplies: true でフォローしているユーザーの他人の visibility: followers な投稿への返信が含まれない', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + await api('following/create', { userId: carol.id }, bob); + await api('following/create', { userId: bob.id }, alice); + await api('following/update', { userId: bob.id, withReplies: true }, alice); + await setTimeout(250); + const carolNote = await post(carol, { text: 'hi', visibility: 'followers' }); + const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); + + await waitForPushToTl(); + + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + }); + + test('withReplies: true でフォローしているユーザーの行った別のフォローしているユーザーの visibility: followers な投稿への返信が含まれる', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + await api('following/create', { userId: bob.id }, alice); + await api('following/create', { userId: carol.id }, alice); + await api('following/create', { userId: carol.id }, bob); + await api('following/update', { userId: bob.id, withReplies: true }, alice); + await setTimeout(250); + const carolNote = await post(carol, { text: 'hi', visibility: 'followers' }); + const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); + + await waitForPushToTl(); + + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true); + assert.strictEqual(res.body.find((note: any) => note.id === carolNote.id)?.text, 'hi'); + }); + + test('withReplies: true でフォローしているユーザーの自分の visibility: followers な投稿への返信が含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + await api('following/create', { userId: bob.id }, alice); + await api('following/create', { userId: alice.id }, bob); + await api('following/update', { userId: bob.id, withReplies: true }, alice); + await setTimeout(250); + const aliceNote = await post(alice, { text: 'hi', visibility: 'followers' }); + const bobNote = await post(bob, { text: 'hi', replyId: aliceNote.id }); + + await waitForPushToTl(); + + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); + }); + + test('他人の他人への返信が含まれない', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + const carolNote = await post(carol, { text: 'hi' }); + const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); + + await waitForPushToTl(); + + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), true); + }); + + test('リモートユーザーのノートが含まれない', async () => { + const [alice, bob] = await Promise.all([signup(), signup({ host: genHost() })]); + + const bobNote = await post(bob, { text: 'hi' }); + + await waitForPushToTl(); + + const res = await api('notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + }); + + test('フォローしているリモートユーザーのノートが含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup({ host: genHost() })]); + + await sendEnvUpdateRequest({ key: 'FORCE_FOLLOW_REMOTE_USER_FOR_TESTING', value: 'true' }); + await api('following/create', { userId: bob.id }, alice); + + const bobNote = await post(bob, { text: 'hi' }); + + await waitForPushToTl(); + + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + }); + + test('フォローしているリモートユーザーの visibility: home なノートが含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup({ host: genHost() })]); + + await sendEnvUpdateRequest({ key: 'FORCE_FOLLOW_REMOTE_USER_FOR_TESTING', value: 'true' }); + await api('following/create', { userId: bob.id }, alice); + + const bobNote = await post(bob, { text: 'hi', visibility: 'home' }); + + await waitForPushToTl(); + + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + }); + + test('withReplies: false でフォローしていないユーザーからの自分への返信が含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + await setTimeout(250); + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'hi', replyId: aliceNote.id }); + + await waitForPushToTl(); + + const res = await api('notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + }); + + test('[withReplies: true] 他人の他人への返信が含まれる', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + const carolNote = await post(carol, { text: 'hi' }); + const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); + + await waitForPushToTl(); + + const res = await api('notes/hybrid-timeline', { limit: 100, withReplies: true }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + }); + + test('[withFiles: true] ファイル付きノートのみ含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const file = await uploadUrl(bob, 'https://raw.githubusercontent.com/misskey-dev/assets/main/public/icon.png'); + const bobNote1 = await post(bob, { text: 'hi' }); + const bobNote2 = await post(bob, { fileIds: [file.id] }); + + await waitForPushToTl(); + + const res = await api('notes/hybrid-timeline', { limit: 100, withFiles: true }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote1.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); + }, 1000 * 10); + + test('凍結: 凍結後に凍結されたユーザーのノートは見えなくなる', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + await api('following/create', { userId: bob.id }, alice); + await api('following/create', { userId: carol.id }, alice); + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'yo' }); + const carolNote = await post(carol, { text: 'kon\'nichiwa' }); + + await waitForPushToTl(); + + await api('admin/suspend-user', { userId: carol.id }, root); + + await setTimeout(250); + + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + }); + }); + + describe('User List TL', () => { + test('リスインしているフォローしていないユーザーのノートが含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); + await api('users/lists/push', { listId: list.id, userId: bob.id }, alice); + await setTimeout(250); + const bobNote = await post(bob, { text: 'hi' }); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { listId: list.id }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + }); + + test('リスインしているフォローしていないユーザーの visibility: home なノートが含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); + await api('users/lists/push', { listId: list.id, userId: bob.id }, alice); + await setTimeout(250); + const bobNote = await post(bob, { text: 'hi', visibility: 'home' }); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { listId: list.id }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + }); + + test('リスインしているフォローしていないユーザーの visibility: followers なノートが含まれない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); + await api('users/lists/push', { listId: list.id, userId: bob.id }, alice); + await setTimeout(250); + const bobNote = await post(bob, { text: 'hi', visibility: 'followers' }); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { listId: list.id }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + }); + + test('リスインしているフォローしていないユーザーの他人への返信が含まれない', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); + await api('users/lists/push', { listId: list.id, userId: bob.id }, alice); + await setTimeout(250); + const carolNote = await post(carol, { text: 'hi' }); + const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { listId: list.id }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + }); + + test('リスインしているフォローしていないユーザーのユーザー自身への返信が含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); + await api('users/lists/push', { listId: list.id, userId: bob.id }, alice); + await setTimeout(250); + const bobNote1 = await post(bob, { text: 'hi' }); + const bobNote2 = await post(bob, { text: 'hi', replyId: bobNote1.id }); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { listId: list.id }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote1.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); + }); + + test('withReplies: false でリスインしているフォローしていないユーザーからの自分への返信が含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); + await api('users/lists/push', { listId: list.id, userId: bob.id }, alice); + await api('users/lists/update-membership', { listId: list.id, userId: bob.id, withReplies: false }, alice); + await setTimeout(250); + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'hi', replyId: aliceNote.id }); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { listId: list.id }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + }); + + test('withReplies: false でリスインしているフォローしていないユーザーの他人への返信が含まれない', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); + await api('users/lists/push', { listId: list.id, userId: bob.id }, alice); + await api('users/lists/update-membership', { listId: list.id, userId: bob.id, withReplies: false }, alice); + await setTimeout(250); + const carolNote = await post(carol, { text: 'hi' }); + const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { listId: list.id }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + }); + + test('withReplies: true でリスインしているフォローしていないユーザーの他人への返信が含まれる', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); + await api('users/lists/push', { listId: list.id, userId: bob.id }, alice); + await api('users/lists/update-membership', { listId: list.id, userId: bob.id, withReplies: true }, alice); + await setTimeout(250); + const carolNote = await post(carol, { text: 'hi' }); + const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { listId: list.id }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + }); + + test('リスインしているフォローしているユーザーの visibility: home なノートが含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + await api('following/create', { userId: bob.id }, alice); + const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); + await api('users/lists/push', { listId: list.id, userId: bob.id }, alice); + await setTimeout(250); + const bobNote = await post(bob, { text: 'hi', visibility: 'home' }); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { listId: list.id }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + }); + + test('リスインしているフォローしているユーザーの visibility: followers なノートが含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + await api('following/create', { userId: bob.id }, alice); + const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); + await api('users/lists/push', { listId: list.id, userId: bob.id }, alice); + await setTimeout(250); + const bobNote = await post(bob, { text: 'hi', visibility: 'followers' }); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { listId: list.id }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.find(note => note.id === bobNote.id)?.text, 'hi'); + }); + + test('リスインしている自分の visibility: followers なノートが含まれる', async () => { + const [alice] = await Promise.all([signup(), signup()]); + + const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); + await api('users/lists/push', { listId: list.id, userId: alice.id }, alice); + await setTimeout(250); + const aliceNote = await post(alice, { text: 'hi', visibility: 'followers' }); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { listId: list.id }, alice); + + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + assert.strictEqual(res.body.find(note => note.id === aliceNote.id)?.text, 'hi'); + }); + + test('リスインしているユーザーのチャンネルノートが含まれない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await api('channels/create', { name: 'channel' }, bob).then(x => x.body); + const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); + await api('users/lists/push', { listId: list.id, userId: bob.id }, alice); + await setTimeout(250); + const bobNote = await post(bob, { text: 'hi', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { listId: list.id }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + }); + + test('[withFiles: true] リスインしているユーザーのファイル付きノートのみ含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); + await api('users/lists/push', { listId: list.id, userId: bob.id }, alice); + const file = await uploadUrl(bob, 'https://raw.githubusercontent.com/misskey-dev/assets/main/public/icon.png'); + const bobNote1 = await post(bob, { text: 'hi' }); + const bobNote2 = await post(bob, { fileIds: [file.id] }); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { listId: list.id, withFiles: true }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote1.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); + }, 1000 * 10); + + test('リスインしているユーザーの自身宛ての visibility: specified なノートが含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); + await api('users/lists/push', { listId: list.id, userId: bob.id }, alice); + await setTimeout(250); + const bobNote = await post(bob, { text: 'hi', visibility: 'specified', visibleUserIds: [alice.id] }); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { listId: list.id }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.find(note => note.id === bobNote.id)?.text, 'hi'); + }); + + test('リスインしているユーザーの自身宛てではない visibility: specified なノートが含まれない', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); + await api('users/lists/push', { listId: list.id, userId: bob.id }, alice); + await api('users/lists/push', { listId: list.id, userId: carol.id }, alice); + await setTimeout(250); + const bobNote = await post(bob, { text: 'hi', visibility: 'specified', visibleUserIds: [carol.id] }); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { listId: list.id }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + }); + + test('凍結: 凍結後に凍結されたユーザーのノートは見えなくなる', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); + await api('users/lists/push', { listId: list.id, userId: bob.id }, alice); + await api('users/lists/push', { listId: list.id, userId: carol.id }, alice); + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'yo' }); + const carolNote = await post(carol, { text: 'kon\'nichiwa' }); + + await waitForPushToTl(); + + await api('admin/suspend-user', { userId: carol.id }, root); + + await setTimeout(250); + + const res = await api('notes/user-list-timeline', { listId: list.id }, alice); + assert.strictEqual(res.body.length, 1); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + }); + }); + + describe('User TL', () => { + test('ノートが含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const bobNote = await post(bob, { text: 'hi' }); + + await waitForPushToTl(); + + const res = await api('users/notes', { userId: bob.id }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + }); + + test('フォローしていないユーザーの visibility: followers なノートが含まれない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const bobNote = await post(bob, { text: 'hi', visibility: 'followers' }); + + await waitForPushToTl(); + + const res = await api('users/notes', { userId: bob.id }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + }); + + test('フォローしているユーザーの visibility: followers なノートが含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + await api('following/create', { userId: bob.id }, alice); + await setTimeout(250); + const bobNote = await post(bob, { text: 'hi', visibility: 'followers' }); + + await waitForPushToTl(); + + const res = await api('users/notes', { userId: bob.id }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.find(note => note.id === bobNote.id)?.text, 'hi'); + }); + + test('自身の visibility: followers なノートが含まれる', async () => { + const [alice] = await Promise.all([signup()]); + + const aliceNote = await post(alice, { text: 'hi', visibility: 'followers' }); + + await waitForPushToTl(); + + const res = await api('users/notes', { userId: alice.id }, alice); + + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + assert.strictEqual(res.body.find(note => note.id === aliceNote.id)?.text, 'hi'); + }); + + test('チャンネル投稿が含まれない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await api('channels/create', { name: 'channel' }, bob).then(x => x.body); + const bobNote = await post(bob, { text: 'hi', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('users/notes', { userId: bob.id }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + }); + + test('[withReplies: false] 他人への返信が含まれない', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + const carolNote = await post(carol, { text: 'hi' }); + const bobNote1 = await post(bob, { text: 'hi' }); + const bobNote2 = await post(bob, { text: 'hi', replyId: carolNote.id }); + + await waitForPushToTl(); + + const res = await api('users/notes', { userId: bob.id }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote1.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote2.id), false); + }); + + test('[withReplies: true] 他人への返信が含まれる', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + const carolNote = await post(carol, { text: 'hi' }); + const bobNote1 = await post(bob, { text: 'hi' }); + const bobNote2 = await post(bob, { text: 'hi', replyId: carolNote.id }); + + await waitForPushToTl(); + + const res = await api('users/notes', { userId: bob.id, withReplies: true }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote1.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); + }); + + test('[withReplies: true] 他人への visibility: specified な返信が含まれない', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + const carolNote = await post(carol, { text: 'hi' }); + const bobNote1 = await post(bob, { text: 'hi' }); + const bobNote2 = await post(bob, { text: 'hi', replyId: carolNote.id, visibility: 'specified' }); + + await waitForPushToTl(); + + const res = await api('users/notes', { userId: bob.id, withReplies: true }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote1.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote2.id), false); + }); + + test('[withFiles: true] ファイル付きノートのみ含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const file = await uploadUrl(bob, 'https://raw.githubusercontent.com/misskey-dev/assets/main/public/icon.png'); + const bobNote1 = await post(bob, { text: 'hi' }); + const bobNote2 = await post(bob, { fileIds: [file.id] }); + + await waitForPushToTl(); + + const res = await api('users/notes', { userId: bob.id, withFiles: true }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote1.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); + }, 1000 * 10); + + test('[withChannelNotes: true] チャンネル投稿が含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await api('channels/create', { name: 'channel' }, bob).then(x => x.body); + const bobNote = await post(bob, { text: 'hi', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('users/notes', { userId: bob.id, withChannelNotes: true }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + }); + + test('[withChannelNotes: true] 他人が取得した場合センシティブチャンネル投稿が含まれない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await api('channels/create', { name: 'channel', isSensitive: true }, bob).then(x => x.body); + const bobNote = await post(bob, { text: 'hi', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('users/notes', { userId: bob.id, withChannelNotes: true }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + }); + + test('[withChannelNotes: true] 自分が取得した場合センシティブチャンネル投稿が含まれる', async () => { + const [bob] = await Promise.all([signup()]); + + const channel = await api('channels/create', { name: 'channel', isSensitive: true }, bob).then(x => x.body); + const bobNote = await post(bob, { text: 'hi', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('users/notes', { userId: bob.id, withChannelNotes: true }, bob); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + }); + + test('ミュートしているユーザーに関連する投稿が含まれない', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + await api('mute/create', { userId: carol.id }, alice); + await setTimeout(250); + const carolNote = await post(carol, { text: 'hi' }); + const bobNote = await post(bob, { text: 'hi', renoteId: carolNote.id }); + + await waitForPushToTl(); + + const res = await api('users/notes', { userId: bob.id }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + }); + + test('ミュートしているユーザーのノートの、関係のないユーザによる引用ノートの、リノートが含まれない', async () => { + const [alice, bob, carol, dave] = await Promise.all([signup(), signup(), signup(), signup()]); + + await api('mute/create', { userId: carol.id }, alice); + await setTimeout(250); + const carolNote = await post(carol, { text: 'hi' }); + const daveNote = await post(dave, { text: 'quote hi', renoteId: carolNote.id }); + const bobNote = await post(bob, { renoteId: daveNote.id }); + + await waitForPushToTl(); + + const res = await api('users/notes', { userId: bob.id, limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + }); + + test('ミュートしているユーザーのノートの、関係のないユーザによるリプライの、リノートが含まれない', async () => { + const [alice, bob, carol, dave] = await Promise.all([signup(), signup(), signup(), signup()]); + + await api('following/create', { userId: bob.id }, alice); + await api('mute/create', { userId: carol.id }, alice); + await setTimeout(250); + const carolNote = await post(carol, { text: 'hi' }); + const daveNote = await post(dave, { text: 'quote hi', replyId: carolNote.id }); + const bobNote = await post(bob, { renoteId: daveNote.id }); + + await waitForPushToTl(); + + const res = await api('users/notes', { userId: bob.id, limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + }); + + test('ミュートしていても userId に指定したユーザーの投稿が含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + await api('mute/create', { userId: bob.id }, alice); + await setTimeout(250); + const bobNote1 = await post(bob, { text: 'hi' }); + const bobNote2 = await post(bob, { text: 'hi', replyId: bobNote1.id }); + const bobNote3 = await post(bob, { text: 'hi', renoteId: bobNote1.id }); + const bobNote4 = await post(bob, { renoteId: bobNote2.id }); + const bobNote5 = await post(bob, { renoteId: bobNote3.id }); + + await waitForPushToTl(); + + const res = await api('users/notes', { userId: bob.id }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote1.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote3.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote4.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote5.id), true); + }); + + test('自身の visibility: specified なノートが含まれる', async () => { + const [alice] = await Promise.all([signup()]); + + const aliceNote = await post(alice, { text: 'hi', visibility: 'specified' }); + + await waitForPushToTl(); + + const res = await api('users/notes', { userId: alice.id, withReplies: true }, alice); + + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + }); + + test('visibleUserIds に指定されてない visibility: specified なノートが含まれない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const bobNote = await post(bob, { text: 'hi', visibility: 'specified' }); + + await waitForPushToTl(); + + const res = await api('users/notes', { userId: bob.id, withReplies: true }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + }); + + /** @see https://github.com/misskey-dev/misskey/issues/14000 */ + test('FTT: sinceId にキャッシュより古いノートを指定しても、sinceId による絞り込みが正しく動作する', async () => { + const alice = await signup(); + const noteSince = await post(alice, { text: 'Note where id will be `sinceId`.' }); + const note1 = await post(alice, { text: '1' }); + const note2 = await post(alice, { text: '2' }); + await redisForTimelines.del('list:userTimeline:' + alice.id); + const note3 = await post(alice, { text: '3' }); + + const res = await api('users/notes', { userId: alice.id, sinceId: noteSince.id }); + assert.deepStrictEqual(res.body, [note1, note2, note3]); + }); + + test('FTT: sinceId にキャッシュより古いノートを指定しても、sinceId と untilId による絞り込みが正しく動作する', async () => { + const alice = await signup(); + const noteSince = await post(alice, { text: 'Note where id will be `sinceId`.' }); + const note1 = await post(alice, { text: '1' }); + const note2 = await post(alice, { text: '2' }); + await redisForTimelines.del('list:userTimeline:' + alice.id); + const note3 = await post(alice, { text: '3' }); + const noteUntil = await post(alice, { text: 'Note where id will be `untilId`.' }); + await post(alice, { text: '4' }); + + const res = await api('users/notes', { userId: alice.id, sinceId: noteSince.id, untilId: noteUntil.id }); + assert.deepStrictEqual(res.body, [note3, note2, note1]); + }); + }); + + // TODO: リノートミュート済みユーザーのテスト + // TODO: ページネーションのテスト }); - - describe('Home TL', () => { - test.concurrent('自分の visibility: followers なノートが含まれる', async () => { - const [alice] = await Promise.all([signup()]); - - const aliceNote = await post(alice, { text: 'hi', visibility: 'followers' }); - - await waitForPushToTl(); - - const res = await api('notes/timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); - assert.strictEqual(res.body.find(note => note.id === aliceNote.id)?.text, 'hi'); - }); - - test.concurrent('フォローしているユーザーのノートが含まれる', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('following/create', { userId: bob.id }, alice); - await setTimeout(1000); - const bobNote = await post(bob, { text: 'hi' }); - const carolNote = await post(carol, { text: 'hi' }); - - await waitForPushToTl(); - - const res = await api('notes/timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); - assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); - }); - - test.concurrent('フォローしているユーザーの visibility: followers なノートが含まれる', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('following/create', { userId: bob.id }, alice); - await setTimeout(1000); - const bobNote = await post(bob, { text: 'hi', visibility: 'followers' }); - const carolNote = await post(carol, { text: 'hi' }); - - await waitForPushToTl(); - - const res = await api('notes/timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); - assert.strictEqual(res.body.find(note => note.id === bobNote.id)?.text, 'hi'); - assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); - }); - - test.concurrent('withReplies: false でフォローしているユーザーの他人への返信が含まれない', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('following/create', { userId: bob.id }, alice); - await setTimeout(1000); - const carolNote = await post(carol, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); - - await waitForPushToTl(); - - const res = await api('notes/timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); - assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); - }); - - test.concurrent('withReplies: true でフォローしているユーザーの他人への返信が含まれる', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('following/create', { userId: bob.id }, alice); - await api('following/update', { userId: bob.id, withReplies: true }, alice); - await setTimeout(1000); - const carolNote = await post(carol, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); - - await waitForPushToTl(); - - const res = await api('notes/timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); - assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); - }); - - test.concurrent('withReplies: true でフォローしているユーザーの他人へのDM返信が含まれない', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('following/create', { userId: bob.id }, alice); - await api('following/update', { userId: bob.id, withReplies: true }, alice); - await setTimeout(1000); - const carolNote = await post(carol, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id, visibility: 'specified', visibleUserIds: [carolNote.id] }); - - await waitForPushToTl(); - - const res = await api('notes/timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); - assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); - }); - - test.concurrent('withReplies: true でフォローしているユーザーの他人の visibility: followers な投稿への返信が含まれない', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('following/create', { userId: carol.id }, bob); - await api('following/create', { userId: bob.id }, alice); - await api('following/update', { userId: bob.id, withReplies: true }, alice); - await setTimeout(1000); - const carolNote = await post(carol, { text: 'hi', visibility: 'followers' }); - const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); - - await waitForPushToTl(); - - const res = await api('notes/timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); - assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); - }); - - test.concurrent('withReplies: true でフォローしているユーザーの行った別のフォローしているユーザーの visibility: followers な投稿への返信が含まれる', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('following/create', { userId: bob.id }, alice); - await api('following/create', { userId: carol.id }, alice); - await api('following/create', { userId: carol.id }, bob); - await api('following/update', { userId: bob.id, withReplies: true }, alice); - await setTimeout(1000); - const carolNote = await post(carol, { text: 'hi', visibility: 'followers' }); - const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); - - await waitForPushToTl(); - - const res = await api('notes/timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); - assert.strictEqual(res.body.some(note => note.id === carolNote.id), true); - assert.strictEqual(res.body.find(note => note.id === carolNote.id)?.text, 'hi'); - }); - - test.concurrent('withReplies: true でフォローしているユーザーの自分の visibility: followers な投稿への返信が含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - await api('following/create', { userId: bob.id }, alice); - await api('following/create', { userId: alice.id }, bob); - await api('following/update', { userId: bob.id, withReplies: true }, alice); - await setTimeout(1000); - const aliceNote = await post(alice, { text: 'hi', visibility: 'followers' }); - const bobNote = await post(bob, { text: 'hi', replyId: aliceNote.id }); - - await waitForPushToTl(); - - const res = await api('notes/timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); - }); - - test.concurrent('withReplies: true でフォローしているユーザーの行った別のフォローしているユーザーの投稿への visibility: specified な返信が含まれない', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('following/create', { userId: bob.id }, alice); - await api('following/create', { userId: carol.id }, alice); - await api('following/update', { userId: bob.id, withReplies: true }, alice); - await setTimeout(1000); - const carolNote = await post(carol, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id, visibility: 'specified', visibleUserIds: [carolNote.id] }); - - await waitForPushToTl(); - - const res = await api('notes/timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); - assert.strictEqual(res.body.some(note => note.id === carolNote.id), true); - }); - - test.concurrent('withReplies: false でフォローしているユーザーのそのユーザー自身への返信が含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - await api('following/create', { userId: bob.id }, alice); - await setTimeout(1000); - const bobNote1 = await post(bob, { text: 'hi' }); - const bobNote2 = await post(bob, { text: 'hi', replyId: bobNote1.id }); - - await waitForPushToTl(); - - const res = await api('notes/timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote1.id), true); - assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); - }); - - test.concurrent('withReplies: false でフォローしているユーザーからの自分への返信が含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - await api('following/create', { userId: bob.id }, alice); - await setTimeout(1000); - const aliceNote = await post(alice, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi', replyId: aliceNote.id }); - - await waitForPushToTl(); - - const res = await api('notes/timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); - assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); - }); - - test.concurrent('自分の他人への返信が含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const bobNote = await post(bob, { text: 'hi' }); - const aliceNote = await post(alice, { text: 'hi', replyId: bobNote.id }); - - await waitForPushToTl(); - - const res = await api('notes/timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); - assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); - }); - - test.concurrent('フォローしているユーザーの他人の投稿のリノートが含まれる', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('following/create', { userId: bob.id }, alice); - await setTimeout(1000); - const carolNote = await post(carol, { text: 'hi' }); - const bobNote = await post(bob, { renoteId: carolNote.id }); - - await waitForPushToTl(); - - const res = await api('notes/timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); - assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); - }); - - test.concurrent('[withRenotes: false] フォローしているユーザーの他人の投稿のリノートが含まれない', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('following/create', { userId: bob.id }, alice); - await setTimeout(1000); - const carolNote = await post(carol, { text: 'hi' }); - const bobNote = await post(bob, { renoteId: carolNote.id }); - - await waitForPushToTl(); - - const res = await api('notes/timeline', { - withRenotes: false, - }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); - assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); - }); - - test.concurrent('[withRenotes: false] フォローしているユーザーの他人の投稿の引用が含まれる', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('following/create', { userId: bob.id }, alice); - await setTimeout(1000); - const carolNote = await post(carol, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi', renoteId: carolNote.id }); - - await waitForPushToTl(); - - const res = await api('notes/timeline', { - withRenotes: false, - }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); - assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); - }); - - test.concurrent('フォローしているユーザーの他人への visibility: specified なノートが含まれない', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('following/create', { userId: bob.id }, alice); - await setTimeout(1000); - const bobNote = await post(bob, { text: 'hi', visibility: 'specified', visibleUserIds: [carol.id] }); - - await waitForPushToTl(); - - const res = await api('notes/timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); - }); - - test.concurrent('フォローしているユーザーが行ったミュートしているユーザーのリノートが含まれない', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('following/create', { userId: bob.id }, alice); - await api('mute/create', { userId: carol.id }, alice); - await setTimeout(1000); - const carolNote = await post(carol, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi', renoteId: carolNote.id }); - - await waitForPushToTl(); - - const res = await api('notes/timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); - assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); - }); - - test.concurrent('withReplies: true でフォローしているユーザーが行ったミュートしているユーザーの投稿への返信が含まれない', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('following/create', { userId: bob.id }, alice); - await api('following/update', { userId: bob.id, withReplies: true }, alice); - await api('mute/create', { userId: carol.id }, alice); - await setTimeout(1000); - const carolNote = await post(carol, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); - - await waitForPushToTl(); - - const res = await api('notes/timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); - assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); - }); - - test.concurrent('ミュートしているユーザーのノートの、関係のないユーザによる引用ノートの、フォローしているユーザーによるリノートが含まれない', async () => { - const [alice, bob, carol, dave] = await Promise.all([signup(), signup(), signup(), signup()]); - - await api('following/create', { userId: bob.id }, alice); - await api('mute/create', { userId: carol.id }, alice); - await setTimeout(1000); - const carolNote = await post(carol, { text: 'hi' }); - const daveNote = await post(dave, { text: 'quote hi', renoteId: carolNote.id }); - const bobNote = await post(bob, { renoteId: daveNote.id }); - - await waitForPushToTl(); - - const res = await api('notes/timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); - assert.strictEqual(res.body.some(note => note.id === daveNote.id), false); - assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); - }); - - test.concurrent('ミュートしているユーザーのノートの、関係のないユーザによるリプライの、フォローしているユーザーによるリノートが含まれない', async () => { - const [alice, bob, carol, dave] = await Promise.all([signup(), signup(), signup(), signup()]); - - await api('following/create', { userId: bob.id }, alice); - await api('mute/create', { userId: carol.id }, alice); - await setTimeout(1000); - const carolNote = await post(carol, { text: 'hi' }); - const daveNote = await post(dave, { text: 'quote hi', replyId: carolNote.id }); - const bobNote = await post(bob, { renoteId: daveNote.id }); - - await waitForPushToTl(); - - const res = await api('notes/timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); - assert.strictEqual(res.body.some(note => note.id === daveNote.id), false); - assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); - }); - - test.concurrent('フォローしているリモートユーザーのノートが含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup({ host: genHost() })]); - - await sendEnvUpdateRequest({ key: 'FORCE_FOLLOW_REMOTE_USER_FOR_TESTING', value: 'true' }); - await api('following/create', { userId: bob.id }, alice); - - const bobNote = await post(bob, { text: 'hi' }); - - await waitForPushToTl(); - - const res = await api('notes/timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); - }); - - test.concurrent('フォローしているリモートユーザーの visibility: home なノートが含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup({ host: genHost() })]); - - await sendEnvUpdateRequest({ key: 'FORCE_FOLLOW_REMOTE_USER_FOR_TESTING', value: 'true' }); - await api('following/create', { userId: bob.id }, alice); - - const bobNote = await post(bob, { text: 'hi', visibility: 'home' }); - - await waitForPushToTl(); - - const res = await api('notes/timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); - }); - - test.concurrent('[withFiles: true] フォローしているユーザーのファイル付きノートのみ含まれる', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('following/create', { userId: bob.id }, alice); - await setTimeout(1000); - const [bobFile, carolFile] = await Promise.all([ - uploadUrl(bob, 'https://raw.githubusercontent.com/misskey-dev/assets/main/public/icon.png'), - uploadUrl(carol, 'https://raw.githubusercontent.com/misskey-dev/assets/main/public/icon.png'), - ]); - const bobNote1 = await post(bob, { text: 'hi' }); - const bobNote2 = await post(bob, { fileIds: [bobFile.id] }); - const carolNote1 = await post(carol, { text: 'hi' }); - const carolNote2 = await post(carol, { fileIds: [carolFile.id] }); - - await waitForPushToTl(); - - const res = await api('notes/timeline', { limit: 100, withFiles: true }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote1.id), false); - assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); - assert.strictEqual(res.body.some(note => note.id === carolNote1.id), false); - assert.strictEqual(res.body.some(note => note.id === carolNote2.id), false); - }, 1000 * 30); - - test.concurrent('フォローしているユーザーのチャンネル投稿が含まれない', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const channel = await api('channels/create', { name: 'channel' }, bob).then(x => x.body); - await api('following/create', { userId: bob.id }, alice); - await setTimeout(1000); - const bobNote = await post(bob, { text: 'hi', channelId: channel.id }); - - await waitForPushToTl(); - - const res = await api('notes/timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); - }); - - test.concurrent('自分の visibility: specified なノートが含まれる', async () => { - const [alice] = await Promise.all([signup()]); - - const aliceNote = await post(alice, { text: 'hi', visibility: 'specified' }); - - await waitForPushToTl(); - - const res = await api('notes/timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); - assert.strictEqual(res.body.find(note => note.id === aliceNote.id)?.text, 'hi'); - }); - - test.concurrent('フォローしているユーザーの自身を visibleUserIds に指定した visibility: specified なノートが含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - await api('following/create', { userId: bob.id }, alice); - await setTimeout(1000); - const bobNote = await post(bob, { text: 'hi', visibility: 'specified', visibleUserIds: [alice.id] }); - - await waitForPushToTl(); - - const res = await api('notes/timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); - assert.strictEqual(res.body.find(note => note.id === bobNote.id)?.text, 'hi'); - }); - - test.concurrent('フォローしていないユーザーの自身を visibleUserIds に指定した visibility: specified なノートが含まれない', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const bobNote = await post(bob, { text: 'hi', visibility: 'specified', visibleUserIds: [alice.id] }); - - await waitForPushToTl(); - - const res = await api('notes/timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); - }); - - test.concurrent('フォローしているユーザーの自身を visibleUserIds に指定していない visibility: specified なノートが含まれない', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('following/create', { userId: bob.id }, alice); - await setTimeout(1000); - const bobNote = await post(bob, { text: 'hi', visibility: 'specified', visibleUserIds: [carol.id] }); - - await waitForPushToTl(); - - const res = await api('notes/timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); - }); - - test.concurrent('フォローしていないユーザーからの visibility: specified なノートに返信したときの自身のノートが含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const bobNote = await post(bob, { text: 'hi', visibility: 'specified', visibleUserIds: [alice.id] }); - const aliceNote = await post(alice, { text: 'ok', visibility: 'specified', visibleUserIds: [bob.id], replyId: bobNote.id }); - - await waitForPushToTl(); - - const res = await api('notes/timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); - assert.strictEqual(res.body.find(note => note.id === aliceNote.id)?.text, 'ok'); - }); - - /* TODO - test.concurrent('自身の visibility: specified なノートへのフォローしていないユーザーからの返信が含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const aliceNote = await post(alice, { text: 'hi', visibility: 'specified', visibleUserIds: [bob.id] }); - const bobNote = await post(bob, { text: 'ok', visibility: 'specified', visibleUserIds: [alice.id], replyId: aliceNote.id }); - - await waitForPushToTl(); - - const res = await api('notes/timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); - assert.strictEqual(res.body.find(note => note.id === bobNote.id).text, 'ok'); - }); - */ - - // ↑の挙動が理想だけど実装が面倒かも - test.concurrent('自身の visibility: specified なノートへのフォローしていないユーザーからの返信が含まれない', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const aliceNote = await post(alice, { text: 'hi', visibility: 'specified', visibleUserIds: [bob.id] }); - const bobNote = await post(bob, { text: 'ok', visibility: 'specified', visibleUserIds: [alice.id], replyId: aliceNote.id }); - - await waitForPushToTl(); - - const res = await api('notes/timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); - }); - - test.concurrent('FTT: ローカルユーザーの HTL にはプッシュされる', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('following/create', { - userId: alice.id, - }, bob); - - const aliceNote = await post(alice, { text: 'I\'m Alice.' }); - const bobNote = await post(bob, { text: 'I\'m Bob.' }); - const carolNote = await post(carol, { text: 'I\'m Carol.' }); - - await waitForPushToTl(); - - // NOTE: notes/timeline だと DB へのフォールバックが効くので Redis を直接見て確かめる - assert.strictEqual(await redisForTimelines.exists(`list:homeTimeline:${bob.id}`), 1); - - const bobHTL = await redisForTimelines.lrange(`list:homeTimeline:${bob.id}`, 0, -1); - assert.strictEqual(bobHTL.includes(aliceNote.id), true); - assert.strictEqual(bobHTL.includes(bobNote.id), true); - assert.strictEqual(bobHTL.includes(carolNote.id), false); - }); - - test.concurrent('FTT: リモートユーザーの HTL にはプッシュされない', async () => { - const [alice, bob] = await Promise.all([signup(), signup({ host: genHost() })]); - - await api('following/create', { - userId: alice.id, - }, bob); - - await post(alice, { text: 'I\'m Alice.' }); - await post(bob, { text: 'I\'m Bob.' }); - - await waitForPushToTl(); - - // NOTE: notes/timeline だと DB へのフォールバックが効くので Redis を直接見て確かめる - assert.strictEqual(await redisForTimelines.exists(`list:homeTimeline:${bob.id}`), 0); - }); - }); - - describe('Local TL', () => { - test.concurrent('visibility: home なノートが含まれない', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - const carolNote = await post(carol, { text: 'hi', visibility: 'home' }); - const bobNote = await post(bob, { text: 'hi' }); - - await waitForPushToTl(); - - const res = await api('notes/local-timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); - assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); - }); - - test.concurrent('他人の他人への返信が含まれない', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - const carolNote = await post(carol, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); - - await waitForPushToTl(); - - const res = await api('notes/local-timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); - assert.strictEqual(res.body.some(note => note.id === carolNote.id), true); - }); - - test.concurrent('他人のその人自身への返信が含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const bobNote1 = await post(bob, { text: 'hi' }); - const bobNote2 = await post(bob, { text: 'hi', replyId: bobNote1.id }); - - await waitForPushToTl(); - - const res = await api('notes/local-timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote1.id), true); - assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); - }); - - test.concurrent('チャンネル投稿が含まれない', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const channel = await api('channels/create', { name: 'channel' }, bob).then(x => x.body); - const bobNote = await post(bob, { text: 'hi', channelId: channel.id }); - - await waitForPushToTl(); - - const res = await api('notes/local-timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); - }); - - test.concurrent('リモートユーザーのノートが含まれない', async () => { - const [alice, bob] = await Promise.all([signup(), signup({ host: genHost() })]); - - const bobNote = await post(bob, { text: 'hi' }); - - await waitForPushToTl(); - - const res = await api('notes/local-timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); - }); - - // 含まれても良いと思うけど実装が面倒なので含まれない - test.concurrent('フォローしているユーザーの visibility: home なノートが含まれない', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('following/create', { userId: carol.id }, alice); - await setTimeout(1000); - const carolNote = await post(carol, { text: 'hi', visibility: 'home' }); - const bobNote = await post(bob, { text: 'hi' }); - - await waitForPushToTl(); - - const res = await api('notes/local-timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); - assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); - }); - - test.concurrent('ミュートしているユーザーのノートが含まれない', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('mute/create', { userId: carol.id }, alice); - await setTimeout(1000); - const carolNote = await post(carol, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi' }); - - await waitForPushToTl(); - - const res = await api('notes/local-timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); - assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); - }); - - test.concurrent('フォローしているユーザーが行ったミュートしているユーザーのリノートが含まれない', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('following/create', { userId: bob.id }, alice); - await api('mute/create', { userId: carol.id }, alice); - await setTimeout(1000); - const carolNote = await post(carol, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi', renoteId: carolNote.id }); - - await waitForPushToTl(); - - const res = await api('notes/local-timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); - assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); - }); - - test.concurrent('withReplies: true でフォローしているユーザーが行ったミュートしているユーザーの投稿への返信が含まれない', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('following/create', { userId: bob.id }, alice); - await api('following/update', { userId: bob.id, withReplies: true }, alice); - await api('mute/create', { userId: carol.id }, alice); - await setTimeout(1000); - const carolNote = await post(carol, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); - - await waitForPushToTl(); - - const res = await api('notes/local-timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); - assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); - }); - - test.concurrent('ミュートしているユーザーのノートの、関係のないユーザによる引用ノートの、リノートが含まれない', async () => { - const [alice, bob, carol, dave] = await Promise.all([signup(), signup(), signup(), signup()]); - - await api('mute/create', { userId: carol.id }, alice); - await setTimeout(1000); - const carolNote = await post(carol, { text: 'hi' }); - const daveNote = await post(dave, { text: 'quote hi', renoteId: carolNote.id }); - const bobNote = await post(bob, { renoteId: daveNote.id }); - - await waitForPushToTl(); - - const res = await api('notes/local-timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); - assert.strictEqual(res.body.some(note => note.id === daveNote.id), false); - assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); - }); - - test.concurrent('ミュートしているユーザーのノートの、関係のないユーザによるリプライの、リノートが含まれない', async () => { - const [alice, bob, carol, dave] = await Promise.all([signup(), signup(), signup(), signup()]); - - await api('mute/create', { userId: carol.id }, alice); - await setTimeout(1000); - const carolNote = await post(carol, { text: 'hi' }); - const daveNote = await post(dave, { text: 'quote hi', replyId: carolNote.id }); - const bobNote = await post(bob, { renoteId: daveNote.id }); - - await waitForPushToTl(); - - const res = await api('notes/local-timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); - assert.strictEqual(res.body.some(note => note.id === daveNote.id), false); - assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); - }); - - test.concurrent('withReplies: false でフォローしているユーザーからの自分への返信が含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - await api('following/create', { userId: bob.id }, alice); - await setTimeout(1000); - const aliceNote = await post(alice, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi', replyId: aliceNote.id }); - - await waitForPushToTl(); - - const res = await api('notes/local-timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); - assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); - }); - - test.concurrent('withReplies: false でフォローしていないユーザーからの自分への返信が含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - await setTimeout(1000); - const aliceNote = await post(alice, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi', replyId: aliceNote.id }); - - await waitForPushToTl(); - - const res = await api('notes/local-timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); - assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); - }); - - test.concurrent('[withReplies: true] 他人の他人への返信が含まれる', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - const carolNote = await post(carol, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); - - await waitForPushToTl(); - - const res = await api('notes/local-timeline', { limit: 100, withReplies: true }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); - }); - - test.concurrent('[withFiles: true] ファイル付きノートのみ含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const file = await uploadUrl(bob, 'https://raw.githubusercontent.com/misskey-dev/assets/main/public/icon.png'); - const bobNote1 = await post(bob, { text: 'hi' }); - const bobNote2 = await post(bob, { fileIds: [file.id] }); - - await waitForPushToTl(); - - const res = await api('notes/local-timeline', { limit: 100, withFiles: true }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote1.id), false); - assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); - }, 1000 * 10); - }); - - describe('Social TL', () => { - test.concurrent('ローカルユーザーのノートが含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const bobNote = await post(bob, { text: 'hi' }); - - await waitForPushToTl(); - - const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); - }); - - test.concurrent('ローカルユーザーの visibility: home なノートが含まれない', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const bobNote = await post(bob, { text: 'hi', visibility: 'home' }); - - await waitForPushToTl(); - - const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); - }); - - test.concurrent('フォローしているローカルユーザーの visibility: home なノートが含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - await api('following/create', { userId: bob.id }, alice); - await setTimeout(1000); - const bobNote = await post(bob, { text: 'hi', visibility: 'home' }); - - await waitForPushToTl(); - - const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); - }); - - test.concurrent('withReplies: false でフォローしているユーザーからの自分への返信が含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - await api('following/create', { userId: bob.id }, alice); - await setTimeout(1000); - const aliceNote = await post(alice, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi', replyId: aliceNote.id }); - - await waitForPushToTl(); - - const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); - assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); - }); - - test.concurrent('withReplies: true でフォローしているユーザーの他人の visibility: followers な投稿への返信が含まれない', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('following/create', { userId: carol.id }, bob); - await api('following/create', { userId: bob.id }, alice); - await api('following/update', { userId: bob.id, withReplies: true }, alice); - await setTimeout(1000); - const carolNote = await post(carol, { text: 'hi', visibility: 'followers' }); - const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); - - await waitForPushToTl(); - - const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); - }); - - test.concurrent('withReplies: true でフォローしているユーザーの行った別のフォローしているユーザーの visibility: followers な投稿への返信が含まれる', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('following/create', { userId: bob.id }, alice); - await api('following/create', { userId: carol.id }, alice); - await api('following/create', { userId: carol.id }, bob); - await api('following/update', { userId: bob.id, withReplies: true }, alice); - await setTimeout(1000); - const carolNote = await post(carol, { text: 'hi', visibility: 'followers' }); - const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); - - await waitForPushToTl(); - - const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true); - assert.strictEqual(res.body.find((note: any) => note.id === carolNote.id)?.text, 'hi'); - }); - - test.concurrent('withReplies: true でフォローしているユーザーの自分の visibility: followers な投稿への返信が含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - await api('following/create', { userId: bob.id }, alice); - await api('following/create', { userId: alice.id }, bob); - await api('following/update', { userId: bob.id, withReplies: true }, alice); - await setTimeout(1000); - const aliceNote = await post(alice, { text: 'hi', visibility: 'followers' }); - const bobNote = await post(bob, { text: 'hi', replyId: aliceNote.id }); - - await waitForPushToTl(); - - const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); - }); - - test.concurrent('他人の他人への返信が含まれない', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - const carolNote = await post(carol, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); - - await waitForPushToTl(); - - const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); - assert.strictEqual(res.body.some(note => note.id === carolNote.id), true); - }); - - test.concurrent('リモートユーザーのノートが含まれない', async () => { - const [alice, bob] = await Promise.all([signup(), signup({ host: genHost() })]); - - const bobNote = await post(bob, { text: 'hi' }); - - await waitForPushToTl(); - - const res = await api('notes/local-timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); - }); - - test.concurrent('フォローしているリモートユーザーのノートが含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup({ host: genHost() })]); - - await sendEnvUpdateRequest({ key: 'FORCE_FOLLOW_REMOTE_USER_FOR_TESTING', value: 'true' }); - await api('following/create', { userId: bob.id }, alice); - - const bobNote = await post(bob, { text: 'hi' }); - - await waitForPushToTl(); - - const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); - }); - - test.concurrent('フォローしているリモートユーザーの visibility: home なノートが含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup({ host: genHost() })]); - - await sendEnvUpdateRequest({ key: 'FORCE_FOLLOW_REMOTE_USER_FOR_TESTING', value: 'true' }); - await api('following/create', { userId: bob.id }, alice); - - const bobNote = await post(bob, { text: 'hi', visibility: 'home' }); - - await waitForPushToTl(); - - const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); - }); - - test.concurrent('withReplies: false でフォローしていないユーザーからの自分への返信が含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - await setTimeout(1000); - const aliceNote = await post(alice, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi', replyId: aliceNote.id }); - - await waitForPushToTl(); - - const res = await api('notes/local-timeline', { limit: 100 }, alice); - - assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); - assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); - }); - - test.concurrent('[withReplies: true] 他人の他人への返信が含まれる', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - const carolNote = await post(carol, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); - - await waitForPushToTl(); - - const res = await api('notes/hybrid-timeline', { limit: 100, withReplies: true }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); - }); - - test.concurrent('[withFiles: true] ファイル付きノートのみ含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const file = await uploadUrl(bob, 'https://raw.githubusercontent.com/misskey-dev/assets/main/public/icon.png'); - const bobNote1 = await post(bob, { text: 'hi' }); - const bobNote2 = await post(bob, { fileIds: [file.id] }); - - await waitForPushToTl(); - - const res = await api('notes/hybrid-timeline', { limit: 100, withFiles: true }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote1.id), false); - assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); - }, 1000 * 10); - }); - - describe('User List TL', () => { - test.concurrent('リスインしているフォローしていないユーザーのノートが含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); - await api('users/lists/push', { listId: list.id, userId: bob.id }, alice); - await setTimeout(1000); - const bobNote = await post(bob, { text: 'hi' }); - - await waitForPushToTl(); - - const res = await api('notes/user-list-timeline', { listId: list.id }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); - }); - - test.concurrent('リスインしているフォローしていないユーザーの visibility: home なノートが含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); - await api('users/lists/push', { listId: list.id, userId: bob.id }, alice); - await setTimeout(1000); - const bobNote = await post(bob, { text: 'hi', visibility: 'home' }); - - await waitForPushToTl(); - - const res = await api('notes/user-list-timeline', { listId: list.id }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); - }); - - test.concurrent('リスインしているフォローしていないユーザーの visibility: followers なノートが含まれない', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); - await api('users/lists/push', { listId: list.id, userId: bob.id }, alice); - await setTimeout(1000); - const bobNote = await post(bob, { text: 'hi', visibility: 'followers' }); - - await waitForPushToTl(); - - const res = await api('notes/user-list-timeline', { listId: list.id }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); - }); - - test.concurrent('リスインしているフォローしていないユーザーの他人への返信が含まれない', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); - await api('users/lists/push', { listId: list.id, userId: bob.id }, alice); - await setTimeout(1000); - const carolNote = await post(carol, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); - - await waitForPushToTl(); - - const res = await api('notes/user-list-timeline', { listId: list.id }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); - }); - - test.concurrent('リスインしているフォローしていないユーザーのユーザー自身への返信が含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); - await api('users/lists/push', { listId: list.id, userId: bob.id }, alice); - await setTimeout(1000); - const bobNote1 = await post(bob, { text: 'hi' }); - const bobNote2 = await post(bob, { text: 'hi', replyId: bobNote1.id }); - - await waitForPushToTl(); - - const res = await api('notes/user-list-timeline', { listId: list.id }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote1.id), true); - assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); - }); - - test.concurrent('withReplies: false でリスインしているフォローしていないユーザーからの自分への返信が含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); - await api('users/lists/push', { listId: list.id, userId: bob.id }, alice); - await api('users/lists/update-membership', { listId: list.id, userId: bob.id, withReplies: false }, alice); - await setTimeout(1000); - const aliceNote = await post(alice, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi', replyId: aliceNote.id }); - - await waitForPushToTl(); - - const res = await api('notes/user-list-timeline', { listId: list.id }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); - }); - - test.concurrent('withReplies: false でリスインしているフォローしていないユーザーの他人への返信が含まれない', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); - await api('users/lists/push', { listId: list.id, userId: bob.id }, alice); - await api('users/lists/update-membership', { listId: list.id, userId: bob.id, withReplies: false }, alice); - await setTimeout(1000); - const carolNote = await post(carol, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); - - await waitForPushToTl(); - - const res = await api('notes/user-list-timeline', { listId: list.id }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); - }); - - test.concurrent('withReplies: true でリスインしているフォローしていないユーザーの他人への返信が含まれる', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); - await api('users/lists/push', { listId: list.id, userId: bob.id }, alice); - await api('users/lists/update-membership', { listId: list.id, userId: bob.id, withReplies: true }, alice); - await setTimeout(1000); - const carolNote = await post(carol, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); - - await waitForPushToTl(); - - const res = await api('notes/user-list-timeline', { listId: list.id }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); - }); - - test.concurrent('リスインしているフォローしているユーザーの visibility: home なノートが含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - await api('following/create', { userId: bob.id }, alice); - const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); - await api('users/lists/push', { listId: list.id, userId: bob.id }, alice); - await setTimeout(1000); - const bobNote = await post(bob, { text: 'hi', visibility: 'home' }); - - await waitForPushToTl(); - - const res = await api('notes/user-list-timeline', { listId: list.id }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); - }); - - test.concurrent('リスインしているフォローしているユーザーの visibility: followers なノートが含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - await api('following/create', { userId: bob.id }, alice); - const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); - await api('users/lists/push', { listId: list.id, userId: bob.id }, alice); - await setTimeout(1000); - const bobNote = await post(bob, { text: 'hi', visibility: 'followers' }); - - await waitForPushToTl(); - - const res = await api('notes/user-list-timeline', { listId: list.id }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); - assert.strictEqual(res.body.find(note => note.id === bobNote.id)?.text, 'hi'); - }); - - test.concurrent('リスインしている自分の visibility: followers なノートが含まれる', async () => { - const [alice] = await Promise.all([signup(), signup()]); - - const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); - await api('users/lists/push', { listId: list.id, userId: alice.id }, alice); - await setTimeout(1000); - const aliceNote = await post(alice, { text: 'hi', visibility: 'followers' }); - - await waitForPushToTl(); - - const res = await api('notes/user-list-timeline', { listId: list.id }, alice); - - assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); - assert.strictEqual(res.body.find(note => note.id === aliceNote.id)?.text, 'hi'); - }); - - test.concurrent('リスインしているユーザーのチャンネルノートが含まれない', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const channel = await api('channels/create', { name: 'channel' }, bob).then(x => x.body); - const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); - await api('users/lists/push', { listId: list.id, userId: bob.id }, alice); - await setTimeout(1000); - const bobNote = await post(bob, { text: 'hi', channelId: channel.id }); - - await waitForPushToTl(); - - const res = await api('notes/user-list-timeline', { listId: list.id }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); - }); - - test.concurrent('[withFiles: true] リスインしているユーザーのファイル付きノートのみ含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); - await api('users/lists/push', { listId: list.id, userId: bob.id }, alice); - const file = await uploadUrl(bob, 'https://raw.githubusercontent.com/misskey-dev/assets/main/public/icon.png'); - const bobNote1 = await post(bob, { text: 'hi' }); - const bobNote2 = await post(bob, { fileIds: [file.id] }); - - await waitForPushToTl(); - - const res = await api('notes/user-list-timeline', { listId: list.id, withFiles: true }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote1.id), false); - assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); - }, 1000 * 10); - - test.concurrent('リスインしているユーザーの自身宛ての visibility: specified なノートが含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); - await api('users/lists/push', { listId: list.id, userId: bob.id }, alice); - await setTimeout(1000); - const bobNote = await post(bob, { text: 'hi', visibility: 'specified', visibleUserIds: [alice.id] }); - - await waitForPushToTl(); - - const res = await api('notes/user-list-timeline', { listId: list.id }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); - assert.strictEqual(res.body.find(note => note.id === bobNote.id)?.text, 'hi'); - }); - - test.concurrent('リスインしているユーザーの自身宛てではない visibility: specified なノートが含まれない', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); - await api('users/lists/push', { listId: list.id, userId: bob.id }, alice); - await api('users/lists/push', { listId: list.id, userId: carol.id }, alice); - await setTimeout(1000); - const bobNote = await post(bob, { text: 'hi', visibility: 'specified', visibleUserIds: [carol.id] }); - - await waitForPushToTl(); - - const res = await api('notes/user-list-timeline', { listId: list.id }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); - }); - }); - - describe('User TL', () => { - test.concurrent('ノートが含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const bobNote = await post(bob, { text: 'hi' }); - - await waitForPushToTl(); - - const res = await api('users/notes', { userId: bob.id }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); - }); - - test.concurrent('フォローしていないユーザーの visibility: followers なノートが含まれない', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const bobNote = await post(bob, { text: 'hi', visibility: 'followers' }); - - await waitForPushToTl(); - - const res = await api('users/notes', { userId: bob.id }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); - }); - - test.concurrent('フォローしているユーザーの visibility: followers なノートが含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - await api('following/create', { userId: bob.id }, alice); - await setTimeout(1000); - const bobNote = await post(bob, { text: 'hi', visibility: 'followers' }); - - await waitForPushToTl(); - - const res = await api('users/notes', { userId: bob.id }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); - assert.strictEqual(res.body.find(note => note.id === bobNote.id)?.text, 'hi'); - }); - - test.concurrent('自身の visibility: followers なノートが含まれる', async () => { - const [alice] = await Promise.all([signup()]); - - const aliceNote = await post(alice, { text: 'hi', visibility: 'followers' }); - - await waitForPushToTl(); - - const res = await api('users/notes', { userId: alice.id }, alice); - - assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); - assert.strictEqual(res.body.find(note => note.id === aliceNote.id)?.text, 'hi'); - }); - - test.concurrent('チャンネル投稿が含まれない', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const channel = await api('channels/create', { name: 'channel' }, bob).then(x => x.body); - const bobNote = await post(bob, { text: 'hi', channelId: channel.id }); - - await waitForPushToTl(); - - const res = await api('users/notes', { userId: bob.id }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); - }); - - test.concurrent('[withReplies: false] 他人への返信が含まれない', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - const carolNote = await post(carol, { text: 'hi' }); - const bobNote1 = await post(bob, { text: 'hi' }); - const bobNote2 = await post(bob, { text: 'hi', replyId: carolNote.id }); - - await waitForPushToTl(); - - const res = await api('users/notes', { userId: bob.id }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote1.id), true); - assert.strictEqual(res.body.some(note => note.id === bobNote2.id), false); - }); - - test.concurrent('[withReplies: true] 他人への返信が含まれる', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - const carolNote = await post(carol, { text: 'hi' }); - const bobNote1 = await post(bob, { text: 'hi' }); - const bobNote2 = await post(bob, { text: 'hi', replyId: carolNote.id }); - - await waitForPushToTl(); - - const res = await api('users/notes', { userId: bob.id, withReplies: true }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote1.id), true); - assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); - }); - - test.concurrent('[withReplies: true] 他人への visibility: specified な返信が含まれない', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - const carolNote = await post(carol, { text: 'hi' }); - const bobNote1 = await post(bob, { text: 'hi' }); - const bobNote2 = await post(bob, { text: 'hi', replyId: carolNote.id, visibility: 'specified' }); - - await waitForPushToTl(); - - const res = await api('users/notes', { userId: bob.id, withReplies: true }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote1.id), true); - assert.strictEqual(res.body.some(note => note.id === bobNote2.id), false); - }); - - test.concurrent('[withFiles: true] ファイル付きノートのみ含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const file = await uploadUrl(bob, 'https://raw.githubusercontent.com/misskey-dev/assets/main/public/icon.png'); - const bobNote1 = await post(bob, { text: 'hi' }); - const bobNote2 = await post(bob, { fileIds: [file.id] }); - - await waitForPushToTl(); - - const res = await api('users/notes', { userId: bob.id, withFiles: true }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote1.id), false); - assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); - }, 1000 * 10); - - test.concurrent('[withChannelNotes: true] チャンネル投稿が含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const channel = await api('channels/create', { name: 'channel' }, bob).then(x => x.body); - const bobNote = await post(bob, { text: 'hi', channelId: channel.id }); - - await waitForPushToTl(); - - const res = await api('users/notes', { userId: bob.id, withChannelNotes: true }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); - }); - - test.concurrent('[withChannelNotes: true] 他人が取得した場合センシティブチャンネル投稿が含まれない', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const channel = await api('channels/create', { name: 'channel', isSensitive: true }, bob).then(x => x.body); - const bobNote = await post(bob, { text: 'hi', channelId: channel.id }); - - await waitForPushToTl(); - - const res = await api('users/notes', { userId: bob.id, withChannelNotes: true }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); - }); - - test.concurrent('[withChannelNotes: true] 自分が取得した場合センシティブチャンネル投稿が含まれる', async () => { - const [bob] = await Promise.all([signup()]); - - const channel = await api('channels/create', { name: 'channel', isSensitive: true }, bob).then(x => x.body); - const bobNote = await post(bob, { text: 'hi', channelId: channel.id }); - - await waitForPushToTl(); - - const res = await api('users/notes', { userId: bob.id, withChannelNotes: true }, bob); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); - }); - - test.concurrent('ミュートしているユーザーに関連する投稿が含まれない', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - - await api('mute/create', { userId: carol.id }, alice); - await setTimeout(1000); - const carolNote = await post(carol, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi', renoteId: carolNote.id }); - - await waitForPushToTl(); - - const res = await api('users/notes', { userId: bob.id }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); - }); - - test.concurrent('ミュートしているユーザーのノートの、関係のないユーザによる引用ノートの、リノートが含まれない', async () => { - const [alice, bob, carol, dave] = await Promise.all([signup(), signup(), signup(), signup()]); - - await api('mute/create', { userId: carol.id }, alice); - await setTimeout(1000); - const carolNote = await post(carol, { text: 'hi' }); - const daveNote = await post(dave, { text: 'quote hi', renoteId: carolNote.id }); - const bobNote = await post(bob, { renoteId: daveNote.id }); - - await waitForPushToTl(); - - const res = await api('users/notes', { userId: bob.id, limit: 100 }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); - }); - - test.concurrent('ミュートしているユーザーのノートの、関係のないユーザによるリプライの、リノートが含まれない', async () => { - const [alice, bob, carol, dave] = await Promise.all([signup(), signup(), signup(), signup()]); - - await api('following/create', { userId: bob.id }, alice); - await api('mute/create', { userId: carol.id }, alice); - await setTimeout(1000); - const carolNote = await post(carol, { text: 'hi' }); - const daveNote = await post(dave, { text: 'quote hi', replyId: carolNote.id }); - const bobNote = await post(bob, { renoteId: daveNote.id }); - - await waitForPushToTl(); - - const res = await api('users/notes', { userId: bob.id, limit: 100 }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); - }); - - test.concurrent('ミュートしていても userId に指定したユーザーの投稿が含まれる', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - await api('mute/create', { userId: bob.id }, alice); - await setTimeout(1000); - const bobNote1 = await post(bob, { text: 'hi' }); - const bobNote2 = await post(bob, { text: 'hi', replyId: bobNote1.id }); - const bobNote3 = await post(bob, { text: 'hi', renoteId: bobNote1.id }); - const bobNote4 = await post(bob, { renoteId: bobNote2.id }); - const bobNote5 = await post(bob, { renoteId: bobNote3.id }); - - await waitForPushToTl(); - - const res = await api('users/notes', { userId: bob.id }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote1.id), true); - assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); - assert.strictEqual(res.body.some(note => note.id === bobNote3.id), true); - assert.strictEqual(res.body.some(note => note.id === bobNote4.id), true); - assert.strictEqual(res.body.some(note => note.id === bobNote5.id), true); - }); - - test.concurrent('自身の visibility: specified なノートが含まれる', async () => { - const [alice] = await Promise.all([signup()]); - - const aliceNote = await post(alice, { text: 'hi', visibility: 'specified' }); - - await waitForPushToTl(); - - const res = await api('users/notes', { userId: alice.id, withReplies: true }, alice); - - assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); - }); - - test.concurrent('visibleUserIds に指定されてない visibility: specified なノートが含まれない', async () => { - const [alice, bob] = await Promise.all([signup(), signup()]); - - const bobNote = await post(bob, { text: 'hi', visibility: 'specified' }); - - await waitForPushToTl(); - - const res = await api('users/notes', { userId: bob.id, withReplies: true }, alice); - - assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); - }); - - /** @see https://github.com/misskey-dev/misskey/issues/14000 */ - test.concurrent('FTT: sinceId にキャッシュより古いノートを指定しても、sinceId による絞り込みが正しく動作する', async () => { - const alice = await signup(); - const noteSince = await post(alice, { text: 'Note where id will be `sinceId`.' }); - const note1 = await post(alice, { text: '1' }); - const note2 = await post(alice, { text: '2' }); - await redisForTimelines.del('list:userTimeline:' + alice.id); - const note3 = await post(alice, { text: '3' }); - - const res = await api('users/notes', { userId: alice.id, sinceId: noteSince.id }); - assert.deepStrictEqual(res.body, [note1, note2, note3]); - }); - - test.concurrent('FTT: sinceId にキャッシュより古いノートを指定しても、sinceId と untilId による絞り込みが正しく動作する', async () => { - const alice = await signup(); - const noteSince = await post(alice, { text: 'Note where id will be `sinceId`.' }); - const note1 = await post(alice, { text: '1' }); - const note2 = await post(alice, { text: '2' }); - await redisForTimelines.del('list:userTimeline:' + alice.id); - const note3 = await post(alice, { text: '3' }); - const noteUntil = await post(alice, { text: 'Note where id will be `untilId`.' }); - await post(alice, { text: '4' }); - - const res = await api('users/notes', { userId: alice.id, sinceId: noteSince.id, untilId: noteUntil.id }); - assert.deepStrictEqual(res.body, [note3, note2, note1]); - }); - }); - - // TODO: リノートミュート済みユーザーのテスト - // TODO: ページネーションのテスト }); From 479525a71df1b0125bc317367bfd73b3c2cfa27f Mon Sep 17 00:00:00 2001 From: tamaina Date: Mon, 14 Jul 2025 14:21:23 +0900 Subject: [PATCH 07/25] clean up --- packages/backend/test/e2e/timelines.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/backend/test/e2e/timelines.ts b/packages/backend/test/e2e/timelines.ts index 237b5885c5..a4f255abac 100644 --- a/packages/backend/test/e2e/timelines.ts +++ b/packages/backend/test/e2e/timelines.ts @@ -25,7 +25,6 @@ describe('Timelines', () => { beforeAll(async () => { redisForTimelines = new Redis(loadConfig().redisForTimelines); root = await signup({ username: 'root' }); - //console.log(await api('admin/update-meta', { enableFanoutTimeline }, root)); }, 1000 * 60 * 2); describe.each([ @@ -37,7 +36,7 @@ describe('Timelines', () => { } beforeAll(async () => { - console.log(await api('admin/update-meta', { enableFanoutTimeline }, root)); + await api('admin/update-meta', { enableFanoutTimeline }, root); }, 1000 * 60 * 2); describe('Home TL', () => { From 6ad3f07d485b18db71244c386386a82643ffaf33 Mon Sep 17 00:00:00 2001 From: tamaina Date: Mon, 14 Jul 2025 14:36:07 +0900 Subject: [PATCH 08/25] revert --- packages/backend/src/core/FanoutTimelineService.ts | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/packages/backend/src/core/FanoutTimelineService.ts b/packages/backend/src/core/FanoutTimelineService.ts index 3d1f571066..b00814219b 100644 --- a/packages/backend/src/core/FanoutTimelineService.ts +++ b/packages/backend/src/core/FanoutTimelineService.ts @@ -113,17 +113,4 @@ export class FanoutTimelineService { public purge(name: FanoutTimelineName) { return this.redisForTimelines.del('list:' + name); } - - @bindThis - public purgeByUserIds(userIds: MiUser['id'][]) { - return Promise.all(userIds.flatMap(userId => [ - this.purge(`homeTimeline:${userId}`), - this.purge(`homeTimelineWithFiles:${userId}`), - this.purge(`localTimelineWithReplyTo:${userId}`), - this.purge(`userTimeline:${userId}`), - this.purge(`userTimelineWithFiles:${userId}`), - this.purge(`userTimelineWithReplies:${userId}`), - this.purge(`userTimelineWithChannel:${userId}`), - ])); - } } From 16cb881fa289891b21528fbaa33fb6c44f863d84 Mon Sep 17 00:00:00 2001 From: tamaina Date: Mon, 14 Jul 2025 14:36:32 +0900 Subject: [PATCH 09/25] clean up --- packages/backend/src/core/FanoutTimelineService.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/backend/src/core/FanoutTimelineService.ts b/packages/backend/src/core/FanoutTimelineService.ts index b00814219b..24999bf4da 100644 --- a/packages/backend/src/core/FanoutTimelineService.ts +++ b/packages/backend/src/core/FanoutTimelineService.ts @@ -8,7 +8,6 @@ import * as Redis from 'ioredis'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; import { IdService } from '@/core/IdService.js'; -import { MiUser } from '@/models/User.js'; export type FanoutTimelineName = ( // home timeline From c5928980f8c400fb372bf25b0c25648de12df77d Mon Sep 17 00:00:00 2001 From: tamaina Date: Tue, 15 Jul 2025 10:13:15 +0900 Subject: [PATCH 10/25] skip test about reply --- packages/backend/test/e2e/timelines.ts | 30 ++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/packages/backend/test/e2e/timelines.ts b/packages/backend/test/e2e/timelines.ts index a4f255abac..e3516845f4 100644 --- a/packages/backend/test/e2e/timelines.ts +++ b/packages/backend/test/e2e/timelines.ts @@ -103,6 +103,8 @@ describe('Timelines', () => { }); test('withReplies: true でフォローしているユーザーの他人への返信が含まれる', async () => { + /* FIXME: https://github.com/misskey-dev/misskey/issues/12065 */ if (!enableFanoutTimeline) return; + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -155,6 +157,8 @@ describe('Timelines', () => { }); test('withReplies: true でフォローしているユーザーの行った別のフォローしているユーザーの visibility: followers な投稿への返信が含まれる', async () => { + /* FIXME: https://github.com/misskey-dev/misskey/issues/12065 */ if (!enableFanoutTimeline) return; + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -175,6 +179,8 @@ describe('Timelines', () => { }); test('withReplies: true でフォローしているユーザーの自分の visibility: followers な投稿への返信が含まれる', async () => { + /* FIXME: https://github.com/misskey-dev/misskey/issues/12065 */ if (!enableFanoutTimeline) return; + const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -227,6 +233,8 @@ describe('Timelines', () => { }); test('withReplies: false でフォローしているユーザーからの自分への返信が含まれる', async () => { + /* FIXME: https://github.com/misskey-dev/misskey/issues/12065 */ if (!enableFanoutTimeline) return; + const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -243,6 +251,8 @@ describe('Timelines', () => { }); test('自分の他人への返信が含まれる', async () => { + /* FIXME: https://github.com/misskey-dev/misskey/issues/12065 */ if (!enableFanoutTimeline) return; + const [alice, bob] = await Promise.all([signup(), signup()]); const bobNote = await post(bob, { text: 'hi' }); @@ -519,6 +529,8 @@ describe('Timelines', () => { }); test('フォローしていないユーザーからの visibility: specified なノートに返信したときの自身のノートが含まれる', async () => { + /* FIXME: https://github.com/misskey-dev/misskey/issues/12065 */ if (!enableFanoutTimeline) return; + const [alice, bob] = await Promise.all([signup(), signup()]); const bobNote = await post(bob, { text: 'hi', visibility: 'specified', visibleUserIds: [alice.id] }); @@ -535,14 +547,10 @@ describe('Timelines', () => { /* TODO test('自身の visibility: specified なノートへのフォローしていないユーザーからの返信が含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); - const aliceNote = await post(alice, { text: 'hi', visibility: 'specified', visibleUserIds: [bob.id] }); const bobNote = await post(bob, { text: 'ok', visibility: 'specified', visibleUserIds: [alice.id], replyId: aliceNote.id }); - await waitForPushToTl(); - const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); assert.strictEqual(res.body.find(note => note.id === bobNote.id).text, 'ok'); }); @@ -800,6 +808,8 @@ describe('Timelines', () => { }); test('withReplies: false でフォローしているユーザーからの自分への返信が含まれる', async () => { + /* FIXME: https://github.com/misskey-dev/misskey/issues/12065 */ if (!enableFanoutTimeline) return; + const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -816,6 +826,8 @@ describe('Timelines', () => { }); test('withReplies: false でフォローしていないユーザーからの自分への返信が含まれる', async () => { + /* FIXME: https://github.com/misskey-dev/misskey/issues/12065 */ if (!enableFanoutTimeline) return; + const [alice, bob] = await Promise.all([signup(), signup()]); await setTimeout(250); @@ -920,6 +932,8 @@ describe('Timelines', () => { }); test('withReplies: false でフォローしているユーザーからの自分への返信が含まれる', async () => { + /* FIXME: https://github.com/misskey-dev/misskey/issues/12065 */ if (!enableFanoutTimeline) return; + const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -954,6 +968,8 @@ describe('Timelines', () => { }); test('withReplies: true でフォローしているユーザーの行った別のフォローしているユーザーの visibility: followers な投稿への返信が含まれる', async () => { + /* FIXME: https://github.com/misskey-dev/misskey/issues/12065 */ if (!enableFanoutTimeline) return; + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -974,6 +990,8 @@ describe('Timelines', () => { }); test('withReplies: true でフォローしているユーザーの自分の visibility: followers な投稿への返信が含まれる', async () => { + /* FIXME: https://github.com/misskey-dev/misskey/issues/12065 */ if (!enableFanoutTimeline) return; + const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -1048,6 +1066,8 @@ describe('Timelines', () => { }); test('withReplies: false でフォローしていないユーザーからの自分への返信が含まれる', async () => { + /* FIXME: https://github.com/misskey-dev/misskey/issues/12065 */ if (!enableFanoutTimeline) return; + const [alice, bob] = await Promise.all([signup(), signup()]); await setTimeout(250); @@ -1446,6 +1466,8 @@ describe('Timelines', () => { }); test('[withReplies: false] 他人への返信が含まれない', async () => { + /* FIXME: https://github.com/misskey-dev/misskey/issues/12065 */ if (!enableFanoutTimeline) return; + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); const carolNote = await post(carol, { text: 'hi' }); From 26e6c148cb10e33f4d5039b1f00e2bbe4788d8bd Mon Sep 17 00:00:00 2001 From: tamaina Date: Tue, 15 Jul 2025 12:01:41 +0900 Subject: [PATCH 11/25] Fix #16289 --- .../server/api/endpoints/notes/timeline.ts | 8 +++- packages/backend/test/e2e/timelines.ts | 42 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/server/api/endpoints/notes/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts index c76cca1518..1f3631ae3d 100644 --- a/packages/backend/src/server/api/endpoints/notes/timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts @@ -237,7 +237,13 @@ export default class extends Endpoint { // eslint- } if (ps.withRenotes === false) { - query.andWhere('note.renoteId IS NULL'); + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteId IS NULL'); + qb.orWhere(new Brackets(qb => { + qb.orWhere('note.text IS NOT NULL'); + qb.orWhere('note.fileIds != \'{}\''); + })); + })); } //#endregion diff --git a/packages/backend/test/e2e/timelines.ts b/packages/backend/test/e2e/timelines.ts index e3516845f4..f9ad66c119 100644 --- a/packages/backend/test/e2e/timelines.ts +++ b/packages/backend/test/e2e/timelines.ts @@ -282,6 +282,48 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); }); + test('[withRenotes: false] フォローしているユーザーの投稿が含まれる', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + await api('following/create', { userId: bob.id }, alice); + await setTimeout(250); + const bobNote = await post(bob, { text: 'hi' }); + const carolNote = await post(carol, { text: 'hi' }); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { + limit: 100, + withRenotes: false, + }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + }); + + test('[withRenotes: false] フォローしているユーザーのファイルのみの投稿が含まれる', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + await api('following/create', { userId: bob.id }, alice); + await setTimeout(250); + const [bobFile, carolFile] = await Promise.all([ + uploadUrl(bob, 'https://raw.githubusercontent.com/misskey-dev/assets/main/public/icon.png'), + uploadUrl(carol, 'https://raw.githubusercontent.com/misskey-dev/assets/main/public/icon.png'), + ]); + const bobNote = await post(bob, { fileIds: [bobFile.id] }); + const carolNote = await post(carol, { fileIds: [carolFile.id] }); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { + limit: 100, + withRenotes: false, + }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + }); + test('[withRenotes: false] フォローしているユーザーの他人の投稿のリノートが含まれない', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); From f5d4b2744ba39e906365ada2a58c6acf07c4123e Mon Sep 17 00:00:00 2001 From: tamaina Date: Wed, 16 Jul 2025 18:36:43 +0900 Subject: [PATCH 12/25] fix migration --- .../migration/1752410859370-FollowingIsFollowerSuspended.js | 3 ++- ...410900000-FollowingIsFollowerSuspendedCopySuspendedState.js | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/backend/migration/1752410859370-FollowingIsFollowerSuspended.js b/packages/backend/migration/1752410859370-FollowingIsFollowerSuspended.js index 8542bcdd8b..ce63c16fb7 100644 --- a/packages/backend/migration/1752410859370-FollowingIsFollowerSuspended.js +++ b/packages/backend/migration/1752410859370-FollowingIsFollowerSuspended.js @@ -2,7 +2,8 @@ * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ -module.exports = class FollowingIsFollowerSuspended1752410859370 { + +export class FollowingIsFollowerSuspended1752410859370 { name = 'FollowingIsFollowerSuspended1752410859370' async up(queryRunner) { diff --git a/packages/backend/migration/1752410900000-FollowingIsFollowerSuspendedCopySuspendedState.js b/packages/backend/migration/1752410900000-FollowingIsFollowerSuspendedCopySuspendedState.js index 4620442a13..185d8cbe1a 100644 --- a/packages/backend/migration/1752410900000-FollowingIsFollowerSuspendedCopySuspendedState.js +++ b/packages/backend/migration/1752410900000-FollowingIsFollowerSuspendedCopySuspendedState.js @@ -2,7 +2,8 @@ * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ -module.exports = class FollowingIsFollowerSuspendedCopySuspendedState1752410900000 { + +export class FollowingIsFollowerSuspendedCopySuspendedState1752410900000 { name = 'FollowingIsFollowerSuspendedCopySuspendedState1752410900000' async up(queryRunner) { From 85f4730308a016cd48dcaf60127caa80653a126e Mon Sep 17 00:00:00 2001 From: tamaina Date: Wed, 16 Jul 2025 19:10:20 +0900 Subject: [PATCH 13/25] update test --- packages/backend/test/e2e/timelines.ts | 161 +++++++++++++----- .../backend/test/unit/UserSuspendService.ts | 30 ++-- 2 files changed, 130 insertions(+), 61 deletions(-) diff --git a/packages/backend/test/e2e/timelines.ts b/packages/backend/test/e2e/timelines.ts index f9ad66c119..757d86dbec 100644 --- a/packages/backend/test/e2e/timelines.ts +++ b/packages/backend/test/e2e/timelines.ts @@ -11,7 +11,7 @@ import { setTimeout } from 'node:timers/promises'; import { Redis } from 'ioredis'; import { api, post, randomString, sendEnvUpdateRequest, signup, uploadUrl, role } from '../utils.js'; import { loadConfig } from '@/config.js'; -import { SignupResponse, Role } from 'misskey-js/entities.js'; +import { SignupResponse, Role, Note } from 'misskey-js/entities.js'; function genHost() { return randomString() + '.example.com'; @@ -654,26 +654,45 @@ describe('Timelines', () => { assert.strictEqual(await redisForTimelines.exists(`list:homeTimeline:${bob.id}`), 0); }); - test('凍結: 凍結後に凍結されたユーザーのノートは見えなくなる', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + describe('凍結', async () => { + let alice: SignupResponse, bob: SignupResponse, carol: SignupResponse; + let aliceNote: Note, bobNote: Note, carolNote: Note; - await api('following/create', { userId: bob.id }, alice); - await api('following/create', { userId: carol.id }, alice); - const aliceNote = await post(alice, { text: 'hi' }); - const bobNote = await post(bob, { text: 'yo' }); - const carolNote = await post(carol, { text: 'kon\'nichiwa' }); + beforeAll(async () => { + [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - await waitForPushToTl(); + await api('following/create', { userId: bob.id }, alice); + await api('following/create', { userId: carol.id }, alice); + aliceNote = await post(alice, { text: 'hi' }); + bobNote = await post(bob, { text: 'yo' }); + carolNote = await post(carol, { text: 'kon\'nichiwa' }); - await api('admin/suspend-user', { userId: carol.id }, root); + await waitForPushToTl(); + }); - await setTimeout(250); + test('凍結後に凍結されたユーザーのノートは見えなくなる', async () => { + await api('admin/suspend-user', { userId: carol.id }, root); + await setTimeout(100); - const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.length, 2); - assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); - assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); - assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + const res = await api('notes/timeline', { limit: 100 }, alice); + assert.strictEqual(res.body.length, 2); + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + }); + + test('凍結解除後に凍結されていたユーザーのノートは見えるようになる', async () => { + await api('admin/unsuspend-user', { userId: carol.id }, root); + await setTimeout(100); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.length, 3); + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), true); + assert.strictEqual(res.body.find(note => note.id === carolNote.id)?.text, 'kon\'nichiwa'); + }); }); }); @@ -912,25 +931,43 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); }, 1000 * 10); - test('凍結: 凍結後に凍結されたユーザーのノートは見えなくなる', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + describe('凍結', async () => { + let alice: SignupResponse, bob: SignupResponse, carol: SignupResponse; + let aliceNote: Note, bobNote: Note, carolNote: Note; - await api('following/create', { userId: bob.id }, alice); - await api('following/create', { userId: carol.id }, alice); - const aliceNote = await post(alice, { text: 'hi' }); - const bobNote = await post(bob, { text: 'yo' }); - const carolNote = await post(carol, { text: 'kon\'nichiwa' }); + beforeAll(async () => { + [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - await waitForPushToTl(); + aliceNote = await post(alice, { text: 'hi' }); + bobNote = await post(bob, { text: 'yo' }); + carolNote = await post(carol, { text: 'kon\'nichiwa' }); - await api('admin/suspend-user', { userId: carol.id }, root); + await waitForPushToTl(); + }); - await setTimeout(250); + test('凍結後に凍結されたユーザーのノートは見えなくなる', async () => { + await api('admin/suspend-user', { userId: carol.id }, root); + await setTimeout(100); - const res = await api('notes/local-timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); - assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); - assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + const res = await api('notes/local-timeline', { limit: 100 }, alice); + assert.strictEqual(res.body.length, 2); + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + }); + + test('凍結解除後に凍結されていたユーザーのノートは見えるようになる', async () => { + await api('admin/unsuspend-user', { userId: carol.id }, root); + await setTimeout(100); + + const res = await api('notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.length, 3); + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), true); + assert.strictEqual(res.body.find(note => note.id === carolNote.id)?.text, 'kon\'nichiwa'); + }); }); }); @@ -1152,25 +1189,61 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); }, 1000 * 10); - test('凍結: 凍結後に凍結されたユーザーのノートは見えなくなる', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + describe('凍結', async () => { + /* + * bob = 未フォローのローカルユーザー (凍結対象でない) + * carol = 未フォローのローカルユーザー (凍結対象) + * dave = フォローしているローカルユーザー (凍結対象) + * elle = フォローしているリモートユーザー (凍結対象) + */ + let alice: SignupResponse, bob: SignupResponse, carol: SignupResponse, dave: SignupResponse, elle: SignupResponse; + let aliceNote: Note, bobNote: Note, carolNote: Note, daveNote: Note, elleNote: Note; - await api('following/create', { userId: bob.id }, alice); - await api('following/create', { userId: carol.id }, alice); - const aliceNote = await post(alice, { text: 'hi' }); - const bobNote = await post(bob, { text: 'yo' }); - const carolNote = await post(carol, { text: 'kon\'nichiwa' }); + beforeAll(async () => { + [alice, bob, carol, dave, elle] = await Promise.all([signup(), signup(), signup(), signup(), signup({ host: genHost() })]); - await waitForPushToTl(); + aliceNote = await post(alice, { text: 'hi' }); + bobNote = await post(bob, { text: 'yo' }); + carolNote = await post(carol, { text: 'kon\'nichiwa' }); + daveNote = await post(dave, { text: 'hello' }); + elleNote = await post(elle, { text: 'hi there' }); - await api('admin/suspend-user', { userId: carol.id }, root); + await api('following/create', { userId: dave.id }, alice); + await api('following/create', { userId: elle.id }, alice); - await setTimeout(250); + await waitForPushToTl(); + }); - const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); - assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); - assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + test('凍結後に凍結されたユーザーのノートは見えなくなる', async () => { + await api('admin/suspend-user', { userId: carol.id }, root); + await api('admin/suspend-user', { userId: dave.id }, root); + await api('admin/suspend-user', { userId: elle.id }, root); + await setTimeout(250); + + const res = await api('notes/local-timeline', { limit: 100 }, alice); + assert.strictEqual(res.body.length, 2); + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + assert.strictEqual(res.body.some(note => note.id === daveNote.id), false); + assert.strictEqual(res.body.some(note => note.id === elleNote.id), false); + }); + + test('凍結解除後に凍結されていたユーザーのノートは見えるようになる', async () => { + await api('admin/unsuspend-user', { userId: carol.id }, root); + await api('admin/unsuspend-user', { userId: dave.id }, root); + await api('admin/unsuspend-user', { userId: elle.id }, root); + await setTimeout(250); + + const res = await api('notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.length, 5); + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), true); + assert.strictEqual(res.body.some(note => note.id === daveNote.id), true); + assert.strictEqual(res.body.some(note => note.id === elleNote.id), true); + }); }); }); diff --git a/packages/backend/test/unit/UserSuspendService.ts b/packages/backend/test/unit/UserSuspendService.ts index 89edc6d116..ce8d35f408 100644 --- a/packages/backend/test/unit/UserSuspendService.ts +++ b/packages/backend/test/unit/UserSuspendService.ts @@ -7,7 +7,7 @@ process.env.NODE_ENV = 'test'; import { jest } from '@jest/globals'; import { Test } from '@nestjs/testing'; -import { DataSource } from 'typeorm'; +import { setTimeout } from 'node:timers/promises'; import type { TestingModule } from '@nestjs/testing'; import { GlobalModule } from '@/GlobalModule.js'; import { UserSuspendService } from '@/core/UserSuspendService.js'; @@ -26,10 +26,6 @@ import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; import { secureRndstr } from '@/misc/secure-rndstr.js'; -export async function sleep(ms = 250): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); -} - describe('UserSuspendService', () => { let app: TestingModule; let userSuspendService: UserSuspendService; @@ -169,7 +165,7 @@ describe('UserSuspendService', () => { await createFollowing(user, followee2); await userSuspendService.suspend(user, moderator); - await sleep(); + await setTimeout(250); // フォロー関係が論理削除されているかチェック const followings = await followingsRepository.find({ @@ -187,10 +183,10 @@ describe('UserSuspendService', () => { const moderator = await createUser(); await userSuspendService.suspend(user, moderator); - await sleep(); + await setTimeout(250); // 内部イベントが発行されているかチェック(非同期処理のため少し待つ) - await new Promise(resolve => setTimeout(resolve, 100)); + await setTimeout(100); expect(globalEventService.publishInternalEvent).toHaveBeenCalledWith( 'userChangeSuspendedState', @@ -205,7 +201,7 @@ describe('UserSuspendService', () => { const moderator = await createUser(); await userSuspendService.unsuspend(user, moderator); - await sleep(); + await setTimeout(250); // ユーザーの凍結が解除されているかチェック const unsuspendedUser = await usersRepository.findOneBy({ id: user.id }); @@ -230,7 +226,7 @@ describe('UserSuspendService', () => { await createFollowing(user, followee2, { isFollowerSuspended: true }); await userSuspendService.unsuspend(user, moderator); - await sleep(); + await setTimeout(250); // フォロー関係が復元されているかチェック const followings = await followingsRepository.find({ @@ -248,10 +244,10 @@ describe('UserSuspendService', () => { const moderator = await createUser(); await userSuspendService.unsuspend(user, moderator); - await sleep(); + await setTimeout(250); // 内部イベントが発行されているかチェック(非同期処理のため少し待つ) - await new Promise(resolve => setTimeout(resolve, 100)); + await setTimeout(100); expect(globalEventService.publishInternalEvent).toHaveBeenCalledWith( 'userChangeSuspendedState', @@ -282,7 +278,7 @@ describe('UserSuspendService', () => { // 凍結 await userSuspendService.suspend(user, moderator); - await sleep(); + await setTimeout(250); // 凍結後の状態確認 followings = await followingsRepository.find({ @@ -296,7 +292,7 @@ describe('UserSuspendService', () => { // 凍結解除 const suspendedUser = await usersRepository.findOneByOrFail({ id: user.id }); await userSuspendService.unsuspend(suspendedUser, moderator); - await sleep(); + await setTimeout(250); // 凍結解除後の状態確認 followings = await followingsRepository.find({ @@ -320,7 +316,7 @@ describe('UserSuspendService', () => { apRendererService.addContext.mockReturnValue({ '@context': '...', type: 'Delete' } as any); await userSuspendService.suspend(localUser, moderator); - await sleep(); + await setTimeout(250); // ActivityPub配信が呼ばれているかチェック expect(userEntityService.isLocalUser).toHaveBeenCalledWith(localUser); @@ -339,7 +335,7 @@ describe('UserSuspendService', () => { apRendererService.addContext.mockReturnValue({ '@context': '...', type: 'Undo' } as any); await userSuspendService.unsuspend(localUser, moderator); - await sleep(); + await setTimeout(250); // ActivityPub配信が呼ばれているかチェック expect(userEntityService.isLocalUser).toHaveBeenCalledWith(localUser); @@ -355,7 +351,7 @@ describe('UserSuspendService', () => { userEntityService.isLocalUser.mockReturnValue(false); await userSuspendService.suspend(remoteUser, moderator); - await sleep(); + await setTimeout(250); // ActivityPub配信が呼ばれていないことをチェック expect(userEntityService.isLocalUser).toHaveBeenCalledWith(remoteUser); From 5164a94e96edce6980c11faceaf657a522af6d03 Mon Sep 17 00:00:00 2001 From: tamaina Date: Wed, 16 Jul 2025 19:10:40 +0900 Subject: [PATCH 14/25] clean up --- packages/backend/test/e2e/timelines.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/backend/test/e2e/timelines.ts b/packages/backend/test/e2e/timelines.ts index f9ad66c119..2c53dfff2e 100644 --- a/packages/backend/test/e2e/timelines.ts +++ b/packages/backend/test/e2e/timelines.ts @@ -19,7 +19,6 @@ function genHost() { let redisForTimelines: Redis; let root: SignupResponse; -let roleAdmin: Role; describe('Timelines', () => { beforeAll(async () => { From 90eccd4d9503954055cba0b2533349e06af9d7e7 Mon Sep 17 00:00:00 2001 From: tamaina Date: Wed, 16 Jul 2025 19:25:55 +0900 Subject: [PATCH 15/25] fix --- packages/backend/test/e2e/timelines.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/backend/test/e2e/timelines.ts b/packages/backend/test/e2e/timelines.ts index c22565ce75..642e176dbb 100644 --- a/packages/backend/test/e2e/timelines.ts +++ b/packages/backend/test/e2e/timelines.ts @@ -653,7 +653,7 @@ describe('Timelines', () => { assert.strictEqual(await redisForTimelines.exists(`list:homeTimeline:${bob.id}`), 0); }); - describe('凍結', async () => { + describe('凍結', () => { let alice: SignupResponse, bob: SignupResponse, carol: SignupResponse; let aliceNote: Note, bobNote: Note, carolNote: Note; @@ -930,7 +930,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); }, 1000 * 10); - describe('凍結', async () => { + describe('凍結', () => { let alice: SignupResponse, bob: SignupResponse, carol: SignupResponse; let aliceNote: Note, bobNote: Note, carolNote: Note; @@ -1188,7 +1188,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); }, 1000 * 10); - describe('凍結', async () => { + describe('凍結', () => { /* * bob = 未フォローのローカルユーザー (凍結対象でない) * carol = 未フォローのローカルユーザー (凍結対象) From 399e527cc22ad93b7e7d58bf3312cccd732af6a3 Mon Sep 17 00:00:00 2001 From: tamaina Date: Wed, 16 Jul 2025 20:18:06 +0900 Subject: [PATCH 16/25] ??? --- .../backend/src/core/UserSuspendService.ts | 4 +- packages/backend/test/e2e/timelines.ts | 55 ++++++++++++++++--- 2 files changed, 50 insertions(+), 9 deletions(-) diff --git a/packages/backend/src/core/UserSuspendService.ts b/packages/backend/src/core/UserSuspendService.ts index 601859990a..c9a505ef3f 100644 --- a/packages/backend/src/core/UserSuspendService.ts +++ b/packages/backend/src/core/UserSuspendService.ts @@ -50,7 +50,7 @@ export class UserSuspendService { (async () => { await this.postSuspend(user).catch((e: any) => {}); - await this.unFollowAll(user).catch((e: any) => {}); + await this.suspendFollowings(user).catch((e: any) => {}); })(); } @@ -140,7 +140,7 @@ export class UserSuspendService { } @bindThis - private async unFollowAll(follower: MiUser) { + private async suspendFollowings(follower: MiUser) { await this.followingsRepository.update( { followerId: follower.id, diff --git a/packages/backend/test/e2e/timelines.ts b/packages/backend/test/e2e/timelines.ts index 642e176dbb..b6728c6c33 100644 --- a/packages/backend/test/e2e/timelines.ts +++ b/packages/backend/test/e2e/timelines.ts @@ -9,9 +9,9 @@ import * as assert from 'assert'; import { setTimeout } from 'node:timers/promises'; import { Redis } from 'ioredis'; +import { SignupResponse, Role, Note } from 'misskey-js/entities.js'; import { api, post, randomString, sendEnvUpdateRequest, signup, uploadUrl, role } from '../utils.js'; import { loadConfig } from '@/config.js'; -import { SignupResponse, Role, Note } from 'misskey-js/entities.js'; function genHost() { return randomString() + '.example.com'; @@ -693,6 +693,47 @@ describe('Timelines', () => { assert.strictEqual(res.body.find(note => note.id === carolNote.id)?.text, 'kon\'nichiwa'); }); }); + + describe('凍結(リモート)', () => { + let alice: SignupResponse, bob: SignupResponse, carol: SignupResponse; + let aliceNote: Note, bobNote: Note, carolNote: Note; + + beforeAll(async () => { + [alice, bob, carol] = await Promise.all([signup(), signup({ host: genHost() }), signup({ host: genHost() })]); + + await sendEnvUpdateRequest({ key: 'FORCE_FOLLOW_REMOTE_USER_FOR_TESTING', value: 'true' }); + await api('following/create', { userId: bob.id }, alice); + await api('following/create', { userId: carol.id }, alice); + aliceNote = await post(alice, { text: 'hi' }); + bobNote = await post(bob, { text: 'yo' }); + carolNote = await post(carol, { text: 'kon\'nichiwa' }); + + await waitForPushToTl(); + }); + + test('凍結後に凍結されたユーザーのノートは見えなくなる', async () => { + await api('admin/suspend-user', { userId: carol.id }, root); + await setTimeout(100); + + const res = await api('notes/timeline', { limit: 100 }, alice); + assert.strictEqual(res.body.length, 2); + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + }); + + test('凍結解除後に凍結されていたユーザーのノートは見えるようになる', async () => { + await api('admin/unsuspend-user', { userId: carol.id }, root); + await setTimeout(100); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.length, 3); + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), true); + }); + }); }); describe('Local TL', () => { @@ -949,7 +990,7 @@ describe('Timelines', () => { await setTimeout(100); const res = await api('notes/local-timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.length, 2); + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); @@ -961,7 +1002,6 @@ describe('Timelines', () => { const res = await api('notes/local-timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.length, 3); assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); assert.strictEqual(res.body.some(note => note.id === carolNote.id), true); @@ -1208,6 +1248,8 @@ describe('Timelines', () => { elleNote = await post(elle, { text: 'hi there' }); await api('following/create', { userId: dave.id }, alice); + + await sendEnvUpdateRequest({ key: 'FORCE_FOLLOW_REMOTE_USER_FOR_TESTING', value: 'true' }); await api('following/create', { userId: elle.id }, alice); await waitForPushToTl(); @@ -1219,8 +1261,8 @@ describe('Timelines', () => { await api('admin/suspend-user', { userId: elle.id }, root); await setTimeout(250); - const res = await api('notes/local-timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.length, 2); + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); @@ -1234,9 +1276,8 @@ describe('Timelines', () => { await api('admin/unsuspend-user', { userId: elle.id }, root); await setTimeout(250); - const res = await api('notes/local-timeline', { limit: 100 }, alice); + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.length, 5); assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); assert.strictEqual(res.body.some(note => note.id === carolNote.id), true); From f50364b100d4aa573d85f643eb7e75ba8d3b9688 Mon Sep 17 00:00:00 2001 From: tamaina Date: Wed, 16 Jul 2025 21:33:39 +0900 Subject: [PATCH 17/25] update test --- packages/backend/test/e2e/timelines.ts | 71 ++++++++++++------- .../backend/test/unit/UserSuspendService.ts | 54 ++++++++++++++ 2 files changed, 100 insertions(+), 25 deletions(-) diff --git a/packages/backend/test/e2e/timelines.ts b/packages/backend/test/e2e/timelines.ts index b6728c6c33..c1400a2cff 100644 --- a/packages/backend/test/e2e/timelines.ts +++ b/packages/backend/test/e2e/timelines.ts @@ -9,8 +9,8 @@ import * as assert from 'assert'; import { setTimeout } from 'node:timers/promises'; import { Redis } from 'ioredis'; -import { SignupResponse, Role, Note } from 'misskey-js/entities.js'; -import { api, post, randomString, sendEnvUpdateRequest, signup, uploadUrl, role } from '../utils.js'; +import { SignupResponse, Note, UserList } from 'misskey-js/entities.js'; +import { api, post, randomString, sendEnvUpdateRequest, signup, uploadUrl } from '../utils.js'; import { loadConfig } from '@/config.js'; function genHost() { @@ -667,12 +667,12 @@ describe('Timelines', () => { carolNote = await post(carol, { text: 'kon\'nichiwa' }); await waitForPushToTl(); + + await api('admin/suspend-user', { userId: carol.id }, root); + await setTimeout(100); }); test('凍結後に凍結されたユーザーのノートは見えなくなる', async () => { - await api('admin/suspend-user', { userId: carol.id }, root); - await setTimeout(100); - const res = await api('notes/timeline', { limit: 100 }, alice); assert.strictEqual(res.body.length, 2); assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); @@ -709,12 +709,12 @@ describe('Timelines', () => { carolNote = await post(carol, { text: 'kon\'nichiwa' }); await waitForPushToTl(); + + await api('admin/suspend-user', { userId: carol.id }, root); + await setTimeout(100); }); test('凍結後に凍結されたユーザーのノートは見えなくなる', async () => { - await api('admin/suspend-user', { userId: carol.id }, root); - await setTimeout(100); - const res = await api('notes/timeline', { limit: 100 }, alice); assert.strictEqual(res.body.length, 2); assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); @@ -1253,14 +1253,14 @@ describe('Timelines', () => { await api('following/create', { userId: elle.id }, alice); await waitForPushToTl(); - }); - test('凍結後に凍結されたユーザーのノートは見えなくなる', async () => { await api('admin/suspend-user', { userId: carol.id }, root); await api('admin/suspend-user', { userId: dave.id }, root); await api('admin/suspend-user', { userId: elle.id }, root); await setTimeout(250); + }); + test('凍結後に凍結されたユーザーのノートは見えなくなる', async () => { const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); @@ -1531,26 +1531,47 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); - test('凍結: 凍結後に凍結されたユーザーのノートは見えなくなる', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); - await api('users/lists/push', { listId: list.id, userId: bob.id }, alice); - await api('users/lists/push', { listId: list.id, userId: carol.id }, alice); - const aliceNote = await post(alice, { text: 'hi' }); - const bobNote = await post(bob, { text: 'yo' }); - const carolNote = await post(carol, { text: 'kon\'nichiwa' }); + describe('凍結', () => { + let alice: SignupResponse, bob: SignupResponse, carol: SignupResponse; + let aliceNote: Note, bobNote: Note, carolNote: Note; + let list: UserList; - await waitForPushToTl(); + beforeAll(async () => { + [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - await api('admin/suspend-user', { userId: carol.id }, root); + list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); + await api('users/lists/push', { listId: list.id, userId: bob.id }, alice); + await api('users/lists/push', { listId: list.id, userId: carol.id }, alice); - await setTimeout(250); + aliceNote = await post(alice, { text: 'hi' }); + bobNote = await post(bob, { text: 'yo' }); + carolNote = await post(carol, { text: 'kon\'nichiwa' }); - const res = await api('notes/user-list-timeline', { listId: list.id }, alice); - assert.strictEqual(res.body.length, 1); - assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); - assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + await waitForPushToTl(); + + await api('admin/suspend-user', { userId: carol.id }, root); + await setTimeout(100); + }); + + test('凍結後に凍結されたユーザーのノートは見えなくなる', async () => { + const res = await api('notes/user-list-timeline', { listId: list.id }, alice); + + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + }); + + test('凍結解除後に凍結されていたユーザーのノートは見えるようになる', async () => { + await api('admin/unsuspend-user', { userId: carol.id }, root); + await setTimeout(100); + + const res = await api('notes/user-list-timeline', { listId: list.id }, alice); + + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), true); + }); }); }); diff --git a/packages/backend/test/unit/UserSuspendService.ts b/packages/backend/test/unit/UserSuspendService.ts index ce8d35f408..8d6b01b730 100644 --- a/packages/backend/test/unit/UserSuspendService.ts +++ b/packages/backend/test/unit/UserSuspendService.ts @@ -25,6 +25,11 @@ import { GlobalEventService } from '@/core/GlobalEventService.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; import { secureRndstr } from '@/misc/secure-rndstr.js'; +import { randomString } from '../utils.js'; + +function genHost() { + return randomString() + '.example.com'; +} describe('UserSuspendService', () => { let app: TestingModule; @@ -359,4 +364,53 @@ describe('UserSuspendService', () => { expect(queueService.deliver).not.toHaveBeenCalled(); }); }); + + describe('remote user suspension', () => { + test('should suspend remote user without AP delivery', async () => { + const remoteUser = await createUser({ host: genHost() }); + const moderator = await createUser(); + + await userSuspendService.suspend(remoteUser, moderator); + await setTimeout(250); + + // ユーザーが凍結されているかチェック + const suspendedUser = await usersRepository.findOneBy({ id: remoteUser.id }); + expect(suspendedUser?.isSuspended).toBe(true); + + // モデレーションログが記録されているかチェック + expect(moderationLogService.log).toHaveBeenCalledWith(moderator, 'suspend', { + userId: remoteUser.id, + userUsername: remoteUser.username, + userHost: remoteUser.host, + }); + + // ActivityPub配信が呼ばれていないことを確認 + expect(queueService.deliver).not.toHaveBeenCalled(); + }); + }); + + describe('remote user unsuspension', () => { + test('should unsuspend remote user without AP delivery', async () => { + const remoteUser = await createUser({ host: genHost(), isSuspended: true }); + const moderator = await createUser(); + + await userSuspendService.unsuspend(remoteUser, moderator); + + await setTimeout(250); + + // ユーザーの凍結が解除されているかチェック + const unsuspendedUser = await usersRepository.findOneBy({ id: remoteUser.id }); + expect(unsuspendedUser?.isSuspended).toBe(false); + + // モデレーションログが記録されているかチェック + expect(moderationLogService.log).toHaveBeenCalledWith(moderator, 'unsuspend', { + userId: remoteUser.id, + userUsername: remoteUser.username, + userHost: remoteUser.host, + }); + + // ActivityPub配信が呼ばれていないことを確認 + expect(queueService.deliver).not.toHaveBeenCalled(); + }); + }); }); From 537a49a44883d923d36a6f4300ceaec5f16834a7 Mon Sep 17 00:00:00 2001 From: tamaina Date: Wed, 16 Jul 2025 21:53:19 +0900 Subject: [PATCH 18/25] fixed --- packages/backend/test/e2e/timelines.ts | 64 ++++++++++++++++++++------ 1 file changed, 49 insertions(+), 15 deletions(-) diff --git a/packages/backend/test/e2e/timelines.ts b/packages/backend/test/e2e/timelines.ts index c1400a2cff..68a0afe8ff 100644 --- a/packages/backend/test/e2e/timelines.ts +++ b/packages/backend/test/e2e/timelines.ts @@ -1233,30 +1233,23 @@ describe('Timelines', () => { * bob = 未フォローのローカルユーザー (凍結対象でない) * carol = 未フォローのローカルユーザー (凍結対象) * dave = フォローしているローカルユーザー (凍結対象) - * elle = フォローしているリモートユーザー (凍結対象) */ - let alice: SignupResponse, bob: SignupResponse, carol: SignupResponse, dave: SignupResponse, elle: SignupResponse; - let aliceNote: Note, bobNote: Note, carolNote: Note, daveNote: Note, elleNote: Note; + let alice: SignupResponse, bob: SignupResponse, carol: SignupResponse, dave: SignupResponse; + let aliceNote: Note, bobNote: Note, carolNote: Note, daveNote: Note; beforeAll(async () => { - [alice, bob, carol, dave, elle] = await Promise.all([signup(), signup(), signup(), signup(), signup({ host: genHost() })]); + [alice, bob, carol, dave] = await Promise.all([signup(), signup(), signup(), signup()]); + await api('following/create', { userId: dave.id }, alice); aliceNote = await post(alice, { text: 'hi' }); bobNote = await post(bob, { text: 'yo' }); carolNote = await post(carol, { text: 'kon\'nichiwa' }); daveNote = await post(dave, { text: 'hello' }); - elleNote = await post(elle, { text: 'hi there' }); - - await api('following/create', { userId: dave.id }, alice); - - await sendEnvUpdateRequest({ key: 'FORCE_FOLLOW_REMOTE_USER_FOR_TESTING', value: 'true' }); - await api('following/create', { userId: elle.id }, alice); await waitForPushToTl(); await api('admin/suspend-user', { userId: carol.id }, root); await api('admin/suspend-user', { userId: dave.id }, root); - await api('admin/suspend-user', { userId: elle.id }, root); await setTimeout(250); }); @@ -1267,13 +1260,11 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); assert.strictEqual(res.body.some(note => note.id === daveNote.id), false); - assert.strictEqual(res.body.some(note => note.id === elleNote.id), false); }); test('凍結解除後に凍結されていたユーザーのノートは見えるようになる', async () => { await api('admin/unsuspend-user', { userId: carol.id }, root); await api('admin/unsuspend-user', { userId: dave.id }, root); - await api('admin/unsuspend-user', { userId: elle.id }, root); await setTimeout(250); const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); @@ -1282,6 +1273,50 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); assert.strictEqual(res.body.some(note => note.id === carolNote.id), true); assert.strictEqual(res.body.some(note => note.id === daveNote.id), true); + }); + }); + + describe('凍結 (リモート)', () => { + /* + * carol = 未フォローのリモートユーザー (凍結対象) + * elle = フォローしているリモートユーザー (凍結対象) + */ + let alice: SignupResponse, carol: SignupResponse, elle: SignupResponse; + let aliceNote: Note, carolNote: Note, elleNote: Note; + + beforeAll(async () => { + [alice, carol, elle] = await Promise.all([signup(), signup({ host: genHost() }), signup({ host: genHost() })]); + + await sendEnvUpdateRequest({ key: 'FORCE_FOLLOW_REMOTE_USER_FOR_TESTING', value: 'true' }); + await api('following/create', { userId: elle.id }, alice); + aliceNote = await post(alice, { text: 'hi' }); + carolNote = await post(carol, { text: 'kon\'nichiwa' }); + elleNote = await post(elle, { text: 'hi there' }); + + await waitForPushToTl(); + + await api('admin/suspend-user', { userId: carol.id }, root); + await api('admin/suspend-user', { userId: elle.id }, root); + await setTimeout(250); + }); + + test('凍結後に凍結されたユーザーのノートは見えなくなる', async () => { + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + assert.strictEqual(res.body.some(note => note.id === elleNote.id), false); + }); + + test('凍結解除後に凍結されていたユーザーのノートは見えるようになる', async () => { + await api('admin/unsuspend-user', { userId: carol.id }, root); + await api('admin/unsuspend-user', { userId: elle.id }, root); + await setTimeout(250); + + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); assert.strictEqual(res.body.some(note => note.id === elleNote.id), true); }); }); @@ -1531,7 +1566,6 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); - describe('凍結', () => { let alice: SignupResponse, bob: SignupResponse, carol: SignupResponse; let aliceNote: Note, bobNote: Note, carolNote: Note; @@ -1541,9 +1575,9 @@ describe('Timelines', () => { [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); + await api('users/lists/push', { listId: list.id, userId: bob.id }, alice); await api('users/lists/push', { listId: list.id, userId: carol.id }, alice); - aliceNote = await post(alice, { text: 'hi' }); bobNote = await post(bob, { text: 'yo' }); carolNote = await post(carol, { text: 'kon\'nichiwa' }); From eee0aba8afb588fd64f36064727aa5e78b59bb8d Mon Sep 17 00:00:00 2001 From: tamaina Date: Wed, 16 Jul 2025 22:18:11 +0900 Subject: [PATCH 19/25] cherry pick --- packages/backend/test/e2e/timelines.ts | 289 ++++++++++++++++++++----- 1 file changed, 229 insertions(+), 60 deletions(-) diff --git a/packages/backend/test/e2e/timelines.ts b/packages/backend/test/e2e/timelines.ts index 2c53dfff2e..68a0afe8ff 100644 --- a/packages/backend/test/e2e/timelines.ts +++ b/packages/backend/test/e2e/timelines.ts @@ -9,9 +9,9 @@ import * as assert from 'assert'; import { setTimeout } from 'node:timers/promises'; import { Redis } from 'ioredis'; -import { api, post, randomString, sendEnvUpdateRequest, signup, uploadUrl, role } from '../utils.js'; +import { SignupResponse, Note, UserList } from 'misskey-js/entities.js'; +import { api, post, randomString, sendEnvUpdateRequest, signup, uploadUrl } from '../utils.js'; import { loadConfig } from '@/config.js'; -import { SignupResponse, Role } from 'misskey-js/entities.js'; function genHost() { return randomString() + '.example.com'; @@ -653,26 +653,86 @@ describe('Timelines', () => { assert.strictEqual(await redisForTimelines.exists(`list:homeTimeline:${bob.id}`), 0); }); - test('凍結: 凍結後に凍結されたユーザーのノートは見えなくなる', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + describe('凍結', () => { + let alice: SignupResponse, bob: SignupResponse, carol: SignupResponse; + let aliceNote: Note, bobNote: Note, carolNote: Note; - await api('following/create', { userId: bob.id }, alice); - await api('following/create', { userId: carol.id }, alice); - const aliceNote = await post(alice, { text: 'hi' }); - const bobNote = await post(bob, { text: 'yo' }); - const carolNote = await post(carol, { text: 'kon\'nichiwa' }); + beforeAll(async () => { + [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - await waitForPushToTl(); + await api('following/create', { userId: bob.id }, alice); + await api('following/create', { userId: carol.id }, alice); + aliceNote = await post(alice, { text: 'hi' }); + bobNote = await post(bob, { text: 'yo' }); + carolNote = await post(carol, { text: 'kon\'nichiwa' }); - await api('admin/suspend-user', { userId: carol.id }, root); + await waitForPushToTl(); - await setTimeout(250); + await api('admin/suspend-user', { userId: carol.id }, root); + await setTimeout(100); + }); - const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.length, 2); - assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); - assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); - assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + test('凍結後に凍結されたユーザーのノートは見えなくなる', async () => { + const res = await api('notes/timeline', { limit: 100 }, alice); + assert.strictEqual(res.body.length, 2); + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + }); + + test('凍結解除後に凍結されていたユーザーのノートは見えるようになる', async () => { + await api('admin/unsuspend-user', { userId: carol.id }, root); + await setTimeout(100); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.length, 3); + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), true); + assert.strictEqual(res.body.find(note => note.id === carolNote.id)?.text, 'kon\'nichiwa'); + }); + }); + + describe('凍結(リモート)', () => { + let alice: SignupResponse, bob: SignupResponse, carol: SignupResponse; + let aliceNote: Note, bobNote: Note, carolNote: Note; + + beforeAll(async () => { + [alice, bob, carol] = await Promise.all([signup(), signup({ host: genHost() }), signup({ host: genHost() })]); + + await sendEnvUpdateRequest({ key: 'FORCE_FOLLOW_REMOTE_USER_FOR_TESTING', value: 'true' }); + await api('following/create', { userId: bob.id }, alice); + await api('following/create', { userId: carol.id }, alice); + aliceNote = await post(alice, { text: 'hi' }); + bobNote = await post(bob, { text: 'yo' }); + carolNote = await post(carol, { text: 'kon\'nichiwa' }); + + await waitForPushToTl(); + + await api('admin/suspend-user', { userId: carol.id }, root); + await setTimeout(100); + }); + + test('凍結後に凍結されたユーザーのノートは見えなくなる', async () => { + const res = await api('notes/timeline', { limit: 100 }, alice); + assert.strictEqual(res.body.length, 2); + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + }); + + test('凍結解除後に凍結されていたユーザーのノートは見えるようになる', async () => { + await api('admin/unsuspend-user', { userId: carol.id }, root); + await setTimeout(100); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.length, 3); + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), true); + }); }); }); @@ -911,25 +971,42 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); }, 1000 * 10); - test('凍結: 凍結後に凍結されたユーザーのノートは見えなくなる', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + describe('凍結', () => { + let alice: SignupResponse, bob: SignupResponse, carol: SignupResponse; + let aliceNote: Note, bobNote: Note, carolNote: Note; - await api('following/create', { userId: bob.id }, alice); - await api('following/create', { userId: carol.id }, alice); - const aliceNote = await post(alice, { text: 'hi' }); - const bobNote = await post(bob, { text: 'yo' }); - const carolNote = await post(carol, { text: 'kon\'nichiwa' }); + beforeAll(async () => { + [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - await waitForPushToTl(); + aliceNote = await post(alice, { text: 'hi' }); + bobNote = await post(bob, { text: 'yo' }); + carolNote = await post(carol, { text: 'kon\'nichiwa' }); - await api('admin/suspend-user', { userId: carol.id }, root); + await waitForPushToTl(); + }); - await setTimeout(250); + test('凍結後に凍結されたユーザーのノートは見えなくなる', async () => { + await api('admin/suspend-user', { userId: carol.id }, root); + await setTimeout(100); - const res = await api('notes/local-timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); - assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); - assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + const res = await api('notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + }); + + test('凍結解除後に凍結されていたユーザーのノートは見えるようになる', async () => { + await api('admin/unsuspend-user', { userId: carol.id }, root); + await setTimeout(100); + + const res = await api('notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), true); + assert.strictEqual(res.body.find(note => note.id === carolNote.id)?.text, 'kon\'nichiwa'); + }); }); }); @@ -1151,25 +1228,97 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); }, 1000 * 10); - test('凍結: 凍結後に凍結されたユーザーのノートは見えなくなる', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + describe('凍結', () => { + /* + * bob = 未フォローのローカルユーザー (凍結対象でない) + * carol = 未フォローのローカルユーザー (凍結対象) + * dave = フォローしているローカルユーザー (凍結対象) + */ + let alice: SignupResponse, bob: SignupResponse, carol: SignupResponse, dave: SignupResponse; + let aliceNote: Note, bobNote: Note, carolNote: Note, daveNote: Note; - await api('following/create', { userId: bob.id }, alice); - await api('following/create', { userId: carol.id }, alice); - const aliceNote = await post(alice, { text: 'hi' }); - const bobNote = await post(bob, { text: 'yo' }); - const carolNote = await post(carol, { text: 'kon\'nichiwa' }); + beforeAll(async () => { + [alice, bob, carol, dave] = await Promise.all([signup(), signup(), signup(), signup()]); - await waitForPushToTl(); + await api('following/create', { userId: dave.id }, alice); + aliceNote = await post(alice, { text: 'hi' }); + bobNote = await post(bob, { text: 'yo' }); + carolNote = await post(carol, { text: 'kon\'nichiwa' }); + daveNote = await post(dave, { text: 'hello' }); - await api('admin/suspend-user', { userId: carol.id }, root); + await waitForPushToTl(); - await setTimeout(250); + await api('admin/suspend-user', { userId: carol.id }, root); + await api('admin/suspend-user', { userId: dave.id }, root); + await setTimeout(250); + }); - const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); - assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); - assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + test('凍結後に凍結されたユーザーのノートは見えなくなる', async () => { + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + assert.strictEqual(res.body.some(note => note.id === daveNote.id), false); + }); + + test('凍結解除後に凍結されていたユーザーのノートは見えるようになる', async () => { + await api('admin/unsuspend-user', { userId: carol.id }, root); + await api('admin/unsuspend-user', { userId: dave.id }, root); + await setTimeout(250); + + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), true); + assert.strictEqual(res.body.some(note => note.id === daveNote.id), true); + }); + }); + + describe('凍結 (リモート)', () => { + /* + * carol = 未フォローのリモートユーザー (凍結対象) + * elle = フォローしているリモートユーザー (凍結対象) + */ + let alice: SignupResponse, carol: SignupResponse, elle: SignupResponse; + let aliceNote: Note, carolNote: Note, elleNote: Note; + + beforeAll(async () => { + [alice, carol, elle] = await Promise.all([signup(), signup({ host: genHost() }), signup({ host: genHost() })]); + + await sendEnvUpdateRequest({ key: 'FORCE_FOLLOW_REMOTE_USER_FOR_TESTING', value: 'true' }); + await api('following/create', { userId: elle.id }, alice); + aliceNote = await post(alice, { text: 'hi' }); + carolNote = await post(carol, { text: 'kon\'nichiwa' }); + elleNote = await post(elle, { text: 'hi there' }); + + await waitForPushToTl(); + + await api('admin/suspend-user', { userId: carol.id }, root); + await api('admin/suspend-user', { userId: elle.id }, root); + await setTimeout(250); + }); + + test('凍結後に凍結されたユーザーのノートは見えなくなる', async () => { + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + assert.strictEqual(res.body.some(note => note.id === elleNote.id), false); + }); + + test('凍結解除後に凍結されていたユーザーのノートは見えるようになる', async () => { + await api('admin/unsuspend-user', { userId: carol.id }, root); + await api('admin/unsuspend-user', { userId: elle.id }, root); + await setTimeout(250); + + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + assert.strictEqual(res.body.some(note => note.id === elleNote.id), true); + }); }); }); @@ -1417,26 +1566,46 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); - test('凍結: 凍結後に凍結されたユーザーのノートは見えなくなる', async () => { - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + describe('凍結', () => { + let alice: SignupResponse, bob: SignupResponse, carol: SignupResponse; + let aliceNote: Note, bobNote: Note, carolNote: Note; + let list: UserList; - const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); - await api('users/lists/push', { listId: list.id, userId: bob.id }, alice); - await api('users/lists/push', { listId: list.id, userId: carol.id }, alice); - const aliceNote = await post(alice, { text: 'hi' }); - const bobNote = await post(bob, { text: 'yo' }); - const carolNote = await post(carol, { text: 'kon\'nichiwa' }); + beforeAll(async () => { + [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); - await waitForPushToTl(); + list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); - await api('admin/suspend-user', { userId: carol.id }, root); + await api('users/lists/push', { listId: list.id, userId: bob.id }, alice); + await api('users/lists/push', { listId: list.id, userId: carol.id }, alice); + aliceNote = await post(alice, { text: 'hi' }); + bobNote = await post(bob, { text: 'yo' }); + carolNote = await post(carol, { text: 'kon\'nichiwa' }); - await setTimeout(250); + await waitForPushToTl(); - const res = await api('notes/user-list-timeline', { listId: list.id }, alice); - assert.strictEqual(res.body.length, 1); - assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); - assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + await api('admin/suspend-user', { userId: carol.id }, root); + await setTimeout(100); + }); + + test('凍結後に凍結されたユーザーのノートは見えなくなる', async () => { + const res = await api('notes/user-list-timeline', { listId: list.id }, alice); + + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + }); + + test('凍結解除後に凍結されていたユーザーのノートは見えるようになる', async () => { + await api('admin/unsuspend-user', { userId: carol.id }, root); + await setTimeout(100); + + const res = await api('notes/user-list-timeline', { listId: list.id }, alice); + + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), true); + }); }); }); From 7476dda563de30d0927c109cf15935fbdbc4961f Mon Sep 17 00:00:00 2001 From: tamaina Date: Wed, 16 Jul 2025 22:32:50 +0900 Subject: [PATCH 20/25] add renote test --- packages/backend/test/e2e/timelines.ts | 49 +++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/packages/backend/test/e2e/timelines.ts b/packages/backend/test/e2e/timelines.ts index 68a0afe8ff..106b2857b5 100644 --- a/packages/backend/test/e2e/timelines.ts +++ b/packages/backend/test/e2e/timelines.ts @@ -674,7 +674,6 @@ describe('Timelines', () => { test('凍結後に凍結されたユーザーのノートは見えなくなる', async () => { const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.length, 2); assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); @@ -686,7 +685,6 @@ describe('Timelines', () => { const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.length, 3); assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); assert.strictEqual(res.body.some(note => note.id === carolNote.id), true); @@ -694,6 +692,50 @@ describe('Timelines', () => { }); }); + describe('凍結 (Renote)', () => { + let alice: SignupResponse, bob: SignupResponse, carol: SignupResponse; + let aliceNote: Note, bobNote: Note, carolNote: Note, bobRenote: Note, carolRenote: Note; + + beforeAll(async () => { + [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + await api('following/create', { userId: bob.id }, alice); + await api('following/create', { userId: carol.id }, alice); + aliceNote = await post(alice, { text: 'hi' }); + bobNote = await post(bob, { text: 'yo' }); + carolNote = await post(carol, { text: 'kon\'nichiwa' }); + bobRenote = await post(bob, { renoteId: carolNote.id }); + carolRenote = await post(carol, { renoteId: bobNote.id }); + + await waitForPushToTl(); + + await api('admin/suspend-user', { userId: carol.id }, root); + await setTimeout(100); + }); + + test('凍結後に凍結されたユーザーに対するRenoteや凍結されたユーザーのRenoteが見えなくなる', async () => { + const res = await api('notes/timeline', { limit: 100 }, alice); + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobRenote.id), false); + assert.strictEqual(res.body.some(note => note.id === carolRenote.id), false); + }); + + test('凍結解除後に凍結されていたユーザーに対するRenoteや凍結されたユーザーのRenoteが見えなくなる', async () => { + await api('admin/unsuspend-user', { userId: carol.id }, root); + await setTimeout(100); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobRenote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolRenote.id), true); + }); + }); + describe('凍結(リモート)', () => { let alice: SignupResponse, bob: SignupResponse, carol: SignupResponse; let aliceNote: Note, bobNote: Note, carolNote: Note; @@ -716,7 +758,7 @@ describe('Timelines', () => { test('凍結後に凍結されたユーザーのノートは見えなくなる', async () => { const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.length, 2); + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); @@ -728,7 +770,6 @@ describe('Timelines', () => { const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.length, 3); assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); assert.strictEqual(res.body.some(note => note.id === carolNote.id), true); From 3f4e80f5bf040a5530b5e689eec18d3acf582fce Mon Sep 17 00:00:00 2001 From: tamaina Date: Wed, 16 Jul 2025 23:09:35 +0900 Subject: [PATCH 21/25] Fix https://github.com/misskey-dev/misskey/issues/16293 --- .../src/core/FanoutTimelineEndpointService.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/backend/src/core/FanoutTimelineEndpointService.ts b/packages/backend/src/core/FanoutTimelineEndpointService.ts index 97b617096a..04d6d2c89c 100644 --- a/packages/backend/src/core/FanoutTimelineEndpointService.ts +++ b/packages/backend/src/core/FanoutTimelineEndpointService.ts @@ -20,6 +20,8 @@ import { CacheService } from '@/core/CacheService.js'; import { isReply } from '@/misc/is-reply.js'; import { isInstanceMuted } from '@/misc/is-instance-muted.js'; +type NoteFilter = (note: MiNote) => boolean; + type TimelineOptions = { untilId: string | null, sinceId: string | null, @@ -28,7 +30,7 @@ type TimelineOptions = { me?: { id: MiUser['id'] } | undefined | null, useDbFallback: boolean, redisTimelines: FanoutTimelineName[], - noteFilter?: (note: MiNote) => boolean, + noteFilter?: NoteFilter, alwaysIncludeMyNotes?: boolean; ignoreAuthorFromBlock?: boolean; ignoreAuthorFromMute?: boolean; @@ -79,7 +81,7 @@ export class FanoutTimelineEndpointService { const shouldFallbackToDb = noteIds.length === 0 || ps.sinceId != null && ps.sinceId < oldestNoteId; if (!shouldFallbackToDb) { - let filter = ps.noteFilter ?? (_note => true); + let filter = ps.noteFilter ?? (_note => true) as NoteFilter; if (ps.alwaysIncludeMyNotes && ps.me) { const me = ps.me; @@ -145,15 +147,13 @@ export class FanoutTimelineEndpointService { { const parentFilter = filter; filter = (note) => { - const noteJoined = note as MiNote & { - renoteUser: MiUser | null; - replyUser: MiUser | null; - }; + console.log(JSON.stringify(note, null, 2)); + if (!ps.ignoreAuthorFromUserSuspension) { if (note.user!.isSuspended) return false; } - if (note.userId !== note.renoteUserId && noteJoined.renoteUser?.isSuspended) return false; - if (note.userId !== note.replyUserId && noteJoined.replyUser?.isSuspended) return false; + if (note.userId !== note.renoteUserId && note.renote?.user?.isSuspended) return false; + if (note.userId !== note.replyUserId && note.reply?.user?.isSuspended) return false; return parentFilter(note); }; @@ -200,7 +200,7 @@ export class FanoutTimelineEndpointService { return await ps.dbFallback(ps.untilId, ps.sinceId, ps.limit); } - private async getAndFilterFromDb(noteIds: string[], noteFilter: (note: MiNote) => boolean, idCompare: (a: string, b: string) => number): Promise { + private async getAndFilterFromDb(noteIds: string[], noteFilter: NoteFilter, idCompare: (a: string, b: string) => number): Promise { const query = this.notesRepository.createQueryBuilder('note') .where('note.id IN (:...noteIds)', { noteIds: noteIds }) .innerJoinAndSelect('note.user', 'user') From 4e69755afad86887d24371687630d1d971445fa8 Mon Sep 17 00:00:00 2001 From: tamaina Date: Wed, 16 Jul 2025 23:09:56 +0900 Subject: [PATCH 22/25] remove debug log --- packages/backend/src/core/FanoutTimelineEndpointService.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/backend/src/core/FanoutTimelineEndpointService.ts b/packages/backend/src/core/FanoutTimelineEndpointService.ts index 04d6d2c89c..94c5691bf4 100644 --- a/packages/backend/src/core/FanoutTimelineEndpointService.ts +++ b/packages/backend/src/core/FanoutTimelineEndpointService.ts @@ -147,8 +147,6 @@ export class FanoutTimelineEndpointService { { const parentFilter = filter; filter = (note) => { - console.log(JSON.stringify(note, null, 2)); - if (!ps.ignoreAuthorFromUserSuspension) { if (note.user!.isSuspended) return false; } From bb4af983b711c7cfb2c7ed005507f73510660373 Mon Sep 17 00:00:00 2001 From: tamaina Date: Thu, 17 Jul 2025 10:45:26 +0900 Subject: [PATCH 23/25] clean up --- packages/backend/src/core/UserSuspendService.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/backend/src/core/UserSuspendService.ts b/packages/backend/src/core/UserSuspendService.ts index c9a505ef3f..53daba9701 100644 --- a/packages/backend/src/core/UserSuspendService.ts +++ b/packages/backend/src/core/UserSuspendService.ts @@ -13,7 +13,6 @@ import { DI } from '@/di-symbols.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; -import { RelationshipJobData } from '@/queue/types.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; @Injectable() From 2960c2069e7ba230b19b56add1f5b4a85c237916 Mon Sep 17 00:00:00 2001 From: tamaina Date: Sun, 20 Jul 2025 23:28:55 +0900 Subject: [PATCH 24/25] =?UTF-8?q?=20beforeEach=20=E2=86=92=20beforeAll?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/test/unit/UserSuspendService.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/backend/test/unit/UserSuspendService.ts b/packages/backend/test/unit/UserSuspendService.ts index 8d6b01b730..63e8f7aa00 100644 --- a/packages/backend/test/unit/UserSuspendService.ts +++ b/packages/backend/test/unit/UserSuspendService.ts @@ -79,7 +79,7 @@ describe('UserSuspendService', () => { return following; } - beforeEach(async () => { + beforeAll(async () => { app = await Test.createTestingModule({ imports: [GlobalModule], providers: [ @@ -131,13 +131,10 @@ describe('UserSuspendService', () => { globalEventService = app.get(GlobalEventService) as jest.Mocked; apRendererService = app.get(ApRendererService) as jest.Mocked; moderationLogService = app.get(ModerationLogService) as jest.Mocked; - - // Reset mocks - jest.clearAllMocks(); }); - afterEach(async () => { - await app.close(); + beforeEach(() => { + jest.clearAllMocks(); }); describe('suspend', () => { From 6f83e9decbc2d02c6976afb685394bd6825cdf3b Mon Sep 17 00:00:00 2001 From: tamaina Date: Thu, 31 Jul 2025 14:20:16 +0900 Subject: [PATCH 25/25] fix(test): Fix name of a test in e2e/timelines.ts --- packages/backend/test/e2e/timelines.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/test/e2e/timelines.ts b/packages/backend/test/e2e/timelines.ts index 106b2857b5..4f7d1a4d69 100644 --- a/packages/backend/test/e2e/timelines.ts +++ b/packages/backend/test/e2e/timelines.ts @@ -722,7 +722,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === carolRenote.id), false); }); - test('凍結解除後に凍結されていたユーザーに対するRenoteや凍結されたユーザーのRenoteが見えなくなる', async () => { + test('凍結解除後に凍結されていたユーザーに対するRenoteや凍結されたユーザーのRenoteが見えるようになる', async () => { await api('admin/unsuspend-user', { userId: carol.id }, root); await setTimeout(100);