Merge 97f4c6a2e6
into 218070eb13
This commit is contained in:
commit
a029d5b68b
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class FollowingIsFollowerSuspended1752410859370 {
|
||||
name = 'FollowingIsFollowerSuspended1752410859370'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_ce62b50d882d4e9dee10ad0d2f"`);
|
||||
await queryRunner.query(`ALTER TABLE "following" ADD "isFollowerSuspended" boolean NOT NULL DEFAULT false`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_1896254b78a41a50e0396fdabd" ON "following" ("followeeId", "followerHost", "isFollowerSuspended", "isFollowerHibernated") `);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_d2b8dbf0b772042f4fe241a29d" ON "following" ("followerId", "followeeId", "isFollowerSuspended") `);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_d2b8dbf0b772042f4fe241a29d"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_1896254b78a41a50e0396fdabd"`);
|
||||
await queryRunner.query(`ALTER TABLE "following" DROP COLUMN "isFollowerSuspended"`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_ce62b50d882d4e9dee10ad0d2f" ON "following" ("followeeId", "followerHost", "isFollowerHibernated") `);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class FollowingIsFollowerSuspendedCopySuspendedState1752410900000 {
|
||||
name = 'FollowingIsFollowerSuspendedCopySuspendedState1752410900000'
|
||||
|
||||
async up(queryRunner) {
|
||||
// Update existing records based on user suspension status
|
||||
await queryRunner.query(`
|
||||
UPDATE "following"
|
||||
SET "isFollowerSuspended" = "user"."isSuspended"
|
||||
FROM "user"
|
||||
WHERE "following"."followerId" = "user"."id"
|
||||
`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
}
|
||||
}
|
|
@ -549,6 +549,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
// TODO: キャッシュ
|
||||
this.followingsRepository.findBy({
|
||||
followeeId: user.id,
|
||||
isFollowerSuspended: false,
|
||||
notify: 'normal',
|
||||
}).then(async followings => {
|
||||
if (note.visibility !== 'specified') {
|
||||
|
@ -854,6 +855,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
where: {
|
||||
followeeId: user.id,
|
||||
followerHost: IsNull(),
|
||||
isFollowerSuspended: false,
|
||||
isFollowerHibernated: false,
|
||||
},
|
||||
select: ['followerId', 'withReplies'],
|
||||
|
|
|
@ -174,6 +174,9 @@ export class QueueService {
|
|||
@bindThis
|
||||
public async deliverMany(user: ThinUser, content: IActivity | null, inboxes: Map<string, boolean>) {
|
||||
if (content == null) return null;
|
||||
inboxes.delete(null as unknown as string); // remove null inboxes
|
||||
if (inboxes.size === 0) return null;
|
||||
|
||||
const contentBody = JSON.stringify(content);
|
||||
const digest = ApRequestCreator.createDigest(contentBody);
|
||||
|
||||
|
|
|
@ -229,9 +229,7 @@ export class UserFollowingService implements OnModuleInit {
|
|||
followee: {
|
||||
id: MiUser['id']; host: MiUser['host']; uri: MiUser['host']; inbox: MiUser['inbox']; sharedInbox: MiUser['sharedInbox']
|
||||
},
|
||||
follower: {
|
||||
id: MiUser['id']; host: MiUser['host']; uri: MiUser['host']; inbox: MiUser['inbox']; sharedInbox: MiUser['sharedInbox']
|
||||
},
|
||||
follower: MiUser,
|
||||
silent = false,
|
||||
withReplies?: boolean,
|
||||
): Promise<void> {
|
||||
|
@ -244,6 +242,7 @@ export class UserFollowingService implements OnModuleInit {
|
|||
followerId: follower.id,
|
||||
followeeId: followee.id,
|
||||
withReplies: withReplies,
|
||||
isFollowerSuspended: follower.isSuspended,
|
||||
|
||||
// 非正規化
|
||||
followerHost: follower.host,
|
||||
|
@ -734,6 +733,7 @@ export class UserFollowingService implements OnModuleInit {
|
|||
return this.followingsRepository.createQueryBuilder('following')
|
||||
.select('following.followeeId')
|
||||
.where('following.followerId = :followerId', { followerId: userId })
|
||||
.andWhere('following.isFollowerSuspended = false')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
|
@ -743,6 +743,7 @@ export class UserFollowingService implements OnModuleInit {
|
|||
where: {
|
||||
followerId,
|
||||
followeeId,
|
||||
isFollowerSuspended: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
@ -7,13 +7,12 @@ import { Inject, Injectable } from '@nestjs/common';
|
|||
import { Not, IsNull } from 'typeorm';
|
||||
import type { FollowingsRepository, FollowRequestsRepository, UsersRepository } from '@/models/_.js';
|
||||
import type { MiUser } from '@/models/User.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { RelationshipJobData } from '@/queue/types.js';
|
||||
import { ApDeliverManagerService } from './activitypub/ApDeliverManagerService.js';
|
||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||
|
||||
@Injectable()
|
||||
|
@ -29,9 +28,9 @@ export class UserSuspendService {
|
|||
private followRequestsRepository: FollowRequestsRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private queueService: QueueService,
|
||||
private globalEventService: GlobalEventService,
|
||||
private apRendererService: ApRendererService,
|
||||
private apDeliverManagerService: ApDeliverManagerService,
|
||||
private moderationLogService: ModerationLogService,
|
||||
) {
|
||||
}
|
||||
|
@ -49,8 +48,8 @@ export class UserSuspendService {
|
|||
});
|
||||
|
||||
(async () => {
|
||||
await this.postSuspend(user).catch(e => {});
|
||||
await this.unFollowAll(user).catch(e => {});
|
||||
await this.postSuspend(user).catch((e: any) => {});
|
||||
await this.suspendFollowings(user).catch((e: any) => {});
|
||||
})();
|
||||
}
|
||||
|
||||
|
@ -67,7 +66,8 @@ export class UserSuspendService {
|
|||
});
|
||||
|
||||
(async () => {
|
||||
await this.postUnsuspend(user).catch(e => {});
|
||||
await this.postUnsuspend(user).catch((e: any) => {});
|
||||
await this.restoreFollowings(user).catch((e: any) => {});
|
||||
})();
|
||||
}
|
||||
|
||||
|
@ -83,28 +83,11 @@ export class UserSuspendService {
|
|||
});
|
||||
|
||||
if (this.userEntityService.isLocalUser(user)) {
|
||||
// 知り得る全SharedInboxにDelete配信
|
||||
const content = this.apRendererService.addContext(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user));
|
||||
|
||||
const queue: string[] = [];
|
||||
|
||||
const followings = await this.followingsRepository.find({
|
||||
where: [
|
||||
{ followerSharedInbox: Not(IsNull()) },
|
||||
{ followeeSharedInbox: Not(IsNull()) },
|
||||
],
|
||||
select: ['followerSharedInbox', 'followeeSharedInbox'],
|
||||
});
|
||||
|
||||
const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox);
|
||||
|
||||
for (const inbox of inboxes) {
|
||||
if (inbox != null && !queue.includes(inbox)) queue.push(inbox);
|
||||
}
|
||||
|
||||
for (const inbox of queue) {
|
||||
this.queueService.deliver(user, content, inbox, true);
|
||||
}
|
||||
const manager = this.apDeliverManagerService.createDeliverManager(user, content);
|
||||
manager.addAllKnowingSharedInboxRecipe();
|
||||
manager.addFollowersRecipe();
|
||||
manager.execute();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -113,50 +96,36 @@ export class UserSuspendService {
|
|||
this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: false });
|
||||
|
||||
if (this.userEntityService.isLocalUser(user)) {
|
||||
// 知り得る全SharedInboxにUndo Delete配信
|
||||
const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user), user));
|
||||
|
||||
const queue: string[] = [];
|
||||
|
||||
const followings = await this.followingsRepository.find({
|
||||
where: [
|
||||
{ followerSharedInbox: Not(IsNull()) },
|
||||
{ followeeSharedInbox: Not(IsNull()) },
|
||||
],
|
||||
select: ['followerSharedInbox', 'followeeSharedInbox'],
|
||||
});
|
||||
|
||||
const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox);
|
||||
|
||||
for (const inbox of inboxes) {
|
||||
if (inbox != null && !queue.includes(inbox)) queue.push(inbox);
|
||||
}
|
||||
|
||||
for (const inbox of queue) {
|
||||
this.queueService.deliver(user as any, content, inbox, true);
|
||||
}
|
||||
const manager = this.apDeliverManagerService.createDeliverManager(user, content);
|
||||
manager.addAllKnowingSharedInboxRecipe();
|
||||
manager.addFollowersRecipe();
|
||||
manager.execute();
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async unFollowAll(follower: MiUser) {
|
||||
const followings = await this.followingsRepository.find({
|
||||
where: {
|
||||
private async suspendFollowings(follower: MiUser) {
|
||||
await this.followingsRepository.update(
|
||||
{
|
||||
followerId: follower.id,
|
||||
followeeId: Not(IsNull()),
|
||||
},
|
||||
});
|
||||
{
|
||||
isFollowerSuspended: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const jobs: RelationshipJobData[] = [];
|
||||
for (const following of followings) {
|
||||
if (following.followeeId && following.followerId) {
|
||||
jobs.push({
|
||||
from: { id: following.followerId },
|
||||
to: { id: following.followeeId },
|
||||
silent: true,
|
||||
});
|
||||
@bindThis
|
||||
private async restoreFollowings(follower: MiUser) {
|
||||
// フォロー関係を復元(isFollowerSuspended: false)に変更
|
||||
await this.followingsRepository.update(
|
||||
{
|
||||
followerId: follower.id,
|
||||
},
|
||||
{
|
||||
isFollowerSuspended: false,
|
||||
}
|
||||
}
|
||||
this.queueService.createUnfollowJob(jobs);
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,10 +9,12 @@ import { DI } from '@/di-symbols.js';
|
|||
import type { FollowingsRepository } from '@/models/_.js';
|
||||
import type { MiLocalUser, MiRemoteUser, MiUser } from '@/models/User.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import type { IActivity } from '@/core/activitypub/type.js';
|
||||
import { ThinUser } from '@/queue/types.js';
|
||||
import { AccountUpdateService } from '@/core/AccountUpdateService.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { ApLoggerService } from './ApLoggerService.js';
|
||||
|
||||
interface IRecipe {
|
||||
type: string;
|
||||
|
@ -27,12 +29,19 @@ interface IDirectRecipe extends IRecipe {
|
|||
to: MiRemoteUser;
|
||||
}
|
||||
|
||||
interface IAllKnowingSharedInboxRecipe extends IRecipe {
|
||||
type: 'AllKnowingSharedInbox';
|
||||
}
|
||||
|
||||
const isFollowers = (recipe: IRecipe): recipe is IFollowersRecipe =>
|
||||
recipe.type === 'Followers';
|
||||
|
||||
const isDirect = (recipe: IRecipe): recipe is IDirectRecipe =>
|
||||
recipe.type === 'Direct';
|
||||
|
||||
const isAllKnowingSharedInbox = (recipe: IRecipe): recipe is IAllKnowingSharedInboxRecipe =>
|
||||
recipe.type === 'AllKnowingSharedInbox';
|
||||
|
||||
class DeliverManager {
|
||||
private actor: ThinUser;
|
||||
private activity: IActivity | null;
|
||||
|
@ -40,16 +49,15 @@ class DeliverManager {
|
|||
|
||||
/**
|
||||
* Constructor
|
||||
* @param userEntityService
|
||||
* @param followingsRepository
|
||||
* @param queueService
|
||||
* @param actor Actor
|
||||
* @param activity Activity to deliver
|
||||
*/
|
||||
constructor(
|
||||
private userEntityService: UserEntityService,
|
||||
private followingsRepository: FollowingsRepository,
|
||||
private queueService: QueueService,
|
||||
private logger: Logger,
|
||||
|
||||
actor: { id: MiUser['id']; host: null; },
|
||||
activity: IActivity | null,
|
||||
|
@ -91,6 +99,18 @@ class DeliverManager {
|
|||
this.addRecipe(recipe);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add recipe for all-knowing shared inbox deliver
|
||||
*/
|
||||
@bindThis
|
||||
public addAllKnowingSharedInboxRecipe(): void {
|
||||
const deliver: IAllKnowingSharedInboxRecipe = {
|
||||
type: 'AllKnowingSharedInbox',
|
||||
};
|
||||
|
||||
this.addRecipe(deliver);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add recipe
|
||||
* @param recipe Recipe
|
||||
|
@ -104,11 +124,30 @@ class DeliverManager {
|
|||
* Execute delivers
|
||||
*/
|
||||
@bindThis
|
||||
public async execute(): Promise<void> {
|
||||
public async execute(opts: { ignoreSuspend?: boolean } = {}): Promise<void> {
|
||||
//#region collect inboxes by recipes
|
||||
// The value flags whether it is shared or not.
|
||||
// key: inbox URL, value: whether it is sharedInbox
|
||||
const inboxes = new Map<string, boolean>();
|
||||
|
||||
if (this.recipes.some(r => isAllKnowingSharedInbox(r))) {
|
||||
// all-knowing shared inbox
|
||||
const followings = await this.followingsRepository.createQueryBuilder('f')
|
||||
.select([
|
||||
'f.followerSharedInbox',
|
||||
'f.followeeSharedInbox',
|
||||
])
|
||||
.where('f.followerSharedInbox IS NOT NULL')
|
||||
.orWhere('f.followeeSharedInbox IS NOT NULL')
|
||||
.distinct()
|
||||
.getRawMany<{ f_followerSharedInbox: string | null; f_followeeSharedInbox: string | null; }>();
|
||||
|
||||
for (const following of followings) {
|
||||
if (following.f_followeeSharedInbox) inboxes.set(following.f_followeeSharedInbox, true);
|
||||
if (following.f_followerSharedInbox) inboxes.set(following.f_followerSharedInbox, true);
|
||||
}
|
||||
}
|
||||
|
||||
// build inbox list
|
||||
// Process follower recipes first to avoid duplication when processing direct recipes later.
|
||||
if (this.recipes.some(r => isFollowers(r))) {
|
||||
|
@ -119,6 +158,7 @@ class DeliverManager {
|
|||
where: {
|
||||
followeeId: this.actor.id,
|
||||
followerHost: Not(IsNull()),
|
||||
isFollowerSuspended: opts.ignoreSuspend ? undefined : false,
|
||||
},
|
||||
select: {
|
||||
followerSharedInbox: true,
|
||||
|
@ -142,21 +182,26 @@ class DeliverManager {
|
|||
|
||||
inboxes.set(recipe.to.inbox, false);
|
||||
}
|
||||
//#endregion
|
||||
|
||||
// deliver
|
||||
await this.queueService.deliverMany(this.actor, this.activity, inboxes);
|
||||
this.logger.info(`Deliver queues dispatched: inboxes=${inboxes.size} actorId=${this.actor.id} activityId=${this.activity?.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ApDeliverManagerService {
|
||||
private logger: Logger;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.followingsRepository)
|
||||
private followingsRepository: FollowingsRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private queueService: QueueService,
|
||||
private apLoggerService: ApLoggerService,
|
||||
) {
|
||||
this.logger = this.apLoggerService.logger.createSubLogger('deliver-manager');
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -167,9 +212,9 @@ export class ApDeliverManagerService {
|
|||
@bindThis
|
||||
public async deliverToFollowers(actor: { id: MiLocalUser['id']; host: null; }, activity: IActivity): Promise<void> {
|
||||
const manager = new DeliverManager(
|
||||
this.userEntityService,
|
||||
this.followingsRepository,
|
||||
this.queueService,
|
||||
this.logger,
|
||||
actor,
|
||||
activity,
|
||||
);
|
||||
|
@ -186,9 +231,9 @@ export class ApDeliverManagerService {
|
|||
@bindThis
|
||||
public async deliverToUser(actor: { id: MiLocalUser['id']; host: null; }, activity: IActivity, to: MiRemoteUser): Promise<void> {
|
||||
const manager = new DeliverManager(
|
||||
this.userEntityService,
|
||||
this.followingsRepository,
|
||||
this.queueService,
|
||||
this.logger,
|
||||
actor,
|
||||
activity,
|
||||
);
|
||||
|
@ -205,9 +250,9 @@ export class ApDeliverManagerService {
|
|||
@bindThis
|
||||
public async deliverToUsers(actor: { id: MiLocalUser['id']; host: null; }, activity: IActivity, targets: MiRemoteUser[]): Promise<void> {
|
||||
const manager = new DeliverManager(
|
||||
this.userEntityService,
|
||||
this.followingsRepository,
|
||||
this.queueService,
|
||||
this.logger,
|
||||
actor,
|
||||
activity,
|
||||
);
|
||||
|
@ -218,10 +263,9 @@ export class ApDeliverManagerService {
|
|||
@bindThis
|
||||
public createDeliverManager(actor: { id: MiUser['id']; host: null; }, activity: IActivity | null): DeliverManager {
|
||||
return new DeliverManager(
|
||||
this.userEntityService,
|
||||
this.followingsRepository,
|
||||
this.queueService,
|
||||
|
||||
this.logger,
|
||||
actor,
|
||||
activity,
|
||||
);
|
||||
|
|
|
@ -9,7 +9,8 @@ import { MiUser } from './User.js';
|
|||
|
||||
@Entity('following')
|
||||
@Index(['followerId', 'followeeId'], { unique: true })
|
||||
@Index(['followeeId', 'followerHost', 'isFollowerHibernated'])
|
||||
@Index(['followerId', 'followeeId', 'isFollowerSuspended'])
|
||||
@Index(['followeeId', 'followerHost', 'isFollowerSuspended', 'isFollowerHibernated'])
|
||||
export class MiFollowing {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
|
@ -45,6 +46,11 @@ export class MiFollowing {
|
|||
})
|
||||
public isFollowerHibernated: boolean;
|
||||
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
})
|
||||
public isFollowerSuspended: boolean;
|
||||
|
||||
// タイムラインにその人のリプライまで含めるかどうか
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
|
|
|
@ -50,7 +50,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const query = this.queryService.makePaginationQuery(this.followingsRepository.createQueryBuilder('following'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
|
||||
.andWhere('following.followeeHost = :host', { host: ps.host });
|
||||
.andWhere('following.followeeHost = :host', { host: ps.host })
|
||||
.andWhere('following.isFollowerSuspended = false');
|
||||
|
||||
const followings = await query
|
||||
.limit(ps.limit)
|
||||
|
|
|
@ -50,7 +50,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const query = this.queryService.makePaginationQuery(this.followingsRepository.createQueryBuilder('following'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
|
||||
.andWhere('following.followerHost = :host', { host: ps.host });
|
||||
.andWhere('following.followerHost = :host', { host: ps.host })
|
||||
.andWhere('following.isFollowerSuspended = false');
|
||||
|
||||
const followings = await query
|
||||
.limit(ps.limit)
|
||||
|
|
|
@ -94,11 +94,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
this.followingsRepository.count({
|
||||
where: {
|
||||
followeeHost: Not(IsNull()),
|
||||
isFollowerSuspended: false,
|
||||
},
|
||||
}),
|
||||
this.followingsRepository.count({
|
||||
where: {
|
||||
followerHost: Not(IsNull()),
|
||||
isFollowerSuspended: false,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
|
|
@ -125,6 +125,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
where: {
|
||||
followeeId: user.id,
|
||||
followerId: me.id,
|
||||
isFollowerSuspended: false,
|
||||
},
|
||||
});
|
||||
if (!isFollowing) {
|
||||
|
@ -136,6 +137,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
|
||||
const query = this.queryService.makePaginationQuery(this.followingsRepository.createQueryBuilder('following'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
|
||||
.andWhere('following.followeeId = :userId', { userId: user.id })
|
||||
.andWhere('following.isFollowerSuspended = false')
|
||||
.innerJoinAndSelect('following.follower', 'follower');
|
||||
|
||||
const followings = await query
|
||||
|
|
|
@ -133,6 +133,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
where: {
|
||||
followeeId: user.id,
|
||||
followerId: me.id,
|
||||
isFollowerSuspended: false,
|
||||
},
|
||||
});
|
||||
if (!isFollowing) {
|
||||
|
@ -144,6 +145,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
|
||||
const query = this.queryService.makePaginationQuery(this.followingsRepository.createQueryBuilder('following'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
|
||||
.andWhere('following.followerId = :userId', { userId: user.id })
|
||||
.andWhere('following.isFollowerSuspended = false')
|
||||
.innerJoinAndSelect('following.followee', 'followee');
|
||||
|
||||
if (ps.birthday) {
|
||||
|
|
|
@ -68,7 +68,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
|
||||
const followingQuery = this.followingsRepository.createQueryBuilder('following')
|
||||
.select('following.followeeId')
|
||||
.where('following.followerId = :followerId', { followerId: me.id });
|
||||
.where('following.followerId = :followerId', { followerId: me.id })
|
||||
.andWhere('following.isFollowerSuspended = false');
|
||||
|
||||
query
|
||||
.andWhere(`user.id NOT IN (${ followingQuery.getQuery() })`);
|
||||
|
|
|
@ -0,0 +1,620 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
import { jest } from '@jest/globals';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import type { TestingModule } from '@nestjs/testing';
|
||||
import type { MiLocalUser, MiRemoteUser } from '@/models/User.js';
|
||||
import type { IActivity } from '@/core/activitypub/type.js';
|
||||
import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js';
|
||||
import { ApLoggerService } from '@/core/activitypub/ApLoggerService.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import { FollowingsRepository, UsersRepository } from '@/models/_.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { secureRndstr } from '@/misc/secure-rndstr.js';
|
||||
|
||||
describe('ApDeliverManagerService', () => {
|
||||
let service: ApDeliverManagerService;
|
||||
let followingsRepository: jest.Mocked<FollowingsRepository>;
|
||||
let queueService: jest.Mocked<QueueService>;
|
||||
let apLoggerService: jest.Mocked<ApLoggerService>;
|
||||
|
||||
const mockLocalUser: MiLocalUser = {
|
||||
id: 'local-user-id',
|
||||
host: null,
|
||||
} as MiLocalUser;
|
||||
|
||||
const mockRemoteUser1: MiRemoteUser & { inbox: string; sharedInbox: string; } = {
|
||||
id: 'remote-user-1',
|
||||
host: 'remote.example.com',
|
||||
inbox: 'https://remote.example.com/inbox',
|
||||
sharedInbox: 'https://remote.example.com/shared-inbox',
|
||||
} as MiRemoteUser & { inbox: string; sharedInbox: string; };
|
||||
|
||||
const mockRemoteUser2: MiRemoteUser & { inbox: string; } = {
|
||||
id: 'remote-user-2',
|
||||
host: 'another.example.com',
|
||||
inbox: 'https://another.example.com/inbox',
|
||||
sharedInbox: null,
|
||||
} as MiRemoteUser & { inbox: string; };
|
||||
|
||||
const mockActivity: IActivity = {
|
||||
type: 'Create',
|
||||
id: 'activity-id',
|
||||
actor: 'https://local.example.com/users/local-user-id',
|
||||
object: {
|
||||
type: 'Note',
|
||||
id: 'note-id',
|
||||
content: 'Hello, world!',
|
||||
},
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ApDeliverManagerService,
|
||||
{
|
||||
provide: DI.followingsRepository,
|
||||
useValue: {
|
||||
find: jest.fn(),
|
||||
createQueryBuilder: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: QueueService,
|
||||
useValue: {
|
||||
deliverMany: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ApLoggerService,
|
||||
useValue: {
|
||||
logger: {
|
||||
createSubLogger: jest.fn().mockReturnValue({
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<ApDeliverManagerService>(ApDeliverManagerService);
|
||||
followingsRepository = module.get(DI.followingsRepository);
|
||||
queueService = module.get(QueueService);
|
||||
apLoggerService = module.get(ApLoggerService);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('deliverToFollowers', () => {
|
||||
it('should deliver activity to all followers', async () => {
|
||||
const mockFollowings = [
|
||||
{
|
||||
followerSharedInbox: 'https://remote1.example.com/shared-inbox',
|
||||
followerInbox: 'https://remote1.example.com/inbox',
|
||||
},
|
||||
{
|
||||
followerSharedInbox: 'https://remote2.example.com/shared-inbox',
|
||||
followerInbox: 'https://remote2.example.com/inbox',
|
||||
},
|
||||
{
|
||||
followerSharedInbox: null,
|
||||
followerInbox: 'https://remote3.example.com/inbox',
|
||||
},
|
||||
];
|
||||
|
||||
followingsRepository.find.mockResolvedValue(mockFollowings as any);
|
||||
|
||||
await service.deliverToFollowers(mockLocalUser, mockActivity);
|
||||
|
||||
expect(followingsRepository.find).toHaveBeenCalledWith({
|
||||
where: {
|
||||
followeeId: mockLocalUser.id,
|
||||
followerHost: expect.anything(), // Not(IsNull())
|
||||
isFollowerSuspended: false,
|
||||
},
|
||||
select: {
|
||||
followerSharedInbox: true,
|
||||
followerInbox: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(queueService.deliverMany).toHaveBeenCalledWith(
|
||||
{ id: mockLocalUser.id },
|
||||
mockActivity,
|
||||
expect.any(Map),
|
||||
);
|
||||
|
||||
// 呼び出されたinboxesを確認
|
||||
const [, , inboxes] = queueService.deliverMany.mock.calls[0];
|
||||
expect(inboxes.size).toBe(3);
|
||||
expect(inboxes.has('https://remote1.example.com/shared-inbox')).toBe(true);
|
||||
expect(inboxes.has('https://remote2.example.com/shared-inbox')).toBe(true);
|
||||
expect(inboxes.has('https://remote3.example.com/inbox')).toBe(true);
|
||||
});
|
||||
|
||||
it('should exclude suspended followers by default', async () => {
|
||||
followingsRepository.find.mockResolvedValue([]);
|
||||
|
||||
await service.deliverToFollowers(mockLocalUser, mockActivity);
|
||||
|
||||
expect(followingsRepository.find).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
isFollowerSuspended: false,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deliverToUser', () => {
|
||||
it('should deliver activity to specific remote user', async () => {
|
||||
await service.deliverToUser(mockLocalUser, mockActivity, mockRemoteUser1);
|
||||
|
||||
expect(queueService.deliverMany).toHaveBeenCalledWith(
|
||||
{ id: mockLocalUser.id },
|
||||
mockActivity,
|
||||
expect.any(Map),
|
||||
);
|
||||
|
||||
const [, , inboxes] = queueService.deliverMany.mock.calls[0];
|
||||
expect(inboxes.size).toBe(1);
|
||||
expect(inboxes.has(mockRemoteUser1.inbox)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle user without shared inbox', async () => {
|
||||
await service.deliverToUser(mockLocalUser, mockActivity, mockRemoteUser2);
|
||||
|
||||
const [, , inboxes] = queueService.deliverMany.mock.calls[0];
|
||||
expect(inboxes.size).toBe(1);
|
||||
expect(inboxes.has(mockRemoteUser2.inbox)).toBe(true);
|
||||
});
|
||||
|
||||
it('should skip user with null inbox', async () => {
|
||||
const userWithoutInbox = {
|
||||
...mockRemoteUser1,
|
||||
inbox: null,
|
||||
} as MiRemoteUser;
|
||||
|
||||
await service.deliverToUser(mockLocalUser, mockActivity, userWithoutInbox);
|
||||
|
||||
const [, , inboxes] = queueService.deliverMany.mock.calls[0];
|
||||
expect(inboxes.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deliverToUsers', () => {
|
||||
it('should deliver activity to multiple remote users', async () => {
|
||||
await service.deliverToUsers(mockLocalUser, mockActivity, [mockRemoteUser1, mockRemoteUser2]);
|
||||
|
||||
const [, , inboxes] = queueService.deliverMany.mock.calls[0];
|
||||
expect(inboxes.size).toBe(2);
|
||||
expect(inboxes.has(mockRemoteUser1.inbox)).toBe(true);
|
||||
expect(inboxes.has(mockRemoteUser2.inbox)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createDeliverManager', () => {
|
||||
it('should create a DeliverManager instance', () => {
|
||||
const manager = service.createDeliverManager(mockLocalUser, mockActivity);
|
||||
|
||||
expect(manager).toBeDefined();
|
||||
expect(typeof manager.addFollowersRecipe).toBe('function');
|
||||
expect(typeof manager.addDirectRecipe).toBe('function');
|
||||
expect(typeof manager.addAllKnowingSharedInboxRecipe).toBe('function');
|
||||
expect(typeof manager.execute).toBe('function');
|
||||
});
|
||||
|
||||
it('should allow manual recipe management', async () => {
|
||||
const manager = service.createDeliverManager(mockLocalUser, mockActivity);
|
||||
|
||||
followingsRepository.find.mockResolvedValue([
|
||||
{
|
||||
followerSharedInbox: null,
|
||||
followerInbox: 'https://follower.example.com/inbox',
|
||||
},
|
||||
] as any);
|
||||
|
||||
// フォロワー配信のレシピを追加
|
||||
manager.addFollowersRecipe();
|
||||
// ダイレクト配信のレシピを追加
|
||||
manager.addDirectRecipe(mockRemoteUser1);
|
||||
|
||||
await manager.execute();
|
||||
|
||||
const [, , inboxes] = queueService.deliverMany.mock.calls[0];
|
||||
expect(inboxes.size).toBe(2);
|
||||
expect(inboxes.has('https://follower.example.com/inbox')).toBe(true);
|
||||
expect(inboxes.has(mockRemoteUser1.inbox)).toBe(true);
|
||||
});
|
||||
|
||||
it('should support ignoreSuspend option', async () => {
|
||||
const manager = service.createDeliverManager(mockLocalUser, mockActivity);
|
||||
|
||||
followingsRepository.find.mockResolvedValue([]);
|
||||
|
||||
manager.addFollowersRecipe();
|
||||
await manager.execute({ ignoreSuspend: true });
|
||||
|
||||
expect(followingsRepository.find).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
isFollowerSuspended: undefined, // ignoreSuspend: true なので undefined
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('followers and directs mixture: 先にfollowersでsharedInboxが追加されていた場合、directsでユーザーがそのsharedInboxを持っていたらinboxを追加しない', async () => {
|
||||
const manager = service.createDeliverManager(mockLocalUser, mockActivity);
|
||||
followingsRepository.find.mockResolvedValue([
|
||||
{
|
||||
followerSharedInbox: mockRemoteUser1.sharedInbox,
|
||||
followerInbox: mockRemoteUser2.inbox,
|
||||
},
|
||||
] as any);
|
||||
manager.addFollowersRecipe();
|
||||
manager.addDirectRecipe(mockRemoteUser1);
|
||||
await manager.execute();
|
||||
const [, , inboxes] = queueService.deliverMany.mock.calls[0];
|
||||
expect(inboxes.size).toBe(1);
|
||||
expect(inboxes.has(mockRemoteUser1.sharedInbox)).toBe(true);
|
||||
expect(inboxes.has(mockRemoteUser1.inbox)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should throw error for non-local actor', () => {
|
||||
const remoteActor = { id: 'remote-id', host: 'remote.example.com' } as any;
|
||||
|
||||
expect(() => {
|
||||
service.createDeliverManager(remoteActor, mockActivity);
|
||||
}).toThrow('actor.host must be null');
|
||||
});
|
||||
|
||||
it('should throw error when follower has null inbox', async () => {
|
||||
const mockFollowings = [
|
||||
{
|
||||
followerSharedInbox: null,
|
||||
followerInbox: null, // null inbox
|
||||
},
|
||||
];
|
||||
|
||||
followingsRepository.find.mockResolvedValue(mockFollowings as any);
|
||||
|
||||
await expect(service.deliverToFollowers(mockLocalUser, mockActivity)).rejects.toThrow('inbox is null');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AllKnowingSharedInbox recipe', () => {
|
||||
it('should collect all shared inboxes when using AllKnowingSharedInbox', async () => {
|
||||
const mockQueryBuilder = {
|
||||
select: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
orWhere: jest.fn().mockReturnThis(),
|
||||
distinct: jest.fn().mockReturnThis(),
|
||||
getRawMany: jest.fn<any>().mockResolvedValue([
|
||||
{ f_followerSharedInbox: 'https://shared1.example.com/inbox' },
|
||||
{ f_followeeSharedInbox: 'https://shared2.example.com/inbox' },
|
||||
]),
|
||||
};
|
||||
|
||||
followingsRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
const manager = service.createDeliverManager(mockLocalUser, mockActivity);
|
||||
manager.addAllKnowingSharedInboxRecipe();
|
||||
|
||||
await manager.execute();
|
||||
|
||||
expect(followingsRepository.createQueryBuilder).toHaveBeenCalledWith('f');
|
||||
expect(mockQueryBuilder.select).toHaveBeenCalledWith([
|
||||
'f.followerSharedInbox',
|
||||
'f.followeeSharedInbox',
|
||||
]);
|
||||
|
||||
const [, , inboxes] = queueService.deliverMany.mock.calls[0];
|
||||
expect(inboxes.size).toBe(2);
|
||||
expect(inboxes.has('https://shared1.example.com/inbox')).toBe(true);
|
||||
expect(inboxes.has('https://shared2.example.com/inbox')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ApDeliverManagerService (SQL)', () => {
|
||||
// followerにデータを挿入して、SQLの動作を確認します
|
||||
let app: TestingModule;
|
||||
let service: ApDeliverManagerService;
|
||||
let followingsRepository: FollowingsRepository;
|
||||
let usersRepository: UsersRepository;
|
||||
let queueService: jest.Mocked<QueueService>;
|
||||
|
||||
async function createUser(data: Partial<{ id: string; username: string; host: string | null; inbox: string | null; sharedInbox: string | null; isSuspended: boolean }> = {}): Promise<any> {
|
||||
const user = {
|
||||
id: secureRndstr(16),
|
||||
username: secureRndstr(16),
|
||||
usernameLower: (data.username ?? secureRndstr(16)).toLowerCase(),
|
||||
host: data.host ?? null,
|
||||
inbox: data.inbox ?? null,
|
||||
sharedInbox: data.sharedInbox ?? null,
|
||||
isSuspended: data.isSuspended ?? false,
|
||||
...data,
|
||||
};
|
||||
|
||||
await usersRepository.insert(user);
|
||||
return user;
|
||||
}
|
||||
|
||||
async function createFollowing(follower: any, followee: any, data: Partial<{
|
||||
followerInbox: string | null;
|
||||
followerSharedInbox: string | null;
|
||||
followeeInbox: string | null;
|
||||
followeeSharedInbox: string | null;
|
||||
isFollowerSuspended: boolean;
|
||||
}> = {}): Promise<any> {
|
||||
const following = {
|
||||
id: secureRndstr(16),
|
||||
followerId: follower.id,
|
||||
followeeId: followee.id,
|
||||
followerHost: follower.host,
|
||||
followeeHost: followee.host,
|
||||
followerInbox: data.followerInbox ?? follower.inbox,
|
||||
followerSharedInbox: data.followerSharedInbox ?? follower.sharedInbox,
|
||||
followeeInbox: data.followeeInbox ?? null,
|
||||
followeeSharedInbox: data.followeeSharedInbox ?? null,
|
||||
isFollowerSuspended: data.isFollowerSuspended ?? false,
|
||||
isFollowerHibernated: false,
|
||||
withReplies: false,
|
||||
notify: null,
|
||||
};
|
||||
|
||||
await followingsRepository.insert(following);
|
||||
return following;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
const { Test } = await import('@nestjs/testing');
|
||||
const { GlobalModule } = await import('@/GlobalModule.js');
|
||||
const { DI } = await import('@/di-symbols.js');
|
||||
|
||||
app = await Test.createTestingModule({
|
||||
imports: [GlobalModule],
|
||||
providers: [
|
||||
ApDeliverManagerService,
|
||||
{
|
||||
provide: QueueService,
|
||||
useFactory: () => ({
|
||||
deliverMany: jest.fn(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
provide: ApLoggerService,
|
||||
useValue: {
|
||||
logger: {
|
||||
createSubLogger: jest.fn().mockReturnValue({
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
app.enableShutdownHooks();
|
||||
|
||||
service = app.get<ApDeliverManagerService>(ApDeliverManagerService);
|
||||
followingsRepository = app.get<FollowingsRepository>(DI.followingsRepository);
|
||||
usersRepository = app.get<UsersRepository>(DI.usersRepository);
|
||||
queueService = app.get<QueueService>(QueueService) as jest.Mocked<QueueService>;
|
||||
|
||||
// Reset mocks
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe('deliverToFollowers with real data', () => {
|
||||
it('should deliver to followers excluding suspended ones', async () => {
|
||||
// Create local user (followee)
|
||||
const localUser = await createUser({
|
||||
host: null,
|
||||
username: 'localuser',
|
||||
});
|
||||
|
||||
// Create remote followers
|
||||
const activeFollower = await createUser({
|
||||
host: 'active.example.com',
|
||||
username: 'activefollower',
|
||||
inbox: 'https://active.example.com/inbox',
|
||||
sharedInbox: 'https://active.example.com/shared-inbox',
|
||||
isSuspended: false,
|
||||
});
|
||||
|
||||
const suspendedFollower = await createUser({
|
||||
host: 'suspended.example.com',
|
||||
username: 'suspendedfollower',
|
||||
inbox: 'https://suspended.example.com/inbox',
|
||||
sharedInbox: 'https://suspended.example.com/shared-inbox',
|
||||
isSuspended: true,
|
||||
});
|
||||
|
||||
const followerWithoutSharedInbox = await createUser({
|
||||
host: 'noshared.example.com',
|
||||
username: 'noshared',
|
||||
inbox: 'https://noshared.example.com/inbox',
|
||||
sharedInbox: null,
|
||||
isSuspended: false,
|
||||
});
|
||||
|
||||
// Create following relationships
|
||||
await createFollowing(activeFollower, localUser, {
|
||||
followerInbox: activeFollower.inbox,
|
||||
followerSharedInbox: activeFollower.sharedInbox,
|
||||
isFollowerSuspended: false,
|
||||
});
|
||||
|
||||
await createFollowing(suspendedFollower, localUser, {
|
||||
followerInbox: suspendedFollower.inbox,
|
||||
followerSharedInbox: suspendedFollower.sharedInbox,
|
||||
isFollowerSuspended: true, // 凍結されたフォロワー
|
||||
});
|
||||
|
||||
await createFollowing(followerWithoutSharedInbox, localUser, {
|
||||
followerInbox: followerWithoutSharedInbox.inbox,
|
||||
followerSharedInbox: null,
|
||||
isFollowerSuspended: false,
|
||||
});
|
||||
|
||||
const mockActivity = {
|
||||
type: 'Create',
|
||||
id: 'test-activity',
|
||||
actor: `https://local.example.com/users/${localUser.id}`,
|
||||
object: { type: 'Note', content: 'Hello' },
|
||||
} as any;
|
||||
|
||||
// Execute delivery
|
||||
await service.deliverToFollowers(localUser, mockActivity);
|
||||
|
||||
// Verify delivery was queued
|
||||
expect(queueService.deliverMany).toHaveBeenCalledTimes(1);
|
||||
const [actor, activity, inboxes] = queueService.deliverMany.mock.calls[0];
|
||||
|
||||
expect(actor.id).toBe(localUser.id);
|
||||
expect(activity).toBe(mockActivity);
|
||||
|
||||
// Check inboxes - should include active followers but exclude suspended ones
|
||||
expect(inboxes.size).toBe(2);
|
||||
expect(inboxes.has('https://active.example.com/shared-inbox')).toBe(true);
|
||||
expect(inboxes.has('https://noshared.example.com/inbox')).toBe(true);
|
||||
expect(inboxes.has('https://suspended.example.com/shared-inbox')).toBe(false);
|
||||
});
|
||||
|
||||
it('should include suspended followers when ignoreSuspend is true', async () => {
|
||||
const localUser = await createUser({ host: null });
|
||||
const suspendedFollower = await createUser({
|
||||
host: 'suspended.example.com',
|
||||
inbox: 'https://suspended.example.com/inbox',
|
||||
isSuspended: true,
|
||||
});
|
||||
|
||||
await createFollowing(suspendedFollower, localUser, {
|
||||
isFollowerSuspended: true,
|
||||
});
|
||||
|
||||
const manager = service.createDeliverManager(localUser, { type: 'Test' } as any);
|
||||
manager.addFollowersRecipe();
|
||||
|
||||
// Execute with ignoreSuspend: true
|
||||
await manager.execute({ ignoreSuspend: true });
|
||||
|
||||
const [, , inboxes] = queueService.deliverMany.mock.calls[0];
|
||||
expect(inboxes.size).toBe(1);
|
||||
expect(inboxes.has('https://suspended.example.com/inbox')).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle mixed follower types correctly', async () => {
|
||||
const localUser = await createUser({ host: null });
|
||||
|
||||
// フォロワー1: shared inbox あり
|
||||
const follower1 = await createUser({
|
||||
host: 'server1.example.com',
|
||||
inbox: 'https://server1.example.com/users/user1/inbox',
|
||||
sharedInbox: 'https://server1.example.com/inbox',
|
||||
});
|
||||
|
||||
// フォロワー2: 同じサーバーの別ユーザー(shared inbox は同じ)
|
||||
const follower2 = await createUser({
|
||||
host: 'server1.example.com',
|
||||
inbox: 'https://server1.example.com/users/user2/inbox',
|
||||
sharedInbox: 'https://server1.example.com/inbox',
|
||||
});
|
||||
|
||||
// フォロワー3: 別サーバー、shared inbox なし
|
||||
const follower3 = await createUser({
|
||||
host: 'server2.example.com',
|
||||
inbox: 'https://server2.example.com/users/user3/inbox',
|
||||
sharedInbox: null,
|
||||
});
|
||||
|
||||
await createFollowing(follower1, localUser);
|
||||
await createFollowing(follower2, localUser);
|
||||
await createFollowing(follower3, localUser);
|
||||
|
||||
await service.deliverToFollowers(localUser, { type: 'Test' } as any);
|
||||
|
||||
const [, , inboxes] = queueService.deliverMany.mock.calls[0];
|
||||
|
||||
// shared inbox は重複排除されるので、2つのinboxのみ
|
||||
expect(inboxes.size).toBe(2);
|
||||
expect(inboxes.has('https://server1.example.com/inbox')).toBe(true); // shared inbox
|
||||
expect(inboxes.has('https://server2.example.com/users/user3/inbox')).toBe(true); // individual inbox
|
||||
|
||||
// individual inbox は shared inbox があるので使用されない
|
||||
expect(inboxes.has('https://server1.example.com/users/user1/inbox')).toBe(false);
|
||||
expect(inboxes.has('https://server1.example.com/users/user2/inbox')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AllKnowingSharedInbox with real data', () => {
|
||||
it('should collect all unique shared inboxes from database', async () => {
|
||||
// Create users with various inbox configurations
|
||||
const user1 = await createUser({ host: null });
|
||||
const user2 = await createUser({ host: null });
|
||||
|
||||
const remoteUser1 = await createUser({
|
||||
host: 'server1.example.com',
|
||||
sharedInbox: 'https://server1.example.com/shared',
|
||||
});
|
||||
|
||||
const remoteUser2 = await createUser({
|
||||
host: 'server2.example.com',
|
||||
sharedInbox: 'https://server2.example.com/shared',
|
||||
});
|
||||
|
||||
const remoteUser3 = await createUser({
|
||||
host: 'server1.example.com', // 同じサーバー
|
||||
sharedInbox: 'https://server1.example.com/shared', // 同じ shared inbox
|
||||
});
|
||||
|
||||
// Create following relationships
|
||||
await createFollowing(remoteUser1, user1, {
|
||||
followerSharedInbox: 'https://server1.example.com/shared',
|
||||
});
|
||||
|
||||
await createFollowing(user1, remoteUser2, {
|
||||
followerSharedInbox: null,
|
||||
followeeSharedInbox: 'https://server2.example.com/shared',
|
||||
});
|
||||
|
||||
await createFollowing(remoteUser3, user2, {
|
||||
followerSharedInbox: 'https://server1.example.com/shared', // 重複
|
||||
});
|
||||
|
||||
const manager = service.createDeliverManager(user1, { type: 'Test' } as any);
|
||||
manager.addAllKnowingSharedInboxRecipe();
|
||||
|
||||
await manager.execute();
|
||||
|
||||
const [, , inboxes] = queueService.deliverMany.mock.calls[0];
|
||||
|
||||
// 重複は除去されて2つのユニークな shared inbox
|
||||
expect(inboxes.size).toBe(2);
|
||||
expect(inboxes.has('https://server1.example.com/shared')).toBe(true);
|
||||
expect(inboxes.has('https://server2.example.com/shared')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,436 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
import { jest } from '@jest/globals';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import type { TestingModule } from '@nestjs/testing';
|
||||
import { GlobalModule } from '@/GlobalModule.js';
|
||||
import { UserSuspendService } from '@/core/UserSuspendService.js';
|
||||
import {
|
||||
MiFollowing,
|
||||
MiUser,
|
||||
FollowingsRepository,
|
||||
FollowRequestsRepository,
|
||||
UsersRepository,
|
||||
} from '@/models/_.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
|
||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||
import { secureRndstr } from '@/misc/secure-rndstr.js';
|
||||
import { randomString } from '../utils.js';
|
||||
import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js';
|
||||
import { RelayService } from '@/core/RelayService.js';
|
||||
import { ApLoggerService } from '@/core/activitypub/ApLoggerService.js';
|
||||
|
||||
function genHost() {
|
||||
return randomString() + '.example.com';
|
||||
}
|
||||
|
||||
describe('UserSuspendService', () => {
|
||||
let app: TestingModule;
|
||||
let userSuspendService: UserSuspendService;
|
||||
let usersRepository: UsersRepository;
|
||||
let followingsRepository: FollowingsRepository;
|
||||
let followRequestsRepository: FollowRequestsRepository;
|
||||
let userEntityService: jest.Mocked<UserEntityService>;
|
||||
let queueService: jest.Mocked<QueueService>;
|
||||
let globalEventService: jest.Mocked<GlobalEventService>;
|
||||
let apRendererService: jest.Mocked<ApRendererService>;
|
||||
let moderationLogService: jest.Mocked<ModerationLogService>;
|
||||
|
||||
async function createUser(data: Partial<MiUser> = {}): Promise<MiUser> {
|
||||
const user = {
|
||||
id: secureRndstr(16),
|
||||
username: secureRndstr(16),
|
||||
usernameLower: secureRndstr(16).toLowerCase(),
|
||||
host: null,
|
||||
isSuspended: false,
|
||||
...data,
|
||||
} as MiUser;
|
||||
|
||||
await usersRepository.insert(user);
|
||||
return user;
|
||||
}
|
||||
|
||||
async function createFollowing(follower: MiUser, followee: MiUser, data: Partial<MiFollowing> = {}): Promise<MiFollowing> {
|
||||
const following = {
|
||||
id: secureRndstr(16),
|
||||
followerId: follower.id,
|
||||
followeeId: followee.id,
|
||||
isFollowerSuspended: false,
|
||||
isFollowerHibernated: false,
|
||||
withReplies: false,
|
||||
notify: null,
|
||||
followerHost: follower.host,
|
||||
followerInbox: null,
|
||||
followerSharedInbox: null,
|
||||
followeeHost: followee.host,
|
||||
followeeInbox: null,
|
||||
followeeSharedInbox: null,
|
||||
...data,
|
||||
} as MiFollowing;
|
||||
|
||||
await followingsRepository.insert(following);
|
||||
return following;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await Test.createTestingModule({
|
||||
imports: [GlobalModule],
|
||||
providers: [
|
||||
UserSuspendService,
|
||||
ApDeliverManagerService,
|
||||
{
|
||||
provide: UserEntityService,
|
||||
useFactory: () => ({
|
||||
isLocalUser: jest.fn(),
|
||||
genLocalUserUri: jest.fn(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
provide: QueueService,
|
||||
useFactory: () => ({
|
||||
deliverMany: jest.fn(),
|
||||
deliver: jest.fn(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
provide: GlobalEventService,
|
||||
useFactory: () => ({
|
||||
publishInternalEvent: jest.fn(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
provide: ModerationLogService,
|
||||
useFactory: () => ({
|
||||
log: jest.fn(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
provide: RelayService,
|
||||
useFactory: () => ({
|
||||
deliverToRelays: jest.fn(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
provide: ApRendererService,
|
||||
useFactory: () => ({
|
||||
renderDelete: jest.fn(),
|
||||
renderUndo: jest.fn(),
|
||||
renderPerson: jest.fn(),
|
||||
renderUpdate: jest.fn(),
|
||||
addContext: jest.fn(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
provide: ApLoggerService,
|
||||
useFactory: () => ({
|
||||
logger: {
|
||||
createSubLogger: jest.fn().mockReturnValue({
|
||||
info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn(),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
app.enableShutdownHooks();
|
||||
|
||||
userSuspendService = app.get<UserSuspendService>(UserSuspendService);
|
||||
usersRepository = app.get<UsersRepository>(DI.usersRepository);
|
||||
followingsRepository = app.get<FollowingsRepository>(DI.followingsRepository);
|
||||
followRequestsRepository = app.get<FollowRequestsRepository>(DI.followRequestsRepository);
|
||||
userEntityService = app.get<UserEntityService>(UserEntityService) as jest.Mocked<UserEntityService>;
|
||||
queueService = app.get<QueueService>(QueueService) as jest.Mocked<QueueService>;
|
||||
globalEventService = app.get<GlobalEventService>(GlobalEventService) as jest.Mocked<GlobalEventService>;
|
||||
apRendererService = app.get<ApRendererService>(ApRendererService) as jest.Mocked<ApRendererService>;
|
||||
moderationLogService = app.get<ModerationLogService>(ModerationLogService) as jest.Mocked<ModerationLogService>;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('suspend', () => {
|
||||
test('should suspend user and update database', async () => {
|
||||
const user = await createUser();
|
||||
const moderator = await createUser();
|
||||
|
||||
await userSuspendService.suspend(user, moderator);
|
||||
|
||||
// ユーザーが凍結されているかチェック
|
||||
const suspendedUser = await usersRepository.findOneBy({ id: user.id });
|
||||
expect(suspendedUser?.isSuspended).toBe(true);
|
||||
|
||||
// モデレーションログが記録されているかチェック
|
||||
expect(moderationLogService.log).toHaveBeenCalledWith(moderator, 'suspend', {
|
||||
userId: user.id,
|
||||
userUsername: user.username,
|
||||
userHost: user.host,
|
||||
});
|
||||
});
|
||||
|
||||
test('should mark follower relationships as suspended', async () => {
|
||||
const user = await createUser();
|
||||
const followee1 = await createUser();
|
||||
const followee2 = await createUser();
|
||||
const moderator = await createUser();
|
||||
|
||||
// ユーザーがフォローしている関係を作成
|
||||
await createFollowing(user, followee1);
|
||||
await createFollowing(user, followee2);
|
||||
|
||||
await userSuspendService.suspend(user, moderator);
|
||||
await setTimeout(250);
|
||||
|
||||
// フォロー関係が論理削除されているかチェック
|
||||
const followings = await followingsRepository.find({
|
||||
where: { followerId: user.id },
|
||||
});
|
||||
|
||||
expect(followings).toHaveLength(2);
|
||||
followings.forEach(following => {
|
||||
expect(following.isFollowerSuspended).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('should publish internal event for suspension', async () => {
|
||||
const user = await createUser();
|
||||
const moderator = await createUser();
|
||||
|
||||
await userSuspendService.suspend(user, moderator);
|
||||
await setTimeout(250);
|
||||
|
||||
// 内部イベントが発行されているかチェック(非同期処理のため少し待つ)
|
||||
await setTimeout(100);
|
||||
|
||||
expect(globalEventService.publishInternalEvent).toHaveBeenCalledWith(
|
||||
'userChangeSuspendedState',
|
||||
{ id: user.id, isSuspended: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unsuspend', () => {
|
||||
test('should unsuspend user and update database', async () => {
|
||||
const user = await createUser({ isSuspended: true });
|
||||
const moderator = await createUser();
|
||||
|
||||
await userSuspendService.unsuspend(user, moderator);
|
||||
await setTimeout(250);
|
||||
|
||||
// ユーザーの凍結が解除されているかチェック
|
||||
const unsuspendedUser = await usersRepository.findOneBy({ id: user.id });
|
||||
expect(unsuspendedUser?.isSuspended).toBe(false);
|
||||
|
||||
// モデレーションログが記録されているかチェック
|
||||
expect(moderationLogService.log).toHaveBeenCalledWith(moderator, 'unsuspend', {
|
||||
userId: user.id,
|
||||
userUsername: user.username,
|
||||
userHost: user.host,
|
||||
});
|
||||
});
|
||||
|
||||
test('should restore follower relationships', async () => {
|
||||
const user = await createUser({ isSuspended: true });
|
||||
const followee1 = await createUser();
|
||||
const followee2 = await createUser();
|
||||
const moderator = await createUser();
|
||||
|
||||
// 凍結状態のフォロー関係を作成
|
||||
await createFollowing(user, followee1, { isFollowerSuspended: true });
|
||||
await createFollowing(user, followee2, { isFollowerSuspended: true });
|
||||
|
||||
await userSuspendService.unsuspend(user, moderator);
|
||||
await setTimeout(250);
|
||||
|
||||
// フォロー関係が復元されているかチェック
|
||||
const followings = await followingsRepository.find({
|
||||
where: { followerId: user.id },
|
||||
});
|
||||
|
||||
expect(followings).toHaveLength(2);
|
||||
followings.forEach(following => {
|
||||
expect(following.isFollowerSuspended).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test('should publish internal event for unsuspension', async () => {
|
||||
const user = await createUser({ isSuspended: true });
|
||||
const moderator = await createUser();
|
||||
|
||||
await userSuspendService.unsuspend(user, moderator);
|
||||
await setTimeout(250);
|
||||
|
||||
// 内部イベントが発行されているかチェック(非同期処理のため少し待つ)
|
||||
await setTimeout(100);
|
||||
|
||||
expect(globalEventService.publishInternalEvent).toHaveBeenCalledWith(
|
||||
'userChangeSuspendedState',
|
||||
{ id: user.id, isSuspended: false },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration test: suspend and unsuspend cycle', () => {
|
||||
test('should preserve follow relationships through suspend/unsuspend cycle', async () => {
|
||||
const user = await createUser();
|
||||
const followee1 = await createUser();
|
||||
const followee2 = await createUser();
|
||||
const moderator = await createUser();
|
||||
|
||||
// 初期のフォロー関係を作成
|
||||
await createFollowing(user, followee1);
|
||||
await createFollowing(user, followee2);
|
||||
|
||||
// 初期状態の確認
|
||||
let followings = await followingsRepository.find({
|
||||
where: { followerId: user.id },
|
||||
});
|
||||
expect(followings).toHaveLength(2);
|
||||
followings.forEach(following => {
|
||||
expect(following.isFollowerSuspended).toBe(false);
|
||||
});
|
||||
|
||||
// 凍結
|
||||
await userSuspendService.suspend(user, moderator);
|
||||
await setTimeout(250);
|
||||
|
||||
// 凍結後の状態確認
|
||||
followings = await followingsRepository.find({
|
||||
where: { followerId: user.id },
|
||||
});
|
||||
expect(followings).toHaveLength(2);
|
||||
followings.forEach(following => {
|
||||
expect(following.isFollowerSuspended).toBe(true);
|
||||
});
|
||||
|
||||
// 凍結解除
|
||||
const suspendedUser = await usersRepository.findOneByOrFail({ id: user.id });
|
||||
await userSuspendService.unsuspend(suspendedUser, moderator);
|
||||
await setTimeout(250);
|
||||
|
||||
// 凍結解除後の状態確認
|
||||
followings = await followingsRepository.find({
|
||||
where: { followerId: user.id },
|
||||
});
|
||||
expect(followings).toHaveLength(2);
|
||||
followings.forEach(following => {
|
||||
expect(following.isFollowerSuspended).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ActivityPub delivery', () => {
|
||||
test('should deliver Delete activity on suspend of local user', async () => {
|
||||
const localUser = await createUser({ host: null });
|
||||
const moderator = await createUser();
|
||||
|
||||
userEntityService.isLocalUser.mockReturnValue(true);
|
||||
userEntityService.genLocalUserUri.mockReturnValue(`https://example.com/users/${localUser.id}`);
|
||||
apRendererService.renderDelete.mockReturnValue({ type: 'Delete' } as any);
|
||||
apRendererService.addContext.mockReturnValue({ '@context': '...', type: 'Delete' } as any);
|
||||
|
||||
await userSuspendService.suspend(localUser, moderator);
|
||||
await setTimeout(250);
|
||||
|
||||
// ActivityPub配信が呼ばれているかチェック
|
||||
expect(userEntityService.isLocalUser).toHaveBeenCalledWith(localUser);
|
||||
expect(apRendererService.renderDelete).toHaveBeenCalled();
|
||||
expect(apRendererService.addContext).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should deliver Undo Delete activity on unsuspend of local user', async () => {
|
||||
const localUser = await createUser({ host: null, isSuspended: true });
|
||||
const moderator = await createUser();
|
||||
|
||||
userEntityService.isLocalUser.mockReturnValue(true);
|
||||
userEntityService.genLocalUserUri.mockReturnValue(`https://example.com/users/${localUser.id}`);
|
||||
apRendererService.renderDelete.mockReturnValue({ type: 'Delete' } as any);
|
||||
apRendererService.renderUndo.mockReturnValue({ type: 'Undo' } as any);
|
||||
apRendererService.addContext.mockReturnValue({ '@context': '...', type: 'Undo' } as any);
|
||||
|
||||
await userSuspendService.unsuspend(localUser, moderator);
|
||||
await setTimeout(250);
|
||||
|
||||
// ActivityPub配信が呼ばれているかチェック
|
||||
expect(userEntityService.isLocalUser).toHaveBeenCalledWith(localUser);
|
||||
expect(apRendererService.renderDelete).toHaveBeenCalled();
|
||||
expect(apRendererService.renderUndo).toHaveBeenCalled();
|
||||
expect(apRendererService.addContext).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should not deliver any activity on suspend of remote user', async () => {
|
||||
const remoteUser = await createUser({ host: 'remote.example.com' });
|
||||
const moderator = await createUser();
|
||||
|
||||
userEntityService.isLocalUser.mockReturnValue(false);
|
||||
|
||||
await userSuspendService.suspend(remoteUser, moderator);
|
||||
await setTimeout(250);
|
||||
|
||||
// ActivityPub配信が呼ばれていないことをチェック
|
||||
expect(userEntityService.isLocalUser).toHaveBeenCalledWith(remoteUser);
|
||||
expect(apRendererService.renderDelete).not.toHaveBeenCalled();
|
||||
expect(queueService.deliver).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('remote user suspension', () => {
|
||||
test('should suspend remote user without AP delivery', async () => {
|
||||
const remoteUser = await createUser({ host: genHost() });
|
||||
const moderator = await createUser();
|
||||
|
||||
await userSuspendService.suspend(remoteUser, moderator);
|
||||
await setTimeout(250);
|
||||
|
||||
// ユーザーが凍結されているかチェック
|
||||
const suspendedUser = await usersRepository.findOneBy({ id: remoteUser.id });
|
||||
expect(suspendedUser?.isSuspended).toBe(true);
|
||||
|
||||
// モデレーションログが記録されているかチェック
|
||||
expect(moderationLogService.log).toHaveBeenCalledWith(moderator, 'suspend', {
|
||||
userId: remoteUser.id,
|
||||
userUsername: remoteUser.username,
|
||||
userHost: remoteUser.host,
|
||||
});
|
||||
|
||||
// ActivityPub配信が呼ばれていないことを確認
|
||||
expect(queueService.deliver).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('remote user unsuspension', () => {
|
||||
test('should unsuspend remote user without AP delivery', async () => {
|
||||
const remoteUser = await createUser({ host: genHost(), isSuspended: true });
|
||||
const moderator = await createUser();
|
||||
|
||||
await userSuspendService.unsuspend(remoteUser, moderator);
|
||||
|
||||
await setTimeout(250);
|
||||
|
||||
// ユーザーの凍結が解除されているかチェック
|
||||
const unsuspendedUser = await usersRepository.findOneBy({ id: remoteUser.id });
|
||||
expect(unsuspendedUser?.isSuspended).toBe(false);
|
||||
|
||||
// モデレーションログが記録されているかチェック
|
||||
expect(moderationLogService.log).toHaveBeenCalledWith(moderator, 'unsuspend', {
|
||||
userId: remoteUser.id,
|
||||
userUsername: remoteUser.username,
|
||||
userHost: remoteUser.host,
|
||||
});
|
||||
|
||||
// ActivityPub配信が呼ばれていないことを確認
|
||||
expect(queueService.deliver).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue