From 28fdf1b9a60fab91c3a7ce87de6e06c9c8f44b7a Mon Sep 17 00:00:00 2001 From: samunohito <46447427+samunohito@users.noreply.github.com> Date: Sat, 29 Jun 2024 09:42:34 +0900 Subject: [PATCH] =?UTF-8?q?=E3=83=86=E3=82=B9=E3=83=88=E3=81=AE=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0=E3=81=A8=E6=A4=9C=E5=87=BA=E3=81=97=E3=81=9F=E4=B8=8D?= =?UTF-8?q?=E5=82=99=E3=81=AE=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/jest.config.unit.cjs | 1 + .../src/core/ChannelFollowingService.ts | 48 +- .../backend/src/core/ChannelMutingService.ts | 48 +- .../server/api/endpoints/antennas/notes.ts | 18 + .../api/endpoints/notes/hybrid-timeline.ts | 41 +- .../api/endpoints/notes/local-timeline.ts | 21 +- .../server/api/endpoints/notes/timeline.ts | 55 +- .../api/endpoints/notes/user-list-timeline.ts | 14 + .../src/server/api/endpoints/roles/notes.ts | 18 + .../src/server/api/endpoints/users/notes.ts | 31 +- packages/backend/test/e2e/antennas.ts | 21 + packages/backend/test/e2e/timelines.ts | 1283 +++++++++++++++-- packages/backend/test/jest.setup.unit.cjs | 9 + .../test/unit/ChannelFollowingService.ts | 235 +++ .../backend/test/unit/ChannelMutingService.ts | 334 +++++ 15 files changed, 1974 insertions(+), 203 deletions(-) create mode 100644 packages/backend/test/jest.setup.unit.cjs create mode 100644 packages/backend/test/unit/ChannelFollowingService.ts create mode 100644 packages/backend/test/unit/ChannelMutingService.ts diff --git a/packages/backend/jest.config.unit.cjs b/packages/backend/jest.config.unit.cjs index aa5992936b..957d0635c1 100644 --- a/packages/backend/jest.config.unit.cjs +++ b/packages/backend/jest.config.unit.cjs @@ -7,6 +7,7 @@ const base = require('./jest.config.cjs') module.exports = { ...base, + globalSetup: "/test/jest.setup.unit.cjs", testMatch: [ "/test/unit/**/*.ts", "/src/**/*.test.ts", diff --git a/packages/backend/src/core/ChannelFollowingService.ts b/packages/backend/src/core/ChannelFollowingService.ts index 12251595e2..d320a5ea36 100644 --- a/packages/backend/src/core/ChannelFollowingService.ts +++ b/packages/backend/src/core/ChannelFollowingService.ts @@ -6,7 +6,7 @@ import { Inject, Injectable, OnModuleInit } from '@nestjs/common'; import Redis from 'ioredis'; import { DI } from '@/di-symbols.js'; -import type { ChannelFollowingsRepository } from '@/models/_.js'; +import type { ChannelFollowingsRepository, ChannelsRepository, MiUser } from '@/models/_.js'; import { MiChannel } from '@/models/_.js'; import { IdService } from '@/core/IdService.js'; import { GlobalEvents, GlobalEventService } from '@/core/GlobalEventService.js'; @@ -23,6 +23,8 @@ export class ChannelFollowingService implements OnModuleInit { private redisClient: Redis.Redis, @Inject(DI.redisForSub) private redisForSub: Redis.Redis, + @Inject(DI.channelsRepository) + private channelsRepository: ChannelsRepository, @Inject(DI.channelFollowingsRepository) private channelFollowingsRepository: ChannelFollowingsRepository, private idService: IdService, @@ -45,6 +47,50 @@ export class ChannelFollowingService implements OnModuleInit { onModuleInit() { } + /** + * フォローしているチャンネルの一覧を取得する. + * @param params + * @param [opts] + * @param {(boolean|undefined)} [opts.idOnly=false] チャンネルIDのみを取得するかどうか. ID以外のフィールドに値がセットされなくなり、他テーブルとのJOINも一切されなくなるので注意. + * @param {(boolean|undefined)} [opts.joinUser=undefined] チャンネルオーナーのユーザ情報をJOINするかどうか(falseまたは省略時はJOINしない). + * @param {(boolean|undefined)} [opts.joinBannerFile=undefined] バナー画像のドライブファイルをJOINするかどうか(falseまたは省略時はJOINしない). + */ + @bindThis + public async list( + params: { + requestUserId: MiUser['id'], + }, + opts?: { + idOnly?: boolean; + joinUser?: boolean; + joinBannerFile?: boolean; + }, + ): Promise { + if (opts?.idOnly) { + const q = this.channelFollowingsRepository.createQueryBuilder('channel_following') + .select('channel_following.followeeId') + .where('channel_following.followerId = :userId', { userId: params.requestUserId }); + + return q + .getRawMany<{ channel_following_followeeId: string }>() + .then(xs => xs.map(x => ({ id: x.channel_following_followeeId } as MiChannel))); + } else { + const q = this.channelsRepository.createQueryBuilder('channel') + .innerJoin('channel_following', 'channel_following', 'channel_following.followeeId = channel.id') + .where('channel_following.followerId = :userId', { userId: params.requestUserId }); + + if (opts?.joinUser) { + q.innerJoinAndSelect('channel.user', 'user'); + } + + if (opts?.joinBannerFile) { + q.leftJoinAndSelect('channel.banner', 'drive_file'); + } + + return q.getMany(); + } + } + @bindThis public async follow( requestUser: MiLocalUser, diff --git a/packages/backend/src/core/ChannelMutingService.ts b/packages/backend/src/core/ChannelMutingService.ts index 6347972392..bf5b848d44 100644 --- a/packages/backend/src/core/ChannelMutingService.ts +++ b/packages/backend/src/core/ChannelMutingService.ts @@ -5,7 +5,7 @@ import { Inject, Injectable } from '@nestjs/common'; import Redis from 'ioredis'; -import { In } from 'typeorm'; +import { Brackets, In } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { ChannelMutingRepository, ChannelsRepository, MiChannel, MiChannelMuting, MiUser } from '@/models/_.js'; import { IdService } from '@/core/IdService.js'; @@ -47,6 +47,7 @@ export class ChannelMutingService { * ミュートしているチャンネルの一覧を取得する. * @param params * @param [opts] + * @param {(boolean|undefined)} [opts.idOnly=false] チャンネルIDのみを取得するかどうか. ID以外のフィールドに値がセットされなくなり、他テーブルとのJOINも一切されなくなるので注意. * @param {(boolean|undefined)} [opts.joinUser=undefined] チャンネルオーナーのユーザ情報をJOINするかどうか(falseまたは省略時はJOINしない). * @param {(boolean|undefined)} [opts.joinBannerFile=undefined] バナー画像のドライブファイルをJOINするかどうか(falseまたは省略時はJOINしない). */ @@ -56,27 +57,42 @@ export class ChannelMutingService { requestUserId: MiUser['id'], }, opts?: { + idOnly?: boolean; joinUser?: boolean; joinBannerFile?: boolean; }, ): Promise { - const q = this.channelsRepository.createQueryBuilder('channel') - .innerJoin('channel_muting', 'channel_muting', 'channel_muting.channelId = channel.id') - .where('channel_muting.userId = :userId', { userId: params.requestUserId }) - .andWhere(qb => { - qb.where('channel_muting.expiresAt IS NULL') - .orWhere('channel_muting.expiresAt > :now', { now: new Date() }); - }); + if (opts?.idOnly) { + const q = this.channelMutingRepository.createQueryBuilder('channel_muting') + .select('channel_muting.channelId') + .where('channel_muting.userId = :userId', { userId: params.requestUserId }) + .andWhere(new Brackets(qb => { + qb.where('channel_muting.expiresAt IS NULL') + .orWhere('channel_muting.expiresAt > :now', { now: new Date() }); + })); - if (opts?.joinUser) { - q.innerJoinAndSelect('channel.user', 'user'); + return q + .getRawMany<{ channel_muting_channelId: string }>() + .then(xs => xs.map(x => ({ id: x.channel_muting_channelId } as MiChannel))); + } else { + const q = this.channelsRepository.createQueryBuilder('channel') + .innerJoin('channel_muting', 'channel_muting', 'channel_muting.channelId = channel.id') + .where('channel_muting.userId = :userId', { userId: params.requestUserId }) + .andWhere(new Brackets(qb => { + qb.where('channel_muting.expiresAt IS NULL') + .orWhere('channel_muting.expiresAt > :now', { now: new Date() }); + })); + + if (opts?.joinUser) { + q.innerJoinAndSelect('channel.user', 'user'); + } + + if (opts?.joinBannerFile) { + q.leftJoinAndSelect('channel.banner', 'drive_file'); + } + + return q.getMany(); } - - if (opts?.joinBannerFile) { - q.leftJoinAndSelect('channel.banner', 'drive_file'); - } - - return q.getMany(); } /** diff --git a/packages/backend/src/server/api/endpoints/antennas/notes.ts b/packages/backend/src/server/api/endpoints/antennas/notes.ts index f4dfe1ecc4..49f1df9692 100644 --- a/packages/backend/src/server/api/endpoints/antennas/notes.ts +++ b/packages/backend/src/server/api/endpoints/antennas/notes.ts @@ -5,6 +5,7 @@ import { Inject, Injectable } from '@nestjs/common'; import * as Redis from 'ioredis'; +import { Brackets } from 'typeorm'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { NotesRepository, AntennasRepository } from '@/models/_.js'; import { QueryService } from '@/core/QueryService.js'; @@ -15,6 +16,7 @@ import { IdService } from '@/core/IdService.js'; import { FanoutTimelineService } from '@/core/FanoutTimelineService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { trackPromise } from '@/misc/promise-tracker.js'; +import { ChannelMutingService } from '@/core/ChannelMutingService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -74,6 +76,7 @@ export default class extends Endpoint { // eslint- private noteReadService: NoteReadService, private fanoutTimelineService: FanoutTimelineService, private globalEventService: GlobalEventService, + private channelMutingService: ChannelMutingService, ) { super(meta, paramDef, async (ps, me) => { const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); @@ -113,6 +116,21 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('renote.user', 'renoteUser'); + // -- ミュートされたチャンネル対策 + const mutingChannelIds = await this.channelMutingService + .list({ requestUserId: me.id }, { idOnly: true }) + .then(x => x.map(x => x.id)); + if (mutingChannelIds.length > 0) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.channelId IS NULL'); + qb.orWhere('note.channelId NOT IN (:...mutingChannelIds)', { mutingChannelIds }); + })); + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteChannelId IS NULL'); + qb.orWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds }); + })); + } + this.queryService.generateVisibilityQuery(query, me); this.queryService.generateMutedUserQuery(query, me); this.queryService.generateBlockedUserQuery(query, me); diff --git a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts index af92ae4849..5e1bca3e99 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -21,6 +21,7 @@ import { MiLocalUser } from '@/models/User.js'; import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; import { ChannelMutingService } from '@/core/ChannelMutingService.js'; import { ApiError } from '../../error.js'; +import { ChannelFollowingService } from "@/core/ChannelFollowingService.js"; export const meta = { tags: ['notes'], @@ -77,16 +78,14 @@ export default class extends Endpoint { // eslint- constructor( @Inject(DI.notesRepository) private notesRepository: NotesRepository, - @Inject(DI.channelFollowingsRepository) - private channelFollowingsRepository: ChannelFollowingsRepository, private noteEntityService: NoteEntityService, private roleService: RoleService, private activeUsersChart: ActiveUsersChart, private idService: IdService, - private cacheService: CacheService, private queryService: QueryService, private userFollowingService: UserFollowingService, private channelMutingService: ChannelMutingService, + private channelFollowingService: ChannelFollowingService, private metaService: MetaService, private fanoutTimelineEndpointService: FanoutTimelineEndpointService, ) { @@ -184,12 +183,13 @@ export default class extends Endpoint { // eslint- withReplies: boolean, }, me: MiLocalUser) { const followees = await this.userFollowingService.getFollowees(me.id); - const followingChannels = await this.channelFollowingsRepository.find({ - where: { - followerId: me.id, - }, - }); - const mutingChannelIds = await this.channelMutingService.list({ requestUserId: me.id }).then(x => x.map(x => x.id)); + + const mutingChannelIds = await this.channelMutingService + .list({ requestUserId: me.id }, { idOnly: true }) + .then(x => x.map(x => x.id)); + const followingChannelIds = await this.channelFollowingService + .list({ requestUserId: me.id }, { idOnly: true }) + .then(x => x.map(x => x.id).filter(x => !mutingChannelIds.includes(x))); const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) .andWhere(new Brackets(qb => { @@ -208,9 +208,7 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('renote.user', 'renoteUser'); - if (followingChannels.length > 0) { - const followingChannelIds = followingChannels.map(x => x.followeeId); - + if (followingChannelIds.length > 0) { query.andWhere(new Brackets(qb => { qb.where('note.channelId IN (:...followingChannelIds)', { followingChannelIds }); qb.orWhere('note.channelId IS NULL'); @@ -221,23 +219,8 @@ export default class extends Endpoint { // eslint- if (mutingChannelIds.length > 0) { query.andWhere(new Brackets(qb => { - qb - // ミュートしてるチャンネルは含めない - .where(new Brackets(qb2 => { - qb2 - .andWhere(new Brackets(qb3 => { - qb3 - .andWhere('note.channelId IS NOT NULL') - .andWhere('note.channelId NOT IN (:...mutingChannelIds)', { mutingChannelIds }); - })) - .andWhere(new Brackets(qb3 => { - qb3 - .andWhere('note.renoteChannelId IS NOT NULL') - .andWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds }); - })); - })) - // チャンネルの投稿ではない - .orWhere('note.channelId IS NULL'); + qb.orWhere('note.renoteChannelId IS NULL'); + qb.orWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds }); })); } diff --git a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts index be82b5a8a7..5fe1a27047 100644 --- a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts @@ -18,6 +18,7 @@ import { MetaService } from '@/core/MetaService.js'; import { MiLocalUser } from '@/models/User.js'; import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; import { ApiError } from '../../error.js'; +import { ChannelMutingService } from "@/core/ChannelMutingService.js"; export const meta = { tags: ['notes'], @@ -77,6 +78,7 @@ export default class extends Endpoint { // eslint- private fanoutTimelineEndpointService: FanoutTimelineEndpointService, private queryService: QueryService, private metaService: MetaService, + private channelMutingService: ChannelMutingService, ) { super(meta, paramDef, async (ps, me) => { const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); @@ -123,6 +125,7 @@ export default class extends Endpoint { // eslint- : ['localTimeline'], alwaysIncludeMyNotes: true, excludePureRenotes: !ps.withRenotes, + excludeMutedChannels: true, dbFallback: async (untilId, sinceId, limit) => await this.getFromDb({ untilId, sinceId, @@ -159,9 +162,21 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateVisibilityQuery(query, me); - if (me) this.queryService.generateMutedUserQuery(query, me); - if (me) this.queryService.generateBlockedUserQuery(query, me); - if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me); + if (me) { + this.queryService.generateMutedUserQuery(query, me); + this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); + + const mutedChannelIds = await this.channelMutingService + .list({ requestUserId: me.id }, { idOnly: true }) + .then(x => x.map(x => x.id)); + if (mutedChannelIds.length > 0) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteChannelId IS NULL') + .orWhere('note.renoteChannelId NOT IN (:...mutedChannelIds)', { mutedChannelIds }); + })); + } + } if (ps.withFiles) { query.andWhere('note.fileIds != \'{}\''); diff --git a/packages/backend/src/server/api/endpoints/notes/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts index 38b27da1ed..ad5c53f16b 100644 --- a/packages/backend/src/server/api/endpoints/notes/timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts @@ -5,7 +5,7 @@ import { Brackets } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; -import type { NotesRepository, ChannelFollowingsRepository, ChannelMutingRepository } from '@/models/_.js'; +import type { NotesRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueryService } from '@/core/QueryService.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js'; @@ -18,6 +18,7 @@ import { MiLocalUser } from '@/models/User.js'; import { MetaService } from '@/core/MetaService.js'; import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; import { ChannelMutingService } from '@/core/ChannelMutingService.js'; +import { ChannelFollowingService } from '@/core/ChannelFollowingService.js'; export const meta = { tags: ['notes'], @@ -60,9 +61,6 @@ export default class extends Endpoint { // eslint- @Inject(DI.notesRepository) private notesRepository: NotesRepository, - @Inject(DI.channelFollowingsRepository) - private channelFollowingsRepository: ChannelFollowingsRepository, - private noteEntityService: NoteEntityService, private activeUsersChart: ActiveUsersChart, private idService: IdService, @@ -70,6 +68,7 @@ export default class extends Endpoint { // eslint- private fanoutTimelineEndpointService: FanoutTimelineEndpointService, private userFollowingService: UserFollowingService, private channelMutingService: ChannelMutingService, + private channelFollowingService: ChannelFollowingService, private queryService: QueryService, private metaService: MetaService, ) { @@ -144,12 +143,13 @@ export default class extends Endpoint { // eslint- private async getFromDb(ps: { untilId: string | null; sinceId: string | null; limit: number; includeMyRenotes: boolean; includeRenotedMyNotes: boolean; includeLocalRenotes: boolean; withFiles: boolean; withRenotes: boolean; }, me: MiLocalUser) { const followees = await this.userFollowingService.getFollowees(me.id); - const followingChannels = await this.channelFollowingsRepository.find({ - where: { - followerId: me.id, - }, - }); - const mutingChannelIds = await this.channelMutingService.list({ requestUserId: me.id }).then(x => x.map(x => x.id)); + + const mutingChannelIds = await this.channelMutingService + .list({ requestUserId: me.id }, { idOnly: true }) + .then(x => x.map(x => x.id)); + const followingChannelIds = await this.channelFollowingService + .list({ requestUserId: me.id }, { idOnly: true }) + .then(x => x.map(x => x.id).filter(x => !mutingChannelIds.includes(x))); //#region Construct query const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) @@ -159,10 +159,9 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('renote.user', 'renoteUser'); - if (followees.length > 0 && followingChannels.length > 0) { + if (followees.length > 0 && followingChannelIds.length > 0) { // ユーザー・チャンネルともにフォローあり const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)]; - const followingChannelIds = followingChannels.map(x => x.followeeId); query.andWhere(new Brackets(qb => { qb .where(new Brackets(qb2 => { @@ -179,12 +178,18 @@ export default class extends Endpoint { // eslint- qb .andWhere('note.channelId IS NULL') .andWhere('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds }); + if (mutingChannelIds.length > 0) { + qb.andWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds }); + } })); - } else if (followingChannels.length > 0) { + } else if (followingChannelIds.length > 0) { // チャンネルフォローのみ(ユーザーフォローなし) - const followingChannelIds = followingChannels.map(x => x.followeeId); query.andWhere(new Brackets(qb => { qb + // renoteChannelIdは見る必要が無い + // ・HTLに流れてくるチャンネル=フォローしているチャンネル + // ・HTLにフォロー外のチャンネルが流れるのは、フォローしているユーザがそのチャンネル投稿をリノートした場合のみ + // つまり、ユーザフォローしてない前提のこのブロックでは見る必要が無い .where('note.channelId IN (:...followingChannelIds)', { followingChannelIds }) .orWhere('note.userId = :meId', { meId: me.id }); })); @@ -197,28 +202,6 @@ export default class extends Endpoint { // eslint- })); } - if (mutingChannelIds.length > 0) { - query.andWhere(new Brackets(qb => { - qb - // ミュートしてるチャンネルは含めない - .where(new Brackets(qb2 => { - qb2 - .andWhere(new Brackets(qb3 => { - qb3 - .andWhere('note.channelId IS NOT NULL') - .andWhere('note.channelId NOT IN (:...mutingChannelIds)', { mutingChannelIds }); - })) - .andWhere(new Brackets(qb3 => { - qb3 - .andWhere('note.renoteChannelId IS NOT NULL') - .andWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds }); - })); - })) - // チャンネルの投稿ではない - .orWhere('note.channelId IS NULL'); - })); - } - query.andWhere(new Brackets(qb => { qb .where('note.replyId IS NULL') // 返信ではない diff --git a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts index 43877e61ef..0483a03a8b 100644 --- a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts @@ -17,6 +17,7 @@ import { MiLocalUser } from '@/models/User.js'; import { MetaService } from '@/core/MetaService.js'; import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; import { ApiError } from '../../error.js'; +import { ChannelMutingService } from "@/core/ChannelMutingService.js"; export const meta = { tags: ['notes', 'lists'], @@ -85,6 +86,7 @@ export default class extends Endpoint { // eslint- private fanoutTimelineEndpointService: FanoutTimelineEndpointService, private queryService: QueryService, private metaService: MetaService, + private channelMutingService: ChannelMutingService, ) { super(meta, paramDef, async (ps, me) => { const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); @@ -128,6 +130,7 @@ export default class extends Endpoint { // eslint- redisTimelines: ps.withFiles ? [`userListTimelineWithFiles:${list.id}`] : [`userListTimeline:${list.id}`], alwaysIncludeMyNotes: true, excludePureRenotes: !ps.withRenotes, + excludeMutedChannels: true, dbFallback: async (untilId, sinceId, limit) => await this.getFromDb(list, { untilId, sinceId, @@ -191,6 +194,17 @@ export default class extends Endpoint { // eslint- this.queryService.generateBlockedUserQuery(query, me); this.queryService.generateMutedUserRenotesQueryForNotes(query, me); + // -- ミュートされたチャンネルのリノート対策 + const mutedChannelIds = await this.channelMutingService + .list({ requestUserId: me.id }, { idOnly: true }) + .then(x => x.map(x => x.id)); + if (mutedChannelIds.length > 0) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteChannelId IS NULL') + .orWhere('note.renoteChannelId NOT IN (:...mutedChannelIds)', { mutedChannelIds }); + })); + } + if (ps.includeMyRenotes === false) { query.andWhere(new Brackets(qb => { qb.orWhere('note.userId != :meId', { meId: me.id }); diff --git a/packages/backend/src/server/api/endpoints/roles/notes.ts b/packages/backend/src/server/api/endpoints/roles/notes.ts index 71f2782a5d..b8a32ba71c 100644 --- a/packages/backend/src/server/api/endpoints/roles/notes.ts +++ b/packages/backend/src/server/api/endpoints/roles/notes.ts @@ -5,6 +5,7 @@ import { Inject, Injectable } from '@nestjs/common'; import * as Redis from 'ioredis'; +import { Brackets } from 'typeorm'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { NotesRepository, RolesRepository } from '@/models/_.js'; import { QueryService } from '@/core/QueryService.js'; @@ -12,6 +13,7 @@ import { DI } from '@/di-symbols.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { IdService } from '@/core/IdService.js'; import { FanoutTimelineService } from '@/core/FanoutTimelineService.js'; +import { ChannelMutingService } from '@/core/ChannelMutingService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -68,6 +70,7 @@ export default class extends Endpoint { // eslint- private noteEntityService: NoteEntityService, private queryService: QueryService, private fanoutTimelineService: FanoutTimelineService, + private channelMutingService: ChannelMutingService, ) { super(meta, paramDef, async (ps, me) => { const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); @@ -101,6 +104,21 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('renote.user', 'renoteUser'); + // -- ミュートされたチャンネル対策 + const mutingChannelIds = await this.channelMutingService + .list({ requestUserId: me.id }, { idOnly: true }) + .then(x => x.map(x => x.id)); + if (mutingChannelIds.length > 0) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.channelId IS NULL'); + qb.orWhere('note.channelId NOT IN (:...mutingChannelIds)', { mutingChannelIds }); + })); + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteChannelId IS NULL'); + qb.orWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds }); + })); + } + this.queryService.generateVisibilityQuery(query, me); this.queryService.generateMutedUserQuery(query, me); this.queryService.generateBlockedUserQuery(query, me); diff --git a/packages/backend/src/server/api/endpoints/users/notes.ts b/packages/backend/src/server/api/endpoints/users/notes.ts index cc76c12f1d..16bf01b215 100644 --- a/packages/backend/src/server/api/endpoints/users/notes.ts +++ b/packages/backend/src/server/api/endpoints/users/notes.ts @@ -17,6 +17,7 @@ import { MiLocalUser } from '@/models/User.js'; import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; import { FanoutTimelineName } from '@/core/FanoutTimelineService.js'; import { ApiError } from '@/server/api/error.js'; +import { ChannelMutingService } from '@/core/ChannelMutingService.js'; export const meta = { tags: ['users', 'notes'], @@ -69,13 +70,13 @@ export default class extends Endpoint { // eslint- constructor( @Inject(DI.notesRepository) private notesRepository: NotesRepository, - private noteEntityService: NoteEntityService, private queryService: QueryService, private cacheService: CacheService, private idService: IdService, private fanoutTimelineEndpointService: FanoutTimelineEndpointService, private metaService: MetaService, + private channelMutingService: ChannelMutingService, ) { super(meta, paramDef, async (ps, me) => { const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); @@ -127,6 +128,7 @@ export default class extends Endpoint { // eslint- excludeReplies: ps.withChannelNotes && !ps.withReplies, // userTimelineWithChannel may include replies excludeNoFiles: ps.withChannelNotes && ps.withFiles, // userTimelineWithChannel may include notes without files excludePureRenotes: !ps.withRenotes, + excludeMutedChannels: true, noteFilter: note => { if (note.channel?.isSensitive && !isSelf) return false; if (note.visibility === 'specified' && (!me || (me.id !== note.userId && !note.visibleUserIds.some(v => v === me.id)))) return false; @@ -158,6 +160,11 @@ export default class extends Endpoint { // eslint- withFiles: boolean, withRenotes: boolean, }, me: MiLocalUser | null) { + const mutingChannelIds = me + ? await this.channelMutingService + .list({ requestUserId: me.id }, { idOnly: true }) + .then(x => x.map(x => x.id)) + : []; const isSelf = me && (me.id === ps.userId); const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) @@ -170,14 +177,30 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('renote.user', 'renoteUser'); if (ps.withChannelNotes) { - if (!isSelf) query.andWhere(new Brackets(qb => { - qb.orWhere('note.channelId IS NULL'); - qb.orWhere('channel.isSensitive = false'); + query.andWhere(new Brackets(qb => { + if (mutingChannelIds.length > 0) { + qb.andWhere('note.channelId NOT IN (:...mutingChannelIds)', { mutingChannelIds: mutingChannelIds }); + } + + if (!isSelf) { + qb.andWhere(new Brackets(qb2 => { + qb2.orWhere('note.channelId IS NULL'); + qb2.orWhere('channel.isSensitive = false'); + })); + } })); } else { query.andWhere('note.channelId IS NULL'); } + // -- ミュートされたチャンネルのリノート対策 + if (mutingChannelIds.length > 0) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteChannelId IS NULL'); + qb.orWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds }); + })); + } + this.queryService.generateVisibilityQuery(query, me); if (me) { this.queryService.generateMutedUserQuery(query, me, { id: ps.userId }); diff --git a/packages/backend/test/e2e/antennas.ts b/packages/backend/test/e2e/antennas.ts index 6ac14cd8dc..36f596bbba 100644 --- a/packages/backend/test/e2e/antennas.ts +++ b/packages/backend/test/e2e/antennas.ts @@ -69,6 +69,9 @@ describe('アンテナ', () => { let userMutingAlice: User; let userMutedByAlice: User; + let testChannel: misskey.entities.Channel; + let testMutedChannel: misskey.entities.Channel; + beforeAll(async () => { root = await signup({ username: 'root' }); alice = await signup({ username: 'alice' }); @@ -120,6 +123,10 @@ describe('アンテナ', () => { userMutedByAlice = await signup({ username: 'userMutedByAlice' }); await post(userMutedByAlice, { text: 'test' }); await api('mute/create', { userId: userMutedByAlice.id }, alice); + + testChannel = (await api('channels/create', { name: 'test' }, root)).body; + testMutedChannel = (await api('channels/create', { name: 'test-muted' }, root)).body; + await api('channels/mute/create', { channelId: testMutedChannel.id }, alice); }, 1000 * 60 * 10); beforeEach(async () => { @@ -570,6 +577,20 @@ describe('アンテナ', () => { { note: (): Promise => post(bob, { text: `${keyword}` }), included: true }, ], }, + { + label: 'チャンネルノートも含む', + parameters: () => ({ src: 'all' }), + posts: [ + { note: (): Promise => post(bob, { text: `test ${keyword}`, channelId: testChannel.id }), included: true }, + ], + }, + { + label: 'ミュートしてるチャンネルは含まない', + parameters: () => ({ src: 'all' }), + posts: [ + { note: (): Promise => post(bob, { text: `test ${keyword}`, channelId: testMutedChannel.id }) }, + ], + }, ])('が取得できること($label)', async ({ parameters, posts }) => { const antenna = await successfulApiCall({ endpoint: 'antennas/create', diff --git a/packages/backend/test/e2e/timelines.ts b/packages/backend/test/e2e/timelines.ts index 0c937fc212..f3db48412d 100644 --- a/packages/backend/test/e2e/timelines.ts +++ b/packages/backend/test/e2e/timelines.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +// noinspection JSUnusedLocalSymbols /* eslint-disable @typescript-eslint/no-explicit-any */ // How to run: @@ -12,7 +13,18 @@ import * as assert from 'assert'; import { entities } from 'misskey-js'; import { Redis } from 'ioredis'; import { loadConfig } from '@/config.js'; -import { api, post, randomString, sendEnvUpdateRequest, signup, sleep, uploadUrl, UserToken } from '../utils.js'; +import { afterEach, beforeAll } from '@jest/globals'; +import { + api, + initTestDb, + post, + randomString, + sendEnvUpdateRequest, + signup, + sleep, + uploadUrl, + UserToken, +} from '../utils.js'; function genHost() { return randomString() + '.example.com'; @@ -24,25 +36,77 @@ function waitForPushToTl() { let redisForTimelines: Redis; +async function renote(noteId: string, user: UserToken): Promise { + return await api('notes/create', { renoteId: noteId }, user).then(it => it.body.createdNote); +} + async function createChannel(name: string, user: UserToken): Promise { return (await api('channels/create', { name }, user)).body; } -function followChannel(channelId: string, user: UserToken) { - return api('channels/follow', { channelId }, user); +async function followChannel(channelId: string, user: UserToken) { + return await api('channels/follow', { channelId }, user); } -function muteChannel(channelId: string, user: UserToken) { - return api('channels/mute/create', { channelId }, user); +async function muteChannel(channelId: string, user: UserToken) { + await api('channels/mute/create', { channelId }, user); +} + +async function createList(name: string, user: UserToken): Promise { + return (await api('users/lists/create', { name }, user)).body; +} + +async function pushList(listId: string, pushUserIds: string[] = [], user: UserToken) { + for (const userId of pushUserIds) { + await api('users/lists/push', { listId, userId }, user); + } + await sleep(500); +} + +async function createRole(name: string, user: UserToken): Promise { + return (await api('admin/roles/create', { + name, + description: '', + color: '#000000', + iconUrl: '', + target: 'manual', + condFormula: {}, + isPublic: true, + isModerator: false, + isAdministrator: false, + isExplorable: true, + asBadge: false, + canEditMembersByModerator: false, + displayOrder: 0, + policies: {}, + }, user)).body; +} + +async function assignRole(roleId: string, userId: string, user: UserToken) { + await api('admin/roles/assign', { userId, roleId }, user); } describe('Timelines', () => { - beforeAll(() => { + let root: UserToken; + + beforeAll(async () => { redisForTimelines = new Redis(loadConfig().redisForTimelines); + + // FTT無効の状態で見たいときはコメントアウトを外す + await api('admin/update-meta', { enableFanoutTimeline: false }, root); + await sleep(1000); + }); + + afterEach(async () => { + // テスト中に作ったノートをきれいにする。 + // ユーザも作っているが、時間差で動く通知系処理などがあり、このタイミングで消すとエラー落ちするので消さない(ノートさえ消えていれば支障はない) + const db = await initTestDb(true); + await db.query('DELETE FROM "note"'); + await db.query('DELETE FROM "channel"'); }); describe('Home TL', () => { - test.concurrent('自分の visibility: followers なノートが含まれる', async () => { + test('自分の visibility: followers なノートが含まれる', async () => { const [alice] = await Promise.all([signup()]); const aliceNote = await post(alice, { text: 'hi', visibility: 'followers' }); @@ -55,7 +119,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.find((note: any) => note.id === aliceNote.id).text, 'hi'); }); - test.concurrent('フォローしているユーザーのノートが含まれる', async () => { + test('フォローしているユーザーのノートが含まれる', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -71,7 +135,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); }); - test.concurrent('フォローしているユーザーの visibility: followers なノートが含まれる', async () => { + test('フォローしているユーザーの visibility: followers なノートが含まれる', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -88,7 +152,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); }); - test.concurrent('withReplies: false でフォローしているユーザーの他人への返信が含まれない', async () => { + test('withReplies: false でフォローしているユーザーの他人への返信が含まれない', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -104,7 +168,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); }); - test.concurrent('withReplies: true でフォローしているユーザーの他人への返信が含まれる', async () => { + test('withReplies: true でフォローしているユーザーの他人への返信が含まれる', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -121,7 +185,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); }); - test.concurrent('withReplies: true でフォローしているユーザーの他人へのDM返信が含まれない', async () => { + test('withReplies: true でフォローしているユーザーの他人へのDM返信が含まれない', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -143,7 +207,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); }); - test.concurrent('withReplies: true でフォローしているユーザーの他人の visibility: followers な投稿への返信が含まれない', async () => { + test('withReplies: true でフォローしているユーザーの他人の visibility: followers な投稿への返信が含まれない', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -160,7 +224,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); }); - test.concurrent('withReplies: true でフォローしているユーザーの行った別のフォローしているユーザーの visibility: followers な投稿への返信が含まれる', async () => { + test('withReplies: true でフォローしているユーザーの行った別のフォローしているユーザーの visibility: followers な投稿への返信が含まれる', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -180,7 +244,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.find((note: any) => note.id === carolNote.id).text, 'hi'); }); - test.concurrent('withReplies: true でフォローしているユーザーの行った別のフォローしているユーザーの投稿への visibility: specified な返信が含まれない', async () => { + test('withReplies: true でフォローしているユーザーの行った別のフォローしているユーザーの投稿への visibility: specified な返信が含まれない', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -203,7 +267,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true); }); - test.concurrent('withReplies: false でフォローしているユーザーのそのユーザー自身への返信が含まれる', async () => { + test('withReplies: false でフォローしているユーザーのそのユーザー自身への返信が含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -219,7 +283,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); }); - test.concurrent('withReplies: false でフォローしているユーザーからの自分への返信が含まれる', async () => { + test('withReplies: false でフォローしているユーザーからの自分への返信が含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -235,7 +299,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); }); - test.concurrent('自分の他人への返信が含まれる', async () => { + test('自分の他人への返信が含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const bobNote = await post(bob, { text: 'hi' }); @@ -249,7 +313,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); }); - test.concurrent('フォローしているユーザーの他人の投稿のリノートが含まれる', async () => { + test('フォローしているユーザーの他人の投稿のリノートが含まれる', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -265,7 +329,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); }); - test.concurrent('[withRenotes: false] フォローしているユーザーの他人の投稿のリノートが含まれない', async () => { + test('[withRenotes: false] フォローしているユーザーの他人の投稿のリノートが含まれない', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -283,7 +347,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); }); - test.concurrent('[withRenotes: false] フォローしているユーザーの他人の投稿の引用が含まれる', async () => { + test('[withRenotes: false] フォローしているユーザーの他人の投稿の引用が含まれる', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -301,7 +365,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); }); - test.concurrent('フォローしているユーザーの他人への visibility: specified なノートが含まれない', async () => { + test('フォローしているユーザーの他人への visibility: specified なノートが含まれない', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -315,7 +379,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); - test.concurrent('フォローしているユーザーが行ったミュートしているユーザーのリノートが含まれない', async () => { + test('フォローしているユーザーが行ったミュートしているユーザーのリノートが含まれない', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -332,7 +396,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); }); - test.concurrent('withReplies: true でフォローしているユーザーが行ったミュートしているユーザーの投稿への返信が含まれない', async () => { + test('withReplies: true でフォローしているユーザーが行ったミュートしているユーザーの投稿への返信が含まれない', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -350,7 +414,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); }); - test.concurrent('フォローしているリモートユーザーのノートが含まれる', async () => { + test('フォローしているリモートユーザーのノートが含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup({ host: genHost() })]); await sendEnvUpdateRequest({ key: 'FORCE_FOLLOW_REMOTE_USER_FOR_TESTING', value: 'true' }); @@ -365,7 +429,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); }); - test.concurrent('フォローしているリモートユーザーの visibility: home なノートが含まれる', async () => { + 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' }); @@ -380,7 +444,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); }); - test.concurrent('[withFiles: true] フォローしているユーザーのファイル付きノートのみ含まれる', async () => { + test('[withFiles: true] フォローしているユーザーのファイル付きノートのみ含まれる', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -404,7 +468,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === carolNote2.id), false); }, 1000 * 10); - test.concurrent('フォローしているユーザーのチャンネル投稿が含まれない', async () => { + test('フォローしているユーザーのチャンネル投稿が含まれない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const channel = await api('channels/create', { name: 'channel' }, bob).then(x => x.body); @@ -419,7 +483,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); - test.concurrent('自分の visibility: specified なノートが含まれる', async () => { + test('自分の visibility: specified なノートが含まれる', async () => { const [alice] = await Promise.all([signup()]); const aliceNote = await post(alice, { text: 'hi', visibility: 'specified' }); @@ -432,7 +496,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.find((note: any) => note.id === aliceNote.id).text, 'hi'); }); - test.concurrent('フォローしているユーザーの自身を visibleUserIds に指定した visibility: specified なノートが含まれる', async () => { + test('フォローしているユーザーの自身を visibleUserIds に指定した visibility: specified なノートが含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -447,7 +511,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, 'hi'); }); - test.concurrent('フォローしていないユーザーの自身を visibleUserIds に指定した visibility: specified なノートが含まれない', async () => { + 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] }); @@ -459,7 +523,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); - test.concurrent('フォローしているユーザーの自身を visibleUserIds に指定していない visibility: specified なノートが含まれない', async () => { + test('フォローしているユーザーの自身を visibleUserIds に指定していない visibility: specified なノートが含まれない', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -473,7 +537,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); - test.concurrent('フォローしていないユーザーからの visibility: specified なノートに返信したときの自身のノートが含まれる', async () => { + test('フォローしていないユーザーからの visibility: specified なノートに返信したときの自身のノートが含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const bobNote = await post(bob, { text: 'hi', visibility: 'specified', visibleUserIds: [alice.id] }); @@ -493,7 +557,7 @@ describe('Timelines', () => { }); /* TODO - test.concurrent('自身の visibility: specified なノートへのフォローしていないユーザーからの返信が含まれる', async () => { + test('自身の visibility: specified なノートへのフォローしていないユーザーからの返信が含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const aliceNote = await post(alice, { text: 'hi', visibility: 'specified', visibleUserIds: [bob.id] }); @@ -509,7 +573,7 @@ describe('Timelines', () => { */ // ↑の挙動が理想だけど実装が面倒かも - test.concurrent('自身の visibility: specified なノートへのフォローしていないユーザーからの返信が含まれない', async () => { + test('自身の visibility: specified なノートへのフォローしていないユーザーからの返信が含まれない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const aliceNote = await post(alice, { text: 'hi', visibility: 'specified', visibleUserIds: [bob.id] }); @@ -528,7 +592,7 @@ describe('Timelines', () => { }); describe('Channel', () => { - test.concurrent('フォローしていないチャンネルのノートは含まれない', async () => { + test('チャンネル未フォロー + ユーザ未フォロー = TLに流れない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const channel = await createChannel('channel', bob); @@ -543,7 +607,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); - test.concurrent('フォローしているチャンネルのノートが含まれる', async () => { + test('チャンネルフォロー + ユーザ未フォロー = TLに流れる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const channel = await createChannel('channel', bob); @@ -559,7 +623,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); }); - test.concurrent('フォローしているユーザがフォローしていないチャンネルでノートした時は含まれない', async () => { + test('チャンネル未フォロー + ユーザフォロー = TLに流れない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -575,7 +639,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); - test.concurrent('フォローしているユーザがフォローしているチャンネルでノートした時は含まれる', async () => { + test('チャンネルフォロー + ユーザフォロー = TLに流れる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -592,7 +656,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); }); - test.concurrent('チャンネルミュート中であり、かつフォローしていないチャンネルのノートは含まれない', async () => { + test('チャンネル未フォロー + ユーザ未フォロー + チャンネルミュート = TLに流れない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const channel = await createChannel('channel', bob); @@ -608,7 +672,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); - test.concurrent('チャンネルミュート中であれば、フォローしているチャンネルのノートは含まれない', async () => { + test('チャンネルフォロー + ユーザ未フォロー + チャンネルミュート = TLに流れない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const channel = await createChannel('channel', bob); @@ -625,7 +689,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); - test.concurrent('チャンネルミュート中であり、フォローしているユーザがフォローしていないチャンネルでノートした時は含まれない', async () => { + test('チャンネル未フォロー + ユーザフォロー + チャンネルミュート = TLに流れない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -642,7 +706,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); - test.concurrent('チャンネルミュート中であれば、フォローしているユーザがフォローしているチャンネルでノートした時は含まれない', async () => { + test('チャンネルフォロー + ユーザフォロー + チャンネルミュート = TLに流れない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -659,11 +723,151 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); + + test('[チャンネル外リノート] チャンネル未フォロー + ユーザ未フォロー = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + + test('[チャンネル外リノート] チャンネルフォロー + ユーザ未フォロー = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + + test('[チャンネル外リノート] チャンネル未フォロー + ユーザフォロー = TLに流れる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + await api('following/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), true); + }); + + test('[チャンネル外リノート] チャンネルフォロー + ユーザフォロー = TLに流れる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + await api('following/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), true); + }); + + test('[チャンネル外リノート] チャンネル未フォロー + ユーザ未フォロー + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + + test('[チャンネル外リノート] チャンネルフォロー + ユーザ未フォロー + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + + test('[チャンネル外リノート] チャンネル未フォロー + ユーザフォロー + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + await api('following/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + + test('[チャンネル外リノート] チャンネルフォロー + ユーザフォロー + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + await api('following/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); }); }); describe('Local TL', () => { - test.concurrent('visibility: home なノートが含まれない', async () => { + test('visibility: home なノートが含まれない', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); const carolNote = await post(carol, { text: 'hi', visibility: 'home' }); @@ -677,7 +881,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); }); - test.concurrent('他人の他人への返信が含まれない', async () => { + test('他人の他人への返信が含まれない', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); const carolNote = await post(carol, { text: 'hi' }); @@ -691,7 +895,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true); }); - test.concurrent('他人のその人自身への返信が含まれる', async () => { + test('他人のその人自身への返信が含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const bobNote1 = await post(bob, { text: 'hi' }); @@ -705,7 +909,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); }); - test.concurrent('チャンネル投稿が含まれない', async () => { + test('チャンネル投稿が含まれない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const channel = await api('channels/create', { name: 'channel' }, bob).then(x => x.body); @@ -718,7 +922,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); - test.concurrent('リモートユーザーのノートが含まれない', async () => { + test('リモートユーザーのノートが含まれない', async () => { const [alice, bob] = await Promise.all([signup(), signup({ host: genHost() })]); const bobNote = await post(bob, { text: 'hi' }); @@ -731,7 +935,7 @@ describe('Timelines', () => { }); // 含まれても良いと思うけど実装が面倒なので含まれない - test.concurrent('フォローしているユーザーの visibility: home なノートが含まれない', async () => { + test('フォローしているユーザーの visibility: home なノートが含まれない', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('following/create', { userId: carol.id }, alice); @@ -747,7 +951,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); }); - test.concurrent('ミュートしているユーザーのノートが含まれない', async () => { + test('ミュートしているユーザーのノートが含まれない', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('mute/create', { userId: carol.id }, alice); @@ -763,7 +967,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); }); - test.concurrent('フォローしているユーザーが行ったミュートしているユーザーのリノートが含まれない', async () => { + test('フォローしているユーザーが行ったミュートしているユーザーのリノートが含まれない', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -780,7 +984,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); }); - test.concurrent('withReplies: true でフォローしているユーザーが行ったミュートしているユーザーの投稿への返信が含まれない', async () => { + test('withReplies: true でフォローしているユーザーが行ったミュートしているユーザーの投稿への返信が含まれない', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -798,7 +1002,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); }); - test.concurrent('withReplies: false でフォローしているユーザーからの自分への返信が含まれる', async () => { + test('withReplies: false でフォローしているユーザーからの自分への返信が含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -814,7 +1018,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); }); - test.concurrent('[withReplies: true] 他人の他人への返信が含まれる', async () => { + test('[withReplies: true] 他人の他人への返信が含まれる', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); const carolNote = await post(carol, { text: 'hi' }); @@ -827,7 +1031,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); }); - test.concurrent('[withFiles: true] ファイル付きノートのみ含まれる', async () => { + 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'); @@ -843,7 +1047,7 @@ describe('Timelines', () => { }, 1000 * 10); describe('Channel', () => { - test.concurrent('フォローしていないチャンネルのノートは含まれない', async () => { + test('チャンネル未フォロー + ユーザ未フォロー = TLに流れない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const channel = await createChannel('channel', bob); @@ -858,7 +1062,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); - test.concurrent('フォローしていてもチャンネルのノートは含まれない', async () => { + test('チャンネルフォロー + ユーザ未フォロー = TLに流れない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const channel = await createChannel('channel', bob); @@ -874,7 +1078,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); - test.concurrent('フォローしているユーザがフォローしていないチャンネルでノートした時は含まれない', async () => { + test('チャンネル未フォロー + ユーザフォロー = TLに流れない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -890,7 +1094,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); - test.concurrent('フォローしているユーザがフォローしているチャンネルでノートした時も含まれない', async () => { + test('チャンネルフォロー + ユーザフォロー = TLに流れない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -907,7 +1111,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); - test.concurrent('チャンネルミュート中であり、かつフォローしていないチャンネルのノートは含まれない', async () => { + test('チャンネル未フォロー + ユーザ未フォロー + チャンネルミュート = TLに流れない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const channel = await createChannel('channel', bob); @@ -923,7 +1127,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); - test.concurrent('チャンネルミュート中であれば、フォローしているチャンネルのノートは含まれない', async () => { + test('チャンネルフォロー + ユーザ未フォロー + チャンネルミュート = TLに流れない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const channel = await createChannel('channel', bob); @@ -940,7 +1144,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); - test.concurrent('チャンネルミュート中であり、フォローしているユーザがフォローしていないチャンネルでノートした時は含まれない', async () => { + test('チャンネル未フォロー + ユーザフォロー + チャンネルミュート = TLに流れない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -957,7 +1161,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); - test.concurrent('チャンネルミュート中であれば、フォローしているユーザがフォローしているチャンネルでノートした時は含まれない', async () => { + test('チャンネルフォロー + ユーザフォロー + チャンネルミュート = TLに流れない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -974,11 +1178,151 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); + + test('[チャンネル外リノート] チャンネル未フォロー + ユーザ未フォロー = TLに流れる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), true); + }); + + test('[チャンネル外リノート] チャンネルフォロー + ユーザ未フォロー = TLに流れる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), true); + }); + + test('[チャンネル外リノート] チャンネル未フォロー + ユーザフォロー = TLに流れる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + await api('following/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), true); + }); + + test('[チャンネル外リノート] チャンネルフォロー + ユーザフォロー = TLに流れる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + await api('following/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), true); + }); + + test('[チャンネル外リノート] チャンネル未フォロー + ユーザ未フォロー + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + + test('[チャンネル外リノート] チャンネルフォロー + ユーザ未フォロー + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + + test('[チャンネル外リノート] チャンネル未フォロー + ユーザフォロー + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + await api('following/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + + test('[チャンネル外リノート] チャンネルフォロー + ユーザフォロー + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + await api('following/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); }); }); describe('Social TL', () => { - test.concurrent('ローカルユーザーのノートが含まれる', async () => { + test('ローカルユーザーのノートが含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const bobNote = await post(bob, { text: 'hi' }); @@ -990,7 +1334,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); }); - test.concurrent('ローカルユーザーの visibility: home なノートが含まれない', async () => { + test('ローカルユーザーの visibility: home なノートが含まれない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const bobNote = await post(bob, { text: 'hi', visibility: 'home' }); @@ -1002,7 +1346,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); - test.concurrent('フォローしているローカルユーザーの visibility: home なノートが含まれる', async () => { + test('フォローしているローカルユーザーの visibility: home なノートが含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -1016,7 +1360,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); }); - test.concurrent('withReplies: false でフォローしているユーザーからの自分への返信が含まれる', async () => { + test('withReplies: false でフォローしているユーザーからの自分への返信が含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -1032,7 +1376,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); }); - test.concurrent('他人の他人への返信が含まれない', async () => { + test('他人の他人への返信が含まれない', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); const carolNote = await post(carol, { text: 'hi' }); @@ -1046,7 +1390,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true); }); - test.concurrent('リモートユーザーのノートが含まれない', async () => { + test('リモートユーザーのノートが含まれない', async () => { const [alice, bob] = await Promise.all([signup(), signup({ host: genHost() })]); const bobNote = await post(bob, { text: 'hi' }); @@ -1058,7 +1402,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); - test.concurrent('フォローしているリモートユーザーのノートが含まれる', async () => { + test('フォローしているリモートユーザーのノートが含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup({ host: genHost() })]); await sendEnvUpdateRequest({ key: 'FORCE_FOLLOW_REMOTE_USER_FOR_TESTING', value: 'true' }); @@ -1073,7 +1417,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); }); - test.concurrent('フォローしているリモートユーザーの visibility: home なノートが含まれる', async () => { + 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' }); @@ -1088,7 +1432,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); }); - test.concurrent('[withReplies: true] 他人の他人への返信が含まれる', async () => { + test('[withReplies: true] 他人の他人への返信が含まれる', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); const carolNote = await post(carol, { text: 'hi' }); @@ -1101,7 +1445,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); }); - test.concurrent('[withFiles: true] ファイル付きノートのみ含まれる', async () => { + 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'); @@ -1117,7 +1461,7 @@ describe('Timelines', () => { }, 1000 * 10); describe('Channel', () => { - test.concurrent('フォローしていないチャンネルのノートは含まれない', async () => { + test('チャンネル未フォロー + ユーザ未フォロー = TLに流れない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const channel = await createChannel('channel', bob); @@ -1132,7 +1476,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); - test.concurrent('フォローしているチャンネルのノートが含まれる', async () => { + test('チャンネルフォロー + ユーザ未フォロー = TLに流れる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const channel = await createChannel('channel', bob); @@ -1148,7 +1492,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); }); - test.concurrent('フォローしているユーザがフォローしていないチャンネルでノートした時は含まれない', async () => { + test('チャンネル未フォロー + ユーザフォロー = TLに流れない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -1164,7 +1508,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); - test.concurrent('フォローしているユーザがフォローしているチャンネルでノートした時は含まれる', async () => { + test('チャンネルフォロー + ユーザフォロー = TLに流れる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -1181,7 +1525,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); }); - test.concurrent('チャンネルミュート中であり、かつフォローしていないチャンネルのノートは含まれない', async () => { + test('チャンネル未フォロー + ユーザ未フォロー + チャンネルミュート = TLに流れない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const channel = await createChannel('channel', bob); @@ -1197,7 +1541,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); - test.concurrent('チャンネルミュート中であれば、フォローしているチャンネルのノートは含まれない', async () => { + test('チャンネルフォロー + ユーザ未フォロー + チャンネルミュート = TLに流れない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const channel = await createChannel('channel', bob); @@ -1214,7 +1558,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); - test.concurrent('チャンネルミュート中であり、フォローしているユーザがフォローしていないチャンネルでノートした時は含まれない', async () => { + test('チャンネル未フォロー + ユーザフォロー + チャンネルミュート = TLに流れない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -1231,7 +1575,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); - test.concurrent('チャンネルミュート中であれば、フォローしているユーザがフォローしているチャンネルでノートした時は含まれない', async () => { + test('チャンネルフォロー + ユーザフォロー + チャンネルミュート = TLに流れない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -1248,11 +1592,151 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); + + test('[チャンネル外リノート] チャンネル未フォロー + ユーザ未フォロー = TLに流れる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), true); + }); + + test('[チャンネル外リノート] チャンネルフォロー + ユーザ未フォロー = TLに流れる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), true); + }); + + test('[チャンネル外リノート] チャンネル未フォロー + ユーザフォロー = TLに流れる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + await api('following/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), true); + }); + + test('[チャンネル外リノート] チャンネルフォロー + ユーザフォロー = TLに流れる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + await api('following/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), true); + }); + + test('[チャンネル外リノート] チャンネル未フォロー + ユーザ未フォロー + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + + test('[チャンネル外リノート] チャンネルフォロー + ユーザ未フォロー + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + + test('[チャンネル外リノート] チャンネル未フォロー + ユーザフォロー + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + await api('following/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + + test('[チャンネル外リノート] チャンネルフォロー + ユーザフォロー + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + await api('following/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); }); }); describe('User List TL', () => { - test.concurrent('リスインしているフォローしていないユーザーのノートが含まれる', async () => { + test('リスインしているフォローしていないユーザーのノートが含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); @@ -1267,7 +1751,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); }); - test.concurrent('リスインしているフォローしていないユーザーの visibility: home なノートが含まれる', async () => { + 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); @@ -1282,7 +1766,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); }); - test.concurrent('リスインしているフォローしていないユーザーの visibility: followers なノートが含まれない', async () => { + 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); @@ -1297,7 +1781,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); - test.concurrent('リスインしているフォローしていないユーザーの他人への返信が含まれない', async () => { + 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); @@ -1313,7 +1797,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); - test.concurrent('リスインしているフォローしていないユーザーのユーザー自身への返信が含まれる', async () => { + test('リスインしているフォローしていないユーザーのユーザー自身への返信が含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); @@ -1330,7 +1814,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); }); - test.concurrent('withReplies: false でリスインしているフォローしていないユーザーからの自分への返信が含まれる', async () => { + 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); @@ -1347,7 +1831,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); }); - test.concurrent('withReplies: false でリスインしているフォローしていないユーザーの他人への返信が含まれない', async () => { + 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); @@ -1364,7 +1848,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); - test.concurrent('withReplies: true でリスインしているフォローしていないユーザーの他人への返信が含まれる', async () => { + 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); @@ -1381,7 +1865,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); }); - test.concurrent('リスインしているフォローしているユーザーの visibility: home なノートが含まれる', async () => { + test('リスインしているフォローしているユーザーの visibility: home なノートが含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -1397,7 +1881,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); }); - test.concurrent('リスインしているフォローしているユーザーの visibility: followers なノートが含まれる', async () => { + test('リスインしているフォローしているユーザーの visibility: followers なノートが含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -1414,7 +1898,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, 'hi'); }); - test.concurrent('リスインしている自分の visibility: followers なノートが含まれる', async () => { + 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); @@ -1430,7 +1914,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.find((note: any) => note.id === aliceNote.id).text, 'hi'); }); - test.concurrent('リスインしているユーザーのチャンネルノートが含まれない', async () => { + test('リスインしているユーザーのチャンネルノートが含まれない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const channel = await api('channels/create', { name: 'channel' }, bob).then(x => x.body); @@ -1446,7 +1930,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); - test.concurrent('[withFiles: true] リスインしているユーザーのファイル付きノートのみ含まれる', async () => { + 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); @@ -1463,7 +1947,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); }, 1000 * 10); - test.concurrent('リスインしているユーザーの自身宛ての visibility: specified なノートが含まれる', async () => { + 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); @@ -1479,7 +1963,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, 'hi'); }); - test.concurrent('リスインしているユーザーの自身宛てではない visibility: specified なノートが含まれない', async () => { + 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); @@ -1494,10 +1978,316 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); + + describe('Channel', () => { + test('チャンネル未フォロー + リスインしてない = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await createList('list', alice); + + const channel = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { limit: 100, listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('チャンネルフォロー + リスインしてない = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await createList('list', alice); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { limit: 100, listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('チャンネル未フォロー + リスインしてる = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await createList('list', alice); + await pushList(list.id, [bob.id], alice); + + const channel = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { limit: 100, listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('チャンネルフォロー + リスインしてる = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await createList('list', alice); + await pushList(list.id, [bob.id], alice); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { limit: 100, listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('チャンネル未フォロー + リスインしてない + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await createList('list', alice); + + const channel = await createChannel('channel', bob); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { limit: 100, listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('チャンネルフォロー + リスインしてない + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await createList('list', alice); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { limit: 100, listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('チャンネル未フォロー + リスインしてる + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await createList('list', alice); + await pushList(list.id, [bob.id], alice); + + const channel = await createChannel('channel', bob); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { limit: 100, listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('チャンネルフォロー + リスインしてる + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await createList('list', alice); + await pushList(list.id, [bob.id], alice); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { limit: 100, listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('[チャンネル外リノート] チャンネル未フォロー + リスインしてない = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await createList('list', alice); + + const channel = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { limit: 100, listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + + test('[チャンネル外リノート] チャンネルフォロー + リスインしてない = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await createList('list', alice); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { limit: 100, listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + + test('[チャンネル外リノート] チャンネル未フォロー + リスインしてる = TLに流れる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await createList('list', alice); + await pushList(list.id, [bob.id], alice); + + const channel = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { limit: 100, listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), true); + }); + + test('[チャンネル外リノート] チャンネルフォロー + リスインしてる = TLに流れる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await createList('list', alice); + await pushList(list.id, [bob.id], alice); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { limit: 100, listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), true); + }); + + test('[チャンネル外リノート] チャンネル未フォロー + リスインしてない + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await createList('list', alice); + + const channel = await createChannel('channel', bob); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { limit: 100, listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + + test('[チャンネル外リノート] チャンネルフォロー + リスインしてない + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await createList('list', alice); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { limit: 100, listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + + test('[チャンネル外リノート] チャンネル未フォロー + リスインしてる + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await createList('list', alice); + await pushList(list.id, [bob.id], alice); + + const channel = await createChannel('channel', bob); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { limit: 100, listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + + test('[チャンネル外リノート] チャンネルフォロー + リスインしてる + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await createList('list', alice); + await pushList(list.id, [bob.id], alice); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { limit: 100, listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + }); }); describe('User TL', () => { - test.concurrent('ノートが含まれる', async () => { + test('ノートが含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const bobNote = await post(bob, { text: 'hi' }); @@ -1509,7 +2299,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); }); - test.concurrent('フォローしていないユーザーの visibility: followers なノートが含まれない', async () => { + test('フォローしていないユーザーの visibility: followers なノートが含まれない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const bobNote = await post(bob, { text: 'hi', visibility: 'followers' }); @@ -1521,7 +2311,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); - test.concurrent('フォローしているユーザーの visibility: followers なノートが含まれる', async () => { + test('フォローしているユーザーの visibility: followers なノートが含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -1536,7 +2326,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, 'hi'); }); - test.concurrent('自身の visibility: followers なノートが含まれる', async () => { + test('自身の visibility: followers なノートが含まれる', async () => { const [alice] = await Promise.all([signup()]); const aliceNote = await post(alice, { text: 'hi', visibility: 'followers' }); @@ -1549,7 +2339,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.find((note: any) => note.id === aliceNote.id).text, 'hi'); }); - test.concurrent('チャンネル投稿が含まれない', async () => { + test('チャンネル投稿が含まれない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const channel = await api('channels/create', { name: 'channel' }, bob).then(x => x.body); @@ -1562,7 +2352,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); - test.concurrent('[withReplies: false] 他人への返信が含まれない', async () => { + test('[withReplies: false] 他人への返信が含まれない', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); const carolNote = await post(carol, { text: 'hi' }); @@ -1577,7 +2367,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), false); }); - test.concurrent('[withReplies: true] 他人への返信が含まれる', async () => { + test('[withReplies: true] 他人への返信が含まれる', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); const carolNote = await post(carol, { text: 'hi' }); @@ -1592,7 +2382,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); }); - test.concurrent('[withReplies: true] 他人への visibility: specified な返信が含まれない', async () => { + test('[withReplies: true] 他人への visibility: specified な返信が含まれない', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); const carolNote = await post(carol, { text: 'hi' }); @@ -1607,7 +2397,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), false); }); - test.concurrent('[withFiles: true] ファイル付きノートのみ含まれる', async () => { + 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'); @@ -1622,7 +2412,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); }, 1000 * 10); - test.concurrent('[withChannelNotes: true] チャンネル投稿が含まれる', async () => { + 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); @@ -1635,7 +2425,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); }); - test.concurrent('[withChannelNotes: true] 他人が取得した場合センシティブチャンネル投稿が含まれない', async () => { + 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); @@ -1648,7 +2438,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); - test.concurrent('[withChannelNotes: true] 自分が取得した場合センシティブチャンネル投稿が含まれる', async () => { + 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); @@ -1661,7 +2451,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); }); - test.concurrent('ミュートしているユーザーに関連する投稿が含まれない', async () => { + test('ミュートしているユーザーに関連する投稿が含まれない', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('mute/create', { userId: carol.id }, alice); @@ -1676,7 +2466,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); - test.concurrent('ミュートしていても userId に指定したユーザーの投稿が含まれる', async () => { + test('ミュートしていても userId に指定したユーザーの投稿が含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); await api('mute/create', { userId: bob.id }, alice); @@ -1694,7 +2484,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote3.id), true); }); - test.concurrent('自身の visibility: specified なノートが含まれる', async () => { + test('自身の visibility: specified なノートが含まれる', async () => { const [alice] = await Promise.all([signup()]); const aliceNote = await post(alice, { text: 'hi', visibility: 'specified' }); @@ -1706,7 +2496,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); }); - test.concurrent('visibleUserIds に指定されてない visibility: specified なノートが含まれない', async () => { + test('visibleUserIds に指定されてない visibility: specified なノートが含まれない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const bobNote = await post(bob, { text: 'hi', visibility: 'specified' }); @@ -1719,7 +2509,7 @@ describe('Timelines', () => { }); /** @see https://github.com/misskey-dev/misskey/issues/14000 */ - test.concurrent('FTT: sinceId にキャッシュより古いノートを指定しても、sinceId による絞り込みが正しく動作する', async () => { + 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' }); @@ -1731,7 +2521,7 @@ describe('Timelines', () => { assert.deepStrictEqual(res.body, [note1, note2, note3]); }); - test.concurrent('FTT: sinceId にキャッシュより古いノートを指定しても、sinceId と untilId による絞り込みが正しく動作する', async () => { + 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' }); @@ -1744,6 +2534,271 @@ describe('Timelines', () => { const res = await api('users/notes', { userId: alice.id, sinceId: noteSince.id, untilId: noteUntil.id }); assert.deepStrictEqual(res.body, [note3, note2, note1]); }); + + describe('Channel', () => { + test('チャンネルミュートなし = TLに流れる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('users/notes', { userId: bob.id, withChannelNotes: true }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + }); + + test('チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('users/notes', { userId: bob.id, withChannelNotes: true }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('[チャンネル外リノート] チャンネルミュートなし = TLに流れる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('users/notes', { userId: bob.id, withChannelNotes: true }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), true); + }); + + test('[チャンネル外リノート] チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('users/notes', { userId: bob.id, withChannelNotes: true }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + }); + }); + + describe('Role TL', () => { + test('ロールにアサインされているユーザーのノートが含まれる', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + const role = await createRole('role', root); + await assignRole(role.id, alice.id, root); + await assignRole(role.id, bob.id, root); + await assignRole(role.id, carol.id, root); + + const bobNote = await post(bob, { text: 'hi' }); + const carolNote = await post(carol, { text: 'hi' }); + + await waitForPushToTl(); + + const res = await api('roles/notes', { roleId: role.id }, 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); + }); + + test('ロールにアサインされていないユーザーのノートは含まれない', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + const role = await createRole('role', root); + await assignRole(role.id, alice.id, root); + await assignRole(role.id, bob.id, root); + + const bobNote = await post(bob, { text: 'hi' }); + const carolNote = await post(carol, { text: 'hi' }); + + await waitForPushToTl(); + + const res = await api('roles/notes', { roleId: role.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + }); + + test('自分の他人への返信が含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const role = await createRole('role', root); + await assignRole(role.id, alice.id, root); + await assignRole(role.id, bob.id, root); + + const bobNote = await post(bob, { text: 'hi' }); + const aliceNote = await post(alice, { text: 'hi', replyId: bobNote.id }); + + await waitForPushToTl(); + + const res = await api('roles/notes', { roleId: role.id }, 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] = await Promise.all([signup(), signup()]); + + const role = await createRole('role', root); + await assignRole(role.id, alice.id, root); + await assignRole(role.id, bob.id, root); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'hi', replyId: aliceNote.id }); + + await waitForPushToTl(); + + const res = await api('roles/notes', { roleId: role.id }, 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 role = await createRole('role', root); + await assignRole(role.id, alice.id, root); + await assignRole(role.id, bob.id, root); + await assignRole(role.id, carol.id, root); + + await api('mute/create', { userId: carol.id }, alice); + + const bobNote = await post(bob, { text: 'hi' }); + const carolNote = await post(carol, { text: 'hi' }); + + await waitForPushToTl(); + + const res = await api('roles/notes', { roleId: role.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + }); + + test('こちらをブロックしているユーザのノートは含まれない', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + const role = await createRole('role', root); + await assignRole(role.id, alice.id, root); + await assignRole(role.id, bob.id, root); + await assignRole(role.id, carol.id, root); + + await api('blocking/create', { userId: alice.id }, carol); + + const bobNote = await post(bob, { text: 'hi' }); + const carolNote = await post(carol, { text: 'hi' }); + + await waitForPushToTl(); + + const res = await api('roles/notes', { roleId: role.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + }); + + describe('Channel', () => { + test('チャンネルミュートなし = TLに流れる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const role = await createRole('role', root); + await assignRole(role.id, alice.id, root); + await assignRole(role.id, bob.id, root); + + const channel = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('roles/notes', { roleId: role.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + }); + + test('チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const role = await createRole('role', root); + await assignRole(role.id, alice.id, root); + await assignRole(role.id, bob.id, root); + + const channel = await createChannel('channel', bob); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('roles/notes', { roleId: role.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('[チャンネル外リノート] チャンネルミュートなし = TLに流れる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const role = await createRole('role', root); + await assignRole(role.id, alice.id, root); + await assignRole(role.id, bob.id, root); + + const channel = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('roles/notes', { roleId: role.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), true); + }); + + test('[チャンネル外リノート] チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const role = await createRole('role', root); + await assignRole(role.id, alice.id, root); + await assignRole(role.id, bob.id, root); + + const channel = await createChannel('channel', bob); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('roles/notes', { roleId: role.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + }); }); // TODO: リノートミュート済みユーザーのテスト diff --git a/packages/backend/test/jest.setup.unit.cjs b/packages/backend/test/jest.setup.unit.cjs new file mode 100644 index 0000000000..896f9e0b9d --- /dev/null +++ b/packages/backend/test/jest.setup.unit.cjs @@ -0,0 +1,9 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +module.exports = async () => { + // DBはUTC(っぽい)ので、テスト側も合わせておく + process.env.TZ = 'UTC'; +}; diff --git a/packages/backend/test/unit/ChannelFollowingService.ts b/packages/backend/test/unit/ChannelFollowingService.ts new file mode 100644 index 0000000000..9ca4fe0842 --- /dev/null +++ b/packages/backend/test/unit/ChannelFollowingService.ts @@ -0,0 +1,235 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/* eslint-disable */ + +import { afterEach, beforeEach, describe, expect } from '@jest/globals'; +import { Test, TestingModule } from '@nestjs/testing'; +import { GlobalModule } from '@/GlobalModule.js'; +import { CoreModule } from '@/core/CoreModule.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { IdService } from '@/core/IdService.js'; +import { + type ChannelFollowingsRepository, + ChannelsRepository, + DriveFilesRepository, + MiChannel, + MiChannelFollowing, + MiDriveFile, + MiUser, + UserProfilesRepository, + UsersRepository, +} from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; +import { ChannelFollowingService } from "@/core/ChannelFollowingService.js"; +import { MiLocalUser } from "@/models/User.js"; + +describe('ChannelFollowingService', () => { + let app: TestingModule; + let service: ChannelFollowingService; + let channelsRepository: ChannelsRepository; + let channelFollowingsRepository: ChannelFollowingsRepository; + let usersRepository: UsersRepository; + let userProfilesRepository: UserProfilesRepository; + let driveFilesRepository: DriveFilesRepository; + let idService: IdService; + + let alice: MiLocalUser; + let bob: MiLocalUser; + let channel1: MiChannel; + let channel2: MiChannel; + let channel3: MiChannel; + let driveFile1: MiDriveFile; + let driveFile2: MiDriveFile; + + async function createUser(data: Partial = {}) { + const user = await usersRepository + .insert({ + id: idService.gen(), + username: 'username', + usernameLower: 'username', + ...data, + }) + .then(x => usersRepository.findOneByOrFail(x.identifiers[0])); + + await userProfilesRepository.insert({ + userId: user.id, + }); + + return user; + } + + async function createChannel(data: Partial = {}) { + return await channelsRepository + .insert({ + id: idService.gen(), + ...data, + }) + .then(x => channelsRepository.findOneByOrFail(x.identifiers[0])); + } + + async function createChannelFollowing(data: Partial = {}) { + return await channelFollowingsRepository + .insert({ + id: idService.gen(), + ...data, + }) + .then(x => channelFollowingsRepository.findOneByOrFail(x.identifiers[0])); + } + + async function fetchChannelFollowing() { + return await channelFollowingsRepository.findBy({}); + } + + async function createDriveFile(data: Partial = {}) { + return await driveFilesRepository + .insert({ + id: idService.gen(), + md5: 'md5', + name: 'name', + size: 0, + type: 'type', + storedInternal: false, + url: 'url', + ...data, + }) + .then(x => driveFilesRepository.findOneByOrFail(x.identifiers[0])); + } + + beforeAll(async () => { + app = await Test.createTestingModule({ + imports: [ + GlobalModule, + CoreModule, + ], + providers: [ + GlobalEventService, + IdService, + ChannelFollowingService, + ], + }).compile(); + + app.enableShutdownHooks(); + + service = app.get(ChannelFollowingService); + idService = app.get(IdService); + channelsRepository = app.get(DI.channelsRepository); + channelFollowingsRepository = app.get(DI.channelFollowingsRepository); + usersRepository = app.get(DI.usersRepository); + userProfilesRepository = app.get(DI.userProfilesRepository); + driveFilesRepository = app.get(DI.driveFilesRepository); + }); + + afterAll(async () => { + await app.close(); + }); + + beforeEach(async () => { + alice = { ...await createUser({ username: 'alice' }), host: null, uri: null }; + bob = { ...await createUser({ username: 'bob' }), host: null, uri: null }; + driveFile1 = await createDriveFile(); + driveFile2 = await createDriveFile(); + channel1 = await createChannel({ name: 'channel1', userId: alice.id, bannerId: driveFile1.id }); + channel2 = await createChannel({ name: 'channel2', userId: alice.id, bannerId: driveFile2.id }); + channel3 = await createChannel({ name: 'channel3', userId: alice.id, bannerId: driveFile2.id }); + }); + + afterEach(async () => { + await channelFollowingsRepository.delete({}); + await channelsRepository.delete({}); + await userProfilesRepository.delete({}); + await usersRepository.delete({}); + }); + + describe('list', () => { + test('default', async () => { + await createChannelFollowing({ followerId: alice.id, followeeId: channel1.id }); + await createChannelFollowing({ followerId: alice.id, followeeId: channel2.id }); + await createChannelFollowing({ followerId: bob.id, followeeId: channel3.id }); + + const followings = await service.list({ requestUserId: alice.id }); + + expect(followings).toHaveLength(2); + expect(followings[0].id).toBe(channel1.id); + expect(followings[0].userId).toBe(alice.id); + expect(followings[0].user).toBeFalsy(); + expect(followings[0].bannerId).toBe(driveFile1.id); + expect(followings[0].banner).toBeFalsy(); + expect(followings[1].id).toBe(channel2.id); + expect(followings[1].userId).toBe(alice.id); + expect(followings[1].user).toBeFalsy(); + expect(followings[1].bannerId).toBe(driveFile2.id); + expect(followings[1].banner).toBeFalsy(); + }); + + test('idOnly', async () => { + await createChannelFollowing({ followerId: alice.id, followeeId: channel1.id }); + await createChannelFollowing({ followerId: alice.id, followeeId: channel2.id }); + await createChannelFollowing({ followerId: bob.id, followeeId: channel3.id }); + + const followings = await service.list({ requestUserId: alice.id }, { idOnly: true }); + + expect(followings).toHaveLength(2); + expect(followings[0].id).toBe(channel1.id); + expect(followings[1].id).toBe(channel2.id); + }); + + test('joinUser', async () => { + await createChannelFollowing({ followerId: alice.id, followeeId: channel1.id }); + await createChannelFollowing({ followerId: alice.id, followeeId: channel2.id }); + await createChannelFollowing({ followerId: bob.id, followeeId: channel3.id }); + + const followings = await service.list({ requestUserId: alice.id }, { joinUser: true }); + + expect(followings).toHaveLength(2); + expect(followings[0].id).toBe(channel1.id); + expect(followings[0].user).toEqual(alice); + expect(followings[0].banner).toBeFalsy(); + expect(followings[1].id).toBe(channel2.id); + expect(followings[1].user).toEqual(alice); + expect(followings[1].banner).toBeFalsy(); + }); + + test('joinBannerFile', async () => { + await createChannelFollowing({ followerId: alice.id, followeeId: channel1.id }); + await createChannelFollowing({ followerId: alice.id, followeeId: channel2.id }); + await createChannelFollowing({ followerId: bob.id, followeeId: channel3.id }); + + const followings = await service.list({ requestUserId: alice.id }, { joinBannerFile: true }); + + expect(followings).toHaveLength(2); + expect(followings[0].id).toBe(channel1.id); + expect(followings[0].user).toBeFalsy(); + expect(followings[0].banner).toEqual(driveFile1); + expect(followings[1].id).toBe(channel2.id); + expect(followings[1].user).toBeFalsy(); + expect(followings[1].banner).toEqual(driveFile2); + }); + }); + + describe('follow', () => { + test('default', async () => { + await service.follow(alice, channel1); + + const followings = await fetchChannelFollowing(); + + expect(followings).toHaveLength(1); + expect(followings[0].followeeId).toBe(channel1.id); + expect(followings[0].followerId).toBe(alice.id); + }); + }); + + describe('unfollow', () => { + test('default', async () => { + await createChannelFollowing({ followerId: alice.id, followeeId: channel1.id }); + + await service.unfollow(alice, channel1); + + const followings = await fetchChannelFollowing(); + + expect(followings).toHaveLength(0); + }); + }); +}); diff --git a/packages/backend/test/unit/ChannelMutingService.ts b/packages/backend/test/unit/ChannelMutingService.ts new file mode 100644 index 0000000000..5870732e67 --- /dev/null +++ b/packages/backend/test/unit/ChannelMutingService.ts @@ -0,0 +1,334 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/* eslint-disable */ + +import { afterEach, beforeEach, describe, expect } from '@jest/globals'; +import { Test, TestingModule } from '@nestjs/testing'; +import { GlobalModule } from '@/GlobalModule.js'; +import { CoreModule } from '@/core/CoreModule.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { IdService } from '@/core/IdService.js'; +import { ChannelMutingService } from '@/core/ChannelMutingService.js'; +import { + ChannelMutingRepository, + ChannelsRepository, DriveFilesRepository, + MiChannel, + MiChannelMuting, MiDriveFile, + MiUser, + UserProfilesRepository, + UsersRepository, +} from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; +import { sleep } from "../utils.js"; + +describe('ChannelMutingService', () => { + let app: TestingModule; + let service: ChannelMutingService; + let channelsRepository: ChannelsRepository; + let channelMutingRepository: ChannelMutingRepository; + let usersRepository: UsersRepository; + let userProfilesRepository: UserProfilesRepository; + let driveFilesRepository: DriveFilesRepository; + let idService: IdService; + + let alice: MiUser; + let bob: MiUser; + let channel1: MiChannel; + let channel2: MiChannel; + let channel3: MiChannel; + let driveFile1: MiDriveFile; + let driveFile2: MiDriveFile; + + async function createUser(data: Partial = {}) { + const user = await usersRepository + .insert({ + id: idService.gen(), + username: 'username', + usernameLower: 'username', + ...data, + }) + .then(x => usersRepository.findOneByOrFail(x.identifiers[0])); + + await userProfilesRepository.insert({ + userId: user.id, + }); + + return user; + } + + async function createChannel(data: Partial = {}) { + return await channelsRepository + .insert({ + id: idService.gen(), + ...data, + }) + .then(x => channelsRepository.findOneByOrFail(x.identifiers[0])); + } + + async function createChannelMuting(data: Partial = {}) { + return await channelMutingRepository + .insert({ + id: idService.gen(), + ...data, + }) + .then(x => channelMutingRepository.findOneByOrFail(x.identifiers[0])); + } + + async function fetchChannelMuting() { + return await channelMutingRepository.findBy({}); + } + + async function createDriveFile(data: Partial = {}) { + return await driveFilesRepository + .insert({ + id: idService.gen(), + md5: 'md5', + name: 'name', + size: 0, + type: 'type', + storedInternal: false, + url: 'url', + ...data, + }) + .then(x => driveFilesRepository.findOneByOrFail(x.identifiers[0])); + } + + beforeAll(async () => { + app = await Test.createTestingModule({ + imports: [ + GlobalModule, + CoreModule, + ], + providers: [ + GlobalEventService, + IdService, + ChannelMutingService, + ], + }).compile(); + + app.enableShutdownHooks(); + + service = app.get(ChannelMutingService); + idService = app.get(IdService); + channelsRepository = app.get(DI.channelsRepository); + channelMutingRepository = app.get(DI.channelMutingRepository); + usersRepository = app.get(DI.usersRepository); + userProfilesRepository = app.get(DI.userProfilesRepository); + driveFilesRepository = app.get(DI.driveFilesRepository); + }); + + afterAll(async () => { + await app.close(); + }); + + beforeEach(async () => { + alice = await createUser({ username: 'alice' }); + bob = await createUser({ username: 'bob' }); + driveFile1 = await createDriveFile(); + driveFile2 = await createDriveFile(); + channel1 = await createChannel({ name: 'channel1', userId: alice.id, bannerId: driveFile1.id }); + channel2 = await createChannel({ name: 'channel2', userId: alice.id, bannerId: driveFile2.id }); + channel3 = await createChannel({ name: 'channel3', userId: alice.id, bannerId: driveFile2.id }); + }); + + afterEach(async () => { + await channelMutingRepository.delete({}); + await channelsRepository.delete({}); + await userProfilesRepository.delete({}); + await usersRepository.delete({}); + }); + + describe('list', () => { + test('default', async () => { + await createChannelMuting({ userId: alice.id, channelId: channel1.id }); + await createChannelMuting({ userId: alice.id, channelId: channel2.id }); + await createChannelMuting({ userId: bob.id, channelId: channel3.id }); + + const mutings = await service.list({ requestUserId: alice.id }); + + expect(mutings).toHaveLength(2); + expect(mutings[0].id).toBe(channel1.id); + expect(mutings[0].userId).toBe(alice.id); + expect(mutings[0].user).toBeFalsy(); + expect(mutings[0].bannerId).toBe(driveFile1.id); + expect(mutings[0].banner).toBeFalsy(); + expect(mutings[1].id).toBe(channel2.id); + expect(mutings[1].userId).toBe(alice.id); + expect(mutings[1].user).toBeFalsy(); + expect(mutings[1].bannerId).toBe(driveFile2.id); + expect(mutings[1].banner).toBeFalsy(); + }); + + test('withoutExpires', async () => { + const now = new Date(); + const past = new Date(now); + const future = new Date(now); + past.setMinutes(past.getMinutes() - 1); + future.setMinutes(future.getMinutes() + 1); + + await createChannelMuting({ userId: alice.id, channelId: channel1.id, expiresAt: past }); + await createChannelMuting({ userId: alice.id, channelId: channel2.id, expiresAt: null }); + await createChannelMuting({ userId: alice.id, channelId: channel3.id, expiresAt: future }); + + const mutings = await service.list({ requestUserId: alice.id }); + + expect(mutings).toHaveLength(2); + expect(mutings[0].id).toBe(channel2.id); + expect(mutings[1].id).toBe(channel3.id); + }); + + test('idOnly', async () => { + await createChannelMuting({ userId: alice.id, channelId: channel1.id }); + await createChannelMuting({ userId: alice.id, channelId: channel2.id }); + await createChannelMuting({ userId: bob.id, channelId: channel3.id }); + + const mutings = await service.list({ requestUserId: alice.id }, { idOnly: true }); + + expect(mutings).toHaveLength(2); + expect(mutings[0].id).toBe(channel1.id); + expect(mutings[1].id).toBe(channel2.id); + }); + + test('withoutExpires-idOnly', async () => { + const now = new Date(); + const past = new Date(now); + const future = new Date(now); + past.setMinutes(past.getMinutes() - 1); + future.setMinutes(future.getMinutes() + 1); + + await createChannelMuting({ userId: alice.id, channelId: channel1.id, expiresAt: past }); + await createChannelMuting({ userId: alice.id, channelId: channel2.id, expiresAt: null }); + await createChannelMuting({ userId: alice.id, channelId: channel3.id, expiresAt: future }); + + const mutings = await service.list({ requestUserId: alice.id }, { idOnly: true }); + + expect(mutings).toHaveLength(2); + expect(mutings[0].id).toBe(channel2.id); + expect(mutings[1].id).toBe(channel3.id); + }); + + test('joinUser', async () => { + await createChannelMuting({ userId: alice.id, channelId: channel1.id }); + await createChannelMuting({ userId: alice.id, channelId: channel2.id }); + await createChannelMuting({ userId: bob.id, channelId: channel3.id }); + + const mutings = await service.list({ requestUserId: alice.id }, { joinUser: true }); + + expect(mutings).toHaveLength(2); + expect(mutings[0].id).toBe(channel1.id); + expect(mutings[0].user).toEqual(alice); + expect(mutings[0].banner).toBeFalsy(); + expect(mutings[1].id).toBe(channel2.id); + expect(mutings[1].user).toEqual(alice); + expect(mutings[1].banner).toBeFalsy(); + }); + + test('joinBannerFile', async () => { + await createChannelMuting({ userId: alice.id, channelId: channel1.id }); + await createChannelMuting({ userId: alice.id, channelId: channel2.id }); + await createChannelMuting({ userId: bob.id, channelId: channel3.id }); + + const mutings = await service.list({ requestUserId: alice.id }, { joinBannerFile: true }); + + expect(mutings).toHaveLength(2); + expect(mutings[0].id).toBe(channel1.id); + expect(mutings[0].user).toBeFalsy(); + expect(mutings[0].banner).toEqual(driveFile1); + expect(mutings[1].id).toBe(channel2.id); + expect(mutings[1].user).toBeFalsy(); + expect(mutings[1].banner).toEqual(driveFile2); + }); + }); + + describe('findExpiredMutings', () => { + test('default', async () => { + const now = new Date(); + const future = new Date(now); + const past = new Date(now); + future.setMinutes(now.getMinutes() + 1); + past.setMinutes(now.getMinutes() - 1); + + await createChannelMuting({ userId: alice.id, channelId: channel1.id, expiresAt: past }); + await createChannelMuting({ userId: alice.id, channelId: channel2.id, expiresAt: future }); + await createChannelMuting({ userId: bob.id, channelId: channel3.id, expiresAt: past }); + + const mutings = await service.findExpiredMutings(); + + expect(mutings).toHaveLength(2); + expect(mutings[0].channelId).toBe(channel1.id); + expect(mutings[1].channelId).toBe(channel3.id); + }); + }); + + describe('isMuted', () => { + test('isMuted: true', async () => { + // キャッシュを読むのでServiceの機能を使って登録し、キャッシュを作成する + await service.mute({ requestUserId: alice.id, targetChannelId: channel1.id }); + await service.mute({ requestUserId: alice.id, targetChannelId: channel2.id }); + + await sleep(500); + + const result = await service.isMuted({ requestUserId: alice.id, targetChannelId: channel1.id }); + + expect(result).toBe(true); + }); + + test('isMuted: false', async () => { + await service.mute({ requestUserId: alice.id, targetChannelId: channel2.id }); + + await sleep(500); + + const result = await service.isMuted({ requestUserId: alice.id, targetChannelId: channel1.id }); + + expect(result).toBe(false); + }); + }); + + describe('mute', () => { + test('default', async () => { + await service.mute({ requestUserId: alice.id, targetChannelId: channel1.id }); + + const muting = await fetchChannelMuting(); + expect(muting).toHaveLength(1); + expect(muting[0].channelId).toBe(channel1.id); + }); + }); + + describe('unmute', () => { + test('default', async () => { + await createChannelMuting({ userId: alice.id, channelId: channel1.id }); + + let muting = await fetchChannelMuting(); + expect(muting).toHaveLength(1); + expect(muting[0].channelId).toBe(channel1.id); + + await service.unmute({ requestUserId: alice.id, targetChannelId: channel1.id }); + + muting = await fetchChannelMuting(); + expect(muting).toHaveLength(0); + }); + }); + + describe('eraseExpiredMutings', () => { + test('default', async () => { + const now = new Date(); + const future = new Date(now); + const past = new Date(now); + future.setMinutes(now.getMinutes() + 1); + past.setMinutes(now.getMinutes() - 1); + + await createChannelMuting({ userId: alice.id, channelId: channel1.id, expiresAt: past }); + await createChannelMuting({ userId: alice.id, channelId: channel2.id, expiresAt: future }); + await createChannelMuting({ userId: bob.id, channelId: channel3.id, expiresAt: past }); + + await service.eraseExpiredMutings(); + + const mutings = await fetchChannelMuting(); + expect(mutings).toHaveLength(1); + expect(mutings[0].channelId).toBe(channel2.id); + }); + }); +});