feat: refine announcement (misskey-dev#11497)
This commit is contained in:
		| @@ -0,0 +1,27 @@ | ||||
| export class RefineAnnouncement1691649257651 { | ||||
|     name = 'RefineAnnouncement1691649257651' | ||||
|  | ||||
|     async up(queryRunner) { | ||||
|         await queryRunner.query(`ALTER TABLE "announcement" ADD "display" character varying(256) NOT NULL DEFAULT 'normal'`); | ||||
|         await queryRunner.query(`ALTER TABLE "announcement" ADD "needConfirmationToRead" boolean NOT NULL DEFAULT false`); | ||||
|         await queryRunner.query(`ALTER TABLE "announcement" ADD "isActive" boolean NOT NULL DEFAULT true`); | ||||
|         await queryRunner.query(`ALTER TABLE "announcement" ADD "forExistingUsers" boolean NOT NULL DEFAULT false`); | ||||
|         await queryRunner.query(`ALTER TABLE "announcement" ADD "userId" character varying(32)`); | ||||
|         await queryRunner.query(`CREATE INDEX "IDX_bc1afcc8ef7e9400cdc3c0a87e" ON "announcement" ("isActive") `); | ||||
|         await queryRunner.query(`CREATE INDEX "IDX_da795d3a83187e8832005ba19d" ON "announcement" ("forExistingUsers") `); | ||||
|         await queryRunner.query(`CREATE INDEX "IDX_fd25dfe3da37df1715f11ba6ec" ON "announcement" ("userId") `); | ||||
|         await queryRunner.query(`ALTER TABLE "announcement" ADD CONSTRAINT "FK_fd25dfe3da37df1715f11ba6ec8" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); | ||||
|     } | ||||
|  | ||||
|     async down(queryRunner) { | ||||
|         await queryRunner.query(`ALTER TABLE "announcement" DROP CONSTRAINT "FK_fd25dfe3da37df1715f11ba6ec8"`); | ||||
|         await queryRunner.query(`DROP INDEX "public"."IDX_fd25dfe3da37df1715f11ba6ec"`); | ||||
|         await queryRunner.query(`DROP INDEX "public"."IDX_da795d3a83187e8832005ba19d"`); | ||||
|         await queryRunner.query(`DROP INDEX "public"."IDX_bc1afcc8ef7e9400cdc3c0a87e"`); | ||||
|         await queryRunner.query(`ALTER TABLE "announcement" DROP COLUMN "userId"`); | ||||
|         await queryRunner.query(`ALTER TABLE "announcement" DROP COLUMN "forExistingUsers"`); | ||||
|         await queryRunner.query(`ALTER TABLE "announcement" DROP COLUMN "isActive"`); | ||||
|         await queryRunner.query(`ALTER TABLE "announcement" DROP COLUMN "needConfirmationToRead"`); | ||||
|         await queryRunner.query(`ALTER TABLE "announcement" DROP COLUMN "display"`); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,11 @@ | ||||
| export class RefineAnnouncement21691657412740 { | ||||
|     name = 'RefineAnnouncement21691657412740' | ||||
|  | ||||
|     async up(queryRunner) { | ||||
|         await queryRunner.query(`ALTER TABLE "announcement" ADD "icon" character varying(256) NOT NULL DEFAULT 'info'`); | ||||
|     } | ||||
|  | ||||
|     async down(queryRunner) { | ||||
|         await queryRunner.query(`ALTER TABLE "announcement" DROP COLUMN "icon"`); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										135
									
								
								packages/backend/src/core/AnnouncementService.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										135
									
								
								packages/backend/src/core/AnnouncementService.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,135 @@ | ||||
| /* | ||||
|  * SPDX-FileCopyrightText: syuilo and other misskey contributors | ||||
|  * SPDX-License-Identifier: AGPL-3.0-only | ||||
|  */ | ||||
|  | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { Brackets } from 'typeorm'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import type { User } from '@/models/entities/User.js'; | ||||
| import type { AnnouncementReadsRepository, AnnouncementsRepository, Announcement, AnnouncementRead } from '@/models/index.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| import { Packed } from '@/misc/json-schema.js'; | ||||
| import { IdService } from '@/core/IdService.js'; | ||||
| import { GlobalEventService } from '@/core/GlobalEventService.js'; | ||||
|  | ||||
| @Injectable() | ||||
| export class AnnouncementService { | ||||
| 	constructor( | ||||
| 		@Inject(DI.announcementsRepository) | ||||
| 		private announcementsRepository: AnnouncementsRepository, | ||||
|  | ||||
| 		@Inject(DI.announcementReadsRepository) | ||||
| 		private announcementReadsRepository: AnnouncementReadsRepository, | ||||
|  | ||||
| 		private idService: IdService, | ||||
| 		private globalEventService: GlobalEventService, | ||||
| 	) { | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async getReads(userId: User['id']): Promise<AnnouncementRead[]> { | ||||
| 		return this.announcementReadsRepository.findBy({ | ||||
| 			userId: userId, | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async getUnreadAnnouncements(user: User): Promise<Announcement[]> { | ||||
| 		const readsQuery = this.announcementReadsRepository.createQueryBuilder('read') | ||||
| 			.select('read.announcementId') | ||||
| 			.where('read.userId = :userId', { userId: user.id }); | ||||
|  | ||||
| 		const q = this.announcementsRepository.createQueryBuilder('announcement') | ||||
| 			.where('announcement.isActive = true') | ||||
| 			.andWhere(new Brackets(qb => { | ||||
| 				qb.orWhere('announcement.userId = :userId', { userId: user.id }); | ||||
| 				qb.orWhere('announcement.userId IS NULL'); | ||||
| 			})) | ||||
| 			.andWhere(new Brackets(qb => { | ||||
| 				qb.orWhere('announcement.forExistingUsers = false'); | ||||
| 				qb.orWhere('announcement.createdAt > :createdAt', { createdAt: user.createdAt }); | ||||
| 			})) | ||||
| 			.andWhere(`announcement.id NOT IN (${ readsQuery.getQuery() })`); | ||||
|  | ||||
| 		q.setParameters(readsQuery.getParameters()); | ||||
|  | ||||
| 		return q.getMany(); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async create(values: Partial<Announcement>): Promise<{ raw: Announcement; packed: Packed<'Announcement'> }> { | ||||
| 		const announcement = await this.announcementsRepository.insert({ | ||||
| 			id: this.idService.genId(), | ||||
| 			createdAt: new Date(), | ||||
| 			updatedAt: null, | ||||
| 			title: values.title, | ||||
| 			text: values.text, | ||||
| 			imageUrl: values.imageUrl, | ||||
| 			icon: values.icon, | ||||
| 			display: values.display, | ||||
| 			forExistingUsers: values.forExistingUsers, | ||||
| 			needConfirmationToRead: values.needConfirmationToRead, | ||||
| 			userId: values.userId, | ||||
| 		}).then(x => this.announcementsRepository.findOneByOrFail(x.identifiers[0])); | ||||
|  | ||||
| 		const packed = (await this.packMany([announcement]))[0]; | ||||
|  | ||||
| 		if (values.userId) { | ||||
| 			this.globalEventService.publishMainStream(values.userId, 'announcementCreated', { | ||||
| 				announcement: packed, | ||||
| 			}); | ||||
| 		} else { | ||||
| 			this.globalEventService.publishBroadcastStream('announcementCreated', { | ||||
| 				announcement: packed, | ||||
| 			}); | ||||
| 		} | ||||
|  | ||||
| 		return { | ||||
| 			raw: announcement, | ||||
| 			packed: packed, | ||||
| 		}; | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async read(user: User, announcementId: Announcement['id']): Promise<void> { | ||||
| 		try { | ||||
| 			await this.announcementReadsRepository.insert({ | ||||
| 				id: this.idService.genId(), | ||||
| 				createdAt: new Date(), | ||||
| 				announcementId: announcementId, | ||||
| 				userId: user.id, | ||||
| 			}); | ||||
| 		} catch (e) { | ||||
| 			return; | ||||
| 		} | ||||
|  | ||||
| 		if ((await this.getUnreadAnnouncements(user)).length === 0) { | ||||
| 			this.globalEventService.publishMainStream(user.id, 'readAllAnnouncements'); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async packMany( | ||||
| 		announcements: Announcement[], | ||||
| 		me?: { id: User['id'] } | null | undefined, | ||||
| 		options?: { | ||||
| 			reads?: AnnouncementRead[]; | ||||
| 		}, | ||||
| 	): Promise<Packed<'Announcement'>[]> { | ||||
| 		const reads = me ? (options?.reads ?? await this.getReads(me.id)) : []; | ||||
| 		return announcements.map(announcement => ({ | ||||
| 			id: announcement.id, | ||||
| 			createdAt: announcement.createdAt.toISOString(), | ||||
| 			updatedAt: announcement.updatedAt?.toISOString() ?? null, | ||||
| 			text: announcement.text, | ||||
| 			title: announcement.title, | ||||
| 			imageUrl: announcement.imageUrl, | ||||
| 			icon: announcement.icon, | ||||
| 			display: announcement.display, | ||||
| 			needConfirmationToRead: announcement.needConfirmationToRead, | ||||
| 			forYou: announcement.userId === me?.id, | ||||
| 			isRead: reads.some(read => read.announcementId === announcement.id), | ||||
| 		})); | ||||
| 	} | ||||
| } | ||||
| @@ -7,6 +7,7 @@ import { Module } from '@nestjs/common'; | ||||
| import { AccountMoveService } from './AccountMoveService.js'; | ||||
| import { AccountUpdateService } from './AccountUpdateService.js'; | ||||
| import { AiService } from './AiService.js'; | ||||
| import { AnnouncementService } from './AnnouncementService.js'; | ||||
| import { AntennaService } from './AntennaService.js'; | ||||
| import { AppLockService } from './AppLockService.js'; | ||||
| import { AchievementService } from './AchievementService.js'; | ||||
| @@ -130,6 +131,7 @@ const $LoggerService: Provider = { provide: 'LoggerService', useExisting: Logger | ||||
| const $AccountMoveService: Provider = { provide: 'AccountMoveService', useExisting: AccountMoveService }; | ||||
| const $AccountUpdateService: Provider = { provide: 'AccountUpdateService', useExisting: AccountUpdateService }; | ||||
| const $AiService: Provider = { provide: 'AiService', useExisting: AiService }; | ||||
| const $AnnouncementService: Provider = { provide: 'AnnouncementService', useExisting: AnnouncementService }; | ||||
| const $AntennaService: Provider = { provide: 'AntennaService', useExisting: AntennaService }; | ||||
| const $AppLockService: Provider = { provide: 'AppLockService', useExisting: AppLockService }; | ||||
| const $AchievementService: Provider = { provide: 'AchievementService', useExisting: AchievementService }; | ||||
| @@ -257,6 +259,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting | ||||
| 		AccountMoveService, | ||||
| 		AccountUpdateService, | ||||
| 		AiService, | ||||
| 		AnnouncementService, | ||||
| 		AntennaService, | ||||
| 		AppLockService, | ||||
| 		AchievementService, | ||||
| @@ -377,6 +380,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting | ||||
| 		$AccountMoveService, | ||||
| 		$AccountUpdateService, | ||||
| 		$AiService, | ||||
| 		$AnnouncementService, | ||||
| 		$AntennaService, | ||||
| 		$AppLockService, | ||||
| 		$AchievementService, | ||||
| @@ -498,6 +502,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting | ||||
| 		AccountMoveService, | ||||
| 		AccountUpdateService, | ||||
| 		AiService, | ||||
| 		AnnouncementService, | ||||
| 		AntennaService, | ||||
| 		AppLockService, | ||||
| 		AchievementService, | ||||
| @@ -617,6 +622,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting | ||||
| 		$AccountMoveService, | ||||
| 		$AccountUpdateService, | ||||
| 		$AiService, | ||||
| 		$AnnouncementService, | ||||
| 		$AntennaService, | ||||
| 		$AppLockService, | ||||
| 		$AchievementService, | ||||
|   | ||||
| @@ -4,7 +4,6 @@ | ||||
|  */ | ||||
|  | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { In, Not } from 'typeorm'; | ||||
| import * as Redis from 'ioredis'; | ||||
| import _Ajv from 'ajv'; | ||||
| import { ModuleRef } from '@nestjs/core'; | ||||
| @@ -49,7 +48,7 @@ import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; | ||||
| import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; | ||||
| import { IdentifiableError } from '@/misc/identifiable-error.js'; | ||||
| import type { OnModuleInit } from '@nestjs/common'; | ||||
| import type { AntennaService } from '../AntennaService.js'; | ||||
| import type { AnnouncementService } from '../AnnouncementService.js'; | ||||
| import type { CustomEmojiService } from '../CustomEmojiService.js'; | ||||
| import type { NoteEntityService } from './NoteEntityService.js'; | ||||
| import type { DriveFileEntityService } from './DriveFileEntityService.js'; | ||||
| @@ -85,7 +84,7 @@ export class UserEntityService implements OnModuleInit { | ||||
| 	private driveFileEntityService: DriveFileEntityService; | ||||
| 	private pageEntityService: PageEntityService; | ||||
| 	private customEmojiService: CustomEmojiService; | ||||
| 	private antennaService: AntennaService; | ||||
| 	private announcementService: AnnouncementService; | ||||
| 	private roleService: RoleService; | ||||
| 	private federatedInstanceService: FederatedInstanceService; | ||||
|  | ||||
| @@ -164,7 +163,7 @@ export class UserEntityService implements OnModuleInit { | ||||
| 		this.driveFileEntityService = this.moduleRef.get('DriveFileEntityService'); | ||||
| 		this.pageEntityService = this.moduleRef.get('PageEntityService'); | ||||
| 		this.customEmojiService = this.moduleRef.get('CustomEmojiService'); | ||||
| 		this.antennaService = this.moduleRef.get('AntennaService'); | ||||
| 		this.announcementService = this.moduleRef.get('AnnouncementService'); | ||||
| 		this.roleService = this.moduleRef.get('RoleService'); | ||||
| 		this.federatedInstanceService = this.moduleRef.get('FederatedInstanceService'); | ||||
| 	} | ||||
| @@ -244,19 +243,6 @@ export class UserEntityService implements OnModuleInit { | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async getHasUnreadAnnouncement(userId: User['id']): Promise<boolean> { | ||||
| 		const reads = await this.announcementReadsRepository.findBy({ | ||||
| 			userId: userId, | ||||
| 		}); | ||||
|  | ||||
| 		const count = await this.announcementsRepository.countBy(reads.length > 0 ? { | ||||
| 			id: Not(In(reads.map(read => read.announcementId))), | ||||
| 		} : {}); | ||||
|  | ||||
| 		return count > 0; | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async getHasUnreadAntenna(userId: User['id']): Promise<boolean> { | ||||
| 		/* | ||||
| @@ -385,6 +371,7 @@ export class UserEntityService implements OnModuleInit { | ||||
| 		const isModerator = isMe && opts.detail ? await this.roleService.isModerator(user) : null; | ||||
| 		const isAdmin = isMe && opts.detail ? await this.roleService.isAdministrator(user) : null; | ||||
| 		const policies = opts.detail ? await this.roleService.getUserPolicies(user.id) : null; | ||||
| 		const unreadAnnouncements = isMe && opts.detail ? await this.announcementService.getUnreadAnnouncements(user) : null; | ||||
|  | ||||
| 		const falsy = opts.detail ? false : undefined; | ||||
|  | ||||
| @@ -496,7 +483,8 @@ export class UserEntityService implements OnModuleInit { | ||||
| 					where: { userId: user.id, isMentioned: true }, | ||||
| 					take: 1, | ||||
| 				}).then(count => count > 0), | ||||
| 				hasUnreadAnnouncement: this.getHasUnreadAnnouncement(user.id), | ||||
| 				hasUnreadAnnouncement: unreadAnnouncements!.length > 0, | ||||
| 				unreadAnnouncements, | ||||
| 				hasUnreadAntenna: this.getHasUnreadAntenna(user.id), | ||||
| 				hasUnreadChannel: false, // 後方互換性のため | ||||
| 				hasUnreadNotification: this.getHasUnreadNotification(user.id), | ||||
|   | ||||
| @@ -38,6 +38,7 @@ import { packedQueueCountSchema } from '@/models/json-schema/queue.js'; | ||||
| import { packedRenoteMutingSchema } from '@/models/json-schema/renote-muting.js'; | ||||
| import { packedRoleSchema } from '@/models/json-schema/role.js'; | ||||
| import { packedUserListSchema } from '@/models/json-schema/user-list.js'; | ||||
| import { packedAnnouncementSchema } from '@/models/json-schema/announcement.js'; | ||||
|  | ||||
| export const refs = { | ||||
| 	UserLite: packedUserLiteSchema, | ||||
| @@ -49,6 +50,7 @@ export const refs = { | ||||
| 	User: packedUserSchema, | ||||
|  | ||||
| 	UserList: packedUserListSchema, | ||||
| 	Announcement: packedAnnouncementSchema, | ||||
| 	App: packedAppSchema, | ||||
| 	Note: packedNoteSchema, | ||||
| 	NoteReaction: packedNoteReactionSchema, | ||||
|   | ||||
| @@ -3,8 +3,9 @@ | ||||
|  * SPDX-License-Identifier: AGPL-3.0-only | ||||
|  */ | ||||
|  | ||||
| import { Entity, Index, Column, PrimaryColumn } from 'typeorm'; | ||||
| import { Entity, Index, Column, PrimaryColumn, ManyToOne, JoinColumn } from 'typeorm'; | ||||
| import { id } from '../id.js'; | ||||
| import { User } from './User.js'; | ||||
|  | ||||
| @Entity() | ||||
| export class Announcement { | ||||
| @@ -38,6 +39,52 @@ export class Announcement { | ||||
| 	}) | ||||
| 	public imageUrl: string | null; | ||||
|  | ||||
| 	// info, warning, error, success | ||||
| 	@Column('varchar', { | ||||
| 		length: 256, nullable: false, | ||||
| 		default: 'info', | ||||
| 	}) | ||||
| 	public icon: string; | ||||
|  | ||||
| 	// normal ... お知らせページ掲載 | ||||
| 	// banner ... お知らせページ掲載 + バナー表示 | ||||
| 	// dialog ... お知らせページ掲載 + ダイアログ表示 | ||||
| 	@Column('varchar', { | ||||
| 		length: 256, nullable: false, | ||||
| 		default: 'normal', | ||||
| 	}) | ||||
| 	public display: string; | ||||
|  | ||||
| 	@Column('boolean', { | ||||
| 		default: false, | ||||
| 	}) | ||||
| 	public needConfirmationToRead: boolean; | ||||
|  | ||||
| 	@Index() | ||||
| 	@Column('boolean', { | ||||
| 		default: true, | ||||
| 	}) | ||||
| 	public isActive: boolean; | ||||
|  | ||||
| 	@Index() | ||||
| 	@Column('boolean', { | ||||
| 		default: false, | ||||
| 	}) | ||||
| 	public forExistingUsers: boolean; | ||||
|  | ||||
| 	@Index() | ||||
| 	@Column({ | ||||
| 		...id(), | ||||
| 		nullable: true, | ||||
| 	}) | ||||
| 	public userId: User['id'] | null; | ||||
|  | ||||
| 	@ManyToOne(type => User, { | ||||
| 		onDelete: 'CASCADE', | ||||
| 	}) | ||||
| 	@JoinColumn() | ||||
| 	public user: User | null; | ||||
|  | ||||
| 	constructor(data: Partial<Announcement>) { | ||||
| 		if (data == null) return; | ||||
|  | ||||
|   | ||||
							
								
								
									
										58
									
								
								packages/backend/src/models/json-schema/announcement.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								packages/backend/src/models/json-schema/announcement.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,58 @@ | ||||
| /* | ||||
|  * SPDX-FileCopyrightText: syuilo and other misskey contributors | ||||
|  * SPDX-License-Identifier: AGPL-3.0-only | ||||
|  */ | ||||
|  | ||||
| export const packedAnnouncementSchema = { | ||||
| 	type: 'object', | ||||
| 	properties: { | ||||
| 		id: { | ||||
| 			type: 'string', | ||||
| 			optional: false, nullable: false, | ||||
| 			format: 'id', | ||||
| 			example: 'xxxxxxxxxx', | ||||
| 		}, | ||||
| 		createdAt: { | ||||
| 			type: 'string', | ||||
| 			optional: false, nullable: false, | ||||
| 			format: 'date-time', | ||||
| 		}, | ||||
| 		updatedAt: { | ||||
| 			type: 'string', | ||||
| 			optional: false, nullable: true, | ||||
| 			format: 'date-time', | ||||
| 		}, | ||||
| 		text: { | ||||
| 			type: 'string', | ||||
| 			optional: false, nullable: false, | ||||
| 		}, | ||||
| 		title: { | ||||
| 			type: 'string', | ||||
| 			optional: false, nullable: false, | ||||
| 		}, | ||||
| 		imageUrl: { | ||||
| 			type: 'string', | ||||
| 			optional: false, nullable: true, | ||||
| 		}, | ||||
| 		icon: { | ||||
| 			type: 'string', | ||||
| 			optional: false, nullable: false, | ||||
| 		}, | ||||
| 		display: { | ||||
| 			type: 'string', | ||||
| 			optional: false, nullable: false, | ||||
| 		}, | ||||
| 		forYou: { | ||||
| 			type: 'boolean', | ||||
| 			optional: false, nullable: false, | ||||
| 		}, | ||||
| 		needConfirmationToRead: { | ||||
| 			type: 'boolean', | ||||
| 			optional: false, nullable: false, | ||||
| 		}, | ||||
| 		isRead: { | ||||
| 			type: 'boolean', | ||||
| 			optional: true, nullable: false, | ||||
| 		}, | ||||
| 	}, | ||||
| } as const; | ||||
| @@ -3,11 +3,9 @@ | ||||
|  * SPDX-License-Identifier: AGPL-3.0-only | ||||
|  */ | ||||
|  | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { Injectable } from '@nestjs/common'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import type { AnnouncementsRepository } from '@/models/index.js'; | ||||
| import { IdService } from '@/core/IdService.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import { AnnouncementService } from '@/core/AnnouncementService.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	tags: ['admin'], | ||||
| @@ -57,6 +55,11 @@ export const paramDef = { | ||||
| 		title: { type: 'string', minLength: 1 }, | ||||
| 		text: { type: 'string', minLength: 1 }, | ||||
| 		imageUrl: { type: 'string', nullable: true, minLength: 1 }, | ||||
| 		icon: { type: 'string', enum: ['info', 'warning', 'error', 'success'], default: 'info' }, | ||||
| 		display: { type: 'string', enum: ['normal', 'banner', 'dialog'], default: 'normal' }, | ||||
| 		forExistingUsers: { type: 'boolean', default: false }, | ||||
| 		needConfirmationToRead: { type: 'boolean', default: false }, | ||||
| 		userId: { type: 'string', format: 'misskey:id', nullable: true, default: null }, | ||||
| 	}, | ||||
| 	required: ['title', 'text', 'imageUrl'], | ||||
| } as const; | ||||
| @@ -65,22 +68,23 @@ export const paramDef = { | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject(DI.announcementsRepository) | ||||
| 		private announcementsRepository: AnnouncementsRepository, | ||||
|  | ||||
| 		private idService: IdService, | ||||
| 		private announcementService: AnnouncementService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const announcement = await this.announcementsRepository.insert({ | ||||
| 				id: this.idService.genId(), | ||||
| 			const { raw, packed } = await this.announcementService.create({ | ||||
| 				createdAt: new Date(), | ||||
| 				updatedAt: null, | ||||
| 				title: ps.title, | ||||
| 				text: ps.text, | ||||
| 				imageUrl: ps.imageUrl, | ||||
| 			}).then(x => this.announcementsRepository.findOneByOrFail(x.identifiers[0])); | ||||
| 				icon: ps.icon, | ||||
| 				display: ps.display, | ||||
| 				forExistingUsers: ps.forExistingUsers, | ||||
| 				needConfirmationToRead: ps.needConfirmationToRead, | ||||
| 				userId: ps.userId, | ||||
| 			}); | ||||
|  | ||||
| 			return Object.assign({}, announcement, { createdAt: announcement.createdAt.toISOString(), updatedAt: null }); | ||||
| 			return packed; | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -66,6 +66,7 @@ export const paramDef = { | ||||
| 		limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, | ||||
| 		sinceId: { type: 'string', format: 'misskey:id' }, | ||||
| 		untilId: { type: 'string', format: 'misskey:id' }, | ||||
| 		userId: { type: 'string', format: 'misskey:id', nullable: true }, | ||||
| 	}, | ||||
| 	required: [], | ||||
| } as const; | ||||
| @@ -84,6 +85,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const query = this.queryService.makePaginationQuery(this.announcementsRepository.createQueryBuilder('announcement'), ps.sinceId, ps.untilId); | ||||
| 			if (ps.userId) { | ||||
| 				query.andWhere('announcement.userId = :userId', { userId: ps.userId }); | ||||
| 			} else { | ||||
| 				query.andWhere('announcement.userId IS NULL'); | ||||
| 			} | ||||
|  | ||||
| 			const announcements = await query.limit(ps.limit).getMany(); | ||||
|  | ||||
| @@ -102,6 +108,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 				title: announcement.title, | ||||
| 				text: announcement.text, | ||||
| 				imageUrl: announcement.imageUrl, | ||||
| 				icon: announcement.icon, | ||||
| 				display: announcement.display, | ||||
| 				isActive: announcement.isActive, | ||||
| 				forExistingUsers: announcement.forExistingUsers, | ||||
| 				needConfirmationToRead: announcement.needConfirmationToRead, | ||||
| 				userId: announcement.userId, | ||||
| 				reads: reads.get(announcement)!, | ||||
| 			})); | ||||
| 		}); | ||||
|   | ||||
| @@ -31,8 +31,13 @@ export const paramDef = { | ||||
| 		title: { type: 'string', minLength: 1 }, | ||||
| 		text: { type: 'string', minLength: 1 }, | ||||
| 		imageUrl: { type: 'string', nullable: true, minLength: 0 }, | ||||
| 		icon: { type: 'string', enum: ['info', 'warning', 'error', 'success'] }, | ||||
| 		display: { type: 'string', enum: ['normal', 'banner', 'dialog'] }, | ||||
| 		forExistingUsers: { type: 'boolean' }, | ||||
| 		needConfirmationToRead: { type: 'boolean' }, | ||||
| 		isActive: { type: 'boolean' }, | ||||
| 	}, | ||||
| 	required: ['id', 'title', 'text', 'imageUrl'], | ||||
| 	required: ['id'], | ||||
| } as const; | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| @@ -53,6 +58,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 				text: ps.text, | ||||
| 				/* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- 空の文字列の場合、nullを渡すようにするため */ | ||||
| 				imageUrl: ps.imageUrl || null, | ||||
| 				display: ps.display, | ||||
| 				icon: ps.icon, | ||||
| 				forExistingUsers: ps.forExistingUsers, | ||||
| 				needConfirmationToRead: ps.needConfirmationToRead, | ||||
| 				isActive: ps.isActive, | ||||
| 			}); | ||||
| 		}); | ||||
| 	} | ||||
|   | ||||
| @@ -4,8 +4,10 @@ | ||||
|  */ | ||||
|  | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { Brackets } from 'typeorm'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { QueryService } from '@/core/QueryService.js'; | ||||
| import { AnnouncementService } from '@/core/AnnouncementService.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import type { AnnouncementReadsRepository, AnnouncementsRepository } from '@/models/index.js'; | ||||
|  | ||||
| @@ -20,40 +22,7 @@ export const meta = { | ||||
| 		items: { | ||||
| 			type: 'object', | ||||
| 			optional: false, nullable: false, | ||||
| 			properties: { | ||||
| 				id: { | ||||
| 					type: 'string', | ||||
| 					optional: false, nullable: false, | ||||
| 					format: 'id', | ||||
| 					example: 'xxxxxxxxxx', | ||||
| 				}, | ||||
| 				createdAt: { | ||||
| 					type: 'string', | ||||
| 					optional: false, nullable: false, | ||||
| 					format: 'date-time', | ||||
| 				}, | ||||
| 				updatedAt: { | ||||
| 					type: 'string', | ||||
| 					optional: false, nullable: true, | ||||
| 					format: 'date-time', | ||||
| 				}, | ||||
| 				text: { | ||||
| 					type: 'string', | ||||
| 					optional: false, nullable: false, | ||||
| 				}, | ||||
| 				title: { | ||||
| 					type: 'string', | ||||
| 					optional: false, nullable: false, | ||||
| 				}, | ||||
| 				imageUrl: { | ||||
| 					type: 'string', | ||||
| 					optional: false, nullable: true, | ||||
| 				}, | ||||
| 				isRead: { | ||||
| 					type: 'boolean', | ||||
| 					optional: true, nullable: false, | ||||
| 				}, | ||||
| 			}, | ||||
| 			ref: 'Announcement', | ||||
| 		}, | ||||
| 	}, | ||||
| } as const; | ||||
| @@ -62,9 +31,9 @@ export const paramDef = { | ||||
| 	type: 'object', | ||||
| 	properties: { | ||||
| 		limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, | ||||
| 		withUnreads: { type: 'boolean', default: false }, | ||||
| 		sinceId: { type: 'string', format: 'misskey:id' }, | ||||
| 		untilId: { type: 'string', format: 'misskey:id' }, | ||||
| 		isActive: { type: 'boolean', default: true }, | ||||
| 	}, | ||||
| 	required: [], | ||||
| } as const; | ||||
| @@ -80,27 +49,19 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 		private announcementReadsRepository: AnnouncementReadsRepository, | ||||
|  | ||||
| 		private queryService: QueryService, | ||||
| 		private announcementService: AnnouncementService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const query = this.queryService.makePaginationQuery(this.announcementsRepository.createQueryBuilder('announcement'), ps.sinceId, ps.untilId); | ||||
| 			const query = this.queryService.makePaginationQuery(this.announcementsRepository.createQueryBuilder('announcement'), ps.sinceId, ps.untilId) | ||||
| 				.where('announcement.isActive = :isActive', { isActive: ps.isActive }) | ||||
| 				.andWhere(new Brackets(qb => { | ||||
| 					if (me) qb.orWhere('announcement.userId = :meId', { meId: me.id }); | ||||
| 					qb.orWhere('announcement.userId IS NULL'); | ||||
| 				})); | ||||
|  | ||||
| 			const announcements = await query.limit(ps.limit).getMany(); | ||||
|  | ||||
| 			if (me) { | ||||
| 				const reads = (await this.announcementReadsRepository.findBy({ | ||||
| 					userId: me.id, | ||||
| 				})).map(x => x.announcementId); | ||||
|  | ||||
| 				for (const announcement of announcements) { | ||||
| 					(announcement as any).isRead = reads.includes(announcement.id); | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			return (ps.withUnreads ? announcements.filter((a: any) => !a.isRead) : announcements).map((a) => ({ | ||||
| 				...a, | ||||
| 				createdAt: a.createdAt.toISOString(), | ||||
| 				updatedAt: a.updatedAt?.toISOString() ?? null, | ||||
| 			})); | ||||
| 			return this.announcementService.packMany(announcements, me); | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -3,14 +3,9 @@ | ||||
|  * SPDX-License-Identifier: AGPL-3.0-only | ||||
|  */ | ||||
|  | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { Injectable } from '@nestjs/common'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { IdService } from '@/core/IdService.js'; | ||||
| import type { AnnouncementReadsRepository, AnnouncementsRepository } from '@/models/index.js'; | ||||
| import { GlobalEventService } from '@/core/GlobalEventService.js'; | ||||
| import { UserEntityService } from '@/core/entities/UserEntityService.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import { ApiError } from '../../error.js'; | ||||
| import { AnnouncementService } from '@/core/AnnouncementService.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	tags: ['account'], | ||||
| @@ -20,11 +15,6 @@ export const meta = { | ||||
| 	kind: 'write:account', | ||||
|  | ||||
| 	errors: { | ||||
| 		noSuchAnnouncement: { | ||||
| 			message: 'No such announcement.', | ||||
| 			code: 'NO_SUCH_ANNOUNCEMENT', | ||||
| 			id: '184663db-df88-4bc2-8b52-fb85f0681939', | ||||
| 		}, | ||||
| 	}, | ||||
| } as const; | ||||
|  | ||||
| @@ -40,47 +30,10 @@ export const paramDef = { | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject(DI.announcementsRepository) | ||||
| 		private announcementsRepository: AnnouncementsRepository, | ||||
|  | ||||
| 		@Inject(DI.announcementReadsRepository) | ||||
| 		private announcementReadsRepository: AnnouncementReadsRepository, | ||||
|  | ||||
| 		private userEntityService: UserEntityService, | ||||
| 		private idService: IdService, | ||||
| 		private globalEventService: GlobalEventService, | ||||
| 		private announcementService: AnnouncementService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			// Check if announcement exists | ||||
| 			const announcementExist = await this.announcementsRepository.exist({ where: { id: ps.announcementId } }); | ||||
|  | ||||
| 			if (!announcementExist) { | ||||
| 				throw new ApiError(meta.errors.noSuchAnnouncement); | ||||
| 			} | ||||
|  | ||||
| 			// Check if already read | ||||
| 			const alreadyRead = await this.announcementReadsRepository.exist({ | ||||
| 				where: { | ||||
| 					announcementId: ps.announcementId, | ||||
| 					userId: me.id, | ||||
| 				}, | ||||
| 			}); | ||||
|  | ||||
| 			if (alreadyRead) { | ||||
| 				return; | ||||
| 			} | ||||
|  | ||||
| 			// Create read | ||||
| 			await this.announcementReadsRepository.insert({ | ||||
| 				id: this.idService.genId(), | ||||
| 				createdAt: new Date(), | ||||
| 				announcementId: ps.announcementId, | ||||
| 				userId: me.id, | ||||
| 			}); | ||||
|  | ||||
| 			if (!await this.userEntityService.getHasUnreadAnnouncement(me.id)) { | ||||
| 				this.globalEventService.publishMainStream(me.id, 'readAllAnnouncements'); | ||||
| 			} | ||||
| 			await this.announcementService.read(me, ps.announcementId); | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -64,6 +64,9 @@ export interface BroadcastTypes { | ||||
| 			[other: string]: any; | ||||
| 		}[]; | ||||
| 	}; | ||||
| 	announcementCreated: { | ||||
| 		announcement: Packed<'Announcement'>; | ||||
| 	}; | ||||
| } | ||||
|  | ||||
| export interface MainStreamTypes { | ||||
| @@ -105,6 +108,9 @@ export interface MainStreamTypes { | ||||
| 	driveFileCreated: Packed<'DriveFile'>; | ||||
| 	readAntenna: Antenna; | ||||
| 	receiveFollowRequest: Packed<'User'>; | ||||
| 	announcementCreated: { | ||||
| 		announcement: Packed<'Announcement'>; | ||||
| 	}; | ||||
| } | ||||
|  | ||||
| export interface DriveStreamTypes { | ||||
|   | ||||
| @@ -162,6 +162,7 @@ describe('ユーザー', () => { | ||||
| 			hasUnreadChannel: user.hasUnreadChannel, | ||||
| 			hasUnreadNotification: user.hasUnreadNotification, | ||||
| 			hasPendingReceivedFollowRequest: user.hasPendingReceivedFollowRequest, | ||||
| 			unreadAnnouncements: user.unreadAnnouncements, | ||||
| 			mutedWords: user.mutedWords, | ||||
| 			mutedInstances: user.mutedInstances, | ||||
| 			mutingNotificationTypes: user.mutingNotificationTypes, | ||||
| @@ -409,6 +410,7 @@ describe('ユーザー', () => { | ||||
| 		assert.strictEqual(response.hasUnreadChannel, false); | ||||
| 		assert.strictEqual(response.hasUnreadNotification, false); | ||||
| 		assert.strictEqual(response.hasPendingReceivedFollowRequest, false); | ||||
| 		assert.deepStrictEqual(response.unreadAnnouncements, []); | ||||
| 		assert.deepStrictEqual(response.mutedWords, []); | ||||
| 		assert.deepStrictEqual(response.mutedInstances, []); | ||||
| 		assert.deepStrictEqual(response.mutingNotificationTypes, []); | ||||
|   | ||||
							
								
								
									
										194
									
								
								packages/backend/test/unit/AnnouncementService.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										194
									
								
								packages/backend/test/unit/AnnouncementService.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,194 @@ | ||||
| /* | ||||
|  * SPDX-FileCopyrightText: syuilo and other misskey contributors | ||||
|  * SPDX-License-Identifier: AGPL-3.0-only | ||||
|  */ | ||||
|  | ||||
| process.env.NODE_ENV = 'test'; | ||||
|  | ||||
| import { jest } from '@jest/globals'; | ||||
| import { ModuleMocker } from 'jest-mock'; | ||||
| import { Test } from '@nestjs/testing'; | ||||
| import { GlobalModule } from '@/GlobalModule.js'; | ||||
| import { AnnouncementService } from '@/core/AnnouncementService.js'; | ||||
| import type { Announcement, AnnouncementsRepository, AnnouncementReadsRepository, UsersRepository, User } from '@/models/index.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import { genAid } from '@/misc/id/aid.js'; | ||||
| import { CacheService } from '@/core/CacheService.js'; | ||||
| import { IdService } from '@/core/IdService.js'; | ||||
| import { GlobalEventService } from '@/core/GlobalEventService.js'; | ||||
| import { secureRndstr } from '@/misc/secure-rndstr.js'; | ||||
| import type { TestingModule } from '@nestjs/testing'; | ||||
| import type { MockFunctionMetadata } from 'jest-mock'; | ||||
|  | ||||
| const moduleMocker = new ModuleMocker(global); | ||||
|  | ||||
| describe('AnnouncementService', () => { | ||||
| 	let app: TestingModule; | ||||
| 	let announcementService: AnnouncementService; | ||||
| 	let usersRepository: UsersRepository; | ||||
| 	let announcementsRepository: AnnouncementsRepository; | ||||
| 	let announcementReadsRepository: AnnouncementReadsRepository; | ||||
| 	let globalEventService: jest.Mocked<GlobalEventService>; | ||||
|  | ||||
| 	function createUser(data: Partial<User> = {}) { | ||||
| 		const un = secureRndstr(16); | ||||
| 		return usersRepository.insert({ | ||||
| 			id: genAid(new Date()), | ||||
| 			createdAt: new Date(), | ||||
| 			username: un, | ||||
| 			usernameLower: un, | ||||
| 			...data, | ||||
| 		}) | ||||
| 			.then(x => usersRepository.findOneByOrFail(x.identifiers[0])); | ||||
| 	} | ||||
|  | ||||
| 	function createAnnouncement(data: Partial<Announcement> = {}) { | ||||
| 		return announcementsRepository.insert({ | ||||
| 			id: genAid(new Date()), | ||||
| 			createdAt: new Date(), | ||||
| 			updatedAt: null, | ||||
| 			title: 'Title', | ||||
| 			text: 'Text', | ||||
| 			...data, | ||||
| 		}) | ||||
| 			.then(x => announcementsRepository.findOneByOrFail(x.identifiers[0])); | ||||
| 	} | ||||
|  | ||||
| 	beforeEach(async () => { | ||||
| 		app = await Test.createTestingModule({ | ||||
| 			imports: [ | ||||
| 				GlobalModule, | ||||
| 			], | ||||
| 			providers: [ | ||||
| 				AnnouncementService, | ||||
| 				CacheService, | ||||
| 				IdService, | ||||
| 			], | ||||
| 		}) | ||||
| 			.useMocker((token) => { | ||||
| 				if (token === GlobalEventService) { | ||||
| 					return { | ||||
| 						publishMainStream: jest.fn(), | ||||
| 						publishBroadcastStream: jest.fn(), | ||||
| 					}; | ||||
| 				} | ||||
| 				if (typeof token === 'function') { | ||||
| 					const mockMetadata = moduleMocker.getMetadata(token) as MockFunctionMetadata<any, any>; | ||||
| 					const Mock = moduleMocker.generateFromMetadata(mockMetadata); | ||||
| 					return new Mock(); | ||||
| 				} | ||||
| 			}) | ||||
| 			.compile(); | ||||
|  | ||||
| 		app.enableShutdownHooks(); | ||||
|  | ||||
| 		announcementService = app.get<AnnouncementService>(AnnouncementService); | ||||
| 		usersRepository = app.get<UsersRepository>(DI.usersRepository); | ||||
| 		announcementsRepository = app.get<AnnouncementsRepository>(DI.announcementsRepository); | ||||
| 		announcementReadsRepository = app.get<AnnouncementReadsRepository>(DI.announcementReadsRepository); | ||||
| 		globalEventService = app.get<GlobalEventService>(GlobalEventService) as jest.Mocked<GlobalEventService>; | ||||
| 	}); | ||||
|  | ||||
| 	afterEach(async () => { | ||||
| 		await Promise.all([ | ||||
| 			app.get(DI.metasRepository).delete({}), | ||||
| 			usersRepository.delete({}), | ||||
| 			announcementsRepository.delete({}), | ||||
| 			announcementReadsRepository.delete({}), | ||||
| 		]); | ||||
|  | ||||
| 		await app.close(); | ||||
| 	}); | ||||
|  | ||||
| 	describe('getUnreadAnnouncements', () => { | ||||
| 		test('通常', async () => { | ||||
| 			const user = await createUser(); | ||||
| 			const announcement = await createAnnouncement({ | ||||
| 				title: '1', | ||||
| 			}); | ||||
|  | ||||
| 			const result = await announcementService.getUnreadAnnouncements(user); | ||||
|  | ||||
| 			expect(result.length).toBe(1); | ||||
| 			expect(result[0].title).toBe(announcement.title); | ||||
| 		}); | ||||
|  | ||||
| 		test('isActiveがfalseは除外', async () => { | ||||
| 			const user = await createUser(); | ||||
| 			await createAnnouncement({ | ||||
| 				isActive: false, | ||||
| 			}); | ||||
|  | ||||
| 			const result = await announcementService.getUnreadAnnouncements(user); | ||||
|  | ||||
| 			expect(result.length).toBe(0); | ||||
| 		}); | ||||
|  | ||||
| 		test('forExistingUsers', async () => { | ||||
| 			const user = await createUser(); | ||||
| 			const [announcementAfter, announcementBefore, announcementBefore2] = await Promise.all([ | ||||
| 				createAnnouncement({ | ||||
| 					title: 'after', | ||||
| 					createdAt: new Date(), | ||||
| 					forExistingUsers: true, | ||||
| 				}), | ||||
| 				createAnnouncement({ | ||||
| 					title: 'before', | ||||
| 					createdAt: new Date(Date.now() - 1000), | ||||
| 					forExistingUsers: true, | ||||
| 				}), | ||||
| 				createAnnouncement({ | ||||
| 					title: 'before2', | ||||
| 					createdAt: new Date(Date.now() - 1000), | ||||
| 					forExistingUsers: false, | ||||
| 				}), | ||||
| 			]); | ||||
|  | ||||
| 			const result = await announcementService.getUnreadAnnouncements(user); | ||||
|  | ||||
| 			expect(result.length).toBe(2); | ||||
| 			expect(result.some(a => a.title === announcementAfter.title)).toBe(true); | ||||
| 			expect(result.some(a => a.title === announcementBefore.title)).toBe(false); | ||||
| 			expect(result.some(a => a.title === announcementBefore2.title)).toBe(true); | ||||
| 		}); | ||||
| 	}); | ||||
|  | ||||
| 	describe('create', () => { | ||||
| 		test('通常', async () => { | ||||
| 			const result = await announcementService.create({ | ||||
| 				title: 'Title', | ||||
| 				text: 'Text', | ||||
| 			}); | ||||
|  | ||||
| 			expect(result.raw.title).toBe('Title'); | ||||
| 			expect(result.packed.title).toBe('Title'); | ||||
|  | ||||
| 			expect(globalEventService.publishBroadcastStream).toHaveBeenCalled(); | ||||
| 			expect(globalEventService.publishBroadcastStream.mock.lastCall![0]).toBe('announcementCreated'); | ||||
| 			expect((globalEventService.publishBroadcastStream.mock.lastCall![1] as any).announcement).toBe(result.packed); | ||||
| 		}); | ||||
|  | ||||
| 		test('ユーザー指定', async () => { | ||||
| 			const user = await createUser(); | ||||
| 			const result = await announcementService.create({ | ||||
| 				title: 'Title', | ||||
| 				text: 'Text', | ||||
| 				userId: user.id, | ||||
| 			}); | ||||
|  | ||||
| 			expect(result.raw.title).toBe('Title'); | ||||
| 			expect(result.packed.title).toBe('Title'); | ||||
|  | ||||
| 			expect(globalEventService.publishBroadcastStream).not.toHaveBeenCalled(); | ||||
| 			expect(globalEventService.publishMainStream).toHaveBeenCalled(); | ||||
| 			expect(globalEventService.publishMainStream.mock.lastCall![0]).toBe(user.id); | ||||
| 			expect(globalEventService.publishMainStream.mock.lastCall![1]).toBe('announcementCreated'); | ||||
| 			expect((globalEventService.publishMainStream.mock.lastCall![2] as any).announcement).toBe(result.packed); | ||||
| 		}); | ||||
| 	}); | ||||
|  | ||||
| 	describe('read', () => { | ||||
| 		// TODO | ||||
| 	}); | ||||
| }); | ||||
|  | ||||
		Reference in New Issue
	
	Block a user
	 まっちゃとーにゅ
					まっちゃとーにゅ