diff --git a/packages/backend/test/e2e/antennas.ts b/packages/backend/test/e2e/antennas.ts new file mode 100644 index 0000000000000000000000000000000000000000..dd3b09f85ae2aba387a872f52fdcd8aaa08e63a4 --- /dev/null +++ b/packages/backend/test/e2e/antennas.ts @@ -0,0 +1,653 @@ +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 = <T extends { id: string }>(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<Note> => post(alice, { text: `${keyword}` }), included: true }, + { note: (): Promise<Note> => post(userFollowedByAlice, { text: `${keyword}` }), included: true }, + { note: (): Promise<Note> => post(bob, { text: `test ${keyword}` }), included: true }, + { note: (): Promise<Note> => post(carol, { text: `test ${keyword}` }), included: true }, + ], + }, + { + // BUG e4144a1 以é™home指定ã¯å£Šã‚Œã¦ã„ã‚‹(allã¨åŒã˜) + label: 'ホーム指定ã¯allã¨åŒã˜', + parameters: (): object => ({ src: 'home' }), + posts: [ + { note: (): Promise<Note> => post(alice, { text: `${keyword}` }), included: true }, + { note: (): Promise<Note> => post(userFollowedByAlice, { text: `${keyword}` }), included: true }, + { note: (): Promise<Note> => post(bob, { text: `test ${keyword}` }), included: true }, + { note: (): Promise<Note> => post(carol, { text: `test ${keyword}` }), included: true }, + ], + }, + { + // https://github.com/misskey-dev/misskey/issues/9025 + label: 'ãŸã ã—ã€ãƒ•ã‚©ãƒãƒ¯ãƒ¼é™å®šæŠ•ç¨¿ã¨DM投稿をå«ã¾ãªã„。フォãƒãƒ¯ãƒ¼ã§ã‚ã£ã¦ã‚‚。', + parameters: (): object => ({}), + posts: [ + { note: (): Promise<Note> => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'public' }), included: true }, + { note: (): Promise<Note> => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'home' }), included: true }, + { note: (): Promise<Note> => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'followers' }) }, + { note: (): Promise<Note> => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'specified', visibleUserIds: [alice.id] }) }, + ], + }, + { + label: 'ブãƒãƒƒã‚¯ã—ã¦ã„るユーザーã®ãƒŽãƒ¼ãƒˆã¯å«ã‚€', + parameters: (): object => ({}), + posts: [ + { note: (): Promise<Note> => post(userBlockedByAlice, { text: `${keyword}` }), included: true }, + ], + }, + { + label: 'ブãƒãƒƒã‚¯ã•ã‚Œã¦ã„るユーザーã®ãƒŽãƒ¼ãƒˆã¯å«ã¾ãªã„', + parameters: (): object => ({}), + posts: [ + { note: (): Promise<Note> => post(userBlockingAlice, { text: `${keyword}` }) }, + ], + }, + { + label: 'ミュートã—ã¦ã„るユーザーã®ãƒŽãƒ¼ãƒˆã¯å«ã¾ãªã„', + parameters: (): object => ({}), + posts: [ + { note: (): Promise<Note> => post(userMutedByAlice, { text: `${keyword}` }) }, + ], + }, + { + label: 'ミュートã•ã‚Œã¦ã„るユーザーã®ãƒŽãƒ¼ãƒˆã¯å«ã‚€', + parameters: (): object => ({}), + posts: [ + { note: (): Promise<Note> => post(userMutingAlice, { text: `${keyword}` }), included: true }, + ], + }, + { + label: '「見ã¤ã‘ã‚„ã™ãã™ã‚‹ã€ãŒOFFã®ãƒ¦ãƒ¼ã‚¶ãƒ¼ã®ãƒŽãƒ¼ãƒˆã‚‚å«ã¾ã‚Œã‚‹', + parameters: (): object => ({}), + posts: [ + { note: (): Promise<Note> => post(userNotExplorable, { text: `${keyword}` }), included: true }, + ], + }, + { + label: 'éµä»˜ãユーザーã®ãƒŽãƒ¼ãƒˆã‚‚å«ã¾ã‚Œã‚‹', + parameters: (): object => ({}), + posts: [ + { note: (): Promise<Note> => post(userLocking, { text: `${keyword}` }), included: true }, + ], + }, + { + label: 'サイレンスã®ãƒŽãƒ¼ãƒˆã‚‚å«ã¾ã‚Œã‚‹', + parameters: (): object => ({}), + posts: [ + { note: (): Promise<Note> => post(userSilenced, { text: `${keyword}` }), included: true }, + ], + }, + { + label: '削除ユーザーã®ãƒŽãƒ¼ãƒˆã‚‚å«ã¾ã‚Œã‚‹', + parameters: (): object => ({}), + posts: [ + { note: (): Promise<Note> => post(userDeletedBySelf, { text: `${keyword}` }), included: true }, + { note: (): Promise<Note> => post(userDeletedByAdmin, { text: `${keyword}` }), included: true }, + ], + }, + { + label: 'ユーザー指定ã§', + parameters: (): object => ({ src: 'users', users: [`@${bob.username}`, `@${carol.username}`] }), + posts: [ + { note: (): Promise<Note> => post(alice, { text: `test ${keyword}` }) }, + { note: (): Promise<Note> => post(bob, { text: `test ${keyword}` }), included: true }, + { note: (): Promise<Note> => post(carol, { text: `test ${keyword}` }), included: true }, + ], + }, + { + label: 'リスト指定ã§', + parameters: (): object => ({ src: 'list', userListId: aliceList.id }), + posts: [ + { note: (): Promise<Note> => post(alice, { text: `test ${keyword}` }) }, + { note: (): Promise<Note> => post(bob, { text: `test ${keyword}` }), included: true }, + { note: (): Promise<Note> => post(carol, { text: `test ${keyword}` }), included: true }, + ], + }, + { + label: 'CWã«ã‚‚マッãƒã™ã‚‹', + parameters: (): object => ({ keywords: [[keyword]] }), + posts: [ + { note: (): Promise<Note> => post(bob, { text: 'test', cw: `cw ${keyword}` }), included: true }, + ], + }, + { + label: 'ã‚ーワード1ã¤', + parameters: (): object => ({ keywords: [[keyword]] }), + posts: [ + { note: (): Promise<Note> => post(alice, { text: 'test' }) }, + { note: (): Promise<Note> => post(bob, { text: `test ${keyword}` }), included: true }, + { note: (): Promise<Note> => post(carol, { text: 'test' }) }, + ], + }, + { + label: 'ã‚ーワード3ã¤(AND)', + parameters: (): object => ({ keywords: [['A', 'B', 'C']] }), + posts: [ + { note: (): Promise<Note> => post(bob, { text: 'test A' }) }, + { note: (): Promise<Note> => post(bob, { text: 'test A B' }) }, + { note: (): Promise<Note> => post(bob, { text: 'test B C' }) }, + { note: (): Promise<Note> => post(bob, { text: 'test A B C' }), included: true }, + { note: (): Promise<Note> => post(bob, { text: 'test C B A A B C' }), included: true }, + ], + }, + { + label: 'ã‚ーワード3ã¤(OR)', + parameters: (): object => ({ keywords: [['A'], ['B'], ['C']] }), + posts: [ + { note: (): Promise<Note> => post(bob, { text: 'test' }) }, + { note: (): Promise<Note> => post(bob, { text: 'test A' }), included: true }, + { note: (): Promise<Note> => post(bob, { text: 'test A B' }), included: true }, + { note: (): Promise<Note> => post(bob, { text: 'test B C' }), included: true }, + { note: (): Promise<Note> => post(bob, { text: 'test B C A' }), included: true }, + { note: (): Promise<Note> => post(bob, { text: 'test C B' }), included: true }, + { note: (): Promise<Note> => post(bob, { text: 'test C' }), included: true }, + ], + }, + { + label: '除外ワード3ã¤(AND)', + parameters: (): object => ({ excludeKeywords: [['A', 'B', 'C']] }), + posts: [ + { note: (): Promise<Note> => post(bob, { text: `test ${keyword}` }), included: true }, + { note: (): Promise<Note> => post(bob, { text: `test ${keyword} A` }), included: true }, + { note: (): Promise<Note> => post(bob, { text: `test ${keyword} A B` }), included: true }, + { note: (): Promise<Note> => post(bob, { text: `test ${keyword} B C` }), included: true }, + { note: (): Promise<Note> => post(bob, { text: `test ${keyword} B C A` }) }, + { note: (): Promise<Note> => post(bob, { text: `test ${keyword} C B` }), included: true }, + { note: (): Promise<Note> => post(bob, { text: `test ${keyword} C` }), included: true }, + ], + }, + { + label: '除外ワード3ã¤(OR)', + parameters: (): object => ({ excludeKeywords: [['A'], ['B'], ['C']] }), + posts: [ + { note: (): Promise<Note> => post(bob, { text: `test ${keyword}` }), included: true }, + { note: (): Promise<Note> => post(bob, { text: `test ${keyword} A` }) }, + { note: (): Promise<Note> => post(bob, { text: `test ${keyword} A B` }) }, + { note: (): Promise<Note> => post(bob, { text: `test ${keyword} B C` }) }, + { note: (): Promise<Note> => post(bob, { text: `test ${keyword} B C A` }) }, + { note: (): Promise<Note> => post(bob, { text: `test ${keyword} C B` }) }, + { note: (): Promise<Note> => post(bob, { text: `test ${keyword} C` }) }, + ], + }, + { + label: 'ã‚ーワード1ã¤(大文å—å°æ–‡å—区別ã™ã‚‹)', + parameters: (): object => ({ keywords: [['KEYWORD']], caseSensitive: true }), + posts: [ + { note: (): Promise<Note> => post(bob, { text: 'keyword' }) }, + { note: (): Promise<Note> => post(bob, { text: 'kEyWoRd' }) }, + { note: (): Promise<Note> => post(bob, { text: 'KEYWORD' }), included: true }, + ], + }, + { + label: 'ã‚ーワード1ã¤(大文å—å°æ–‡å—区別ã—ãªã„)', + parameters: (): object => ({ keywords: [['KEYWORD']], caseSensitive: false }), + posts: [ + { note: (): Promise<Note> => post(bob, { text: 'keyword' }), included: true }, + { note: (): Promise<Note> => post(bob, { text: 'kEyWoRd' }), included: true }, + { note: (): Promise<Note> => post(bob, { text: 'KEYWORD' }), included: true }, + ], + }, + { + label: '除外ワード1ã¤(大文å—å°æ–‡å—区別ã™ã‚‹)', + parameters: (): object => ({ excludeKeywords: [['KEYWORD']], caseSensitive: true }), + posts: [ + { note: (): Promise<Note> => post(bob, { text: `${keyword}` }), included: true }, + { note: (): Promise<Note> => post(bob, { text: `${keyword} keyword` }), included: true }, + { note: (): Promise<Note> => post(bob, { text: `${keyword} kEyWoRd` }), included: true }, + { note: (): Promise<Note> => post(bob, { text: `${keyword} KEYWORD` }) }, + ], + }, + { + label: '除外ワード1ã¤(大文å—å°æ–‡å—区別ã—ãªã„)', + parameters: (): object => ({ excludeKeywords: [['KEYWORD']], caseSensitive: false }), + posts: [ + { note: (): Promise<Note> => post(bob, { text: `${keyword}` }), included: true }, + { note: (): Promise<Note> => post(bob, { text: `${keyword} keyword` }) }, + { note: (): Promise<Note> => post(bob, { text: `${keyword} kEyWoRd` }) }, + { note: (): Promise<Note> => post(bob, { text: `${keyword} KEYWORD` }) }, + ], + }, + { + label: '添付ファイルをå•ã‚ãªã„', + parameters: (): object => ({ withFile: false }), + posts: [ + { note: (): Promise<Note> => post(bob, { text: `${keyword}`, fileIds: [bobFile.id] }), included: true }, + { note: (): Promise<Note> => post(bob, { text: `${keyword}` }), included: true }, + ], + }, + { + label: '添付ファイル付ãã®ã¿', + parameters: (): object => ({ withFile: true }), + posts: [ + { note: (): Promise<Note> => post(bob, { text: `${keyword}`, fileIds: [bobFile.id] }), included: true }, + { note: (): Promise<Note> => post(bob, { text: `${keyword}` }) }, + ], + }, + { + label: 'リプライ以外', + parameters: (): object => ({ withReplies: false }), + posts: [ + { note: (): Promise<Note> => post(bob, { text: `${keyword}`, replyId: alicePost.id }) }, + { note: (): Promise<Note> => post(bob, { text: `${keyword}` }), included: true }, + ], + }, + { + label: 'リプライもå«ã‚€', + parameters: (): object => ({ withReplies: true }), + posts: [ + { note: (): Promise<Note> => post(bob, { text: `${keyword}`, replyId: alicePost.id }), included: true }, + { note: (): Promise<Note> => 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 + }); +}); diff --git a/packages/backend/test/utils.ts b/packages/backend/test/utils.ts index 809ed2c66cc15ca09d3a5e7b7d066b642b092118..1a4a0b3545e043d585b0905df36adf07c6bdff9b 100644 --- a/packages/backend/test/utils.ts +++ b/packages/backend/test/utils.ts @@ -124,6 +124,13 @@ export const react = async (user: any, note: any, reaction: string): Promise<any }, user); }; +export const userList = async (user: any, userList: any = {}): Promise<any> => { + const res = await api('users/lists/create', { + name: 'test', + }, user); + return res.body; +}; + export const page = async (user: any, page: any = {}): Promise<any> => { const res = await api('pages/create', { alignCenter: false, @@ -380,6 +387,96 @@ export const simpleGet = async (path: string, accept = '*/*', cookie: any = unde }; }; +/** + * ã‚ã‚‹APIエンドãƒã‚¤ãƒ³ãƒˆã®PaginationãŒè¤‡æ•°ã®æ¡ä»¶ã§ä¸€è²«ã—ãŸæŒ™å‹•ã§ã‚ã‚‹ã“ã¨ã‚’テストã—ã¾ã™ã€‚ + * (sinceId, untilId, sinceDate, untilDate, offset, limit) + * @param expected 期待値ã¨ãªã‚‹Entityã®ä¸¦ã³ï¼ˆä¾‹ï¼šNote[]ï¼‰æ˜‡é †é™é †ãŒä¸€è‡´ã—ã¦ã„ã‚‹å¿…è¦ãŒã‚ã‚‹ + * @param fetchEntities Entity[]ã‚’è¿”å´ã™ã‚‹ãƒ†ã‚¹ãƒˆå¯¾è±¡ã®APIを呼ã³å‡ºã™é–¢æ•° + * @param offsetBy 何をã‚ーã¨ã—ã¦Paginationã™ã‚‹ã‹ã€‚ + * @param ordering æ˜‡é †ãƒ»é™é † + */ +export async function testPaginationConsistency<Entity extends { id: string, createdAt?: string }>( + expected: Entity[], + fetchEntities: (paginationParam: { + limit?: number, + offset?: number, + sinceId?: string, + untilId?: string, + sinceDate?: number, + untilDate?: number, + }) => Promise<Entity[]>, + offsetBy: 'offset' | 'id' | 'createdAt' = 'id', + ordering: 'desc' | 'asc' = 'desc'): Promise<void> { + const rangeToParam = (p: { limit?: number, until?: Entity, since?: Entity }): object => { + if (offsetBy === 'id') { + return { limit: p.limit, sinceId: p.since?.id, untilId: p.until?.id }; + } else { + const sinceDate = p.since?.createdAt !== undefined ? new Date(p.since.createdAt).getTime() : undefined; + const untilDate = p.until?.createdAt !== undefined ? new Date(p.until.createdAt).getTime() : undefined; + return { limit: p.limit, sinceDate, untilDate }; + } + }; + + for (const limit of [1, 5, 10, 100, undefined]) { + // 1. sinceId/Dateã¨untilId/Dateã§ä¸¡ç«¯ã‚’指定ã—ã¦å–å¾—ã—ãŸçµæžœãŒæœŸå¾…通りã«ãªã£ã¦ã„ã‚‹ã“㨠+ if (ordering === 'desc') { + const end = expected[expected.length - 1]; + let last = await fetchEntities(rangeToParam({ limit, since: end })); + const actual: Entity[] = []; + while (last.length !== 0) { + actual.push(...last); + last = await fetchEntities(rangeToParam({ limit, until: last[last.length - 1], since: end })); + } + actual.push(end); + assert.deepStrictEqual( + actual.map(({ id, createdAt }) => id + ':' + createdAt), + expected.map(({ id, createdAt }) => id + ':' + createdAt)); + } + + // 2. sinceId/Date指定+limitã§å–å¾—ã—ã¦ã¤ãªãŽåˆã‚ã›ãŸçµæžœãŒæœŸå¾…通りã«ãªã£ã¦ã„ã‚‹ã“㨠+ if (ordering === 'asc') { + // æ˜‡é †ã«ã—ãŸã¨ãã®å…ˆé (一番å¤ã„ã‚‚ã®)ã‚’ã‚‚ã£ã¦ãる(expected[1]を基準ã«é™é †ã«ã—ã¦0番目) + let last = await fetchEntities({ limit: 1, untilId: expected[1].id }); + const actual: Entity[] = []; + while (last.length !== 0) { + actual.push(...last); + last = await fetchEntities(rangeToParam({ limit, since: last[last.length - 1] })); + } + assert.deepStrictEqual( + actual.map(({ id, createdAt }) => id + ':' + createdAt), + expected.map(({ id, createdAt }) => id + ':' + createdAt)); + } + + // 3. untilId指定+limitã§å–å¾—ã—ã¦ã¤ãªãŽåˆã‚ã›ãŸçµæžœãŒæœŸå¾…通りã«ãªã£ã¦ã„ã‚‹ã“㨠+ if (ordering === 'desc') { + let last = await fetchEntities({ limit }); + const actual: Entity[] = []; + while (last.length !== 0) { + actual.push(...last); + last = await fetchEntities(rangeToParam({ limit, until: last[last.length - 1] })); + } + assert.deepStrictEqual( + actual.map(({ id, createdAt }) => id + ':' + createdAt), + expected.map(({ id, createdAt }) => id + ':' + createdAt)); + } + + // 4. offset指定+limitã§å–å¾—ã—ã¦ã¤ãªãŽåˆã‚ã›ãŸçµæžœãŒæœŸå¾…通りã«ãªã£ã¦ã„ã‚‹ã“㨠+ if (offsetBy === 'offset') { + let last = await fetchEntities({ limit, offset: 0 }); + let offset = limit ?? 10; + const actual: Entity[] = []; + while (last.length !== 0) { + actual.push(...last); + last = await fetchEntities({ limit, offset }); + offset += limit ?? 10; + } + assert.deepStrictEqual( + actual.map(({ id, createdAt }) => id + ':' + createdAt), + expected.map(({ id, createdAt }) => id + ':' + createdAt)); + } + } +} + export async function initTestDb(justBorrow = false, initEntities?: any[]) { if (process.env.NODE_ENV !== 'test') throw 'NODE_ENV is not a test';