diff --git a/packages/backend/test/e2e/clips.ts b/packages/backend/test/e2e/clips.ts new file mode 100644 index 0000000000000000000000000000000000000000..f35aae9dc65da1eb4e5bec9c1686d656a5179f39 --- /dev/null +++ b/packages/backend/test/e2e/clips.ts @@ -0,0 +1,962 @@ +process.env.NODE_ENV = 'test'; + +import * as assert from 'assert'; +import { JTDDataType } from 'ajv/dist/jtd'; +import { DEFAULT_POLICIES } from '@/core/RoleService.js'; +import type { Packed } from '@/misc/json-schema.js'; +import { paramDef as CreateParamDef } from '@/server/api/endpoints/clips/create.js'; +import { paramDef as UpdateParamDef } from '@/server/api/endpoints/clips/update.js'; +import { paramDef as DeleteParamDef } from '@/server/api/endpoints/clips/delete.js'; +import { paramDef as ShowParamDef } from '@/server/api/endpoints/clips/show.js'; +import { paramDef as FavoriteParamDef } from '@/server/api/endpoints/clips/favorite.js'; +import { paramDef as UnfavoriteParamDef } from '@/server/api/endpoints/clips/unfavorite.js'; +import { paramDef as AddNoteParamDef } from '@/server/api/endpoints/clips/add-note.js'; +import { paramDef as RemoveNoteParamDef } from '@/server/api/endpoints/clips/remove-note.js'; +import { paramDef as NotesParamDef } from '@/server/api/endpoints/clips/notes.js'; +import { + signup, + post, + startServer, + api, + successfulApiCall, + failedApiCall, + ApiRequest, + hiddenNote, +} from '../utils.js'; +import type { INestApplicationContext } from '@nestjs/common'; + +describe('クリップ', () => { + type User = Packed<'User'>; + type Note = Packed<'Note'>; + type Clip = Packed<'Clip'>; + + let app: INestApplicationContext; + + let alice: User; + let bob: User; + let aliceNote: Note; + let aliceHomeNote: Note; + let aliceFollowersNote: Note; + let aliceSpecifiedNote: Note; + let bobNote: Note; + let bobHomeNote: Note; + let bobFollowersNote: Note; + let bobSpecifiedNote: Note; + + 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)); + }; + + type CreateParam = JTDDataType<typeof CreateParamDef>; + const defaultCreate = (): Partial<CreateParam> => ({ + name: 'test', + }); + const create = async (parameters: Partial<CreateParam> = {}, request: Partial<ApiRequest> = {}): Promise<Clip> => { + const clip = await successfulApiCall<Clip>({ + endpoint: '/clips/create', + parameters: { + ...defaultCreate(), + ...parameters, + }, + user: alice, + ...request, + }); + + // 入力ãŒçµæžœã¨ã—ã¦å…¥ã£ã¦ã„ã‚‹ã“㨠+ assert.deepStrictEqual(clip, { + ...clip, + ...defaultCreate(), + ...parameters, + }); + return clip; + }; + + const createMany = async (parameters: Partial<CreateParam>, count = 10, user = alice): Promise<Clip[]> => { + return await Promise.all([...Array(count)].map((_, i) => create({ + name: `test${i}`, + ...parameters, + }, { user }))); + }; + + type UpdateParam = JTDDataType<typeof UpdateParamDef>; + const update = async (parameters: Partial<UpdateParam>, request: Partial<ApiRequest> = {}): Promise<Clip> => { + const clip = await successfulApiCall<Clip>({ + endpoint: '/clips/update', + parameters: { + name: 'updated', + ...parameters, + }, + user: alice, + ...request, + }); + + // 入力ãŒçµæžœã¨ã—ã¦å…¥ã£ã¦ã„ã‚‹ã“ã¨ã€‚clipIdã¯idã«ãªã‚‹ã®ã§æ¶ˆã—ã¦ãŠã + delete (parameters as { clipId?: string }).clipId; + assert.deepStrictEqual(clip, { + ...clip, + ...parameters, + }); + return clip; + }; + + type DeleteParam = JTDDataType<typeof DeleteParamDef>; + const deleteClip = async (parameters: DeleteParam, request: Partial<ApiRequest> = {}): Promise<void> => { + return await successfulApiCall<void>({ + endpoint: '/clips/delete', + parameters, + user: alice, + ...request, + }, { + status: 204, + }); + }; + + type ShowParam = JTDDataType<typeof ShowParamDef>; + const show = async (parameters: ShowParam, request: Partial<ApiRequest> = {}): Promise<Clip> => { + return await successfulApiCall<Clip>({ + endpoint: '/clips/show', + parameters, + user: alice, + ...request, + }); + }; + + const list = async (request: Partial<ApiRequest>): Promise<Clip[]> => { + return successfulApiCall<Clip[]>({ + endpoint: '/clips/list', + parameters: {}, + user: alice, + ...request, + }); + }; + + const usersClips = async (request: Partial<ApiRequest>): Promise<Clip[]> => { + return await successfulApiCall<Clip[]>({ + endpoint: '/users/clips', + parameters: {}, + user: alice, + ...request, + }); + }; + + beforeAll(async () => { + app = await startServer(); + alice = await signup({ username: 'alice' }); + bob = await signup({ username: 'bob' }); + + // FIXME: misskey-jsã®Noteã¯outdatedãªã®ã§ç›´æŽ¥å¤‰æ›ã§ããªã„ + aliceNote = await post(alice, { text: 'test' }) as any; + aliceHomeNote = await post(alice, { text: 'home only', visibility: 'home' }) as any; + aliceFollowersNote = await post(alice, { text: 'followers only', visibility: 'followers' }) as any; + aliceSpecifiedNote = await post(alice, { text: 'specified only', visibility: 'specified' }) as any; + bobNote = await post(bob, { text: 'test' }) as any; + bobHomeNote = await post(bob, { text: 'home only', visibility: 'home' }) as any; + bobFollowersNote = await post(bob, { text: 'followers only', visibility: 'followers' }) as any; + bobSpecifiedNote = await post(bob, { text: 'specified only', visibility: 'specified' }) as any; + }, 1000 * 60 * 2); + + afterAll(async () => { + await app.close(); + }); + + afterEach(async () => { + // テスト間ã§å½±éŸ¿ã—åˆã‚ãªã„よã†ã«æ¯Žå›žå…¨éƒ¨æ¶ˆã™ã€‚ + for (const user of [alice, bob]) { + const list = await api('/clips/list', { limit: 11 }, user); + for (const clip of list.body) { + await api('/clips/delete', { clipId: clip.id }, user); + } + } + }); + + test('ã®ä½œæˆãŒã§ãã‚‹', async () => { + const res = await create(); + // ISO 8601ã§æ—¥ä»˜ãŒè¿”ã£ã¦ãã‚‹ã“㨠+ assert.strictEqual(res.createdAt, new Date(res.createdAt).toISOString()); + assert.strictEqual(res.lastClippedAt, null); + assert.strictEqual(res.name, 'test'); + assert.strictEqual(res.description, null); + assert.strictEqual(res.isPublic, false); + assert.strictEqual(res.favoritedCount, 0); + assert.strictEqual(res.isFavorited, false); + }); + + test('ã®ä½œæˆã¯ãƒãƒªã‚·ãƒ¼ã§å®šã‚られãŸæ•°ä»¥ä¸Šã¯ã§ããªã„。', async () => { + // ãƒãƒªã‚·ãƒ¼ + 1ã¾ã§ä½œã‚Œã‚‹ã¨ã„ã†æ‰€ãŒãƒŸã‚½ + const clipLimit = DEFAULT_POLICIES.clipLimit + 1; + for (let i = 0; i < clipLimit; i++) { + await create(); + } + + await failedApiCall({ + endpoint: '/clips/create', + parameters: defaultCreate(), + user: alice, + }, { + status: 400, + code: 'TOO_MANY_CLIPS', + id: '920f7c2d-6208-4b76-8082-e632020f5883', + }); + }); + + const createClipAllowedPattern = [ + { label: 'nameãŒæœ€å¤§é•·', parameters: { name: 'x'.repeat(100) } }, + { label: 'private', parameters: { isPublic: false } }, + { label: 'public', parameters: { isPublic: true } }, + { label: 'descriptionãŒnull', parameters: { description: null } }, + { label: 'descriptionãŒæœ€å¤§é•·', parameters: { description: 'a'.repeat(2048) } }, + ]; + test.each(createClipAllowedPattern)('ã®ä½œæˆã¯$labelã§ã‚‚ã§ãã‚‹', async ({ parameters }) => await create(parameters)); + + const createClipDenyPattern = [ + { label: 'nameãŒnull', parameters: { name: null } }, + { label: 'nameãŒæœ€å¤§é•·+1', parameters: { name: 'x'.repeat(101) } }, + { label: 'isPublicãŒboolã˜ã‚ƒãªã„', parameters: { isPublic: 'true' } }, + { label: 'descriptionãŒã‚¼ãƒé•·', parameters: { description: '' } }, + { label: 'descriptionãŒæœ€å¤§é•·+1', parameters: { description: 'a'.repeat(2049) } }, + ]; + test.each(createClipDenyPattern)('ã®ä½œæˆã¯$labelãªã‚‰ã§ããªã„', async ({ parameters }) => failedApiCall({ + endpoint: '/clips/create', + parameters: { + ...defaultCreate(), + ...parameters, + }, + user: alice, + }, { + status: 400, + code: 'INVALID_PARAM', + id: '3d81ceae-475f-4600-b2a8-2bc116157532', + })); + + test('ã®æ›´æ–°ãŒã§ãã‚‹', async () => { + const res = await update({ + clipId: (await create()).id, + name: 'updated', + description: 'new description', + isPublic: true, + }); + + // ISO 8601ã§æ—¥ä»˜ãŒè¿”ã£ã¦ãã‚‹ã“㨠+ assert.strictEqual(res.createdAt, new Date(res.createdAt).toISOString()); + assert.strictEqual(res.lastClippedAt, null); + assert.strictEqual(res.name, 'updated'); + assert.strictEqual(res.description, 'new description'); + assert.strictEqual(res.isPublic, true); + assert.strictEqual(res.favoritedCount, 0); + assert.strictEqual(res.isFavorited, false); + }); + + test.each(createClipAllowedPattern)('ã®æ›´æ–°ã¯$labelã§ã‚‚ã§ãã‚‹', async ({ parameters }) => await update({ + clipId: (await create()).id, + name: 'updated', + ...parameters, + })); + + test.each([ + { label: 'clipIdãŒnull', parameters: { clipId: null } }, + { label: 'å˜åœ¨ã—ãªã„クリップ', parameters: { clipId: 'xxxxxx' }, assertion: { + code: 'NO_SUCH_CLIP', + id: 'b4d92d70-b216-46fa-9a3f-a8c811699257', + } }, + { label: '他人ã®ã‚¯ãƒªãƒƒãƒ—', user: (): User => bob, assertion: { + code: 'NO_SUCH_CLIP', + id: 'b4d92d70-b216-46fa-9a3f-a8c811699257', + } }, + ...createClipDenyPattern as any, + ])('ã®æ›´æ–°ã¯$labelãªã‚‰ã§ããªã„', async ({ parameters, user, assertion }) => failedApiCall({ + endpoint: '/clips/update', + parameters: { + clipId: (await create({}, { user: (user ?? ((): User => alice))() })).id, + name: 'updated', + ...parameters, + }, + user: alice, + }, { + status: 400, + code: 'INVALID_PARAM', + id: '3d81ceae-475f-4600-b2a8-2bc116157532', + ...assertion, + })); + + test('ã®å‰Šé™¤ãŒã§ãã‚‹', async () => { + await deleteClip({ + clipId: (await create()).id, + }); + assert.deepStrictEqual(await list({}), []); + }); + + test.each([ + { label: 'clipIdãŒnull', parameters: { clipId: null } }, + { label: 'å˜åœ¨ã—ãªã„クリップ', parameters: { clipId: 'xxxxxx' }, assertion: { + code: 'NO_SUCH_CLIP', + id: '70ca08ba-6865-4630-b6fb-8494759aa754', + } }, + { label: '他人ã®ã‚¯ãƒªãƒƒãƒ—', user: (): User => bob, assertion: { + code: 'NO_SUCH_CLIP', + id: '70ca08ba-6865-4630-b6fb-8494759aa754', + } }, + ])('ã®å‰Šé™¤ã¯$labelãªã‚‰ã§ããªã„', async ({ parameters, user, assertion }) => failedApiCall({ + endpoint: '/clips/delete', + parameters: { + clipId: (await create({}, { user: (user ?? ((): User => alice))() })).id, + ...parameters, + }, + user: alice, + }, { + status: 400, + code: 'INVALID_PARAM', + id: '3d81ceae-475f-4600-b2a8-2bc116157532', + ...assertion, + })); + + test('ã®ID指定å–å¾—ãŒã§ãã‚‹', async () => { + const clip = await create(); + const res = await show({ clipId: clip.id }); + assert.deepStrictEqual(res, clip); + }); + + test('ã®ID指定å–å¾—ã¯ä»–人ã®Privateãªã‚¯ãƒªãƒƒãƒ—ã¯å–å¾—ã§ããªã„', async () => { + const clip = await create({ isPublic: false }, { user: bob } ); + failedApiCall({ + endpoint: '/clips/show', + parameters: { clipId: clip.id }, + user: alice, + }, { + status: 400, + code: 'NO_SUCH_CLIP', + id: 'c3c5fe33-d62c-44d2-9ea5-d997703f5c20', + }); + }); + + test.each([ + { label: 'clipId未指定', parameters: { clipId: undefined } }, + { label: 'å˜åœ¨ã—ãªã„クリップ', parameters: { clipId: 'xxxxxx' }, assetion: { + code: 'NO_SUCH_CLIP', + id: 'c3c5fe33-d62c-44d2-9ea5-d997703f5c20', + } }, + ])('ã®ID指定å–å¾—ã¯$labelãªã‚‰ã§ããªã„', async ({ parameters, assetion }) => failedApiCall({ + endpoint: '/clips/show', + parameters: { + ...parameters, + }, + user: alice, + }, { + status: 400, + code: 'INVALID_PARAM', + id: '3d81ceae-475f-4600-b2a8-2bc116157532', + ...assetion, + })); + + test('ã®ä¸€è¦§(clips/list)ãŒå–å¾—ã§ãã‚‹(空)', async () => { + const res = await list({}); + assert.deepStrictEqual(res, []); + }); + + test('ã®ä¸€è¦§(clips/list)ãŒå–å¾—ã§ãã‚‹(上é™ã„ã£ã±ã„)', async () => { + const clipLimit = DEFAULT_POLICIES.clipLimit + 1; + const clips = await createMany({}, clipLimit); + const res = await list({ + parameters: { limit: 1 }, // FIXME: 無視ã•ã‚Œã¦11全部返ã£ã¦ãã‚‹ + }); + + // è¿”ã£ã¦ãã‚‹é…列ã«ã¯é †åºä¿éšœãŒãªã„ã®ã§idã§ã‚½ãƒ¼ãƒˆã—ã¦åŽ³å¯†æ¯”較 + assert.deepStrictEqual( + res.sort(compareBy(s => s.id)), + clips.sort(compareBy(s => s.id)), + ); + }); + + test('ã®ä¸€è¦§ãŒå–å¾—ã§ãã‚‹(空)', async () => { + const res = await usersClips({ + parameters: { + userId: alice.id, + }, + }); + assert.deepStrictEqual(res, []); + }); + + test.each([ + { label: '' }, + { label: '他人アカウントã‹ã‚‰', user: (): User => bob }, + ])('ã®ä¸€è¦§ãŒ$labelå–å¾—ã§ãã‚‹', async () => { + const clips = await createMany({ isPublic: true }); + const res = await usersClips({ + parameters: { + userId: alice.id, + }, + }); + + // è¿”ã£ã¦ãã‚‹é…列ã«ã¯é †åºä¿éšœãŒãªã„ã®ã§idã§ã‚½ãƒ¼ãƒˆã—ã¦åŽ³å¯†æ¯”較 + assert.deepStrictEqual( + res.sort(compareBy<Clip>(s => s.id)), + clips.sort(compareBy(s => s.id))); + + // èªè¨¼çŠ¶æ…‹ã§è¦‹ãŸã¨ãã ã‘isFavoritedãŒå…¥ã£ã¦ã„ã‚‹ + for (const clip of res) { + assert.strictEqual(clip.isFavorited, false); + } + }); + + test.each([ + { label: '未èªè¨¼', user: (): undefined => undefined }, + { label: 'å˜åœ¨ã—ãªã„ユーザーã®ã‚‚ã®', parameters: { userId: 'xxxxxxx' } }, + ])('ã®ä¸€è¦§ã¯$labelã§ã‚‚å–å¾—ã§ãã‚‹', async ({ parameters, user }) => { + const clips = await createMany({ isPublic: true }); + const res = await usersClips({ + parameters: { + userId: alice.id, + limit: clips.length, + ...parameters, + }, + user: (user ?? ((): User => alice))(), + }); + + // 未èªè¨¼ã§è¦‹ãŸã¨ãã¯isFavoritedã¯å…¥ã‚‰ãªã„ + for (const clip of res) { + assert.strictEqual('isFavorited' in clip, false); + } + }); + + test('ã®ä¸€è¦§ã¯Privateãªã‚¯ãƒªãƒƒãƒ—ã‚’å«ã¾ãªã„(自分ã®ã‚‚ã®ã§ã‚ã£ã¦ã‚‚。)', async () => { + await create({ isPublic: false }); + const aliceClip = await create({ isPublic: true }); + const res = await usersClips({ + parameters: { + userId: alice.id, + limit: 2, + }, + }); + assert.deepStrictEqual(res, [aliceClip]); + }); + + test('ã®ä¸€è¦§ã¯ID指定ã§ç¯„囲é¸æŠžãŒã§ãã‚‹', async () => { + const clips = await createMany({ isPublic: true }, 7); + clips.sort(compareBy(s => s.id)); + const res = await usersClips({ + parameters: { + userId: alice.id, + sinceId: clips[1].id, + untilId: clips[5].id, + limit: 4, + }, + }); + + // Promise.allã§è¿”ã£ã¦ãã‚‹é…列ã«ã¯é †åºä¿éšœãŒãªã„ã®ã§idã§ã‚½ãƒ¼ãƒˆã—ã¦åŽ³å¯†æ¯”較 + assert.deepStrictEqual( + res.sort(compareBy<Clip>(s => s.id)), + [clips[2], clips[3], clips[4]], // sinceIdã¨untilId自体ã¯çµæžœã«å«ã¾ã‚Œãªã„ + clips[1].id + ' ... ' + clips[3].id + ' with ' + clips.map(s => s.id) + ' vs. ' + res.map(s => s.id)); + }); + + test.each([ + { label: 'userId未指定', parameters: { userId: undefined } }, + { label: 'limitゼãƒ', parameters: { limit: 0 } }, + { label: 'limit最大+1', parameters: { limit: 101 } }, + ])('ã®ä¸€è¦§ã¯$labelã ã¨å–å¾—ã§ããªã„', async ({ parameters }) => failedApiCall({ + endpoint: '/users/clips', + parameters: { + userId: alice.id, + ...parameters, + }, + user: alice, + }, { + status: 400, + code: 'INVALID_PARAM', + id: '3d81ceae-475f-4600-b2a8-2bc116157532', + })); + + test.each([ + { label: '作æˆ', endpoint: '/clips/create' }, + { label: 'æ›´æ–°', endpoint: '/clips/update' }, + { label: '削除', endpoint: '/clips/delete' }, + { label: 'å–å¾—', endpoint: '/clips/list' }, + { label: 'ãŠæ°—ã«å…¥ã‚Šè¨å®š', endpoint: '/clips/favorite' }, + { label: 'ãŠæ°—ã«å…¥ã‚Šè§£é™¤', endpoint: '/clips/unfavorite' }, + { label: 'ãŠæ°—ã«å…¥ã‚Šå–å¾—', endpoint: '/clips/my-favorites' }, + { label: 'ãƒŽãƒ¼ãƒˆè¿½åŠ ', endpoint: '/clips/add-note' }, + { label: 'ノート削除', endpoint: '/clips/remove-note' }, + ])('ã®$labelã¯æœªèªè¨¼ã§ã¯ã§ããªã„', async ({ endpoint }) => await failedApiCall({ + endpoint: endpoint, + parameters: {}, + user: undefined, + }, { + status: 401, + code: 'CREDENTIAL_REQUIRED', + id: '1384574d-a912-4b81-8601-c7b1c4085df1', + })); + + describe('ã®ãŠæ°—ã«å…¥ã‚Š', () => { + let aliceClip: Clip; + + type FavoriteParam = JTDDataType<typeof FavoriteParamDef>; + const favorite = async (parameters: FavoriteParam, request: Partial<ApiRequest> = {}): Promise<void> => { + return successfulApiCall<void>({ + endpoint: '/clips/favorite', + parameters, + user: alice, + ...request, + }, { + status: 204, + }); + }; + + type UnfavoriteParam = JTDDataType<typeof UnfavoriteParamDef>; + const unfavorite = async (parameters: UnfavoriteParam, request: Partial<ApiRequest> = {}): Promise<void> => { + return successfulApiCall<void>({ + endpoint: '/clips/unfavorite', + parameters, + user: alice, + ...request, + }, { + status: 204, + }); + }; + + const myFavorites = async (request: Partial<ApiRequest> = {}): Promise<Clip[]> => { + return successfulApiCall<Clip[]>({ + endpoint: '/clips/my-favorites', + parameters: {}, + user: alice, + ...request, + }); + }; + + beforeEach(async () => { + aliceClip = await create(); + }); + + test('ã‚’è¨å®šã§ãる。', async () => { + await favorite({ clipId: aliceClip.id }); + const clip = await show({ clipId: aliceClip.id }); + assert.strictEqual(clip.favoritedCount, 1); + assert.strictEqual(clip.isFavorited, true); + }); + + test('ã¯Publicãªä»–人ã®ã‚¯ãƒªãƒƒãƒ—ã«è¨å®šã§ãる。', async () => { + const publicClip = await create({ isPublic: true }); + await favorite({ clipId: publicClip.id }, { user: bob }); + const clip = await show({ clipId: publicClip.id }, { user: bob }); + assert.strictEqual(clip.favoritedCount, 1); + assert.strictEqual(clip.isFavorited, true); + + // isFavoritedã¯è¦‹ã‚‹äººã«ã‚ˆã£ã¦åˆ‡ã‚Šæ›¿ã‚る。 + const clip2 = await show({ clipId: publicClip.id }); + assert.strictEqual(clip2.favoritedCount, 1); + assert.strictEqual(clip2.isFavorited, false); + }); + + test('ã¯1ã¤ã®ã‚¯ãƒªãƒƒãƒ—ã«å¯¾ã—ã¦è¤‡æ•°äººãŒè¨å®šã§ãる。', async () => { + const publicClip = await create({ isPublic: true }); + await favorite({ clipId: publicClip.id }, { user: bob }); + await favorite({ clipId: publicClip.id }); + const clip = await show({ clipId: publicClip.id }, { user: bob }); + assert.strictEqual(clip.favoritedCount, 2); + assert.strictEqual(clip.isFavorited, true); + + const clip2 = await show({ clipId: publicClip.id }); + assert.strictEqual(clip2.favoritedCount, 2); + assert.strictEqual(clip2.isFavorited, true); + }); + + test('ã¯11を超ãˆã¦è¨å®šã§ãる。', async () => { + const clips = [ + aliceClip, + ...await createMany({}, 10, alice), + ...await createMany({ isPublic: true }, 10, bob), + ]; + for (const clip of clips) { + await favorite({ clipId: clip.id }); + } + + // pagenationã¯ãªã„。全部一気ã«ã¨ã‚Œã‚‹ã€‚ + const favorited = await myFavorites(); + assert.strictEqual(favorited.length, clips.length); + for (const clip of favorited) { + assert.strictEqual(clip.favoritedCount, 1); + assert.strictEqual(clip.isFavorited, true); + } + }); + + test('ã¯åŒã˜ã‚¯ãƒªãƒƒãƒ—ã«å¯¾ã—ã¦äºŒå›žè¨å®šã§ããªã„。', async () => { + await favorite({ clipId: aliceClip.id }); + await failedApiCall({ + endpoint: '/clips/favorite', + parameters: { + clipId: aliceClip.id, + }, + user: alice, + }, { + status: 400, + code: 'ALREADY_FAVORITED', + id: '92658936-c625-4273-8326-2d790129256e', + }); + }); + + test.each([ + { label: 'clipIdãŒnull', parameters: { clipId: null } }, + { label: 'å˜åœ¨ã—ãªã„クリップ', parameters: { clipId: 'xxxxxx' }, assertion: { + code: 'NO_SUCH_CLIP', + id: '4c2aaeae-80d8-4250-9606-26cb1fdb77a5', + } }, + { label: '他人ã®ã‚¯ãƒªãƒƒãƒ—', user: (): User => bob, assertion: { + code: 'NO_SUCH_CLIP', + id: '4c2aaeae-80d8-4250-9606-26cb1fdb77a5', + } }, + ])('ã®è¨å®šã¯$labelãªã‚‰ã§ããªã„', async ({ parameters, user, assertion }) => failedApiCall({ + endpoint: '/clips/favorite', + parameters: { + clipId: (await create({}, { user: (user ?? ((): User => alice))() })).id, + ...parameters, + }, + user: alice, + }, { + status: 400, + code: 'INVALID_PARAM', + id: '3d81ceae-475f-4600-b2a8-2bc116157532', + ...assertion, + })); + + test('ã‚’è¨å®šè§£é™¤ã§ãる。', async () => { + await favorite({ clipId: aliceClip.id }); + await unfavorite({ clipId: aliceClip.id }); + const clip = await show({ clipId: aliceClip.id }); + assert.strictEqual(clip.favoritedCount, 0); + assert.strictEqual(clip.isFavorited, false); + assert.deepStrictEqual(await myFavorites(), []); + }); + + test.each([ + { label: 'clipIdãŒnull', parameters: { clipId: null } }, + { label: 'å˜åœ¨ã—ãªã„クリップ', parameters: { clipId: 'xxxxxx' }, assertion: { + code: 'NO_SUCH_CLIP', + id: '2603966e-b865-426c-94a7-af4a01241dc1', + } }, + { label: '他人ã®ã‚¯ãƒªãƒƒãƒ—', user: (): User => bob, assertion: { + code: 'NOT_FAVORITED', + id: '90c3a9e8-b321-4dae-bf57-2bf79bbcc187', + } }, + { label: 'ãŠæ°—ã«å…¥ã‚Šã—ã¦ã„ãªã„クリップ', assertion: { + code: 'NOT_FAVORITED', + id: '90c3a9e8-b321-4dae-bf57-2bf79bbcc187', + } }, + ])('ã®è¨å®šè§£é™¤ã¯$labelãªã‚‰ã§ããªã„', async ({ parameters, user, assertion }) => failedApiCall({ + endpoint: '/clips/unfavorite', + parameters: { + clipId: (await create({}, { user: (user ?? ((): User => alice))() })).id, + ...parameters, + }, + user: alice, + }, { + status: 400, + code: 'INVALID_PARAM', + id: '3d81ceae-475f-4600-b2a8-2bc116157532', + ...assertion, + })); + + test('ã‚’å–å¾—ã§ãる。', async () => { + await favorite({ clipId: aliceClip.id }); + const favorited = await myFavorites(); + assert.deepStrictEqual(favorited, [await show({ clipId: aliceClip.id })]); + }); + + test('ã‚’å–å¾—ã—ãŸã¨ã他人ã®ãŠæ°—ã«å…¥ã‚Šã¯å«ã¾ãªã„。', async () => { + await favorite({ clipId: aliceClip.id }); + const favorited = await myFavorites({ user: bob }); + assert.deepStrictEqual(favorited, []); + }); + }); + + describe('ã«ç´ã¥ãノート', () => { + let aliceClip: Clip; + + const sampleNotes = (): Note[] => [ + aliceNote, aliceHomeNote, aliceFollowersNote, aliceSpecifiedNote, + bobNote, bobHomeNote, bobFollowersNote, bobSpecifiedNote, + ]; + + type AddNoteParam = JTDDataType<typeof AddNoteParamDef>; + const addNote = async (parameters: AddNoteParam, request: Partial<ApiRequest> = {}): Promise<void> => { + return successfulApiCall<void>({ + endpoint: '/clips/add-note', + parameters, + user: alice, + ...request, + }, { + status: 204, + }); + }; + + type RemoveNoteParam = JTDDataType<typeof RemoveNoteParamDef>; + const removeNote = async (parameters: RemoveNoteParam, request: Partial<ApiRequest> = {}): Promise<void> => { + return successfulApiCall<void>({ + endpoint: '/clips/remove-note', + parameters, + user: alice, + ...request, + }, { + status: 204, + }); + }; + + type NotesParam = JTDDataType<typeof NotesParamDef>; + const notes = async (parameters: Partial<NotesParam>, request: Partial<ApiRequest> = {}): Promise<Note[]> => { + return successfulApiCall<Note[]>({ + endpoint: '/clips/notes', + parameters, + user: alice, + ...request, + }); + }; + + beforeEach(async () => { + aliceClip = await create(); + }); + + test('ã‚’è¿½åŠ ã§ãる。', async () => { + await addNote({ clipId: aliceClip.id, noteId: aliceNote.id }); + const res = await show({ clipId: aliceClip.id }); + assert.strictEqual(res.lastClippedAt, new Date(res.lastClippedAt ?? '').toISOString()); + assert.deepStrictEqual(await notes({ clipId: aliceClip.id }), [aliceNote]); + + // 他人ã®éžå…¬é–‹ãƒŽãƒ¼ãƒˆã‚‚çªã£è¾¼ã‚ã‚‹ + await addNote({ clipId: aliceClip.id, noteId: bobHomeNote.id }); + await addNote({ clipId: aliceClip.id, noteId: bobFollowersNote.id }); + await addNote({ clipId: aliceClip.id, noteId: bobSpecifiedNote.id }); + }); + + test('ã¨ã—ã¦åŒã˜ãƒŽãƒ¼ãƒˆã‚’二回ç´ã¥ã‘ã‚‹ã“ã¨ã¯ã§ããªã„', async () => { + await addNote({ clipId: aliceClip.id, noteId: aliceNote.id }); + await failedApiCall({ + endpoint: '/clips/add-note', + parameters: { + clipId: aliceClip.id, + noteId: aliceNote.id, + }, + user: alice, + }, { + status: 400, + code: 'ALREADY_CLIPPED', + id: '734806c4-542c-463a-9311-15c512803965', + }); + }); + + // TODO: 17000msãらã„ã‹ã‹ã‚‹... + test('ã‚’ãƒãƒªã‚·ãƒ¼ã§å®šã‚られãŸä¸Šé™ã„ã£ã±ã„(200)を超ãˆã¦è¿½åŠ ã¯ã§ããªã„。', async () => { + const noteLimit = DEFAULT_POLICIES.noteEachClipsLimit + 1; + const noteList = await Promise.all([...Array(noteLimit)].map((_, i) => post(alice, { + text: `test ${i}`, + }) as unknown)) as Note[]; + await Promise.all(noteList.map(s => addNote({ clipId: aliceClip.id, noteId: s.id }))); + + await failedApiCall({ + endpoint: '/clips/add-note', + parameters: { + clipId: aliceClip.id, + noteId: aliceNote.id, + }, + user: alice, + }, { + status: 400, + code: 'TOO_MANY_CLIP_NOTES', + id: 'f0dba960-ff73-4615-8df4-d6ac5d9dc118', + }); + }); + + test('ã¯ä»–人ã®ã‚¯ãƒªãƒƒãƒ—ã¸è¿½åŠ ã§ããªã„。', async () => await failedApiCall({ + endpoint: '/clips/add-note', + parameters: { + clipId: aliceClip.id, + noteId: aliceNote.id, + }, + user: bob, + }, { + status: 400, + code: 'NO_SUCH_CLIP', + id: 'd6e76cc0-a1b5-4c7c-a287-73fa9c716dcf', + })); + + test.each([ + { label: 'clipId未指定', parameters: { clipId: undefined } }, + { label: 'noteId未指定', parameters: { noteId: undefined } }, + { label: 'å˜åœ¨ã—ãªã„クリップ', parameters: { clipId: 'xxxxxx' }, assetion: { + code: 'NO_SUCH_CLIP', + id: 'd6e76cc0-a1b5-4c7c-a287-73fa9c716dcf', + } }, + { label: 'å˜åœ¨ã—ãªã„ノート', parameters: { noteId: 'xxxxxx' }, assetion: { + code: 'NO_SUCH_NOTE', + id: 'fc8c0b49-c7a3-4664-a0a6-b418d386bb8b', + } }, + { label: '他人ã®ã‚¯ãƒªãƒƒãƒ—', user: (): object => bob, assetion: { + code: 'NO_SUCH_CLIP', + id: 'd6e76cc0-a1b5-4c7c-a287-73fa9c716dcf', + } }, + ])('ã®è¿½åŠ ã¯$labelã ã¨ã§ããªã„', async ({ parameters, user, assetion }) => failedApiCall({ + endpoint: '/clips/add-note', + parameters: { + clipId: aliceClip.id, + noteId: aliceNote.id, + ...parameters, + }, + user: (user ?? ((): User => alice))(), + }, { + status: 400, + code: 'INVALID_PARAM', + id: '3d81ceae-475f-4600-b2a8-2bc116157532', + ...assetion, + })); + + test('を削除ã§ãる。', async () => { + await addNote({ clipId: aliceClip.id, noteId: aliceNote.id }); + await removeNote({ clipId: aliceClip.id, noteId: aliceNote.id }); + assert.deepStrictEqual(await notes({ clipId: aliceClip.id }), []); + }); + + test.each([ + { label: 'clipId未指定', parameters: { clipId: undefined } }, + { label: 'noteId未指定', parameters: { noteId: undefined } }, + { label: 'å˜åœ¨ã—ãªã„クリップ', parameters: { clipId: 'xxxxxx' }, assetion: { + code: 'NO_SUCH_CLIP', + id: 'b80525c6-97f7-49d7-a42d-ebccd49cfd52', // add-noteã¨ç•°ãªã‚‹ + } }, + { label: 'å˜åœ¨ã—ãªã„ノート', parameters: { noteId: 'xxxxxx' }, assetion: { + code: 'NO_SUCH_NOTE', + id: 'aff017de-190e-434b-893e-33a9ff5049d8', // add-noteã¨ç•°ãªã‚‹ + } }, + { label: '他人ã®ã‚¯ãƒªãƒƒãƒ—', user: (): object => bob, assetion: { + code: 'NO_SUCH_CLIP', + id: 'b80525c6-97f7-49d7-a42d-ebccd49cfd52', // add-noteã¨ç•°ãªã‚‹ + } }, + ])('ã®å‰Šé™¤ã¯$labelã ã¨ã§ããªã„', async ({ parameters, user, assetion }) => failedApiCall({ + endpoint: '/clips/remove-note', + parameters: { + clipId: aliceClip.id, + noteId: aliceNote.id, + ...parameters, + }, + user: (user ?? ((): User => alice))(), + }, { + status: 400, + code: 'INVALID_PARAM', + id: '3d81ceae-475f-4600-b2a8-2bc116157532', + ...assetion, + })); + + test('ã‚’å–å¾—ã§ãる。', async () => { + const noteList = sampleNotes(); + for (const note of noteList) { + await addNote({ clipId: aliceClip.id, noteId: note.id }); + } + + const res = await notes({ clipId: aliceClip.id }); + + // 自分ã®ãƒŽãƒ¼ãƒˆã¯éžå…¬é–‹ã§ã‚‚入れられるã—ã€è¦‹ãˆã‚‹ + // 他人ã®éžå…¬é–‹ãƒŽãƒ¼ãƒˆã¯å…¥ã‚Œã‚‰ã‚Œã‚‹ã‘ã©ã€é™¤å¤–ã•ã‚Œã‚‹ + const expects = [ + aliceNote, aliceHomeNote, aliceFollowersNote, aliceSpecifiedNote, + bobNote, bobHomeNote, + ]; + assert.deepStrictEqual( + res.sort(compareBy(s => s.id)), + expects.sort(compareBy(s => s.id))); + }); + + test('を始端IDã¨limitã§å–å¾—ã§ãる。', async () => { + const noteList = sampleNotes(); + noteList.sort(compareBy(s => s.id)); + for (const note of noteList) { + await addNote({ clipId: aliceClip.id, noteId: note.id }); + } + + const res = await notes({ + clipId: aliceClip.id, + sinceId: noteList[2].id, + limit: 3, + }); + + // Promise.allã§è¿”ã£ã¦ãã‚‹é…列ã¯IDé †ã§ä¸¦ã‚“ã§ãªã„ã®ã§ã‚½ãƒ¼ãƒˆã—ã¦åŽ³å¯†æ¯”較 + const expects = [noteList[3], noteList[4], noteList[5]]; + assert.deepStrictEqual( + res.sort(compareBy(s => s.id)), + expects.sort(compareBy(s => s.id))); + }); + + test('ã‚’ID範囲指定ã§å–å¾—ã§ãる。', async () => { + const noteList = sampleNotes(); + noteList.sort(compareBy(s => s.id)); + for (const note of noteList) { + await addNote({ clipId: aliceClip.id, noteId: note.id }); + } + + const res = await notes({ + clipId: aliceClip.id, + sinceId: noteList[1].id, + untilId: noteList[4].id, + }); + + // Promise.allã§è¿”ã£ã¦ãã‚‹é…列ã¯IDé †ã§ä¸¦ã‚“ã§ãªã„ã®ã§ã‚½ãƒ¼ãƒˆã—ã¦åŽ³å¯†æ¯”較 + const expects = [noteList[2], noteList[3]]; + assert.deepStrictEqual( + res.sort(compareBy(s => s.id)), + expects.sort(compareBy(s => s.id))); + }); + + test.todo('Remoteã®ãƒŽãƒ¼ãƒˆã‚‚クリップã§ãる。ã©ã†ãƒ†ã‚¹ãƒˆã—よã†ï¼Ÿ'); + + test('ã¯ä»–人ã®Publicãªã‚¯ãƒªãƒƒãƒ—ã‹ã‚‰ã‚‚å–å¾—ã§ãる。', async () => { + const bobClip = await create({ isPublic: true }, { user: bob } ); + await addNote({ clipId: bobClip.id, noteId: aliceNote.id }, { user: bob }); + const res = await notes({ clipId: bobClip.id }); + assert.deepStrictEqual(res, [aliceNote]); + }); + + test('ã¯Publicãªã‚¯ãƒªãƒƒãƒ—ãªã‚‰èªè¨¼ãªã—ã§ã‚‚å–å¾—ã§ãる。(éžå…¬é–‹ãƒŽãƒ¼ãƒˆã¯hideã•ã‚Œã¦è¿”ã£ã¦ãã‚‹)', async () => { + const publicClip = await create({ isPublic: true }); + await addNote({ clipId: publicClip.id, noteId: aliceNote.id }); + await addNote({ clipId: publicClip.id, noteId: aliceHomeNote.id }); + await addNote({ clipId: publicClip.id, noteId: aliceFollowersNote.id }); + await addNote({ clipId: publicClip.id, noteId: aliceSpecifiedNote.id }); + + const res = await notes({ clipId: publicClip.id }, { user: undefined }); + const expects = [ + aliceNote, aliceHomeNote, + // èªè¨¼ãªã—ã ã¨éžå…¬é–‹ãƒŽãƒ¼ãƒˆã¯çµæžœã«ã¯å«ã‚€ã‘ã©hideã•ã‚Œã‚‹ã€‚ + hiddenNote(aliceFollowersNote), hiddenNote(aliceSpecifiedNote), + ]; + assert.deepStrictEqual( + res.sort(compareBy(s => s.id)), + expects.sort(compareBy(s => s.id))); + }); + + test.todo('ブãƒãƒƒã‚¯ã€ãƒŸãƒ¥ãƒ¼ãƒˆã•ã‚ŒãŸãƒ¦ãƒ¼ã‚¶ãƒ¼ã‹ã‚‰ã®è¨å®šï¼†å–å¾—etc.'); + + test.each([ + { label: 'clipId未指定', parameters: { clipId: undefined } }, + { label: 'limitゼãƒ', parameters: { limit: 0 } }, + { label: 'limit最大+1', parameters: { limit: 101 } }, + { label: 'å˜åœ¨ã—ãªã„クリップ', parameters: { clipId: 'xxxxxx' }, assertion: { + code: 'NO_SUCH_CLIP', + id: '1d7645e6-2b6d-4635-b0fe-fe22b0e72e00', + } }, + { label: '他人ã®Privateãªã‚¯ãƒªãƒƒãƒ—ã‹ã‚‰', user: (): object => bob, assertion: { + code: 'NO_SUCH_CLIP', + id: '1d7645e6-2b6d-4635-b0fe-fe22b0e72e00', + } }, + { label: '未èªè¨¼ã§Privateãªã‚¯ãƒªãƒƒãƒ—ã‹ã‚‰', user: (): undefined => undefined, assertion: { + code: 'NO_SUCH_CLIP', + id: '1d7645e6-2b6d-4635-b0fe-fe22b0e72e00', + } }, + ])('ã¯$labelã ã¨å–å¾—ã§ããªã„', async ({ parameters, user, assertion }) => failedApiCall({ + endpoint: '/clips/notes', + parameters: { + clipId: aliceClip.id, + ...parameters, + }, + user: (user ?? ((): User => alice))(), + }, { + status: 400, + code: 'INVALID_PARAM', + id: '3d81ceae-475f-4600-b2a8-2bc116157532', + ...assertion, + })); + }); +}); diff --git a/packages/backend/test/utils.ts b/packages/backend/test/utils.ts index 4d52c2f06284e4c2031bc78c3dde20c5d96990c3..879d5ec79a4d4c35f7afd633e592539bfd4fc304 100644 --- a/packages/backend/test/utils.ts +++ b/packages/backend/test/utils.ts @@ -1,5 +1,7 @@ +import * as assert from 'assert'; import { readFile } from 'node:fs/promises'; import { isAbsolute, basename } from 'node:path'; +import { inspect } from 'node:util'; import WebSocket from 'ws'; import fetch, { Blob, File, RequestInit } from 'node-fetch'; import { DataSource } from 'typeorm'; @@ -22,6 +24,36 @@ export const api = async (endpoint: string, params: any, me?: any) => { return await request(`api/${normalized}`, params, me); }; +export type ApiRequest = { + endpoint: string, + parameters: object, + user: object | undefined, +}; + +export const successfulApiCall = async <T, >(request: ApiRequest, assertion: { + status: number, +} = { status: 200 }): Promise<T> => { + const { endpoint, parameters, user } = request; + const { status } = assertion; + const res = await api(endpoint, parameters, user); + assert.strictEqual(res.status, status, inspect(res.body)); + return res.body; +}; + +export const failedApiCall = async <T, >(request: ApiRequest, assertion: { + status: number, + code: string, + id: string +}): Promise<T> => { + const { endpoint, parameters, user } = request; + const { status, code, id } = assertion; + const res = await api(endpoint, parameters, user); + assert.strictEqual(res.status, status, inspect(res.body)); + assert.strictEqual(res.body.error.code, code, inspect(res.body)); + assert.strictEqual(res.body.error.id, id, inspect(res.body)); + return res.body; +}; + const request = async (path: string, params: any, me?: any): Promise<{ body: any, status: number }> => { const auth = me ? { i: me.token, @@ -69,6 +101,21 @@ export const post = async (user: any, params?: misskey.Endpoints['notes/create'] return res.body ? res.body.createdNote : null; }; +// éžå…¬é–‹ãƒŽãƒ¼ãƒˆã‚’API越ã—ã«è¦‹ãŸã¨ãã®ãƒŽãƒ¼ãƒˆ NoteEntityService.ts +export const hiddenNote = (note: any): any => { + const temp = { + ...note, + fileIds: [], + files: [], + text: null, + cw: null, + isHidden: true, + }; + delete temp.visibleUserIds; + delete temp.poll; + return temp; +}; + export const react = async (user: any, note: any, reaction: string): Promise<any> => { await api('notes/reactions/create', { noteId: note.id,