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,