This commit is contained in:
tamaina 2025-09-25 20:33:24 +09:00 committed by GitHub
commit baba89330c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 505 additions and 27 deletions

View File

@ -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") `);
}
}

View File

@ -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) {
}
}

View File

@ -549,6 +549,7 @@ export class NoteCreateService implements OnApplicationShutdown {
// TODO: キャッシュ // TODO: キャッシュ
this.followingsRepository.findBy({ this.followingsRepository.findBy({
followeeId: user.id, followeeId: user.id,
isFollowerSuspended: false,
notify: 'normal', notify: 'normal',
}).then(async followings => { }).then(async followings => {
if (note.visibility !== 'specified') { if (note.visibility !== 'specified') {
@ -854,6 +855,7 @@ export class NoteCreateService implements OnApplicationShutdown {
where: { where: {
followeeId: user.id, followeeId: user.id,
followerHost: IsNull(), followerHost: IsNull(),
isFollowerSuspended: false,
isFollowerHibernated: false, isFollowerHibernated: false,
}, },
select: ['followerId', 'withReplies'], select: ['followerId', 'withReplies'],

View File

@ -229,9 +229,7 @@ export class UserFollowingService implements OnModuleInit {
followee: { followee: {
id: MiUser['id']; host: MiUser['host']; uri: MiUser['host']; inbox: MiUser['inbox']; sharedInbox: MiUser['sharedInbox'] id: MiUser['id']; host: MiUser['host']; uri: MiUser['host']; inbox: MiUser['inbox']; sharedInbox: MiUser['sharedInbox']
}, },
follower: { follower: MiUser,
id: MiUser['id']; host: MiUser['host']; uri: MiUser['host']; inbox: MiUser['inbox']; sharedInbox: MiUser['sharedInbox']
},
silent = false, silent = false,
withReplies?: boolean, withReplies?: boolean,
): Promise<void> { ): Promise<void> {
@ -244,6 +242,7 @@ export class UserFollowingService implements OnModuleInit {
followerId: follower.id, followerId: follower.id,
followeeId: followee.id, followeeId: followee.id,
withReplies: withReplies, withReplies: withReplies,
isFollowerSuspended: follower.isSuspended,
// 非正規化 // 非正規化
followerHost: follower.host, followerHost: follower.host,
@ -734,6 +733,7 @@ export class UserFollowingService implements OnModuleInit {
return this.followingsRepository.createQueryBuilder('following') return this.followingsRepository.createQueryBuilder('following')
.select('following.followeeId') .select('following.followeeId')
.where('following.followerId = :followerId', { followerId: userId }) .where('following.followerId = :followerId', { followerId: userId })
.andWhere('following.isFollowerSuspended = false')
.getMany(); .getMany();
} }
@ -743,6 +743,7 @@ export class UserFollowingService implements OnModuleInit {
where: { where: {
followerId, followerId,
followeeId, followeeId,
isFollowerSuspended: false,
}, },
}); });
} }

View File

@ -13,7 +13,6 @@ import { DI } from '@/di-symbols.js';
import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { RelationshipJobData } from '@/queue/types.js';
import { ModerationLogService } from '@/core/ModerationLogService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js';
@Injectable() @Injectable()
@ -49,8 +48,8 @@ export class UserSuspendService {
}); });
(async () => { (async () => {
await this.postSuspend(user).catch(e => {}); await this.postSuspend(user).catch((e: any) => {});
await this.unFollowAll(user).catch(e => {}); await this.suspendFollowings(user).catch((e: any) => {});
})(); })();
} }
@ -67,7 +66,8 @@ export class UserSuspendService {
}); });
(async () => { (async () => {
await this.postUnsuspend(user).catch(e => {}); await this.postUnsuspend(user).catch((e: any) => {});
await this.restoreFollowings(user).catch((e: any) => {});
})(); })();
} }
@ -139,24 +139,27 @@ export class UserSuspendService {
} }
@bindThis @bindThis
private async unFollowAll(follower: MiUser) { private async suspendFollowings(follower: MiUser) {
const followings = await this.followingsRepository.find({ await this.followingsRepository.update(
where: { {
followerId: follower.id, 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,
});
} }
} );
this.queueService.createUnfollowJob(jobs); }
@bindThis
private async restoreFollowings(follower: MiUser) {
// フォロー関係を復元isFollowerSuspended: falseに変更
await this.followingsRepository.update(
{
followerId: follower.id,
},
{
isFollowerSuspended: false,
}
);
} }
} }

View File

@ -119,6 +119,7 @@ class DeliverManager {
where: { where: {
followeeId: this.actor.id, followeeId: this.actor.id,
followerHost: Not(IsNull()), followerHost: Not(IsNull()),
isFollowerSuspended: false,
}, },
select: { select: {
followerSharedInbox: true, followerSharedInbox: true,

View File

@ -9,7 +9,8 @@ import { MiUser } from './User.js';
@Entity('following') @Entity('following')
@Index(['followerId', 'followeeId'], { unique: true }) @Index(['followerId', 'followeeId'], { unique: true })
@Index(['followeeId', 'followerHost', 'isFollowerHibernated']) @Index(['followerId', 'followeeId', 'isFollowerSuspended'])
@Index(['followeeId', 'followerHost', 'isFollowerSuspended', 'isFollowerHibernated'])
export class MiFollowing { export class MiFollowing {
@PrimaryColumn(id()) @PrimaryColumn(id())
public id: string; public id: string;
@ -45,6 +46,11 @@ export class MiFollowing {
}) })
public isFollowerHibernated: boolean; public isFollowerHibernated: boolean;
@Column('boolean', {
default: false,
})
public isFollowerSuspended: boolean;
// タイムラインにその人のリプライまで含めるかどうか // タイムラインにその人のリプライまで含めるかどうか
@Column('boolean', { @Column('boolean', {
default: false, default: false,

View File

@ -50,7 +50,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const query = this.queryService.makePaginationQuery(this.followingsRepository.createQueryBuilder('following'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) 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 const followings = await query
.limit(ps.limit) .limit(ps.limit)

View File

@ -50,7 +50,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const query = this.queryService.makePaginationQuery(this.followingsRepository.createQueryBuilder('following'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) 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 const followings = await query
.limit(ps.limit) .limit(ps.limit)

View File

@ -94,11 +94,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.followingsRepository.count({ this.followingsRepository.count({
where: { where: {
followeeHost: Not(IsNull()), followeeHost: Not(IsNull()),
isFollowerSuspended: false,
}, },
}), }),
this.followingsRepository.count({ this.followingsRepository.count({
where: { where: {
followerHost: Not(IsNull()), followerHost: Not(IsNull()),
isFollowerSuspended: false,
}, },
}), }),
]); ]);

View File

@ -125,6 +125,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
where: { where: {
followeeId: user.id, followeeId: user.id,
followerId: me.id, followerId: me.id,
isFollowerSuspended: false,
}, },
}); });
if (!isFollowing) { 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) 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.followeeId = :userId', { userId: user.id })
.andWhere('following.isFollowerSuspended = false')
.innerJoinAndSelect('following.follower', 'follower'); .innerJoinAndSelect('following.follower', 'follower');
const followings = await query const followings = await query

View File

@ -133,6 +133,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
where: { where: {
followeeId: user.id, followeeId: user.id,
followerId: me.id, followerId: me.id,
isFollowerSuspended: false,
}, },
}); });
if (!isFollowing) { 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) 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.followerId = :userId', { userId: user.id })
.andWhere('following.isFollowerSuspended = false')
.innerJoinAndSelect('following.followee', 'followee'); .innerJoinAndSelect('following.followee', 'followee');
if (ps.birthday) { if (ps.birthday) {

View File

@ -68,7 +68,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const followingQuery = this.followingsRepository.createQueryBuilder('following') const followingQuery = this.followingsRepository.createQueryBuilder('following')
.select('following.followeeId') .select('following.followeeId')
.where('following.followerId = :followerId', { followerId: me.id }); .where('following.followerId = :followerId', { followerId: me.id })
.andWhere('following.isFollowerSuspended = false');
query query
.andWhere(`user.id NOT IN (${ followingQuery.getQuery() })`); .andWhere(`user.id NOT IN (${ followingQuery.getQuery() })`);

View File

@ -0,0 +1,413 @@
/*
* 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';
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,
{
provide: UserEntityService,
useFactory: () => ({
isLocalUser: jest.fn(),
genLocalUserUri: jest.fn(),
}),
},
{
provide: QueueService,
useFactory: () => ({
deliver: jest.fn(),
}),
},
{
provide: GlobalEventService,
useFactory: () => ({
publishInternalEvent: jest.fn(),
}),
},
{
provide: ApRendererService,
useFactory: () => ({
addContext: jest.fn(),
renderDelete: jest.fn(),
renderUndo: jest.fn(),
}),
},
{
provide: ModerationLogService,
useFactory: () => ({
log: jest.fn(),
}),
},
],
}).compile();
app.enableShutdownHooks();
userSuspendService = app.get<UserSuspendService>(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();
});
});
});