summaryrefslogtreecommitdiff
path: root/packages/backend
diff options
context:
space:
mode:
authorおさむのひと <46447427+samunohito@users.noreply.github.com>2024-09-19 17:20:50 +0900
committerGitHub <noreply@github.com>2024-09-19 17:20:50 +0900
commit4ac8aad50a1a1ef2ac2a13a04baca445294397ed (patch)
tree37a81a7ca6a760dc0be88b61f409c4d24354d3ca /packages/backend
parentfix(frontend): viteの一時ファイルがgitの変更に含まれないよ... (diff)
downloadsharkey-4ac8aad50a1a1ef2ac2a13a04baca445294397ed.tar.gz
sharkey-4ac8aad50a1a1ef2ac2a13a04baca445294397ed.tar.bz2
sharkey-4ac8aad50a1a1ef2ac2a13a04baca445294397ed.zip
feat: UserWebhook/SystemWebhookのテスト送信機能を追加 (#14489)
* feat: UserWebhook/SystemWebhookのテスト送信機能を追加 * fix CHANGELOG.md * 一部設定をパラメータから上書き出来るように修正 * remove async * regenerate autogen
Diffstat (limited to 'packages/backend')
-rw-r--r--packages/backend/src/core/CoreModule.ts6
-rw-r--r--packages/backend/src/core/QueueService.ts22
-rw-r--r--packages/backend/src/core/SystemWebhookService.ts13
-rw-r--r--packages/backend/src/core/UserWebhookService.ts29
-rw-r--r--packages/backend/src/core/WebhookTestService.ts434
-rw-r--r--packages/backend/src/models/Webhook.ts1
-rw-r--r--packages/backend/src/server/api/EndpointsModule.ts8
-rw-r--r--packages/backend/src/server/api/endpoints.ts4
-rw-r--r--packages/backend/src/server/api/endpoints/admin/system-webhook/test.ts77
-rw-r--r--packages/backend/src/server/api/endpoints/i/webhooks/create.ts1
-rw-r--r--packages/backend/src/server/api/endpoints/i/webhooks/list.ts1
-rw-r--r--packages/backend/src/server/api/endpoints/i/webhooks/show.ts1
-rw-r--r--packages/backend/src/server/api/endpoints/i/webhooks/test.ts76
-rw-r--r--packages/backend/test/unit/SystemWebhookService.ts11
-rw-r--r--packages/backend/test/unit/UserWebhookService.ts245
-rw-r--r--packages/backend/test/unit/WebhookTestService.ts225
16 files changed, 1137 insertions, 17 deletions
diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts
index c9427bbeb7..674241ac12 100644
--- a/packages/backend/src/core/CoreModule.ts
+++ b/packages/backend/src/core/CoreModule.ts
@@ -13,6 +13,7 @@ import {
import { AbuseReportNotificationService } from '@/core/AbuseReportNotificationService.js';
import { SystemWebhookService } from '@/core/SystemWebhookService.js';
import { UserSearchService } from '@/core/UserSearchService.js';
+import { WebhookTestService } from '@/core/WebhookTestService.js';
import { AccountMoveService } from './AccountMoveService.js';
import { AccountUpdateService } from './AccountUpdateService.js';
import { AiService } from './AiService.js';
@@ -211,6 +212,7 @@ const $UserAuthService: Provider = { provide: 'UserAuthService', useExisting: Us
const $VideoProcessingService: Provider = { provide: 'VideoProcessingService', useExisting: VideoProcessingService };
const $UserWebhookService: Provider = { provide: 'UserWebhookService', useExisting: UserWebhookService };
const $SystemWebhookService: Provider = { provide: 'SystemWebhookService', useExisting: SystemWebhookService };
+const $WebhookTestService: Provider = { provide: 'WebhookTestService', useExisting: WebhookTestService };
const $UtilityService: Provider = { provide: 'UtilityService', useExisting: UtilityService };
const $FileInfoService: Provider = { provide: 'FileInfoService', useExisting: FileInfoService };
const $SearchService: Provider = { provide: 'SearchService', useExisting: SearchService };
@@ -359,6 +361,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
VideoProcessingService,
UserWebhookService,
SystemWebhookService,
+ WebhookTestService,
UtilityService,
FileInfoService,
SearchService,
@@ -503,6 +506,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$VideoProcessingService,
$UserWebhookService,
$SystemWebhookService,
+ $WebhookTestService,
$UtilityService,
$FileInfoService,
$SearchService,
@@ -648,6 +652,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
VideoProcessingService,
UserWebhookService,
SystemWebhookService,
+ WebhookTestService,
UtilityService,
FileInfoService,
SearchService,
@@ -791,6 +796,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$VideoProcessingService,
$UserWebhookService,
$SystemWebhookService,
+ $WebhookTestService,
$UtilityService,
$FileInfoService,
$SearchService,
diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts
index 80827a500b..ddb90a051f 100644
--- a/packages/backend/src/core/QueueService.ts
+++ b/packages/backend/src/core/QueueService.ts
@@ -452,10 +452,15 @@ export class QueueService {
/**
* @see UserWebhookDeliverJobData
- * @see WebhookDeliverProcessorService
+ * @see UserWebhookDeliverProcessorService
*/
@bindThis
- public userWebhookDeliver(webhook: MiWebhook, type: typeof webhookEventTypes[number], content: unknown) {
+ public userWebhookDeliver(
+ webhook: MiWebhook,
+ type: typeof webhookEventTypes[number],
+ content: unknown,
+ opts?: { attempts?: number },
+ ) {
const data: UserWebhookDeliverJobData = {
type,
content,
@@ -468,7 +473,7 @@ export class QueueService {
};
return this.userWebhookDeliverQueue.add(webhook.id, data, {
- attempts: 4,
+ attempts: opts?.attempts ?? 4,
backoff: {
type: 'custom',
},
@@ -479,10 +484,15 @@ export class QueueService {
/**
* @see SystemWebhookDeliverJobData
- * @see WebhookDeliverProcessorService
+ * @see SystemWebhookDeliverProcessorService
*/
@bindThis
- public systemWebhookDeliver(webhook: MiSystemWebhook, type: SystemWebhookEventType, content: unknown) {
+ public systemWebhookDeliver(
+ webhook: MiSystemWebhook,
+ type: SystemWebhookEventType,
+ content: unknown,
+ opts?: { attempts?: number },
+ ) {
const data: SystemWebhookDeliverJobData = {
type,
content,
@@ -494,7 +504,7 @@ export class QueueService {
};
return this.systemWebhookDeliverQueue.add(webhook.id, data, {
- attempts: 4,
+ attempts: opts?.attempts ?? 4,
backoff: {
type: 'custom',
},
diff --git a/packages/backend/src/core/SystemWebhookService.ts b/packages/backend/src/core/SystemWebhookService.ts
index bc6851f788..bb7c6b8c0e 100644
--- a/packages/backend/src/core/SystemWebhookService.ts
+++ b/packages/backend/src/core/SystemWebhookService.ts
@@ -54,7 +54,7 @@ export class SystemWebhookService implements OnApplicationShutdown {
* SystemWebhook の一覧を取得する.
*/
@bindThis
- public async fetchSystemWebhooks(params?: {
+ public fetchSystemWebhooks(params?: {
ids?: MiSystemWebhook['id'][];
isActive?: MiSystemWebhook['isActive'];
on?: MiSystemWebhook['on'];
@@ -165,19 +165,24 @@ export class SystemWebhookService implements OnApplicationShutdown {
/**
* SystemWebhook をWebhook配送キューに追加する
* @see QueueService.systemWebhookDeliver
+ * // TODO: contentの型を厳格化する
*/
@bindThis
- public async enqueueSystemWebhook(webhook: MiSystemWebhook | MiSystemWebhook['id'], type: SystemWebhookEventType, content: unknown) {
+ public async enqueueSystemWebhook<T extends SystemWebhookEventType>(
+ webhook: MiSystemWebhook | MiSystemWebhook['id'],
+ type: T,
+ content: unknown,
+ ) {
const webhookEntity = typeof webhook === 'string'
? (await this.fetchActiveSystemWebhooks()).find(a => a.id === webhook)
: webhook;
if (!webhookEntity || !webhookEntity.isActive) {
- this.logger.info(`Webhook is not active or not found : ${webhook}`);
+ this.logger.info(`SystemWebhook is not active or not found : ${webhook}`);
return;
}
if (!webhookEntity.on.includes(type)) {
- this.logger.info(`Webhook ${webhookEntity.id} is not listening to ${type}`);
+ this.logger.info(`SystemWebhook ${webhookEntity.id} is not listening to ${type}`);
return;
}
diff --git a/packages/backend/src/core/UserWebhookService.ts b/packages/backend/src/core/UserWebhookService.ts
index e96bfeea95..8a40a53688 100644
--- a/packages/backend/src/core/UserWebhookService.ts
+++ b/packages/backend/src/core/UserWebhookService.ts
@@ -5,8 +5,8 @@
import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis';
-import type { WebhooksRepository } from '@/models/_.js';
-import type { MiWebhook } from '@/models/Webhook.js';
+import { type WebhooksRepository } from '@/models/_.js';
+import { MiWebhook } from '@/models/Webhook.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import { GlobalEvents } from '@/core/GlobalEventService.js';
@@ -38,6 +38,31 @@ export class UserWebhookService implements OnApplicationShutdown {
return this.activeWebhooks;
}
+ /**
+ * UserWebhook の一覧を取得する.
+ */
+ @bindThis
+ public fetchWebhooks(params?: {
+ ids?: MiWebhook['id'][];
+ isActive?: MiWebhook['active'];
+ on?: MiWebhook['on'];
+ }): Promise<MiWebhook[]> {
+ const query = this.webhooksRepository.createQueryBuilder('webhook');
+ if (params) {
+ if (params.ids && params.ids.length > 0) {
+ query.andWhere('webhook.id IN (:...ids)', { ids: params.ids });
+ }
+ if (params.isActive !== undefined) {
+ query.andWhere('webhook.active = :isActive', { isActive: params.isActive });
+ }
+ if (params.on && params.on.length > 0) {
+ query.andWhere(':on <@ webhook.on', { on: params.on });
+ }
+ }
+
+ return query.getMany();
+ }
+
@bindThis
private async onMessage(_: string, data: string): Promise<void> {
const obj = JSON.parse(data);
diff --git a/packages/backend/src/core/WebhookTestService.ts b/packages/backend/src/core/WebhookTestService.ts
new file mode 100644
index 0000000000..0b4e107d21
--- /dev/null
+++ b/packages/backend/src/core/WebhookTestService.ts
@@ -0,0 +1,434 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Injectable } from '@nestjs/common';
+import { MiAbuseUserReport, MiNote, MiUser, MiWebhook } from '@/models/_.js';
+import { bindThis } from '@/decorators.js';
+import { MiSystemWebhook, type SystemWebhookEventType } from '@/models/SystemWebhook.js';
+import { SystemWebhookService } from '@/core/SystemWebhookService.js';
+import { Packed } from '@/misc/json-schema.js';
+import { type WebhookEventTypes } from '@/models/Webhook.js';
+import { UserWebhookService } from '@/core/UserWebhookService.js';
+import { QueueService } from '@/core/QueueService.js';
+
+const oneDayMillis = 24 * 60 * 60 * 1000;
+
+function generateAbuseReport(override?: Partial<MiAbuseUserReport>): MiAbuseUserReport {
+ return {
+ id: 'dummy-abuse-report1',
+ targetUserId: 'dummy-target-user',
+ targetUser: null,
+ reporterId: 'dummy-reporter-user',
+ reporter: null,
+ assigneeId: null,
+ assignee: null,
+ resolved: false,
+ forwarded: false,
+ comment: 'This is a dummy report for testing purposes.',
+ targetUserHost: null,
+ reporterHost: null,
+ ...override,
+ };
+}
+
+function generateDummyUser(override?: Partial<MiUser>): MiUser {
+ return {
+ id: 'dummy-user-1',
+ updatedAt: new Date(Date.now() - oneDayMillis * 7),
+ lastFetchedAt: new Date(Date.now() - oneDayMillis * 5),
+ lastActiveDate: new Date(Date.now() - oneDayMillis * 3),
+ hideOnlineStatus: false,
+ username: 'dummy1',
+ usernameLower: 'dummy1',
+ name: 'DummyUser1',
+ followersCount: 10,
+ followingCount: 5,
+ movedToUri: null,
+ movedAt: null,
+ alsoKnownAs: null,
+ notesCount: 30,
+ avatarId: null,
+ avatar: null,
+ bannerId: null,
+ banner: null,
+ avatarUrl: null,
+ bannerUrl: null,
+ avatarBlurhash: null,
+ bannerBlurhash: null,
+ avatarDecorations: [],
+ tags: [],
+ isSuspended: false,
+ isLocked: false,
+ isBot: false,
+ isCat: true,
+ isRoot: false,
+ isExplorable: true,
+ isHibernated: false,
+ isDeleted: false,
+ emojis: [],
+ host: null,
+ inbox: null,
+ sharedInbox: null,
+ featured: null,
+ uri: null,
+ followersUri: null,
+ token: null,
+ ...override,
+ };
+}
+
+function generateDummyNote(override?: Partial<MiNote>): MiNote {
+ return {
+ id: 'dummy-note-1',
+ replyId: null,
+ reply: null,
+ renoteId: null,
+ renote: null,
+ threadId: null,
+ text: 'This is a dummy note for testing purposes.',
+ name: null,
+ cw: null,
+ userId: 'dummy-user-1',
+ user: null,
+ localOnly: true,
+ reactionAcceptance: 'likeOnly',
+ renoteCount: 10,
+ repliesCount: 5,
+ clippedCount: 0,
+ reactions: {},
+ visibility: 'public',
+ uri: null,
+ url: null,
+ fileIds: [],
+ attachedFileTypes: [],
+ visibleUserIds: [],
+ mentions: [],
+ mentionedRemoteUsers: '[]',
+ reactionAndUserPairCache: [],
+ emojis: [],
+ tags: [],
+ hasPoll: false,
+ channelId: null,
+ channel: null,
+ userHost: null,
+ replyUserId: null,
+ replyUserHost: null,
+ renoteUserId: null,
+ renoteUserHost: null,
+ ...override,
+ };
+}
+
+function toPackedNote(note: MiNote, detail = true, override?: Packed<'Note'>): Packed<'Note'> {
+ return {
+ id: note.id,
+ createdAt: new Date().toISOString(),
+ deletedAt: null,
+ text: note.text,
+ cw: note.cw,
+ userId: note.userId,
+ user: toPackedUserLite(note.user ?? generateDummyUser()),
+ replyId: note.replyId,
+ renoteId: note.renoteId,
+ isHidden: false,
+ visibility: note.visibility,
+ mentions: note.mentions,
+ visibleUserIds: note.visibleUserIds,
+ fileIds: note.fileIds,
+ files: [],
+ tags: note.tags,
+ poll: null,
+ emojis: note.emojis,
+ channelId: note.channelId,
+ channel: note.channel,
+ localOnly: note.localOnly,
+ reactionAcceptance: note.reactionAcceptance,
+ reactionEmojis: {},
+ reactions: {},
+ reactionCount: 0,
+ renoteCount: note.renoteCount,
+ repliesCount: note.repliesCount,
+ uri: note.uri ?? undefined,
+ url: note.url ?? undefined,
+ reactionAndUserPairCache: note.reactionAndUserPairCache,
+ ...(detail ? {
+ clippedCount: note.clippedCount,
+ reply: note.reply ? toPackedNote(note.reply, false) : null,
+ renote: note.renote ? toPackedNote(note.renote, true) : null,
+ myReaction: null,
+ } : {}),
+ ...override,
+ };
+}
+
+function toPackedUserLite(user: MiUser, override?: Packed<'UserLite'>): Packed<'UserLite'> {
+ return {
+ id: user.id,
+ name: user.name,
+ username: user.username,
+ host: user.host,
+ avatarUrl: user.avatarUrl,
+ avatarBlurhash: user.avatarBlurhash,
+ avatarDecorations: user.avatarDecorations.map(it => ({
+ id: it.id,
+ angle: it.angle,
+ flipH: it.flipH,
+ url: 'https://example.com/dummy-image001.png',
+ offsetX: it.offsetX,
+ offsetY: it.offsetY,
+ })),
+ isBot: user.isBot,
+ isCat: user.isCat,
+ emojis: user.emojis,
+ onlineStatus: 'active',
+ badgeRoles: [],
+ ...override,
+ };
+}
+
+function toPackedUserDetailedNotMe(user: MiUser, override?: Packed<'UserDetailedNotMe'>): Packed<'UserDetailedNotMe'> {
+ return {
+ ...toPackedUserLite(user),
+ url: null,
+ uri: null,
+ movedTo: null,
+ alsoKnownAs: [],
+ createdAt: new Date().toISOString(),
+ updatedAt: user.updatedAt?.toISOString() ?? null,
+ lastFetchedAt: user.lastFetchedAt?.toISOString() ?? null,
+ bannerUrl: user.bannerUrl,
+ bannerBlurhash: user.bannerBlurhash,
+ isLocked: user.isLocked,
+ isSilenced: false,
+ isSuspended: user.isSuspended,
+ description: null,
+ location: null,
+ birthday: null,
+ lang: null,
+ fields: [],
+ verifiedLinks: [],
+ followersCount: user.followersCount,
+ followingCount: user.followingCount,
+ notesCount: user.notesCount,
+ pinnedNoteIds: [],
+ pinnedNotes: [],
+ pinnedPageId: null,
+ pinnedPage: null,
+ publicReactions: true,
+ followersVisibility: 'public',
+ followingVisibility: 'public',
+ twoFactorEnabled: false,
+ usePasswordLessLogin: false,
+ securityKeys: false,
+ roles: [],
+ memo: null,
+ moderationNote: undefined,
+ isFollowing: false,
+ isFollowed: false,
+ hasPendingFollowRequestFromYou: false,
+ hasPendingFollowRequestToYou: false,
+ isBlocking: false,
+ isBlocked: false,
+ isMuted: false,
+ isRenoteMuted: false,
+ notify: 'none',
+ withReplies: true,
+ ...override,
+ };
+}
+
+const dummyUser1 = generateDummyUser();
+const dummyUser2 = generateDummyUser({
+ id: 'dummy-user-2',
+ updatedAt: new Date(Date.now() - oneDayMillis * 30),
+ lastFetchedAt: new Date(Date.now() - oneDayMillis),
+ lastActiveDate: new Date(Date.now() - oneDayMillis),
+ username: 'dummy2',
+ usernameLower: 'dummy2',
+ name: 'DummyUser2',
+ followersCount: 40,
+ followingCount: 50,
+ notesCount: 900,
+});
+const dummyUser3 = generateDummyUser({
+ id: 'dummy-user-3',
+ updatedAt: new Date(Date.now() - oneDayMillis * 15),
+ lastFetchedAt: new Date(Date.now() - oneDayMillis * 2),
+ lastActiveDate: new Date(Date.now() - oneDayMillis * 2),
+ username: 'dummy3',
+ usernameLower: 'dummy3',
+ name: 'DummyUser3',
+ followersCount: 60,
+ followingCount: 70,
+ notesCount: 15900,
+});
+
+@Injectable()
+export class WebhookTestService {
+ public static NoSuchWebhookError = class extends Error {};
+
+ constructor(
+ private userWebhookService: UserWebhookService,
+ private systemWebhookService: SystemWebhookService,
+ private queueService: QueueService,
+ ) {
+ }
+
+ /**
+ * UserWebhookのテスト送信を行う.
+ * 送信されるペイロードはいずれもダミーの値で、実際にはデータベース上に存在しない.
+ *
+ * また、この関数経由で送信されるWebhookは以下の設定を無視する.
+ * - Webhookそのものの有効・無効設定(active)
+ * - 送信対象イベント(on)に関する設定
+ */
+ @bindThis
+ public async testUserWebhook(
+ params: {
+ webhookId: MiWebhook['id'],
+ type: WebhookEventTypes,
+ override?: Partial<Omit<MiWebhook, 'id'>>,
+ },
+ sender: MiUser | null,
+ ) {
+ const webhooks = await this.userWebhookService.fetchWebhooks({ ids: [params.webhookId] })
+ .then(it => it.filter(it => it.userId === sender?.id));
+ if (webhooks.length === 0) {
+ throw new WebhookTestService.NoSuchWebhookError();
+ }
+
+ const webhook = webhooks[0];
+ const send = (contents: unknown) => {
+ const merged = {
+ ...webhook,
+ ...params.override,
+ };
+
+ // テスト目的なのでUserWebhookServiceの機能を経由せず直接キューに追加する(チェック処理などをスキップする意図).
+ // また、Jobの試行回数も1回だけ.
+ this.queueService.userWebhookDeliver(merged, params.type, contents, { attempts: 1 });
+ };
+
+ const dummyNote1 = generateDummyNote({
+ userId: dummyUser1.id,
+ user: dummyUser1,
+ });
+ const dummyReply1 = generateDummyNote({
+ id: 'dummy-reply-1',
+ replyId: dummyNote1.id,
+ reply: dummyNote1,
+ userId: dummyUser1.id,
+ user: dummyUser1,
+ });
+ const dummyRenote1 = generateDummyNote({
+ id: 'dummy-renote-1',
+ renoteId: dummyNote1.id,
+ renote: dummyNote1,
+ userId: dummyUser2.id,
+ user: dummyUser2,
+ text: null,
+ });
+ const dummyMention1 = generateDummyNote({
+ id: 'dummy-mention-1',
+ userId: dummyUser1.id,
+ user: dummyUser1,
+ text: `@${dummyUser2.username} This is a mention to you.`,
+ mentions: [dummyUser2.id],
+ });
+
+ switch (params.type) {
+ case 'note': {
+ send(toPackedNote(dummyNote1));
+ break;
+ }
+ case 'reply': {
+ send(toPackedNote(dummyReply1));
+ break;
+ }
+ case 'renote': {
+ send(toPackedNote(dummyRenote1));
+ break;
+ }
+ case 'mention': {
+ send(toPackedNote(dummyMention1));
+ break;
+ }
+ case 'follow': {
+ send(toPackedUserDetailedNotMe(dummyUser1));
+ break;
+ }
+ case 'followed': {
+ send(toPackedUserLite(dummyUser2));
+ break;
+ }
+ case 'unfollow': {
+ send(toPackedUserDetailedNotMe(dummyUser3));
+ break;
+ }
+ }
+ }
+
+ /**
+ * SystemWebhookのテスト送信を行う.
+ * 送信されるペイロードはいずれもダミーの値で、実際にはデータベース上に存在しない.
+ *
+ * また、この関数経由で送信されるWebhookは以下の設定を無視する.
+ * - Webhookそのものの有効・無効設定(isActive)
+ * - 送信対象イベント(on)に関する設定
+ */
+ @bindThis
+ public async testSystemWebhook(
+ params: {
+ webhookId: MiSystemWebhook['id'],
+ type: SystemWebhookEventType,
+ override?: Partial<Omit<MiSystemWebhook, 'id'>>,
+ },
+ ) {
+ const webhooks = await this.systemWebhookService.fetchSystemWebhooks({ ids: [params.webhookId] });
+ if (webhooks.length === 0) {
+ throw new WebhookTestService.NoSuchWebhookError();
+ }
+
+ const webhook = webhooks[0];
+ const send = (contents: unknown) => {
+ const merged = {
+ ...webhook,
+ ...params.override,
+ };
+
+ // テスト目的なのでSystemWebhookServiceの機能を経由せず直接キューに追加する(チェック処理などをスキップする意図).
+ // また、Jobの試行回数も1回だけ.
+ this.queueService.systemWebhookDeliver(merged, params.type, contents, { attempts: 1 });
+ };
+
+ switch (params.type) {
+ case 'abuseReport': {
+ send(generateAbuseReport({
+ targetUserId: dummyUser1.id,
+ targetUser: dummyUser1,
+ reporterId: dummyUser2.id,
+ reporter: dummyUser2,
+ }));
+ break;
+ }
+ case 'abuseReportResolved': {
+ send(generateAbuseReport({
+ targetUserId: dummyUser1.id,
+ targetUser: dummyUser1,
+ reporterId: dummyUser2.id,
+ reporter: dummyUser2,
+ assigneeId: dummyUser3.id,
+ assignee: dummyUser3,
+ resolved: true,
+ }));
+ break;
+ }
+ case 'userCreated': {
+ send(toPackedUserLite(dummyUser1));
+ break;
+ }
+ }
+ }
+}
diff --git a/packages/backend/src/models/Webhook.ts b/packages/backend/src/models/Webhook.ts
index db24c03b3d..b4cab4edc8 100644
--- a/packages/backend/src/models/Webhook.ts
+++ b/packages/backend/src/models/Webhook.ts
@@ -8,6 +8,7 @@ import { id } from './util/id.js';
import { MiUser } from './User.js';
export const webhookEventTypes = ['mention', 'unfollow', 'follow', 'followed', 'note', 'reply', 'renote', 'reaction'] as const;
+export type WebhookEventTypes = typeof webhookEventTypes[number];
@Entity('webhook')
export class MiWebhook {
diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts
index 41576bedaa..08a0468ab2 100644
--- a/packages/backend/src/server/api/EndpointsModule.ts
+++ b/packages/backend/src/server/api/EndpointsModule.ts
@@ -92,6 +92,7 @@ import * as ep___admin_systemWebhook_delete from './endpoints/admin/system-webho
import * as ep___admin_systemWebhook_list from './endpoints/admin/system-webhook/list.js';
import * as ep___admin_systemWebhook_show from './endpoints/admin/system-webhook/show.js';
import * as ep___admin_systemWebhook_update from './endpoints/admin/system-webhook/update.js';
+import * as ep___admin_systemWebhook_test from './endpoints/admin/system-webhook/test.js';
import * as ep___announcements from './endpoints/announcements.js';
import * as ep___announcements_show from './endpoints/announcements/show.js';
import * as ep___antennas_create from './endpoints/antennas/create.js';
@@ -258,6 +259,7 @@ import * as ep___i_webhooks_show from './endpoints/i/webhooks/show.js';
import * as ep___i_webhooks_list from './endpoints/i/webhooks/list.js';
import * as ep___i_webhooks_update from './endpoints/i/webhooks/update.js';
import * as ep___i_webhooks_delete from './endpoints/i/webhooks/delete.js';
+import * as ep___i_webhooks_test from './endpoints/i/webhooks/test.js';
import * as ep___invite_create from './endpoints/invite/create.js';
import * as ep___invite_delete from './endpoints/invite/delete.js';
import * as ep___invite_list from './endpoints/invite/list.js';
@@ -475,6 +477,7 @@ const $admin_systemWebhook_delete: Provider = { provide: 'ep:admin/system-webhoo
const $admin_systemWebhook_list: Provider = { provide: 'ep:admin/system-webhook/list', useClass: ep___admin_systemWebhook_list.default };
const $admin_systemWebhook_show: Provider = { provide: 'ep:admin/system-webhook/show', useClass: ep___admin_systemWebhook_show.default };
const $admin_systemWebhook_update: Provider = { provide: 'ep:admin/system-webhook/update', useClass: ep___admin_systemWebhook_update.default };
+const $admin_systemWebhook_test: Provider = { provide: 'ep:admin/system-webhook/test', useClass: ep___admin_systemWebhook_test.default };
const $announcements: Provider = { provide: 'ep:announcements', useClass: ep___announcements.default };
const $announcements_show: Provider = { provide: 'ep:announcements/show', useClass: ep___announcements_show.default };
const $antennas_create: Provider = { provide: 'ep:antennas/create', useClass: ep___antennas_create.default };
@@ -641,6 +644,7 @@ const $i_webhooks_list: Provider = { provide: 'ep:i/webhooks/list', useClass: ep
const $i_webhooks_show: Provider = { provide: 'ep:i/webhooks/show', useClass: ep___i_webhooks_show.default };
const $i_webhooks_update: Provider = { provide: 'ep:i/webhooks/update', useClass: ep___i_webhooks_update.default };
const $i_webhooks_delete: Provider = { provide: 'ep:i/webhooks/delete', useClass: ep___i_webhooks_delete.default };
+const $i_webhooks_test: Provider = { provide: 'ep:i/webhooks/test', useClass: ep___i_webhooks_test.default };
const $invite_create: Provider = { provide: 'ep:invite/create', useClass: ep___invite_create.default };
const $invite_delete: Provider = { provide: 'ep:invite/delete', useClass: ep___invite_delete.default };
const $invite_list: Provider = { provide: 'ep:invite/list', useClass: ep___invite_list.default };
@@ -862,6 +866,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
$admin_systemWebhook_list,
$admin_systemWebhook_show,
$admin_systemWebhook_update,
+ $admin_systemWebhook_test,
$announcements,
$announcements_show,
$antennas_create,
@@ -1028,6 +1033,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
$i_webhooks_show,
$i_webhooks_update,
$i_webhooks_delete,
+ $i_webhooks_test,
$invite_create,
$invite_delete,
$invite_list,
@@ -1243,6 +1249,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
$admin_systemWebhook_list,
$admin_systemWebhook_show,
$admin_systemWebhook_update,
+ $admin_systemWebhook_test,
$announcements,
$announcements_show,
$antennas_create,
@@ -1409,6 +1416,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
$i_webhooks_show,
$i_webhooks_update,
$i_webhooks_delete,
+ $i_webhooks_test,
$invite_create,
$invite_delete,
$invite_list,
diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts
index 3dfb7fdad4..2462781f7b 100644
--- a/packages/backend/src/server/api/endpoints.ts
+++ b/packages/backend/src/server/api/endpoints.ts
@@ -98,6 +98,7 @@ import * as ep___admin_systemWebhook_delete from './endpoints/admin/system-webho
import * as ep___admin_systemWebhook_list from './endpoints/admin/system-webhook/list.js';
import * as ep___admin_systemWebhook_show from './endpoints/admin/system-webhook/show.js';
import * as ep___admin_systemWebhook_update from './endpoints/admin/system-webhook/update.js';
+import * as ep___admin_systemWebhook_test from './endpoints/admin/system-webhook/test.js';
import * as ep___announcements from './endpoints/announcements.js';
import * as ep___announcements_show from './endpoints/announcements/show.js';
import * as ep___antennas_create from './endpoints/antennas/create.js';
@@ -264,6 +265,7 @@ import * as ep___i_webhooks_show from './endpoints/i/webhooks/show.js';
import * as ep___i_webhooks_list from './endpoints/i/webhooks/list.js';
import * as ep___i_webhooks_update from './endpoints/i/webhooks/update.js';
import * as ep___i_webhooks_delete from './endpoints/i/webhooks/delete.js';
+import * as ep___i_webhooks_test from './endpoints/i/webhooks/test.js';
import * as ep___invite_create from './endpoints/invite/create.js';
import * as ep___invite_delete from './endpoints/invite/delete.js';
import * as ep___invite_list from './endpoints/invite/list.js';
@@ -479,6 +481,7 @@ const eps = [
['admin/system-webhook/list', ep___admin_systemWebhook_list],
['admin/system-webhook/show', ep___admin_systemWebhook_show],
['admin/system-webhook/update', ep___admin_systemWebhook_update],
+ ['admin/system-webhook/test', ep___admin_systemWebhook_test],
['announcements', ep___announcements],
['announcements/show', ep___announcements_show],
['antennas/create', ep___antennas_create],
@@ -645,6 +648,7 @@ const eps = [
['i/webhooks/show', ep___i_webhooks_show],
['i/webhooks/update', ep___i_webhooks_update],
['i/webhooks/delete', ep___i_webhooks_delete],
+ ['i/webhooks/test', ep___i_webhooks_test],
['invite/create', ep___invite_create],
['invite/delete', ep___invite_delete],
['invite/list', ep___invite_list],
diff --git a/packages/backend/src/server/api/endpoints/admin/system-webhook/test.ts b/packages/backend/src/server/api/endpoints/admin/system-webhook/test.ts
new file mode 100644
index 0000000000..fb2ddf4b44
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/admin/system-webhook/test.ts
@@ -0,0 +1,77 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Injectable } from '@nestjs/common';
+import ms from 'ms';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { WebhookTestService } from '@/core/WebhookTestService.js';
+import { ApiError } from '@/server/api/error.js';
+import { systemWebhookEventTypes } from '@/models/SystemWebhook.js';
+
+export const meta = {
+ tags: ['webhooks'],
+
+ requireCredential: true,
+ requireModerator: true,
+ secure: true,
+ kind: 'read:admin:system-webhook',
+
+ limit: {
+ duration: ms('15min'),
+ max: 60,
+ },
+
+ errors: {
+ noSuchWebhook: {
+ message: 'No such webhook.',
+ code: 'NO_SUCH_WEBHOOK',
+ id: '0c52149c-e913-18f8-5dc7-74870bfe0cf9',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ webhookId: {
+ type: 'string',
+ format: 'misskey:id',
+ },
+ type: {
+ type: 'string',
+ enum: systemWebhookEventTypes,
+ },
+ override: {
+ type: 'object',
+ properties: {
+ url: { type: 'string', nullable: false },
+ secret: { type: 'string', nullable: false },
+ },
+ },
+ },
+ required: ['webhookId', 'type'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private webhookTestService: WebhookTestService,
+ ) {
+ super(meta, paramDef, async (ps) => {
+ try {
+ await this.webhookTestService.testSystemWebhook({
+ webhookId: ps.webhookId,
+ type: ps.type,
+ override: ps.override,
+ });
+ } catch (e) {
+ if (e instanceof WebhookTestService.NoSuchWebhookError) {
+ throw new ApiError(meta.errors.noSuchWebhook);
+ }
+ throw e;
+ }
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/i/webhooks/create.ts b/packages/backend/src/server/api/endpoints/i/webhooks/create.ts
index 9eb7f5b3a0..6e84603f7a 100644
--- a/packages/backend/src/server/api/endpoints/i/webhooks/create.ts
+++ b/packages/backend/src/server/api/endpoints/i/webhooks/create.ts
@@ -13,6 +13,7 @@ import { DI } from '@/di-symbols.js';
import { RoleService } from '@/core/RoleService.js';
import { ApiError } from '@/server/api/error.js';
+// TODO: UserWebhook schemaの適用
export const meta = {
tags: ['webhooks'],
diff --git a/packages/backend/src/server/api/endpoints/i/webhooks/list.ts b/packages/backend/src/server/api/endpoints/i/webhooks/list.ts
index fe07afb2d0..394c178f2a 100644
--- a/packages/backend/src/server/api/endpoints/i/webhooks/list.ts
+++ b/packages/backend/src/server/api/endpoints/i/webhooks/list.ts
@@ -9,6 +9,7 @@ import { webhookEventTypes } from '@/models/Webhook.js';
import type { WebhooksRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
+// TODO: UserWebhook schemaの適用
export const meta = {
tags: ['webhooks', 'account'],
diff --git a/packages/backend/src/server/api/endpoints/i/webhooks/show.ts b/packages/backend/src/server/api/endpoints/i/webhooks/show.ts
index 5ddb79caf2..4a0c09ff0c 100644
--- a/packages/backend/src/server/api/endpoints/i/webhooks/show.ts
+++ b/packages/backend/src/server/api/endpoints/i/webhooks/show.ts
@@ -10,6 +10,7 @@ import type { WebhooksRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '../../../error.js';
+// TODO: UserWebhook schemaの適用
export const meta = {
tags: ['webhooks'],
diff --git a/packages/backend/src/server/api/endpoints/i/webhooks/test.ts b/packages/backend/src/server/api/endpoints/i/webhooks/test.ts
new file mode 100644
index 0000000000..2bf6df9ce2
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/i/webhooks/test.ts
@@ -0,0 +1,76 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Injectable } from '@nestjs/common';
+import ms from 'ms';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { webhookEventTypes } from '@/models/Webhook.js';
+import { WebhookTestService } from '@/core/WebhookTestService.js';
+import { ApiError } from '@/server/api/error.js';
+
+export const meta = {
+ tags: ['webhooks'],
+
+ requireCredential: true,
+ secure: true,
+ kind: 'read:account',
+
+ limit: {
+ duration: ms('15min'),
+ max: 60,
+ },
+
+ errors: {
+ noSuchWebhook: {
+ message: 'No such webhook.',
+ code: 'NO_SUCH_WEBHOOK',
+ id: '0c52149c-e913-18f8-5dc7-74870bfe0cf9',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ webhookId: {
+ type: 'string',
+ format: 'misskey:id',
+ },
+ type: {
+ type: 'string',
+ enum: webhookEventTypes,
+ },
+ override: {
+ type: 'object',
+ properties: {
+ url: { type: 'string' },
+ secret: { type: 'string' },
+ },
+ },
+ },
+ required: ['webhookId', 'type'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+ constructor(
+ private webhookTestService: WebhookTestService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ try {
+ await this.webhookTestService.testUserWebhook({
+ webhookId: ps.webhookId,
+ type: ps.type,
+ override: ps.override,
+ }, me);
+ } catch (e) {
+ if (e instanceof WebhookTestService.NoSuchWebhookError) {
+ throw new ApiError(meta.errors.noSuchWebhook);
+ }
+ throw e;
+ }
+ });
+ }
+}
diff --git a/packages/backend/test/unit/SystemWebhookService.ts b/packages/backend/test/unit/SystemWebhookService.ts
index 790cd1490e..5401dd74d8 100644
--- a/packages/backend/test/unit/SystemWebhookService.ts
+++ b/packages/backend/test/unit/SystemWebhookService.ts
@@ -1,3 +1,4 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
@@ -6,6 +7,7 @@
import { setTimeout } from 'node:timers/promises';
import { afterEach, beforeEach, describe, expect, jest } from '@jest/globals';
import { Test, TestingModule } from '@nestjs/testing';
+import { randomString } from '../utils.js';
import { MiUser } from '@/models/User.js';
import { MiSystemWebhook, SystemWebhookEventType } from '@/models/SystemWebhook.js';
import { SystemWebhooksRepository, UsersRepository } from '@/models/_.js';
@@ -17,7 +19,6 @@ import { DI } from '@/di-symbols.js';
import { QueueService } from '@/core/QueueService.js';
import { LoggerService } from '@/core/LoggerService.js';
import { SystemWebhookService } from '@/core/SystemWebhookService.js';
-import { randomString } from '../utils.js';
describe('SystemWebhookService', () => {
let app: TestingModule;
@@ -313,7 +314,7 @@ describe('SystemWebhookService', () => {
isActive: true,
on: ['abuseReport'],
});
- await service.enqueueSystemWebhook(webhook.id, 'abuseReport', { foo: 'bar' });
+ await service.enqueueSystemWebhook(webhook.id, 'abuseReport', { foo: 'bar' } as any);
expect(queueService.systemWebhookDeliver).toHaveBeenCalled();
});
@@ -323,7 +324,7 @@ describe('SystemWebhookService', () => {
isActive: false,
on: ['abuseReport'],
});
- await service.enqueueSystemWebhook(webhook.id, 'abuseReport', { foo: 'bar' });
+ await service.enqueueSystemWebhook(webhook.id, 'abuseReport', { foo: 'bar' } as any);
expect(queueService.systemWebhookDeliver).not.toHaveBeenCalled();
});
@@ -337,8 +338,8 @@ describe('SystemWebhookService', () => {
isActive: true,
on: ['abuseReportResolved'],
});
- await service.enqueueSystemWebhook(webhook1.id, 'abuseReport', { foo: 'bar' });
- await service.enqueueSystemWebhook(webhook2.id, 'abuseReport', { foo: 'bar' });
+ await service.enqueueSystemWebhook(webhook1.id, 'abuseReport', { foo: 'bar' } as any);
+ await service.enqueueSystemWebhook(webhook2.id, 'abuseReport', { foo: 'bar' } as any);
expect(queueService.systemWebhookDeliver).not.toHaveBeenCalled();
});
diff --git a/packages/backend/test/unit/UserWebhookService.ts b/packages/backend/test/unit/UserWebhookService.ts
new file mode 100644
index 0000000000..0e88835a02
--- /dev/null
+++ b/packages/backend/test/unit/UserWebhookService.ts
@@ -0,0 +1,245 @@
+
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { afterEach, beforeEach, describe, expect, jest } from '@jest/globals';
+import { Test, TestingModule } from '@nestjs/testing';
+import { randomString } from '../utils.js';
+import { MiUser } from '@/models/User.js';
+import { MiWebhook, UsersRepository, WebhooksRepository } from '@/models/_.js';
+import { IdService } from '@/core/IdService.js';
+import { GlobalModule } from '@/GlobalModule.js';
+import { GlobalEventService } from '@/core/GlobalEventService.js';
+import { DI } from '@/di-symbols.js';
+import { QueueService } from '@/core/QueueService.js';
+import { LoggerService } from '@/core/LoggerService.js';
+import { UserWebhookService } from '@/core/UserWebhookService.js';
+
+describe('UserWebhookService', () => {
+ let app: TestingModule;
+ let service: UserWebhookService;
+
+ // --------------------------------------------------------------------------------------
+
+ let usersRepository: UsersRepository;
+ let userWebhooksRepository: WebhooksRepository;
+ let idService: IdService;
+ let queueService: jest.Mocked<QueueService>;
+
+ // --------------------------------------------------------------------------------------
+
+ let root: MiUser;
+
+ // --------------------------------------------------------------------------------------
+
+ async function createUser(data: Partial<MiUser> = {}) {
+ return await usersRepository
+ .insert({
+ id: idService.gen(),
+ ...data,
+ })
+ .then(x => usersRepository.findOneByOrFail(x.identifiers[0]));
+ }
+
+ async function createWebhook(data: Partial<MiWebhook> = {}) {
+ return userWebhooksRepository
+ .insert({
+ id: idService.gen(),
+ name: randomString(),
+ on: ['mention'],
+ url: 'https://example.com',
+ secret: randomString(),
+ userId: root.id,
+ ...data,
+ })
+ .then(x => userWebhooksRepository.findOneByOrFail(x.identifiers[0]));
+ }
+
+ // --------------------------------------------------------------------------------------
+
+ async function beforeAllImpl() {
+ app = await Test
+ .createTestingModule({
+ imports: [
+ GlobalModule,
+ ],
+ providers: [
+ UserWebhookService,
+ IdService,
+ LoggerService,
+ GlobalEventService,
+ {
+ provide: QueueService, useFactory: () => ({ systemWebhookDeliver: jest.fn() }),
+ },
+ ],
+ })
+ .compile();
+
+ usersRepository = app.get(DI.usersRepository);
+ userWebhooksRepository = app.get(DI.webhooksRepository);
+
+ service = app.get(UserWebhookService);
+ idService = app.get(IdService);
+ queueService = app.get(QueueService) as jest.Mocked<QueueService>;
+
+ app.enableShutdownHooks();
+ }
+
+ async function afterAllImpl() {
+ await app.close();
+ }
+
+ async function beforeEachImpl() {
+ root = await createUser({ isRoot: true, username: 'root', usernameLower: 'root' });
+ }
+
+ async function afterEachImpl() {
+ await usersRepository.delete({});
+ await userWebhooksRepository.delete({});
+ }
+
+ // --------------------------------------------------------------------------------------
+
+ describe('アプリを毎回作り直す必要のないグループ', () => {
+ beforeAll(beforeAllImpl);
+ afterAll(afterAllImpl);
+ beforeEach(beforeEachImpl);
+ afterEach(afterEachImpl);
+
+ describe('fetchSystemWebhooks', () => {
+ test('フィルタなし', async () => {
+ const webhook1 = await createWebhook({
+ active: true,
+ on: ['mention'],
+ });
+ const webhook2 = await createWebhook({
+ active: false,
+ on: ['mention'],
+ });
+ const webhook3 = await createWebhook({
+ active: true,
+ on: ['reply'],
+ });
+ const webhook4 = await createWebhook({
+ active: false,
+ on: [],
+ });
+
+ const fetchedWebhooks = await service.fetchWebhooks();
+ expect(fetchedWebhooks).toEqual([webhook1, webhook2, webhook3, webhook4]);
+ });
+
+ test('activeのみ', async () => {
+ const webhook1 = await createWebhook({
+ active: true,
+ on: ['mention'],
+ });
+ const webhook2 = await createWebhook({
+ active: false,
+ on: ['mention'],
+ });
+ const webhook3 = await createWebhook({
+ active: true,
+ on: ['reply'],
+ });
+ const webhook4 = await createWebhook({
+ active: false,
+ on: [],
+ });
+
+ const fetchedWebhooks = await service.fetchWebhooks({ isActive: true });
+ expect(fetchedWebhooks).toEqual([webhook1, webhook3]);
+ });
+
+ test('特定のイベントのみ', async () => {
+ const webhook1 = await createWebhook({
+ active: true,
+ on: ['mention'],
+ });
+ const webhook2 = await createWebhook({
+ active: false,
+ on: ['mention'],
+ });
+ const webhook3 = await createWebhook({
+ active: true,
+ on: ['reply'],
+ });
+ const webhook4 = await createWebhook({
+ active: false,
+ on: [],
+ });
+
+ const fetchedWebhooks = await service.fetchWebhooks({ on: ['mention'] });
+ expect(fetchedWebhooks).toEqual([webhook1, webhook2]);
+ });
+
+ test('activeな特定のイベントのみ', async () => {
+ const webhook1 = await createWebhook({
+ active: true,
+ on: ['mention'],
+ });
+ const webhook2 = await createWebhook({
+ active: false,
+ on: ['mention'],
+ });
+ const webhook3 = await createWebhook({
+ active: true,
+ on: ['reply'],
+ });
+ const webhook4 = await createWebhook({
+ active: false,
+ on: [],
+ });
+
+ const fetchedWebhooks = await service.fetchWebhooks({ on: ['mention'], isActive: true });
+ expect(fetchedWebhooks).toEqual([webhook1]);
+ });
+
+ test('ID指定', async () => {
+ const webhook1 = await createWebhook({
+ active: true,
+ on: ['mention'],
+ });
+ const webhook2 = await createWebhook({
+ active: false,
+ on: ['mention'],
+ });
+ const webhook3 = await createWebhook({
+ active: true,
+ on: ['reply'],
+ });
+ const webhook4 = await createWebhook({
+ active: false,
+ on: [],
+ });
+
+ const fetchedWebhooks = await service.fetchWebhooks({ ids: [webhook1.id, webhook4.id] });
+ expect(fetchedWebhooks).toEqual([webhook1, webhook4]);
+ });
+
+ test('ID指定(他条件とANDになるか見たい)', async () => {
+ const webhook1 = await createWebhook({
+ active: true,
+ on: ['mention'],
+ });
+ const webhook2 = await createWebhook({
+ active: false,
+ on: ['mention'],
+ });
+ const webhook3 = await createWebhook({
+ active: true,
+ on: ['reply'],
+ });
+ const webhook4 = await createWebhook({
+ active: false,
+ on: [],
+ });
+
+ const fetchedWebhooks = await service.fetchWebhooks({ ids: [webhook1.id, webhook4.id], isActive: false });
+ expect(fetchedWebhooks).toEqual([webhook4]);
+ });
+ });
+ });
+});
diff --git a/packages/backend/test/unit/WebhookTestService.ts b/packages/backend/test/unit/WebhookTestService.ts
new file mode 100644
index 0000000000..5e63b86f8f
--- /dev/null
+++ b/packages/backend/test/unit/WebhookTestService.ts
@@ -0,0 +1,225 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Test, TestingModule } from '@nestjs/testing';
+import { beforeAll, describe, jest } from '@jest/globals';
+import { WebhookTestService } from '@/core/WebhookTestService.js';
+import { UserWebhookService } from '@/core/UserWebhookService.js';
+import { SystemWebhookService } from '@/core/SystemWebhookService.js';
+import { GlobalModule } from '@/GlobalModule.js';
+import { MiSystemWebhook, MiUser, MiWebhook, UserProfilesRepository, UsersRepository } from '@/models/_.js';
+import { IdService } from '@/core/IdService.js';
+import { DI } from '@/di-symbols.js';
+import { QueueService } from '@/core/QueueService.js';
+
+describe('WebhookTestService', () => {
+ let app: TestingModule;
+ let service: WebhookTestService;
+
+ // --------------------------------------------------------------------------------------
+
+ let usersRepository: UsersRepository;
+ let userProfilesRepository: UserProfilesRepository;
+ let queueService: jest.Mocked<QueueService>;
+ let userWebhookService: jest.Mocked<UserWebhookService>;
+ let systemWebhookService: jest.Mocked<SystemWebhookService>;
+ let idService: IdService;
+
+ let root: MiUser;
+ let alice: MiUser;
+
+ async function createUser(data: Partial<MiUser> = {}) {
+ const user = await usersRepository
+ .insert({
+ id: idService.gen(),
+ ...data,
+ })
+ .then(x => usersRepository.findOneByOrFail(x.identifiers[0]));
+
+ await userProfilesRepository.insert({
+ userId: user.id,
+ });
+
+ return user;
+ }
+
+ // --------------------------------------------------------------------------------------
+
+ beforeAll(async () => {
+ app = await Test.createTestingModule({
+ imports: [
+ GlobalModule,
+ ],
+ providers: [
+ WebhookTestService,
+ IdService,
+ {
+ provide: QueueService, useFactory: () => ({
+ systemWebhookDeliver: jest.fn(),
+ userWebhookDeliver: jest.fn(),
+ }),
+ },
+ {
+ provide: UserWebhookService, useFactory: () => ({
+ fetchWebhooks: jest.fn(),
+ }),
+ },
+ {
+ provide: SystemWebhookService, useFactory: () => ({
+ fetchSystemWebhooks: jest.fn(),
+ }),
+ },
+ ],
+ }).compile();
+
+ usersRepository = app.get(DI.usersRepository);
+ userProfilesRepository = app.get(DI.userProfilesRepository);
+
+ service = app.get(WebhookTestService);
+ idService = app.get(IdService);
+ queueService = app.get(QueueService) as jest.Mocked<QueueService>;
+ userWebhookService = app.get(UserWebhookService) as jest.Mocked<UserWebhookService>;
+ systemWebhookService = app.get(SystemWebhookService) as jest.Mocked<SystemWebhookService>;
+
+ app.enableShutdownHooks();
+ });
+
+ beforeEach(async () => {
+ root = await createUser({ username: 'root', usernameLower: 'root', isRoot: true });
+ alice = await createUser({ username: 'alice', usernameLower: 'alice', isRoot: false });
+
+ userWebhookService.fetchWebhooks.mockReturnValue(Promise.resolve([
+ { id: 'dummy-webhook', active: true, userId: alice.id } as MiWebhook,
+ ]));
+ systemWebhookService.fetchSystemWebhooks.mockReturnValue(Promise.resolve([
+ { id: 'dummy-webhook', isActive: true } as MiSystemWebhook,
+ ]));
+ });
+
+ afterEach(async () => {
+ queueService.systemWebhookDeliver.mockClear();
+ queueService.userWebhookDeliver.mockClear();
+ userWebhookService.fetchWebhooks.mockClear();
+ systemWebhookService.fetchSystemWebhooks.mockClear();
+
+ await usersRepository.delete({});
+ await userProfilesRepository.delete({});
+ });
+
+ afterAll(async () => {
+ await app.close();
+ });
+
+ // --------------------------------------------------------------------------------------
+
+ describe('testUserWebhook', () => {
+ test('note', async () => {
+ await service.testUserWebhook({ webhookId: 'dummy-webhook', type: 'note' }, alice);
+
+ const calls = queueService.userWebhookDeliver.mock.calls[0];
+ expect((calls[0] as any).id).toBe('dummy-webhook');
+ expect(calls[1]).toBe('note');
+ expect((calls[2] as any).id).toBe('dummy-note-1');
+ });
+
+ test('reply', async () => {
+ await service.testUserWebhook({ webhookId: 'dummy-webhook', type: 'reply' }, alice);
+
+ const calls = queueService.userWebhookDeliver.mock.calls[0];
+ expect((calls[0] as any).id).toBe('dummy-webhook');
+ expect(calls[1]).toBe('reply');
+ expect((calls[2] as any).id).toBe('dummy-reply-1');
+ });
+
+ test('renote', async () => {
+ await service.testUserWebhook({ webhookId: 'dummy-webhook', type: 'renote' }, alice);
+
+ const calls = queueService.userWebhookDeliver.mock.calls[0];
+ expect((calls[0] as any).id).toBe('dummy-webhook');
+ expect(calls[1]).toBe('renote');
+ expect((calls[2] as any).id).toBe('dummy-renote-1');
+ });
+
+ test('mention', async () => {
+ await service.testUserWebhook({ webhookId: 'dummy-webhook', type: 'mention' }, alice);
+
+ const calls = queueService.userWebhookDeliver.mock.calls[0];
+ expect((calls[0] as any).id).toBe('dummy-webhook');
+ expect(calls[1]).toBe('mention');
+ expect((calls[2] as any).id).toBe('dummy-mention-1');
+ });
+
+ test('follow', async () => {
+ await service.testUserWebhook({ webhookId: 'dummy-webhook', type: 'follow' }, alice);
+
+ const calls = queueService.userWebhookDeliver.mock.calls[0];
+ expect((calls[0] as any).id).toBe('dummy-webhook');
+ expect(calls[1]).toBe('follow');
+ expect((calls[2] as any).id).toBe('dummy-user-1');
+ });
+
+ test('followed', async () => {
+ await service.testUserWebhook({ webhookId: 'dummy-webhook', type: 'followed' }, alice);
+
+ const calls = queueService.userWebhookDeliver.mock.calls[0];
+ expect((calls[0] as any).id).toBe('dummy-webhook');
+ expect(calls[1]).toBe('followed');
+ expect((calls[2] as any).id).toBe('dummy-user-2');
+ });
+
+ test('unfollow', async () => {
+ await service.testUserWebhook({ webhookId: 'dummy-webhook', type: 'unfollow' }, alice);
+
+ const calls = queueService.userWebhookDeliver.mock.calls[0];
+ expect((calls[0] as any).id).toBe('dummy-webhook');
+ expect(calls[1]).toBe('unfollow');
+ expect((calls[2] as any).id).toBe('dummy-user-3');
+ });
+
+ describe('NoSuchWebhookError', () => {
+ test('user not match', async () => {
+ userWebhookService.fetchWebhooks.mockClear();
+ userWebhookService.fetchWebhooks.mockReturnValue(Promise.resolve([
+ { id: 'dummy-webhook', active: true } as MiWebhook,
+ ]));
+
+ await expect(service.testUserWebhook({ webhookId: 'dummy-webhook', type: 'note' }, root))
+ .rejects.toThrow(WebhookTestService.NoSuchWebhookError);
+ });
+ });
+ });
+
+ describe('testSystemWebhook', () => {
+ test('abuseReport', async () => {
+ await service.testSystemWebhook({ webhookId: 'dummy-webhook', type: 'abuseReport' });
+
+ const calls = queueService.systemWebhookDeliver.mock.calls[0];
+ expect((calls[0] as any).id).toBe('dummy-webhook');
+ expect(calls[1]).toBe('abuseReport');
+ expect((calls[2] as any).id).toBe('dummy-abuse-report1');
+ expect((calls[2] as any).resolved).toBe(false);
+ });
+
+ test('abuseReportResolved', async () => {
+ await service.testSystemWebhook({ webhookId: 'dummy-webhook', type: 'abuseReportResolved' });
+
+ const calls = queueService.systemWebhookDeliver.mock.calls[0];
+ expect((calls[0] as any).id).toBe('dummy-webhook');
+ expect(calls[1]).toBe('abuseReportResolved');
+ expect((calls[2] as any).id).toBe('dummy-abuse-report1');
+ expect((calls[2] as any).resolved).toBe(true);
+ });
+
+ test('userCreated', async () => {
+ await service.testSystemWebhook({ webhookId: 'dummy-webhook', type: 'userCreated' });
+
+ const calls = queueService.systemWebhookDeliver.mock.calls[0];
+ expect((calls[0] as any).id).toBe('dummy-webhook');
+ expect(calls[1]).toBe('userCreated');
+ expect((calls[2] as any).id).toBe('dummy-user-1');
+ });
+ });
+});