fix(backend): Webhook Test一致性+リアクションhook実装

Signed-off-by: eternal-flame-AD <yume@yumechi.jp>
This commit is contained in:
ゆめ 2024-10-28 18:37:44 -05:00
parent e1ebf46071
commit 91aee4392d
No known key found for this signature in database
6 changed files with 171 additions and 17 deletions

View file

@ -35,6 +35,7 @@ import type {
} from './QueueModule.js'; } from './QueueModule.js';
import type httpSignature from '@peertube/http-signature'; import type httpSignature from '@peertube/http-signature';
import type * as Bull from 'bullmq'; import type * as Bull from 'bullmq';
import type { Packed } from '@/misc/json-schema.js';
@Injectable() @Injectable()
export class QueueService { export class QueueService {
@ -471,7 +472,7 @@ export class QueueService {
public userWebhookDeliver( public userWebhookDeliver(
webhook: MiWebhook, webhook: MiWebhook,
type: typeof webhookEventTypes[number], type: typeof webhookEventTypes[number],
content: unknown, content: Packed<'UserWebhookBody'>,
opts?: { attempts?: number }, opts?: { attempts?: number },
) { ) {
const data: UserWebhookDeliverJobData = { const data: UserWebhookDeliverJobData = {

View file

@ -30,6 +30,9 @@ import { trackPromise } from '@/misc/promise-tracker.js';
import { isQuote, isRenote } from '@/misc/is-renote.js'; import { isQuote, isRenote } from '@/misc/is-renote.js';
import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js'; import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js';
import { PER_NOTE_REACTION_USER_PAIR_CACHE_MAX } from '@/const.js'; import { PER_NOTE_REACTION_USER_PAIR_CACHE_MAX } from '@/const.js';
import { UserWebhookService } from './UserWebhookService.js';
import { QueueService } from './QueueService.js';
import { Packed } from '@/misc/json-schema.js';
const FALLBACK = '\u2764'; const FALLBACK = '\u2764';
@ -94,6 +97,8 @@ export class ReactionService {
private reactionsBufferingService: ReactionsBufferingService, private reactionsBufferingService: ReactionsBufferingService,
private idService: IdService, private idService: IdService,
private featuredService: FeaturedService, private featuredService: FeaturedService,
private queueService: QueueService,
private webhookService: UserWebhookService,
private globalEventService: GlobalEventService, private globalEventService: GlobalEventService,
private apRendererService: ApRendererService, private apRendererService: ApRendererService,
private apDeliverManagerService: ApDeliverManagerService, private apDeliverManagerService: ApDeliverManagerService,
@ -254,12 +259,34 @@ export class ReactionService {
userId: user.id, userId: user.id,
}); });
// リアクションされたユーザーがローカルユーザーなら通知を作成 // リアクションされたユーザーがローカルユーザーなら通知を作成してWebhookを送信
if (note.userHost === null) { if (note.userHost === null) {
this.notificationService.createNotification(note.userId, 'reaction', { this.notificationService.createNotification(note.userId, 'reaction', {
noteId: note.id, noteId: note.id,
reaction: reaction, reaction: reaction,
}, user.id); }, user.id);
this.webhookService.getActiveWebhooks().then(async webhooks => {
webhooks = webhooks.filter(x => x.userId === note.userId && x.on.includes('reaction'));
if (webhooks.length === 0) return;
const noteObj = await this.noteEntityService.pack(note, null, { skipHide: true, withReactionAndUserPairCache: true });
const userObj = await this.userEntityService.pack(user.id, null, { schema: 'UserLite' });
const payload: Packed<'UserWebhookReactionBody'> = {
note: noteObj,
reaction: {
id: record.id,
user: userObj,
reaction: reaction,
},
};
for (const webhook of webhooks) {
this.queueService.userWebhookDeliver(webhook, 'reaction', payload);
}
})
} }
//#region 配信 //#region 配信

View file

@ -259,6 +259,20 @@ function toPackedUserDetailedNotMe(user: MiUser, override?: Packed<'UserDetailed
}; };
} }
function generateDummyReactionPayload(note_override?: Partial<MiNote>): Packed<'UserWebhookReactionBody'> {
const dummyNote = generateDummyNote(note_override);
const dummyReaction = {
id: 'dummy-reaction-1',
user: toPackedUserLite(generateDummyUser()),
reaction: 'test_reaction',
};
return {
note: toPackedNote(dummyNote),
reaction: dummyReaction,
};
}
const dummyUser1 = generateDummyUser(); const dummyUser1 = generateDummyUser();
const dummyUser2 = generateDummyUser({ const dummyUser2 = generateDummyUser({
id: 'dummy-user-2', id: 'dummy-user-2',
@ -285,6 +299,10 @@ const dummyUser3 = generateDummyUser({
notesCount: 15900, notesCount: 15900,
}); });
function wrapBodyEnum<T extends string, U>(tag: T, body: U): { [K in T]: U } {
return { [tag]: body } as { [K in T]: U };
}
@Injectable() @Injectable()
export class WebhookTestService { export class WebhookTestService {
public static NoSuchWebhookError = class extends Error { public static NoSuchWebhookError = class extends Error {
@ -321,7 +339,11 @@ export class WebhookTestService {
} }
const webhook = webhooks[0]; const webhook = webhooks[0];
const send = (contents: unknown) => { const send = (contents:
Packed<'UserWebhookNoteBody'> |
Packed<'UserWebhookUserBody'> |
Packed<'UserWebhookReactionBody'>,
) => {
const merged = { const merged = {
...webhook, ...webhook,
...params.override, ...params.override,
@ -361,33 +383,42 @@ export class WebhookTestService {
switch (params.type) { switch (params.type) {
case 'note': { case 'note': {
send(toPackedNote(dummyNote1)); send(wrapBodyEnum('note', toPackedNote(dummyNote1)));
break; break;
} }
case 'reply': { case 'reply': {
send(toPackedNote(dummyReply1)); send(wrapBodyEnum('note', toPackedNote(dummyReply1)));
break; break;
} }
case 'renote': { case 'renote': {
send(toPackedNote(dummyRenote1)); send(wrapBodyEnum('note', toPackedNote(dummyRenote1)));
break; break;
} }
case 'mention': { case 'mention': {
send(toPackedNote(dummyMention1)); send(wrapBodyEnum('note', toPackedNote(dummyMention1)));
break; break;
} }
case 'follow': { case 'follow': {
send(toPackedUserDetailedNotMe(dummyUser1)); send(wrapBodyEnum('user', toPackedUserDetailedNotMe(dummyUser2)));
break; break;
} }
case 'followed': { case 'followed': {
send(toPackedUserLite(dummyUser2)); send(wrapBodyEnum('user', toPackedUserDetailedNotMe(dummyUser2)));
break; break;
} }
case 'unfollow': { case 'unfollow': {
send(toPackedUserDetailedNotMe(dummyUser3)); send(wrapBodyEnum('user', toPackedUserDetailedNotMe(dummyUser2)));
break; break;
} }
case 'reaction': {
send(generateDummyReactionPayload());
break;
}
default: {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _exhaustiveAssertion: never = params.type;
return;
}
} }
} }

View file

@ -59,6 +59,7 @@ import {
} from '@/models/json-schema/meta.js'; } from '@/models/json-schema/meta.js';
import { packedSystemWebhookSchema } from '@/models/json-schema/system-webhook.js'; import { packedSystemWebhookSchema } from '@/models/json-schema/system-webhook.js';
import { packedAbuseReportNotificationRecipientSchema } from '@/models/json-schema/abuse-report-notification-recipient.js'; import { packedAbuseReportNotificationRecipientSchema } from '@/models/json-schema/abuse-report-notification-recipient.js';
import { packedUserWebhookBodySchema, packedUserWebhookNoteBodySchema, packedUserWebhookReactionBodySchema, packedUserWebhookUserBodySchema } from '@/models/json-schema/user-webhook.js';
export const refs = { export const refs = {
UserLite: packedUserLiteSchema, UserLite: packedUserLiteSchema,
@ -68,6 +69,10 @@ export const refs = {
MeDetailed: packedMeDetailedSchema, MeDetailed: packedMeDetailedSchema,
UserDetailed: packedUserDetailedSchema, UserDetailed: packedUserDetailedSchema,
User: packedUserSchema, User: packedUserSchema,
UserWebhookBody: packedUserWebhookBodySchema,
UserWebhookNoteBody: packedUserWebhookNoteBodySchema,
UserWebhookUserBody: packedUserWebhookUserBodySchema,
UserWebhookReactionBody: packedUserWebhookReactionBodySchema,
UserList: packedUserListSchema, UserList: packedUserListSchema,
Ad: packedAdSchema, Ad: packedAdSchema,

View file

@ -0,0 +1,80 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export const packedUserWebhookNoteBodySchema = {
type: 'object',
properties: {
note: {
type: 'object',
ref: 'Note',
optional: false,
nullable: false,
},
},
nullable: false,
optional: false,
} as const;
export const packedUserWebhookUserBodySchema = {
type: 'object',
properties: {
user: {
type: 'object',
ref: 'UserDetailedNotMe',
optional: false,
nullable: false,
},
},
nullable: false,
optional: false,
} as const;
export const packedUserWebhookReactionBodySchema = {
type: 'object',
properties: {
note: {
type: 'object',
ref: 'Note',
optional: false,
nullable: false,
},
reaction: {
type: 'object',
properties: {
id: {
type: 'string',
optional: false,
nullable: false,
},
user: {
type: 'object',
ref: 'UserLite',
optional: false,
nullable: false,
},
reaction: {
type: 'string',
optional: false,
nullable: false,
},
},
optional: false,
nullable: false,
},
},
nullable: false,
optional: false,
} as const;
export const packedUserWebhookBodySchema = {
type: 'object',
oneOf: [
packedUserWebhookNoteBodySchema,
packedUserWebhookUserBodySchema,
packedUserWebhookReactionBodySchema,
],
nullable: false,
optional: false,
} as const;

View file

@ -14,6 +14,7 @@ import { MiSystemWebhook, MiUser, MiWebhook, UserProfilesRepository, UsersReposi
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { QueueService } from '@/core/QueueService.js'; import { QueueService } from '@/core/QueueService.js';
import { Packed } from '@/misc/json-schema.js';
describe('WebhookTestService', () => { describe('WebhookTestService', () => {
let app: TestingModule; let app: TestingModule;
@ -122,7 +123,7 @@ describe('WebhookTestService', () => {
const calls = queueService.userWebhookDeliver.mock.calls[0]; const calls = queueService.userWebhookDeliver.mock.calls[0];
expect((calls[0] as any).id).toBe('dummy-webhook'); expect((calls[0] as any).id).toBe('dummy-webhook');
expect(calls[1]).toBe('note'); expect(calls[1]).toBe('note');
expect((calls[2] as any).id).toBe('dummy-note-1'); expect((calls[2] as Packed<'UserWebhookNoteBody'>).note.id).toBe('dummy-note-1');
}); });
test('reply', async () => { test('reply', async () => {
@ -131,7 +132,7 @@ describe('WebhookTestService', () => {
const calls = queueService.userWebhookDeliver.mock.calls[0]; const calls = queueService.userWebhookDeliver.mock.calls[0];
expect((calls[0] as any).id).toBe('dummy-webhook'); expect((calls[0] as any).id).toBe('dummy-webhook');
expect(calls[1]).toBe('reply'); expect(calls[1]).toBe('reply');
expect((calls[2] as any).id).toBe('dummy-reply-1'); expect((calls[2] as Packed<'UserWebhookNoteBody'>).note.id).toBe('dummy-reply-1');
}); });
test('renote', async () => { test('renote', async () => {
@ -140,7 +141,7 @@ describe('WebhookTestService', () => {
const calls = queueService.userWebhookDeliver.mock.calls[0]; const calls = queueService.userWebhookDeliver.mock.calls[0];
expect((calls[0] as any).id).toBe('dummy-webhook'); expect((calls[0] as any).id).toBe('dummy-webhook');
expect(calls[1]).toBe('renote'); expect(calls[1]).toBe('renote');
expect((calls[2] as any).id).toBe('dummy-renote-1'); expect((calls[2] as Packed<'UserWebhookNoteBody'>).note.id).toBe('dummy-renote-1');
}); });
test('mention', async () => { test('mention', async () => {
@ -149,7 +150,7 @@ describe('WebhookTestService', () => {
const calls = queueService.userWebhookDeliver.mock.calls[0]; const calls = queueService.userWebhookDeliver.mock.calls[0];
expect((calls[0] as any).id).toBe('dummy-webhook'); expect((calls[0] as any).id).toBe('dummy-webhook');
expect(calls[1]).toBe('mention'); expect(calls[1]).toBe('mention');
expect((calls[2] as any).id).toBe('dummy-mention-1'); expect((calls[2] as Packed<'UserWebhookNoteBody'>).note.id).toBe('dummy-mention-1');
}); });
test('follow', async () => { test('follow', async () => {
@ -158,7 +159,7 @@ describe('WebhookTestService', () => {
const calls = queueService.userWebhookDeliver.mock.calls[0]; const calls = queueService.userWebhookDeliver.mock.calls[0];
expect((calls[0] as any).id).toBe('dummy-webhook'); expect((calls[0] as any).id).toBe('dummy-webhook');
expect(calls[1]).toBe('follow'); expect(calls[1]).toBe('follow');
expect((calls[2] as any).id).toBe('dummy-user-1'); expect((calls[2] as Packed<'UserWebhookUserBody'>).user.id).toBe('dummy-user-2');
}); });
test('followed', async () => { test('followed', async () => {
@ -167,7 +168,7 @@ describe('WebhookTestService', () => {
const calls = queueService.userWebhookDeliver.mock.calls[0]; const calls = queueService.userWebhookDeliver.mock.calls[0];
expect((calls[0] as any).id).toBe('dummy-webhook'); expect((calls[0] as any).id).toBe('dummy-webhook');
expect(calls[1]).toBe('followed'); expect(calls[1]).toBe('followed');
expect((calls[2] as any).id).toBe('dummy-user-2'); expect((calls[2] as Packed<'UserWebhookUserBody'>).user.id).toBe('dummy-user-2');
}); });
test('unfollow', async () => { test('unfollow', async () => {
@ -176,7 +177,16 @@ describe('WebhookTestService', () => {
const calls = queueService.userWebhookDeliver.mock.calls[0]; const calls = queueService.userWebhookDeliver.mock.calls[0];
expect((calls[0] as any).id).toBe('dummy-webhook'); expect((calls[0] as any).id).toBe('dummy-webhook');
expect(calls[1]).toBe('unfollow'); expect(calls[1]).toBe('unfollow');
expect((calls[2] as any).id).toBe('dummy-user-3'); expect((calls[2] as Packed<'UserWebhookUserBody'>).user.id).toBe('dummy-user-2');
});
test('reaction', async () => {
await service.testUserWebhook({ webhookId: 'dummy-webhook', type: 'reaction' }, alice);
const calls = queueService.userWebhookDeliver.mock.calls[0];
expect((calls[0] as any).id).toBe('dummy-webhook');
expect(calls[1]).toBe('reaction');
expect((calls[2] as Packed<'UserWebhookReactionBody'>).reaction.id).toBe('dummy-reaction-1');
}); });
describe('NoSuchWebhookError', () => { describe('NoSuchWebhookError', () => {