From f14ca240595351db76349532564680d98b441299 Mon Sep 17 00:00:00 2001 From: fruitriin Date: Wed, 20 May 2026 00:08:14 +0900 Subject: [PATCH 1/4] =?UTF-8?q?test(backend):=20=E9=80=9A=E7=9F=A5?= =?UTF-8?q?=E3=81=AE=E6=9C=AA=E8=AA=AD=E3=82=AB=E3=82=A6=E3=83=B3=E3=83=88?= =?UTF-8?q?=E3=81=A8=E8=A1=A8=E7=A4=BA=E3=81=AE=E4=B8=8D=E4=B8=80=E8=87=B4?= =?UTF-8?q?=E3=82=92=E6=A4=9C=E5=87=BA=E3=81=99=E3=82=8B=20e2e=20=E3=83=86?= =?UTF-8?q?=E3=82=B9=E3=83=88=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UserEntityService.getNotificationsInfo() が Redis Stream の生エントリ数で未読 を返していたため、packMany で除外される通知 (解決済みフォローリクエスト・ 削除済みノート等) もカウントに含まれ、「通知ページに表示は無いのにバッジが 消えない」状態を引き起こしていた (misskey-dev/misskey#17427)。 unreadNotificationsCount と i/notifications のレスポンス件数が一致することを 3 シナリオで検証する: - 承認済みフォローリクエスト通知が packMany で除外されてもバッジに残らない - mention 通知の件数がバッジと一致する - メンション元ノートが削除されると mention 通知がバッジから除外される Co-Authored-By: Claude Opus 4.7 (1M context) --- .../test/e2e/notification-unread-count.ts | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 packages/backend/test/e2e/notification-unread-count.ts diff --git a/packages/backend/test/e2e/notification-unread-count.ts b/packages/backend/test/e2e/notification-unread-count.ts new file mode 100644 index 00000000000..0c4f28ae52e --- /dev/null +++ b/packages/backend/test/e2e/notification-unread-count.ts @@ -0,0 +1,126 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +process.env.NODE_ENV = 'test'; + +import * as assert from 'node:assert'; +import { setTimeout } from 'node:timers/promises'; +import { beforeAll, beforeEach, describe, test } from 'vitest'; +import { api, post, signup } from '../utils.js'; +import type * as misskey from 'misskey-js'; + +// このテストは https://github.com/misskey-dev/misskey/issues/17427 +// 「通知バルーンが消えない問題」の回帰防止。 +// +// 旧 UserEntityService.getNotificationsInfo() は Redis Stream の生エントリ数で +// 未読カウントを返していたため、packMany で除外される通知 (解決済みフォロー +// リクエスト・削除済みノート・サスペンドされた notifier 等) もカウントに含まれ、 +// 「通知ページを開いても何もないのにバッジだけ残る」状態を引き起こしていた。 +// +// 修正後は packMany 相当のバリデータを通した件数を返すため、 +// API レスポンスとバッジカウントは常に一致する。 +describe('Notification unread count consistency (misskey-dev/misskey#17427)', () => { + let alice: misskey.entities.SignupResponse; + let bob: misskey.entities.SignupResponse; + + beforeAll(async () => { + alice = await signup({ username: 'alice_n17427' }); + bob = await signup({ username: 'bob_n17427' }); + }, 1000 * 60 * 2); + + // 各テスト間で Stream エントリ自体を破棄してテスト間の汚染を防ぐ。 + // mark-all-as-read だと latestReadNotificationId を進めるだけで Stream は残る。 + beforeEach(async () => { + await api('notifications/flush', {}, alice); + await api('notifications/flush', {}, bob); + await setTimeout(500); + }); + + test('承認済みフォローリクエスト通知は表示されず、カウントにも含まれない', async () => { + // alice をロックする → bob がフォローするとフォローリクエストになる + await api('i/update', { isLocked: true }, alice); + + // bob → alice フォローリクエスト発生 (alice に receiveFollowRequest 通知) + await api('following/create', { userId: alice.id }, bob); + + // createNotification の setTimeout(2000) 後に unreadNotification が発火するため待機 + await setTimeout(2500); + + // 承認前: 通知が 1 件未読として見える状態 + const beforeAccept = await api('i', {}, alice); + assert.strictEqual(beforeAccept.status, 200); + assert.strictEqual(beforeAccept.body.unreadNotificationsCount, 1, + '承認前: フォローリクエスト通知でカウント 1'); + assert.strictEqual(beforeAccept.body.hasUnreadNotification, true); + + // alice 側で承認 → receiveFollowRequest が解決済みになる & + // 同時に follow 通知が新規発生する (フォロワー追加の通知) + await api('following/requests/accept', { userId: bob.id }, alice); + await setTimeout(200); + + // 承認後の状態を観測する。 + // markAsRead=false で内部の readAllNotification を呼ばないようにする + const afterAcceptI = await api('i', {}, alice); + const notificationsRes = await api('i/notifications', { markAsRead: false }, alice); + assert.strictEqual(notificationsRes.status, 200); + + // receiveFollowRequest 通知は packMany で除外されているはず + const remainingReceiveFollowRequest = notificationsRes.body.filter( + (n: { type: string }) => n.type === 'receiveFollowRequest', + ); + assert.strictEqual(remainingReceiveFollowRequest.length, 0, + '承認後: 解決済みの receiveFollowRequest 通知は API レスポンスに現れない'); + + // 修正の本旨: バッジカウントは API レスポンスの件数と一致する + assert.strictEqual(afterAcceptI.body.unreadNotificationsCount, notificationsRes.body.length, + 'unreadNotificationsCount は packMany フィルタ後の件数と一致する (Stream の生件数ではなく)'); + + // 後片付け + await api('i/update', { isLocked: false }, alice); + await api('following/delete', { userId: alice.id }, bob); + }, 1000 * 30); + + test('mention 通知が来たときは API 件数 = バッジカウントが揃う', async () => { + await post(bob, { text: `@alice_n17427 hi ${Date.now()}` }); + // createNotificationInternal の setTimeout(2000) 経過待ち + await setTimeout(2500); + + const userInfo = await api('i', {}, alice); + const notificationsRes = await api('i/notifications', { markAsRead: false }, alice); + + assert.strictEqual(userInfo.body.unreadNotificationsCount, notificationsRes.body.length, + 'unreadNotificationsCount は packMany フィルタ後の件数と一致する'); + assert.ok(userInfo.body.unreadNotificationsCount >= 1, + 'mention 通知 1 件以上で unreadNotificationsCount >= 1'); + }, 1000 * 30); + + test('元のメンション元ノートが削除されると、その通知はカウントから除外される', async () => { + // bob から alice にメンション → alice に通知が立つ + const bobNote = await post(bob, { text: `@alice_n17427 will be deleted ${Date.now()}` }); + await setTimeout(2500); + + // 通知が立った状態を確認 + const beforeDelete = await api('i', {}, alice); + assert.ok(beforeDelete.body.unreadNotificationsCount >= 1, 'ノート削除前はカウント >= 1'); + + // bob がノートを削除する → packMany で「ノート無し」として除外されるはず + await api('notes/delete', { noteId: bobNote.id }, bob); + await setTimeout(500); + + const afterDelete = await api('i', {}, alice); + const notificationsRes = await api('i/notifications', { markAsRead: false }, alice); + + // API レスポンスから該当通知が消えていることを確認 + const stillVisible = notificationsRes.body.filter( + (n: { type: string; note?: { id: string } }) => n.type === 'mention' && n.note?.id === bobNote.id, + ); + assert.strictEqual(stillVisible.length, 0, + 'ノート削除後: mention 通知は packMany で除外され API レスポンスに現れない'); + + // バッジカウントも同様に減っているはず (修正前は Stream に残っていたのでカウントだけ残る現象が出た) + assert.strictEqual(afterDelete.body.unreadNotificationsCount, notificationsRes.body.length, + 'ノート削除後: unreadNotificationsCount は API レスポンス件数と一致する'); + }, 1000 * 30); +}); From 4b60085b384c35f4fa3122453421bd70b15ed32e Mon Sep 17 00:00:00 2001 From: fruitriin Date: Wed, 20 May 2026 00:40:27 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix(backend):=20=E9=80=9A=E7=9F=A5=E3=81=AE?= =?UTF-8?q?=E6=9C=AA=E8=AA=AD=E3=83=90=E3=83=83=E3=82=B8=E3=81=8C=E9=80=9A?= =?UTF-8?q?=E5=B8=B8=E6=93=8D=E4=BD=9C=E3=81=A7=E6=B6=88=E3=81=88=E3=81=AA?= =?UTF-8?q?=E3=81=8F=E3=81=AA=E3=82=8B=E5=95=8F=E9=A1=8C=E3=82=92=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3=20(#17427)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UserEntityService.getNotificationsInfo() は Redis Stream の生エントリ数で 未読カウントを返していたため、packMany で表示時に除外される通知 (削除済み ノート・解決済みフォローリクエスト・サスペンドされた notifier・削除済み ロール等) もカウントに含まれていた。結果として「通知ページには何も表示 されないのにバッジに数字だけ残る」状態が発生し、その状態は唯一 "全件既読" ボタン (notifications/mark-all-as-read) でしか解消できなかった。 NotificationEntityService.packMany と同じバリデータを通した件数を未読数と して返すように変更する。これにより API レスポンスとバッジカウントが常に 一致し、解決済み通知や元データが消失した通知がカウントから自動的に除外 される。 Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 1 + .../src/core/entities/UserEntityService.ts | 36 +++++++++++++------ 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d084006562c..34c8b3123aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ - Enhance: リモートノートクリーニングジョブの削除対象検索処理のパフォーマンス改善 - Fix: センシティブメディア自動検出周りの依存関係・ファイルの解決に失敗する問題を修正 - Fix: フォロワー限定投稿を指名投稿で引用した際に、引用した投稿の公開範囲が意図せず変更される問題を修正 +- Fix: 通知ページに表示されない通知 (解決済みフォローリクエスト・削除済みノート等) がバッジの未読カウントに残り、「全件既読」以外の通常操作で消えなくなる問題を修正 (#17427) ## 2026.5.4 diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 996f0bad2e8..ee261788c2c 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -49,7 +49,9 @@ import type { CustomEmojiService } from '@/core/CustomEmojiService.js'; import { AvatarDecorationService } from '@/core/AvatarDecorationService.js'; import { ChatService } from '@/core/ChatService.js'; import type { OnModuleInit } from '@nestjs/common'; +import type { MiNotification } from '@/models/Notification.js'; import type { NoteEntityService } from './NoteEntityService.js'; +import type { NotificationEntityService } from './NotificationEntityService.js'; import type { PageEntityService } from './PageEntityService.js'; import { toArray } from '@/misc/prelude/array.js'; @@ -87,6 +89,7 @@ export type UserRelation = { export class UserEntityService implements OnModuleInit { private apPersonService: ApPersonService; private noteEntityService: NoteEntityService; + private notificationEntityService: NotificationEntityService; private pageEntityService: PageEntityService; private customEmojiService: CustomEmojiService; private announcementService: AnnouncementService; @@ -143,6 +146,7 @@ export class UserEntityService implements OnModuleInit { onModuleInit() { this.apPersonService = this.moduleRef.get('ApPersonService'); this.noteEntityService = this.moduleRef.get('NoteEntityService'); + this.notificationEntityService = this.moduleRef.get('NotificationEntityService'); this.pageEntityService = this.moduleRef.get('PageEntityService'); this.customEmojiService = this.moduleRef.get('CustomEmojiService'); this.announcementService = this.moduleRef.get('AnnouncementService'); @@ -342,21 +346,33 @@ export class UserEntityService implements OnModuleInit { const latestReadNotificationId = await this.redisClient.get(`latestReadNotification:${userId}`); - if (!latestReadNotificationId) { - response.unreadCount = await this.redisClient.xlen(`notificationTimeline:${userId}`); - } else { - const latestNotificationIdsRes = await this.redisClient.xrevrange( + // 未読範囲の Stream エントリを取得する。 + // latestReadNotificationId が無い (一度も既読化していない) 場合は Stream 全体が未読範囲。 + // 既読位置がある場合は exclusive 比較で「既読位置より新しい」エントリだけ拾う。 + const notificationsRes = latestReadNotificationId + ? await this.redisClient.xrevrange( + `notificationTimeline:${userId}`, + '+', + '(' + latestReadNotificationId, + ) + : await this.redisClient.xrevrange( `notificationTimeline:${userId}`, '+', - latestReadNotificationId, + '-', ); - response.unreadCount = (latestNotificationIdsRes.length - 1 >= 0) ? latestNotificationIdsRes.length - 1 : 0; - } + if (notificationsRes.length === 0) return response; - if (response.unreadCount > 0) { - response.hasUnread = true; - } + // Stream 上のエントリ数をそのまま未読カウントにすると、 + // packMany で除外される通知 (削除済みノート・サスペンドされた notifier・ + // 解決済みフォローリクエスト・削除済みロール等) も含めてしまい、 + // 「通知ページには表示されないのにバッジだけ残る」という乖離が発生する (misskey-dev/misskey#17427)。 + // API 表示と一致させるため、packMany と同じバリデータを通した件数を未読数とする。 + const notifications = notificationsRes.map(x => JSON.parse(x[1][1])) as MiNotification[]; + const validNotifications = await this.notificationEntityService.packMany(notifications, userId); + + response.unreadCount = validNotifications.length; + response.hasUnread = response.unreadCount > 0; return response; } From 946a1a65bae208f510440f9ef1cffd3f28b204cd Mon Sep 17 00:00:00 2001 From: fruitriin Date: Wed, 3 Jun 2026 09:32:09 +0900 Subject: [PATCH 3/4] =?UTF-8?q?test(backend):=20UserEntityService=20?= =?UTF-8?q?=E3=83=86=E3=82=B9=E3=83=88=E3=81=A7=20NotificationEntityServic?= =?UTF-8?q?e=20=E3=82=92=20providers=20=E3=81=AB=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #17427 で UserEntityService.onModuleInit に moduleRef.get('NotificationEntityService') を追加したことで、 テストのローカル providers 配列に未登録だった NotificationEntityService が DI 解決できず unit テストが落ちていた。 Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/backend/test/unit/entities/UserEntityService.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/backend/test/unit/entities/UserEntityService.ts b/packages/backend/test/unit/entities/UserEntityService.ts index 1b5f9ed874c..11a2f85e54d 100644 --- a/packages/backend/test/unit/entities/UserEntityService.ts +++ b/packages/backend/test/unit/entities/UserEntityService.ts @@ -23,6 +23,7 @@ import { DI } from '@/di-symbols.js'; import { AvatarDecorationService } from '@/core/AvatarDecorationService.js'; import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { NotificationEntityService } from '@/core/entities/NotificationEntityService.js'; import { PageEntityService } from '@/core/entities/PageEntityService.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js'; import { AnnouncementService } from '@/core/AnnouncementService.js'; @@ -145,6 +146,7 @@ describe('UserEntityService', () => { UserEntityService, ApPersonService, NoteEntityService, + NotificationEntityService, PageEntityService, CustomEmojiService, AnnouncementService, From a2a81d727e592abe1599a38927db1634eaa6b98e Mon Sep 17 00:00:00 2001 From: fruitriin Date: Wed, 3 Jun 2026 09:52:16 +0900 Subject: [PATCH 4/4] =?UTF-8?q?test(backend):=20UserEntityService=20?= =?UTF-8?q?=E3=83=86=E3=82=B9=E3=83=88=E3=81=A7=20NotificationEntityServic?= =?UTF-8?q?e=20=E3=81=AE=E6=8E=A8=E7=A7=BB=E7=9A=84=E4=BE=9D=E5=AD=98?= =?UTF-8?q?=E3=82=82=20providers=20=E3=81=AB=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NotificationEntityService.onModuleInit が moduleRef.get('RoleEntityService') / moduleRef.get('ChatEntityService') を呼ぶため、これらもテストモジュールに登録しないと `Nest could not find RoleEntityService element` で落ちる。 Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/backend/test/unit/entities/UserEntityService.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/backend/test/unit/entities/UserEntityService.ts b/packages/backend/test/unit/entities/UserEntityService.ts index 11a2f85e54d..094695907d6 100644 --- a/packages/backend/test/unit/entities/UserEntityService.ts +++ b/packages/backend/test/unit/entities/UserEntityService.ts @@ -25,6 +25,8 @@ import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NotificationEntityService } from '@/core/entities/NotificationEntityService.js'; import { PageEntityService } from '@/core/entities/PageEntityService.js'; +import { RoleEntityService } from '@/core/entities/RoleEntityService.js'; +import { ChatEntityService } from '@/core/entities/ChatEntityService.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js'; import { AnnouncementService } from '@/core/AnnouncementService.js'; import { RoleService } from '@/core/RoleService.js'; @@ -148,6 +150,8 @@ describe('UserEntityService', () => { NoteEntityService, NotificationEntityService, PageEntityService, + RoleEntityService, + ChatEntityService, CustomEmojiService, AnnouncementService, RoleService,