From 1761c13a5bc1123e51f38a09d0c0e172c50fb129 Mon Sep 17 00:00:00 2001 From: everpcpc Date: Sun, 27 Oct 2024 16:34:35 +0800 Subject: [PATCH] feat: add subject and relation api (#784) --- drizzle/orm.ts | 6 +- drizzle/schema.ts | 32 +- lib/types/convert.ts | 2 +- lib/types/fetcher.ts | 13 +- routes/private/index.ts | 2 + .../routes/__snapshots__/subject.test.ts.snap | 301 ++++++++++++++---- .../__snapshots__/subjectPost.test.ts.snap | 70 ++++ routes/private/routes/collection.ts | 2 +- routes/private/routes/subject.test.ts | 176 +--------- routes/private/routes/subject.ts | 146 +++++++++ routes/private/routes/subjectPost.test.ts | 185 +++++++++++ 11 files changed, 683 insertions(+), 252 deletions(-) create mode 100644 routes/private/routes/__snapshots__/subjectPost.test.ts.snap create mode 100644 routes/private/routes/subject.ts create mode 100644 routes/private/routes/subjectPost.test.ts diff --git a/drizzle/orm.ts b/drizzle/orm.ts index 8abb6fe8..3450080b 100644 --- a/drizzle/orm.ts +++ b/drizzle/orm.ts @@ -3,11 +3,13 @@ import type * as schema from './schema.ts'; export type IUser = typeof schema.chiiUser.$inferSelect; export type IUserFields = typeof schema.chiiUserFields.$inferSelect; -export type IFriends = typeof schema.chiiFriends.$inferSelect; +export type IFriend = typeof schema.chiiFriends.$inferSelect; export type ISubject = typeof schema.chiiSubjects.$inferSelect; export type ISubjectFields = typeof schema.chiiSubjectFields.$inferSelect; -export type ISubjectInterests = typeof schema.chiiSubjectInterests.$inferSelect; +export type ISubjectInterest = typeof schema.chiiSubjectInterests.$inferSelect; + +export type ISubjectRelation = typeof schema.chiiSubjectRelations.$inferSelect; export type ICharacter = typeof schema.chiiCharacters.$inferSelect; export type IPerson = typeof schema.chiiPersons.$inferSelect; diff --git a/drizzle/schema.ts b/drizzle/schema.ts index 7e1a1afe..965fb12e 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -1029,31 +1029,23 @@ export const chiiSubjectRec = mysqlTable( export const chiiSubjectRelations = mysqlTable( 'chii_subject_relations', { - rltSubjectId: mediumint('rlt_subject_id').notNull(), - rltSubjectTypeId: tinyint('rlt_subject_type_id').notNull(), - rltRelationType: smallint('rlt_relation_type').notNull(), - rltRelatedSubjectId: mediumint('rlt_related_subject_id').notNull(), - rltRelatedSubjectTypeId: tinyint('rlt_related_subject_type_id').notNull(), - rltViceVersa: tinyint('rlt_vice_versa').notNull(), - rltOrder: tinyint('rlt_order').notNull(), + id: mediumint('rlt_subject_id').notNull(), + type: tinyint('rlt_subject_type_id').notNull(), + relation: smallint('rlt_relation_type').notNull(), + relatedID: mediumint('rlt_related_subject_id').notNull(), + relatedType: tinyint('rlt_related_subject_type_id').notNull(), + viceVersa: tinyint('rlt_vice_versa').notNull(), + order: tinyint('rlt_order').notNull(), }, (table) => { return { rltRelatedSubjectTypeId: index('rlt_related_subject_type_id').on( - table.rltRelatedSubjectTypeId, - table.rltOrder, - ), - rltSubjectTypeId: index('rlt_subject_type_id').on(table.rltSubjectTypeId), - rltRelationType: index('rlt_relation_type').on( - table.rltRelationType, - table.rltSubjectId, - table.rltRelatedSubjectId, - ), - rltSubjectId: unique('rlt_subject_id').on( - table.rltSubjectId, - table.rltRelatedSubjectId, - table.rltViceVersa, + table.relatedID, + table.order, ), + rltSubjectTypeId: index('rlt_subject_type_id').on(table.type), + rltRelationType: index('rlt_relation_type').on(table.relatedType, table.id, table.relatedID), + rltSubjectId: unique('rlt_subject_id').on(table.id, table.relatedID, table.viceVersa), }; }, ); diff --git a/lib/types/convert.ts b/lib/types/convert.ts index 99e12a28..39e59c7c 100644 --- a/lib/types/convert.ts +++ b/lib/types/convert.ts @@ -31,7 +31,7 @@ export function toUser(user: orm.IUser): res.IUser { }; } -export function toFriend(user: orm.IUser, friend: orm.IFriends): res.IFriend { +export function toFriend(user: orm.IUser, friend: orm.IFriend): res.IFriend { return { user: toUser(user), grade: friend.grade, diff --git a/lib/types/fetcher.ts b/lib/types/fetcher.ts index bd3fc112..14036114 100644 --- a/lib/types/fetcher.ts +++ b/lib/types/fetcher.ts @@ -4,12 +4,21 @@ import * as schema from '@app/drizzle/schema'; import * as convert from './convert.ts'; import type * as res from './res.ts'; -export async function fetchSubjectByID(id: number): Promise { +export async function fetchSubjectByID( + id: number, + allowNsfw = false, +): Promise { const data = await db .select() .from(schema.chiiSubjects) .innerJoin(schema.chiiSubjectFields, op.eq(schema.chiiSubjects.id, schema.chiiSubjectFields.id)) - .where(op.and(op.eq(schema.chiiSubjects.id, id), op.eq(schema.chiiSubjects.ban, 0))) + .where( + op.and( + op.eq(schema.chiiSubjects.id, id), + op.eq(schema.chiiSubjects.ban, 0), + allowNsfw ? undefined : op.eq(schema.chiiSubjects.nsfw, false), + ), + ) .execute(); for (const d of data) { return convert.toSubject(d.subject, d.subject_field); diff --git a/routes/private/index.ts b/routes/private/index.ts index 9ba36239..4e0b25db 100644 --- a/routes/private/index.ts +++ b/routes/private/index.ts @@ -10,6 +10,7 @@ import type { App } from '@app/routes/type.ts'; import * as collection from './routes/collection.ts'; import * as login from './routes/login.ts'; import * as post from './routes/post.ts'; +import * as subject from './routes/subject.ts'; import * as group from './routes/topic.ts'; import * as user from './routes/user.ts'; import * as wiki from './routes/wiki/index.ts'; @@ -46,6 +47,7 @@ async function API(app: App) { await app.register(login.setup); await app.register(group.setup); await app.register(post.setup); + await app.register(subject.setup); await app.register(user.setup); await app.register(wiki.setup, { prefix: '/wiki' }); } diff --git a/routes/private/routes/__snapshots__/subject.test.ts.snap b/routes/private/routes/__snapshots__/subject.test.ts.snap index a780103c..f8320be3 100644 --- a/routes/private/routes/__snapshots__/subject.test.ts.snap +++ b/routes/private/routes/__snapshots__/subject.test.ts.snap @@ -1,70 +1,251 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`delete ep comment > not allowed not login 1`] = ` +exports[`subject > should get subject 1`] = ` Object { - "code": "NEED_LOGIN", - "error": "Unauthorized", - "message": "you need to login before delete a comment", - "statusCode": 401, + "airtime": Object { + "date": "2002-04-02", + "month": 4, + "weekday": 2, + "year": 2002, + }, + "collection": Object { + "1": 1159, + "2": 4534, + "3": 215, + "4": 463, + "5": 136, + }, + "eps": 27, + "id": 12, + "images": Object { + "common": "https://lain.bgm.tv/pic/cover/c/c2/0a/12_aDoa8.jpg", + "grid": "https://lain.bgm.tv/pic/cover/g/c2/0a/12_aDoa8.jpg", + "large": "https://lain.bgm.tv/pic/cover/l/c2/0a/12_aDoa8.jpg", + "medium": "https://lain.bgm.tv/pic/cover/m/c2/0a/12_aDoa8.jpg", + "small": "https://lain.bgm.tv/pic/cover/s/c2/0a/12_aDoa8.jpg", + }, + "infobox": Object { + "Copyright": Array [ + Object { + "v": "", + }, + ], + "中文名": Array [ + Object { + "v": "人形电脑天使心", + }, + ], + "人物设定": Array [ + Object { + "v": "阿部恒", + }, + ], + "其他": Array [ + Object { + "v": "", + }, + ], + "其他电视台": Array [ + Object { + "v": "", + }, + ], + "别名": Array [ + Object { + "k": "en", + "v": "Chobits", + }, + ], + "制片人": Array [ + Object { + "v": "源生哲雄、関戸雄一、小野達矢", + }, + ], + "制片协力": Array [ + Object { + "v": "パイオニアLDC、ムービック", + }, + ], + "副导演": Array [ + Object { + "v": "田中洋之(除13)、井上英紀(仅13)", + }, + ], + "动画制作": Array [ + Object { + "v": "MADHOUSE", + }, + ], + "原作": Array [ + Object { + "v": "CLAMP", + }, + ], + "官方网站": Array [ + Object { + "v": "http://www.tbs.co.jp/chobits", + }, + ], + "导演": Array [ + Object { + "v": "浅香守生", + }, + ], + "录音助手": Array [ + Object { + "v": "鳥羽瀬縁", + }, + ], + "录音调整": Array [ + Object { + "v": "内田誠", + }, + ], + "播放电视台": Array [ + Object { + "v": "日本TBS", + }, + ], + "播放结束": Array [ + Object { + "v": "2002年9月24日", + }, + ], + "放送开始": Array [ + Object { + "v": "2002年4月2日", + }, + ], + "放送星期": Array [ + Object { + "v": "星期二", + }, + ], + "文艺": Array [ + Object { + "v": "浦畑達彦", + }, + ], + "製作": Array [ + Object { + "v": "TBS、ちょびっツ製作委員会", + }, + ], + "设定制作": Array [ + Object { + "v": "加茂靖子", + }, + ], + "话数": Array [ + Object { + "v": "27", + }, + ], + "音乐": Array [ + Object { + "v": "高浪敬太郎", + }, + ], + "音响助手": Array [ + Object { + "v": "三井友和", + }, + ], + "音响监督": Array [ + Object { + "v": "三間雅文", + }, + ], + "音效": Array [ + Object { + "v": "小山健二", + }, + ], + }, + "locked": false, + "metaTags": Array [], + "name": "ちょびっツ", + "nameCN": "人形电脑天使心", + "nsfw": false, + "platform": Object { + "alias": "tv", + "enableHeader": true, + "id": 1, + "order": 0, + "type": "TV", + "typeCN": "TV", + "wikiTpl": "TVAnime", + }, + "rating": Object { + "count": Array [ + 4, + 4, + 5, + 13, + 60, + 395, + 987, + 1226, + 369, + 168, + ], + "score": 7.57, + "total": 3231, + }, + "redirect": 0, + "series": false, + "seriesEntry": 0, + "summary": "在不久的将来,电子技术飞速发展,电脑成为人们生活中不可缺少的一部分.主角的名字是本须和秀树,是个19岁的少年,由于考试失败,来到东京上补习班,过着贫穷潦倒的生活…… +到达东京的第一天,他很幸运的在垃圾堆捡到一个人型电脑,一直以来秀树都非常渴望拥有个人电脑.当他抱着她带返公寓后,却不知如何开机,在意想不到的地方找到开关并开启后,故事就此展开 +本须和秀树捡到了人型计算机〔唧〕。虽然不晓得她到底是不是〔Chobits〕,但她的身上似乎藏有极大的秘密。看到秀树为了钱而烦恼,唧出去找打工,没想到却找到了危险的工作!为了让秀树开心,唧开始到色情小屋打工。但她在遭到过度激烈的强迫要求之后失控。让周遭计算机因此而强制停摆。 +另一方面,秀树发现好友新保与补习班的清水老师有着不可告人的关系……", + "type": 2, + "volumes": 0, } `; -exports[`delete ep comment > not allowed wrong user 1`] = ` +exports[`subject > should get subject relations 1`] = ` Object { - "code": "NOT_ALLOWED", - "error": "Unauthorized", - "message": "you don't have permission to delete a comment which is not yours", - "statusCode": 401, -} -`; - -exports[`delete ep comment > ok 1`] = `Object {}`; - -exports[`get ep comment > ok 1`] = ` -Array [ - Object { - "content": "sandbox", - "createdAt": 1640462712, - "creatorID": 382951, - "epID": 1075440, - "id": 1034989, - "relatedID": 0, - "replies": Array [], - "state": 0, - "user": Object { - "avatar": Object { - "large": "https://lain.bgm.tv/pic/user/l/000/38/29/382951.jpg?r=1571167246", - "medium": "https://lain.bgm.tv/pic/user/m/000/38/29/382951.jpg?r=1571167246", - "small": "https://lain.bgm.tv/pic/user/s/000/38/29/382951.jpg?r=1571167246", - }, - "id": 382951, - "nickname": "树洞酱", - "sign": "treeholechan@gmail.com 密码:lovemeplease", - "user_group": 10, - "username": "382951", + "data": Array [ + Object { + "order": 0, + "relation": 1, + "subject": Object { + "id": 497, + "images": Object { + "common": "https://lain.bgm.tv/pic/cover/c/73/80/497_7mGTv.jpg", + "grid": "https://lain.bgm.tv/pic/cover/g/73/80/497_7mGTv.jpg", + "large": "https://lain.bgm.tv/pic/cover/l/73/80/497_7mGTv.jpg", + "medium": "https://lain.bgm.tv/pic/cover/m/73/80/497_7mGTv.jpg", + "small": "https://lain.bgm.tv/pic/cover/s/73/80/497_7mGTv.jpg", + }, + "locked": false, + "name": "ちょびっツ", + "nameCN": "人形电脑天使心", + "nsfw": false, + "type": 1, + }, }, - }, - Object { - "content": "这是一条测试内容", - "createdAt": 1719389390, - "creatorID": 382951, - "epID": 1075440, - "id": 1569792, - "relatedID": 0, - "replies": Array [], - "state": 0, - "user": Object { - "avatar": Object { - "large": "https://lain.bgm.tv/pic/user/l/000/38/29/382951.jpg?r=1571167246", - "medium": "https://lain.bgm.tv/pic/user/m/000/38/29/382951.jpg?r=1571167246", - "small": "https://lain.bgm.tv/pic/user/s/000/38/29/382951.jpg?r=1571167246", - }, - "id": 382951, - "nickname": "树洞酱", - "sign": "treeholechan@gmail.com 密码:lovemeplease", - "user_group": 10, - "username": "382951", + Object { + "order": 0, + "relation": 1004, + "subject": Object { + "id": 11, + "images": Object { + "common": "https://lain.bgm.tv/pic/cover/c/65/12/11_bsxG3.jpg", + "grid": "https://lain.bgm.tv/pic/cover/g/65/12/11_bsxG3.jpg", + "large": "https://lain.bgm.tv/pic/cover/l/65/12/11_bsxG3.jpg", + "medium": "https://lain.bgm.tv/pic/cover/m/65/12/11_bsxG3.jpg", + "small": "https://lain.bgm.tv/pic/cover/s/65/12/11_bsxG3.jpg", + }, + "locked": false, + "name": "ちょびっツの「ツ」の字 - Chobits Fan Book", + "nameCN": "", + "nsfw": false, + "type": 1, + }, }, - }, -] + ], + "total": 2, +} `; diff --git a/routes/private/routes/__snapshots__/subjectPost.test.ts.snap b/routes/private/routes/__snapshots__/subjectPost.test.ts.snap new file mode 100644 index 00000000..a780103c --- /dev/null +++ b/routes/private/routes/__snapshots__/subjectPost.test.ts.snap @@ -0,0 +1,70 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`delete ep comment > not allowed not login 1`] = ` +Object { + "code": "NEED_LOGIN", + "error": "Unauthorized", + "message": "you need to login before delete a comment", + "statusCode": 401, +} +`; + +exports[`delete ep comment > not allowed wrong user 1`] = ` +Object { + "code": "NOT_ALLOWED", + "error": "Unauthorized", + "message": "you don't have permission to delete a comment which is not yours", + "statusCode": 401, +} +`; + +exports[`delete ep comment > ok 1`] = `Object {}`; + +exports[`get ep comment > ok 1`] = ` +Array [ + Object { + "content": "sandbox", + "createdAt": 1640462712, + "creatorID": 382951, + "epID": 1075440, + "id": 1034989, + "relatedID": 0, + "replies": Array [], + "state": 0, + "user": Object { + "avatar": Object { + "large": "https://lain.bgm.tv/pic/user/l/000/38/29/382951.jpg?r=1571167246", + "medium": "https://lain.bgm.tv/pic/user/m/000/38/29/382951.jpg?r=1571167246", + "small": "https://lain.bgm.tv/pic/user/s/000/38/29/382951.jpg?r=1571167246", + }, + "id": 382951, + "nickname": "树洞酱", + "sign": "treeholechan@gmail.com 密码:lovemeplease", + "user_group": 10, + "username": "382951", + }, + }, + Object { + "content": "这是一条测试内容", + "createdAt": 1719389390, + "creatorID": 382951, + "epID": 1075440, + "id": 1569792, + "relatedID": 0, + "replies": Array [], + "state": 0, + "user": Object { + "avatar": Object { + "large": "https://lain.bgm.tv/pic/user/l/000/38/29/382951.jpg?r=1571167246", + "medium": "https://lain.bgm.tv/pic/user/m/000/38/29/382951.jpg?r=1571167246", + "small": "https://lain.bgm.tv/pic/user/s/000/38/29/382951.jpg?r=1571167246", + }, + "id": 382951, + "nickname": "树洞酱", + "sign": "treeholechan@gmail.com 密码:lovemeplease", + "user_group": 10, + "username": "382951", + }, + }, +] +`; diff --git a/routes/private/routes/collection.ts b/routes/private/routes/collection.ts index 3b67175f..dfd16aba 100644 --- a/routes/private/routes/collection.ts +++ b/routes/private/routes/collection.ts @@ -132,7 +132,7 @@ const UserCollectionsSummary = t.Object( ); function toUserSubjectCollection( - interest: orm.ISubjectInterests, + interest: orm.ISubjectInterest, subject: orm.ISubject, fields: orm.ISubjectFields, ): IUserSubjectCollection { diff --git a/routes/private/routes/subject.test.ts b/routes/private/routes/subject.test.ts index 6a2b8f4d..e0666447 100644 --- a/routes/private/routes/subject.test.ts +++ b/routes/private/routes/subject.test.ts @@ -1,185 +1,29 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; import { emptyAuth } from '@app/lib/auth/index.ts'; -import * as orm from '@app/lib/orm/index.ts'; import { createTestServer } from '@app/tests/utils.ts'; -import { setup } from './post.ts'; +import { setup } from './subject.ts'; -beforeEach(async () => { - await orm.EpisodeCommentRepo.update( - { - id: 1569874, - }, - { - state: 0, - content: 'before-test', - }, - ); -}); - -async function testServer(...arg: Parameters) { - const app = createTestServer(...arg); - - await app.register(setup); - return app; -} - -describe('get ep comment', () => { - test('not found', async () => { - const app = await testServer(); - const res = await app.inject({ - url: '/subjects/-/episode/114514/comments', - method: 'get', - }); - expect(JSON.parse(res.body)).toEqual([]); - }); - - test('ok', async () => { +describe('subject', () => { + test('should get subject', async () => { const app = createTestServer(); await app.register(setup); const res = await app.inject({ method: 'get', - url: '/subjects/-/episode/1075440/comments', - }); - const comments = res.json(); - expect(comments.slice(0, 2)).toMatchSnapshot(); - }); -}); - -describe('create ep comment', () => { - test('not allowed not login', async () => { - const app = await testServer(); - const res = await app.inject({ - url: '/subjects/-/episode/1075440/comments', - method: 'post', - payload: { content: '114514', 'cf-turnstile-response': 'fake-response' }, - }); - expect(res.statusCode).toBe(401); - }); - - test('ok', async () => { - const app = createTestServer({ - auth: { - ...emptyAuth(), - login: true, - userID: 2, - }, - }); - await app.register(setup); - const res = await app.inject({ - url: '/subjects/-/episode/1075440/comments', - method: 'post', - payload: { content: '114514', 'cf-turnstile-response': 'fake-response' }, - }); - const pst = await orm.EpisodeCommentRepo.findOneBy({ - id: res.json().id, - }); - expect(pst?.content).toBe('114514'); - }); -}); - -describe('edit ep comment', () => { - test('should edit ep comment', async () => { - const app = createTestServer({ - auth: { - ...emptyAuth(), - login: true, - userID: 382951, - }, - }); - - await app.register(setup); - - const res = await app.inject({ - url: '/subjects/-/episode/-/comments/1569874', - method: 'put', - payload: { content: 'new comment' }, - }); - - expect(res.statusCode).toBe(200); - - const pst = await orm.EpisodeCommentRepo.findOneBy({ - id: 1569874, + url: '/subjects/12', }); - - expect(pst?.content).toBe('new comment'); - }); - - test('should not edit ep comment', async () => { - const app = createTestServer({ - auth: { - ...emptyAuth(), - login: true, - userID: 382951 + 1, - }, - }); - - await app.register(setup); - - const res = await app.inject({ - url: '/subjects/-/episode/-/comments/1569874', - method: 'put', - payload: { content: 'new comment again' }, - }); - - expect(res.json()).toMatchInlineSnapshot(` - Object { - "code": "NOT_ALLOWED", - "error": "Unauthorized", - "message": "you don't have permission to edit a comment which is not yours", - "statusCode": 401, - } - `); - expect(res.statusCode).toBe(401); - }); -}); - -describe('delete ep comment', () => { - beforeEach(() => { - vi.restoreAllMocks(); - }); - - test('not found', async () => { - const app = await testServer({ auth: { login: true, userID: 2 } }); - const res = await app.inject({ - url: '/subjects/-/episode/-/comments/114514', - method: 'delete', - }); - - expect(res.statusCode).toBe(404); - }); - - test('not allowed not login', async () => { - const app = await testServer(); - const res = await app.inject({ - url: '/subjects/-/episode/-/comments/114514', - method: 'delete', - }); - - expect(res.json()).toMatchSnapshot(); - expect(res.statusCode).toBe(401); - }); - - test('not allowed wrong user', async () => { - const app = await testServer({ auth: { login: true, userID: 1122 } }); - const res = await app.inject({ - url: '/subjects/-/episode/-/comments/1569874', - method: 'delete', - }); - expect(res.json()).toMatchSnapshot(); - expect(res.statusCode).toBe(401); }); - test('ok', async () => { - const app = await testServer({ auth: { login: true, userID: 382951 } }); - + test('should get subject relations', async () => { + const app = createTestServer(); + await app.register(setup); const res = await app.inject({ - method: 'delete', - url: '/subjects/-/episode/-/comments/1569874', + method: 'get', + url: '/subjects/12/relations', + query: { limit: '2', offset: '0' }, }); expect(res.json()).toMatchSnapshot(); - expect(res.statusCode).toBe(200); }); }); diff --git a/routes/private/routes/subject.ts b/routes/private/routes/subject.ts new file mode 100644 index 00000000..9b7398f0 --- /dev/null +++ b/routes/private/routes/subject.ts @@ -0,0 +1,146 @@ +import type { Static } from '@sinclair/typebox'; +import { Type as t } from '@sinclair/typebox'; + +import { db, op } from '@app/drizzle/db.ts'; +import type * as orm from '@app/drizzle/orm.ts'; +import * as schema from '@app/drizzle/schema'; +import { NotFoundError } from '@app/lib/error.ts'; +import { Security, Tag } from '@app/lib/openapi/index.ts'; +import { SubjectType } from '@app/lib/subject/type.ts'; +import * as convert from '@app/lib/types/convert.ts'; +import * as res from '@app/lib/types/res.ts'; +import { formatErrors } from '@app/lib/types/res.ts'; +import type { App } from '@app/routes/type.ts'; + +export type ISubjectRelation = Static; +const SubjectRelation = t.Object( + { + subject: t.Ref(res.SlimSubject), + relation: t.Integer(), + order: t.Integer(), + }, + { $id: 'SubjectRelation' }, +); + +function toSubjectRelation( + subject: orm.ISubject, + relation: orm.ISubjectRelation, +): ISubjectRelation { + return { + subject: convert.toSlimSubject(subject), + relation: relation.relation, + order: relation.order, + }; +} + +// eslint-disable-next-line @typescript-eslint/require-await +export async function setup(app: App) { + app.addSchema(SubjectRelation); + + app.get( + '/subjects/:subjectID', + { + schema: { + summary: '获取条目', + operationId: 'getSubject', + tags: [Tag.Subject], + security: [{ [Security.CookiesSession]: [], [Security.HTTPBearer]: [] }], + params: t.Object({ + subjectID: t.Integer(), + }), + response: { + 200: t.Ref(res.Subject), + 404: t.Ref(res.Error, { + 'x-examples': formatErrors(new NotFoundError('subject')), + }), + }, + }, + }, + async ({ auth, params: { subjectID } }) => { + const data = await db + .select() + .from(schema.chiiSubjects) + .innerJoin( + schema.chiiSubjectFields, + op.eq(schema.chiiSubjects.id, schema.chiiSubjectFields.id), + ) + .where( + op.and( + op.eq(schema.chiiSubjects.id, subjectID), + op.eq(schema.chiiSubjects.ban, 0), + auth.allowNsfw ? undefined : op.eq(schema.chiiSubjects.nsfw, false), + ), + ) + .execute(); + for (const d of data) { + return convert.toSubject(d.subject, d.subject_field); + } + throw new NotFoundError(`subject ${subjectID}`); + }, + ); + + app.get( + '/subjects/:subjectID/relations', + { + schema: { + summary: '获取条目的关联条目', + operationId: 'getSubjectRelations', + tags: [Tag.Subject], + security: [{ [Security.CookiesSession]: [], [Security.HTTPBearer]: [] }], + params: t.Object({ + subjectID: t.Integer(), + }), + querystring: t.Object({ + type: t.Optional(t.Enum(SubjectType, { description: '条目类型' })), + limit: t.Optional( + t.Integer({ default: 20, minimum: 1, maximum: 100, description: 'max 100' }), + ), + offset: t.Optional(t.Integer({ default: 0, minimum: 0, description: 'min 0' })), + }), + response: { + 200: res.Paged(t.Ref(SubjectRelation)), + 404: t.Ref(res.Error, { + 'x-examples': formatErrors(new NotFoundError('subject')), + }), + }, + }, + }, + async ({ auth, params: { subjectID }, query: { type, limit = 20, offset = 0 } }) => { + const condition = op.and( + op.eq(schema.chiiSubjectRelations.id, subjectID), + type ? op.eq(schema.chiiSubjectRelations.relatedType, type) : undefined, + op.eq(schema.chiiSubjects.ban, 0), + auth.allowNsfw ? undefined : op.eq(schema.chiiSubjects.nsfw, false), + ); + const [{ count = 0 } = {}] = await db + .select({ count: op.count() }) + .from(schema.chiiSubjectRelations) + .innerJoin( + schema.chiiSubjects, + op.eq(schema.chiiSubjectRelations.relatedID, schema.chiiSubjects.id), + ) + .where(condition) + .execute(); + const data = await db + .select() + .from(schema.chiiSubjectRelations) + .innerJoin( + schema.chiiSubjects, + op.eq(schema.chiiSubjectRelations.relatedID, schema.chiiSubjects.id), + ) + .where(condition) + .orderBy( + op.asc(schema.chiiSubjectRelations.relation), + op.asc(schema.chiiSubjectRelations.order), + ) + .limit(limit) + .offset(offset) + .execute(); + const relations = data.map((d) => toSubjectRelation(d.subject, d.chii_subject_relations)); + return { + data: relations, + total: count, + }; + }, + ); +} diff --git a/routes/private/routes/subjectPost.test.ts b/routes/private/routes/subjectPost.test.ts new file mode 100644 index 00000000..6a2b8f4d --- /dev/null +++ b/routes/private/routes/subjectPost.test.ts @@ -0,0 +1,185 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { emptyAuth } from '@app/lib/auth/index.ts'; +import * as orm from '@app/lib/orm/index.ts'; +import { createTestServer } from '@app/tests/utils.ts'; + +import { setup } from './post.ts'; + +beforeEach(async () => { + await orm.EpisodeCommentRepo.update( + { + id: 1569874, + }, + { + state: 0, + content: 'before-test', + }, + ); +}); + +async function testServer(...arg: Parameters) { + const app = createTestServer(...arg); + + await app.register(setup); + return app; +} + +describe('get ep comment', () => { + test('not found', async () => { + const app = await testServer(); + const res = await app.inject({ + url: '/subjects/-/episode/114514/comments', + method: 'get', + }); + expect(JSON.parse(res.body)).toEqual([]); + }); + + test('ok', async () => { + const app = createTestServer(); + await app.register(setup); + const res = await app.inject({ + method: 'get', + url: '/subjects/-/episode/1075440/comments', + }); + const comments = res.json(); + expect(comments.slice(0, 2)).toMatchSnapshot(); + }); +}); + +describe('create ep comment', () => { + test('not allowed not login', async () => { + const app = await testServer(); + const res = await app.inject({ + url: '/subjects/-/episode/1075440/comments', + method: 'post', + payload: { content: '114514', 'cf-turnstile-response': 'fake-response' }, + }); + expect(res.statusCode).toBe(401); + }); + + test('ok', async () => { + const app = createTestServer({ + auth: { + ...emptyAuth(), + login: true, + userID: 2, + }, + }); + await app.register(setup); + const res = await app.inject({ + url: '/subjects/-/episode/1075440/comments', + method: 'post', + payload: { content: '114514', 'cf-turnstile-response': 'fake-response' }, + }); + const pst = await orm.EpisodeCommentRepo.findOneBy({ + id: res.json().id, + }); + expect(pst?.content).toBe('114514'); + }); +}); + +describe('edit ep comment', () => { + test('should edit ep comment', async () => { + const app = createTestServer({ + auth: { + ...emptyAuth(), + login: true, + userID: 382951, + }, + }); + + await app.register(setup); + + const res = await app.inject({ + url: '/subjects/-/episode/-/comments/1569874', + method: 'put', + payload: { content: 'new comment' }, + }); + + expect(res.statusCode).toBe(200); + + const pst = await orm.EpisodeCommentRepo.findOneBy({ + id: 1569874, + }); + + expect(pst?.content).toBe('new comment'); + }); + + test('should not edit ep comment', async () => { + const app = createTestServer({ + auth: { + ...emptyAuth(), + login: true, + userID: 382951 + 1, + }, + }); + + await app.register(setup); + + const res = await app.inject({ + url: '/subjects/-/episode/-/comments/1569874', + method: 'put', + payload: { content: 'new comment again' }, + }); + + expect(res.json()).toMatchInlineSnapshot(` + Object { + "code": "NOT_ALLOWED", + "error": "Unauthorized", + "message": "you don't have permission to edit a comment which is not yours", + "statusCode": 401, + } + `); + expect(res.statusCode).toBe(401); + }); +}); + +describe('delete ep comment', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + test('not found', async () => { + const app = await testServer({ auth: { login: true, userID: 2 } }); + const res = await app.inject({ + url: '/subjects/-/episode/-/comments/114514', + method: 'delete', + }); + + expect(res.statusCode).toBe(404); + }); + + test('not allowed not login', async () => { + const app = await testServer(); + const res = await app.inject({ + url: '/subjects/-/episode/-/comments/114514', + method: 'delete', + }); + + expect(res.json()).toMatchSnapshot(); + expect(res.statusCode).toBe(401); + }); + + test('not allowed wrong user', async () => { + const app = await testServer({ auth: { login: true, userID: 1122 } }); + const res = await app.inject({ + url: '/subjects/-/episode/-/comments/1569874', + method: 'delete', + }); + + expect(res.json()).toMatchSnapshot(); + expect(res.statusCode).toBe(401); + }); + + test('ok', async () => { + const app = await testServer({ auth: { login: true, userID: 382951 } }); + + const res = await app.inject({ + method: 'delete', + url: '/subjects/-/episode/-/comments/1569874', + }); + expect(res.json()).toMatchSnapshot(); + expect(res.statusCode).toBe(200); + }); +});