process.env.NODE_ENV = 'test'; import * as assert from 'assert'; import { inspect } from 'node:util'; import { DEFAULT_POLICIES } from '@/core/RoleService.js'; import type { Packed } from '@/misc/json-schema.js'; import { signup, post, userList, page, role, startServer, api, successfulApiCall, failedApiCall, uploadFile, testPaginationConsistency, } from '../utils.js'; import type * as misskey from 'misskey-js'; import type { INestApplicationContext } from '@nestjs/common'; const compareBy = (selector: (s: T) => string = (s: T): string => s.id) => (a: T, b: T): number => { return selector(a).localeCompare(selector(b)); }; describe('アンテナ', () => { // エンティティとしてのアンテナを主眼においたテストを記述する // (Antennaを返すエンドポイント、Antennaエンティティを書き換えるエンドポイント、Antennaからノートを取得するエンドポイントをテストする) // BUG misskey-jsとjson-schemaが一致していない。 // - srcのenumにgroupが残っている // - userGroupIdが残っている, isActiveがない type Antenna = misskey.entities.Antenna | Packed<'Antenna'>; type User = misskey.entities.MeDetailed & { token: string }; type Note = misskey.entities.Note; // アンテナを作成できる最小のパラメタ const defaultParam = { caseSensitive: false, excludeKeywords: [['']], keywords: [['keyword']], name: 'test', notify: false, src: 'all' as const, userListId: null, users: [''], withFile: false, withReplies: false, }; let app: INestApplicationContext; let root: User; let alice: User; let bob: User; let carol: User; let alicePost: Note; let aliceList: misskey.entities.UserList; let bobFile: misskey.entities.DriveFile; let bobList: misskey.entities.UserList; let userNotExplorable: User; let userLocking: User; let userSilenced: User; let userSuspended: User; let userDeletedBySelf: User; let userDeletedByAdmin: User; let userFollowingAlice: User; let userFollowedByAlice: User; let userBlockingAlice: User; let userBlockedByAlice: User; let userMutingAlice: User; let userMutedByAlice: User; beforeAll(async () => { app = await startServer(); }, 1000 * 60 * 2); beforeAll(async () => { root = await signup({ username: 'root' }); alice = await signup({ username: 'alice' }); alicePost = await post(alice, { text: 'test' }); aliceList = await userList(alice, {}); bob = await signup({ username: 'bob' }); aliceList = await userList(alice, {}); bobFile = (await uploadFile(bob)).body; bobList = await userList(bob); carol = await signup({ username: 'carol' }); await api('users/lists/push', { listId: aliceList.id, userId: bob.id }, alice); await api('users/lists/push', { listId: aliceList.id, userId: carol.id }, alice); userNotExplorable = await signup({ username: 'userNotExplorable' }); await post(userNotExplorable, { text: 'test' }); await api('i/update', { isExplorable: false }, userNotExplorable); userLocking = await signup({ username: 'userLocking' }); await post(userLocking, { text: 'test' }); await api('i/update', { isLocked: true }, userLocking); userSilenced = await signup({ username: 'userSilenced' }); await post(userSilenced, { text: 'test' }); const roleSilenced = await role(root, {}, { canPublicNote: { priority: 0, useDefault: false, value: false } }); await api('admin/roles/assign', { userId: userSilenced.id, roleId: roleSilenced.id }, root); userSuspended = await signup({ username: 'userSuspended' }); await post(userSuspended, { text: 'test' }); await successfulApiCall({ endpoint: 'i/update', parameters: { description: '#user_testuserSuspended' }, user: userSuspended }); await api('admin/suspend-user', { userId: userSuspended.id }, root); userDeletedBySelf = await signup({ username: 'userDeletedBySelf', password: 'userDeletedBySelf' }); await post(userDeletedBySelf, { text: 'test' }); await api('i/delete-account', { password: 'userDeletedBySelf' }, userDeletedBySelf); userDeletedByAdmin = await signup({ username: 'userDeletedByAdmin' }); await post(userDeletedByAdmin, { text: 'test' }); await api('admin/delete-account', { userId: userDeletedByAdmin.id }, root); userFollowedByAlice = await signup({ username: 'userFollowedByAlice' }); await post(userFollowedByAlice, { text: 'test' }); await api('following/create', { userId: userFollowedByAlice.id }, alice); userFollowingAlice = await signup({ username: 'userFollowingAlice' }); await post(userFollowingAlice, { text: 'test' }); await api('following/create', { userId: alice.id }, userFollowingAlice); userBlockingAlice = await signup({ username: 'userBlockingAlice' }); await post(userBlockingAlice, { text: 'test' }); await api('blocking/create', { userId: alice.id }, userBlockingAlice); userBlockedByAlice = await signup({ username: 'userBlockedByAlice' }); await post(userBlockedByAlice, { text: 'test' }); await api('blocking/create', { userId: userBlockedByAlice.id }, alice); userMutingAlice = await signup({ username: 'userMutingAlice' }); await post(userMutingAlice, { text: 'test' }); await api('mute/create', { userId: alice.id }, userMutingAlice); userMutedByAlice = await signup({ username: 'userMutedByAlice' }); await post(userMutedByAlice, { text: 'test' }); await api('mute/create', { userId: userMutedByAlice.id }, alice); }, 1000 * 60 * 10); afterAll(async () => { await app.close(); }); beforeEach(async () => { // テスト間で影響し合わないように毎回全部消す。 for (const user of [alice, bob]) { const list = await api('/antennas/list', {}, user); for (const antenna of list.body) { await api('/antennas/delete', { antennaId: antenna.id }, user); } } }); //#region 作成(antennas/create) test('が作成できること、キーが過不足なく入っていること。', async () => { const response = await successfulApiCall({ endpoint: 'antennas/create', parameters: { ...defaultParam }, user: alice, }); assert.match(response.id, /[0-9a-z]{10}/); const expected = { id: response.id, caseSensitive: false, createdAt: new Date(response.createdAt).toISOString(), excludeKeywords: [['']], hasUnreadNote: false, isActive: true, keywords: [['keyword']], name: 'test', notify: false, src: 'all', userListId: null, users: [''], withFile: false, withReplies: false, } as Antenna; assert.deepStrictEqual(response, expected); }); test('が上限いっぱいまで作成できること', async () => { // antennaLimit + 1まで作れるのがキモ const response = await Promise.all([...Array(DEFAULT_POLICIES.antennaLimit + 1)].map(() => successfulApiCall({ endpoint: 'antennas/create', parameters: { ...defaultParam }, user: alice, }))); const expected = await successfulApiCall({ endpoint: 'antennas/list', parameters: {}, user: alice }); assert.deepStrictEqual( response.sort(compareBy(s => s.id)), expected.sort(compareBy(s => s.id))); failedApiCall({ endpoint: 'antennas/create', parameters: { ...defaultParam }, user: alice, }, { status: 400, code: 'TOO_MANY_ANTENNAS', id: 'faf47050-e8b5-438c-913c-db2b1576fde4', }); }); test('を作成するとき他人のリストを指定したらエラーになる', async () => { failedApiCall({ endpoint: 'antennas/create', parameters: { ...defaultParam, src: 'list', userListId: bobList.id }, user: alice, }, { status: 400, code: 'NO_SUCH_USER_LIST', id: '95063e93-a283-4b8b-9aa5-bcdb8df69a7f', }); }); const antennaParamPattern = [ { parameters: (): object => ({ name: 'x'.repeat(100) }) }, { parameters: (): object => ({ name: 'x' }) }, { parameters: (): object => ({ src: 'home' }) }, { parameters: (): object => ({ src: 'all' }) }, { parameters: (): object => ({ src: 'users' }) }, { parameters: (): object => ({ src: 'list' }) }, { parameters: (): object => ({ userListId: null }) }, { parameters: (): object => ({ src: 'list', userListId: aliceList.id }) }, { parameters: (): object => ({ keywords: [['x']] }) }, { parameters: (): object => ({ keywords: [['a', 'b', 'c'], ['x'], ['y'], ['z']] }) }, { parameters: (): object => ({ excludeKeywords: [['a', 'b', 'c'], ['x'], ['y'], ['z']] }) }, { parameters: (): object => ({ users: [alice.username] }) }, { parameters: (): object => ({ users: [alice.username, bob.username, carol.username] }) }, { parameters: (): object => ({ caseSensitive: false }) }, { parameters: (): object => ({ caseSensitive: true }) }, { parameters: (): object => ({ withReplies: false }) }, { parameters: (): object => ({ withReplies: true }) }, { parameters: (): object => ({ withFile: false }) }, { parameters: (): object => ({ withFile: true }) }, { parameters: (): object => ({ notify: false }) }, { parameters: (): object => ({ notify: true }) }, ]; test.each(antennaParamPattern)('を作成できること($#)', async ({ parameters }) => { const response = await successfulApiCall({ endpoint: 'antennas/create', parameters: { ...defaultParam, ...parameters() }, user: alice, }); const expected = { ...response, ...parameters() }; assert.deepStrictEqual(response, expected); }); //#endregion //#region 更新(antennas/update) test.each(antennaParamPattern)('を変更できること($#)', async ({ parameters }) => { const antenna = await successfulApiCall({ endpoint: 'antennas/create', parameters: defaultParam, user: alice }); const response = await successfulApiCall({ endpoint: 'antennas/update', parameters: { antennaId: antenna.id, ...defaultParam, ...parameters() }, user: alice, }); const expected = { ...response, ...parameters() }; assert.deepStrictEqual(response, expected); }); test.todo('は他人のものは変更できない'); test('を変更するとき他人のリストを指定したらエラーになる', async () => { const antenna = await successfulApiCall({ endpoint: 'antennas/create', parameters: defaultParam, user: alice }); failedApiCall({ endpoint: 'antennas/update', parameters: { antennaId: antenna.id, ...defaultParam, src: 'list', userListId: bobList.id }, user: alice, }, { status: 400, code: 'NO_SUCH_USER_LIST', id: '1c6b35c9-943e-48c2-81e4-2844989407f7', }); }); //#endregion //#region 表示(antennas/show) test('をID指定で表示できること。', async () => { const antenna = await successfulApiCall({ endpoint: 'antennas/create', parameters: defaultParam, user: alice }); const response = await successfulApiCall({ endpoint: 'antennas/show', parameters: { antennaId: antenna.id }, user: alice, }); const expected = { ...antenna }; assert.deepStrictEqual(response, expected); }); test.todo('は他人のものをID指定で表示できない'); //#endregion //#region 一覧(antennas/list) test('をリスト形式で取得できること。', async () => { const antenna = await successfulApiCall({ endpoint: 'antennas/create', parameters: defaultParam, user: alice }); await successfulApiCall({ endpoint: 'antennas/create', parameters: defaultParam, user: bob }); const response = await successfulApiCall({ endpoint: 'antennas/list', parameters: {}, user: alice, }); const expected = [{ ...antenna }]; assert.deepStrictEqual(response, expected); }); //#endregion //#region 削除(antennas/delete) test('を削除できること。', async () => { const antenna = await successfulApiCall({ endpoint: 'antennas/create', parameters: defaultParam, user: alice }); const response = await successfulApiCall({ endpoint: 'antennas/delete', parameters: { antennaId: antenna.id }, user: alice, }); assert.deepStrictEqual(response, null); const list = await successfulApiCall({ endpoint: 'antennas/list', parameters: {}, user: alice }); assert.deepStrictEqual(list, []); }); test.todo('は他人のものを削除できない'); //#endregion describe('のノート', () => { //#region アンテナのノート取得(antennas/notes) test('を取得できること。', async () => { const keyword = 'キーワード'; await post(bob, { text: `test ${keyword} beforehand` }); const antenna = await successfulApiCall({ endpoint: 'antennas/create', parameters: { ...defaultParam, keywords: [[keyword]] }, user: alice, }); const note = await post(bob, { text: `test ${keyword}` }); const response = await successfulApiCall({ endpoint: 'antennas/notes', parameters: { antennaId: antenna.id }, user: alice, }); const expected = [note]; assert.deepStrictEqual(response, expected); }); const keyword = 'キーワード'; test.each([ { label: '全体から', parameters: (): object => ({ src: 'all' }), posts: [ { note: (): Promise => post(alice, { text: `${keyword}` }), included: true }, { note: (): Promise => post(userFollowedByAlice, { text: `${keyword}` }), included: true }, { note: (): Promise => post(bob, { text: `test ${keyword}` }), included: true }, { note: (): Promise => post(carol, { text: `test ${keyword}` }), included: true }, ], }, { // BUG e4144a1 以降home指定は壊れている(allと同じ) label: 'ホーム指定はallと同じ', parameters: (): object => ({ src: 'home' }), posts: [ { note: (): Promise => post(alice, { text: `${keyword}` }), included: true }, { note: (): Promise => post(userFollowedByAlice, { text: `${keyword}` }), included: true }, { note: (): Promise => post(bob, { text: `test ${keyword}` }), included: true }, { note: (): Promise => post(carol, { text: `test ${keyword}` }), included: true }, ], }, { // https://github.com/misskey-dev/misskey/issues/9025 label: 'ただし、フォロワー限定投稿とDM投稿を含まない。フォロワーであっても。', parameters: (): object => ({}), posts: [ { note: (): Promise => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'public' }), included: true }, { note: (): Promise => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'home' }), included: true }, { note: (): Promise => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'followers' }) }, { note: (): Promise => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'specified', visibleUserIds: [alice.id] }) }, ], }, { label: 'ブロックしているユーザーのノートは含む', parameters: (): object => ({}), posts: [ { note: (): Promise => post(userBlockedByAlice, { text: `${keyword}` }), included: true }, ], }, { label: 'ブロックされているユーザーのノートは含まない', parameters: (): object => ({}), posts: [ { note: (): Promise => post(userBlockingAlice, { text: `${keyword}` }) }, ], }, { label: 'ミュートしているユーザーのノートは含まない', parameters: (): object => ({}), posts: [ { note: (): Promise => post(userMutedByAlice, { text: `${keyword}` }) }, ], }, { label: 'ミュートされているユーザーのノートは含む', parameters: (): object => ({}), posts: [ { note: (): Promise => post(userMutingAlice, { text: `${keyword}` }), included: true }, ], }, { label: '「見つけやすくする」がOFFのユーザーのノートも含まれる', parameters: (): object => ({}), posts: [ { note: (): Promise => post(userNotExplorable, { text: `${keyword}` }), included: true }, ], }, { label: '鍵付きユーザーのノートも含まれる', parameters: (): object => ({}), posts: [ { note: (): Promise => post(userLocking, { text: `${keyword}` }), included: true }, ], }, { label: 'サイレンスのノートも含まれる', parameters: (): object => ({}), posts: [ { note: (): Promise => post(userSilenced, { text: `${keyword}` }), included: true }, ], }, { label: '削除ユーザーのノートも含まれる', parameters: (): object => ({}), posts: [ { note: (): Promise => post(userDeletedBySelf, { text: `${keyword}` }), included: true }, { note: (): Promise => post(userDeletedByAdmin, { text: `${keyword}` }), included: true }, ], }, { label: 'ユーザー指定で', parameters: (): object => ({ src: 'users', users: [`@${bob.username}`, `@${carol.username}`] }), posts: [ { note: (): Promise => post(alice, { text: `test ${keyword}` }) }, { note: (): Promise => post(bob, { text: `test ${keyword}` }), included: true }, { note: (): Promise => post(carol, { text: `test ${keyword}` }), included: true }, ], }, { label: 'リスト指定で', parameters: (): object => ({ src: 'list', userListId: aliceList.id }), posts: [ { note: (): Promise => post(alice, { text: `test ${keyword}` }) }, { note: (): Promise => post(bob, { text: `test ${keyword}` }), included: true }, { note: (): Promise => post(carol, { text: `test ${keyword}` }), included: true }, ], }, { label: 'CWにもマッチする', parameters: (): object => ({ keywords: [[keyword]] }), posts: [ { note: (): Promise => post(bob, { text: 'test', cw: `cw ${keyword}` }), included: true }, ], }, { label: 'キーワード1つ', parameters: (): object => ({ keywords: [[keyword]] }), posts: [ { note: (): Promise => post(alice, { text: 'test' }) }, { note: (): Promise => post(bob, { text: `test ${keyword}` }), included: true }, { note: (): Promise => post(carol, { text: 'test' }) }, ], }, { label: 'キーワード3つ(AND)', parameters: (): object => ({ keywords: [['A', 'B', 'C']] }), posts: [ { note: (): Promise => post(bob, { text: 'test A' }) }, { note: (): Promise => post(bob, { text: 'test A B' }) }, { note: (): Promise => post(bob, { text: 'test B C' }) }, { note: (): Promise => post(bob, { text: 'test A B C' }), included: true }, { note: (): Promise => post(bob, { text: 'test C B A A B C' }), included: true }, ], }, { label: 'キーワード3つ(OR)', parameters: (): object => ({ keywords: [['A'], ['B'], ['C']] }), posts: [ { note: (): Promise => post(bob, { text: 'test' }) }, { note: (): Promise => post(bob, { text: 'test A' }), included: true }, { note: (): Promise => post(bob, { text: 'test A B' }), included: true }, { note: (): Promise => post(bob, { text: 'test B C' }), included: true }, { note: (): Promise => post(bob, { text: 'test B C A' }), included: true }, { note: (): Promise => post(bob, { text: 'test C B' }), included: true }, { note: (): Promise => post(bob, { text: 'test C' }), included: true }, ], }, { label: '除外ワード3つ(AND)', parameters: (): object => ({ excludeKeywords: [['A', 'B', 'C']] }), posts: [ { note: (): Promise => post(bob, { text: `test ${keyword}` }), included: true }, { note: (): Promise => post(bob, { text: `test ${keyword} A` }), included: true }, { note: (): Promise => post(bob, { text: `test ${keyword} A B` }), included: true }, { note: (): Promise => post(bob, { text: `test ${keyword} B C` }), included: true }, { note: (): Promise => post(bob, { text: `test ${keyword} B C A` }) }, { note: (): Promise => post(bob, { text: `test ${keyword} C B` }), included: true }, { note: (): Promise => post(bob, { text: `test ${keyword} C` }), included: true }, ], }, { label: '除外ワード3つ(OR)', parameters: (): object => ({ excludeKeywords: [['A'], ['B'], ['C']] }), posts: [ { note: (): Promise => post(bob, { text: `test ${keyword}` }), included: true }, { note: (): Promise => post(bob, { text: `test ${keyword} A` }) }, { note: (): Promise => post(bob, { text: `test ${keyword} A B` }) }, { note: (): Promise => post(bob, { text: `test ${keyword} B C` }) }, { note: (): Promise => post(bob, { text: `test ${keyword} B C A` }) }, { note: (): Promise => post(bob, { text: `test ${keyword} C B` }) }, { note: (): Promise => post(bob, { text: `test ${keyword} C` }) }, ], }, { label: 'キーワード1つ(大文字小文字区別する)', parameters: (): object => ({ keywords: [['KEYWORD']], caseSensitive: true }), posts: [ { note: (): Promise => post(bob, { text: 'keyword' }) }, { note: (): Promise => post(bob, { text: 'kEyWoRd' }) }, { note: (): Promise => post(bob, { text: 'KEYWORD' }), included: true }, ], }, { label: 'キーワード1つ(大文字小文字区別しない)', parameters: (): object => ({ keywords: [['KEYWORD']], caseSensitive: false }), posts: [ { note: (): Promise => post(bob, { text: 'keyword' }), included: true }, { note: (): Promise => post(bob, { text: 'kEyWoRd' }), included: true }, { note: (): Promise => post(bob, { text: 'KEYWORD' }), included: true }, ], }, { label: '除外ワード1つ(大文字小文字区別する)', parameters: (): object => ({ excludeKeywords: [['KEYWORD']], caseSensitive: true }), posts: [ { note: (): Promise => post(bob, { text: `${keyword}` }), included: true }, { note: (): Promise => post(bob, { text: `${keyword} keyword` }), included: true }, { note: (): Promise => post(bob, { text: `${keyword} kEyWoRd` }), included: true }, { note: (): Promise => post(bob, { text: `${keyword} KEYWORD` }) }, ], }, { label: '除外ワード1つ(大文字小文字区別しない)', parameters: (): object => ({ excludeKeywords: [['KEYWORD']], caseSensitive: false }), posts: [ { note: (): Promise => post(bob, { text: `${keyword}` }), included: true }, { note: (): Promise => post(bob, { text: `${keyword} keyword` }) }, { note: (): Promise => post(bob, { text: `${keyword} kEyWoRd` }) }, { note: (): Promise => post(bob, { text: `${keyword} KEYWORD` }) }, ], }, { label: '添付ファイルを問わない', parameters: (): object => ({ withFile: false }), posts: [ { note: (): Promise => post(bob, { text: `${keyword}`, fileIds: [bobFile.id] }), included: true }, { note: (): Promise => post(bob, { text: `${keyword}` }), included: true }, ], }, { label: '添付ファイル付きのみ', parameters: (): object => ({ withFile: true }), posts: [ { note: (): Promise => post(bob, { text: `${keyword}`, fileIds: [bobFile.id] }), included: true }, { note: (): Promise => post(bob, { text: `${keyword}` }) }, ], }, { label: 'リプライ以外', parameters: (): object => ({ withReplies: false }), posts: [ { note: (): Promise => post(bob, { text: `${keyword}`, replyId: alicePost.id }) }, { note: (): Promise => post(bob, { text: `${keyword}` }), included: true }, ], }, { label: 'リプライも含む', parameters: (): object => ({ withReplies: true }), posts: [ { note: (): Promise => post(bob, { text: `${keyword}`, replyId: alicePost.id }), included: true }, { note: (): Promise => post(bob, { text: `${keyword}` }), included: true }, ], }, ])('が取得できること($label)', async ({ parameters, posts }) => { const antenna = await successfulApiCall({ endpoint: 'antennas/create', parameters: { ...defaultParam, keywords: [[keyword]], ...parameters() }, user: alice, }); const notes = await posts.reduce(async (prev, current) => { // includedに関わらずnote()は評価して投稿する。 const p = await prev; const n = await current.note(); if (current.included) return p.concat(n); return p; }, Promise.resolve([] as Note[])); // alice視点でNoteを取り直す const expected = await Promise.all(notes.reverse().map(s => successfulApiCall({ endpoint: 'notes/show', parameters: { noteId: s.id }, user: alice, }))); const response = await successfulApiCall({ endpoint: 'antennas/notes', parameters: { antennaId: antenna.id }, user: alice, }); assert.deepStrictEqual( response.map(({ userId, id, text }) => ({ userId, id, text })), expected.map(({ userId, id, text }) => ({ userId, id, text }))); assert.deepStrictEqual(response, expected); }); test.skip('が取得でき、日付指定のPaginationに一貫性があること', async () => { }); test.each([ { label: 'ID指定', offsetBy: 'id' }, // BUG sinceDate, untilDateはsinceIdや他のエンドポイントとは異なり、その時刻に一致するレコードを含んでしまう。 // { label: '日付指定', offsetBy: 'createdAt' }, ] as const)('が取得でき、$labelのPaginationに一貫性があること', async ({ offsetBy }) => { const antenna = await successfulApiCall({ endpoint: 'antennas/create', parameters: { ...defaultParam, keywords: [[keyword]] }, user: alice, }); const notes = await [...Array(30)].reduce(async (prev, current, index) => { const p = await prev; const n = await post(alice, { text: `${keyword} (${index})` }); return [n].concat(p); }, Promise.resolve([] as Note[])); // antennas/notesは降順のみで、昇順をサポートしない。 await testPaginationConsistency(notes, async (paginationParam) => { return successfulApiCall({ endpoint: 'antennas/notes', parameters: { antennaId: antenna.id, ...paginationParam }, user: alice, }) as any as Note[]; }, offsetBy, 'desc'); }); // BUG 7日過ぎると作り直すしかない。 https://github.com/misskey-dev/misskey/issues/10476 test.todo('を取得したときActiveに戻る'); //#endregion }); });