From 45f512b6910a9a56638606853f3f19b805fb26de Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 04:19:20 +0000 Subject: [PATCH] Complete legacy-compatible board rooms --- app/game-api/src/router/board/index.ts | 353 ++++---- app/game-api/test/boardRouter.test.ts | 301 +++++++ app/game-frontend/e2e/board.spec.ts | 445 ++++++++++ app/game-frontend/e2e/playwright.config.mjs | 9 +- app/game-frontend/package.json | 3 +- app/game-frontend/src/stores/mainDashboard.ts | 8 +- app/game-frontend/src/views/BoardView.vue | 811 ++++++++++-------- app/game-frontend/src/views/MainView.vue | 10 + packages/infra/src/db.ts | 2 + .../board-ref-measure.mjs | 119 +++ 10 files changed, 1496 insertions(+), 565 deletions(-) create mode 100644 app/game-api/test/boardRouter.test.ts create mode 100644 app/game-frontend/e2e/board.spec.ts create mode 100644 tools/frontend-legacy-parity/board-ref-measure.mjs diff --git a/app/game-api/src/router/board/index.ts b/app/game-api/src/router/board/index.ts index b046748..7f8b3f3 100644 --- a/app/game-api/src/router/board/index.ts +++ b/app/game-api/src/router/board/index.ts @@ -5,31 +5,37 @@ import { promises as fs } from 'fs'; import { randomUUID } from 'crypto'; import sharp, { type WebpOptions } from 'sharp'; -import { GamePrisma } from '@sammo-ts/infra'; - import { authedProcedure, router } from '../../trpc.js'; import { getMyGeneral } from '../shared/general.js'; +import { resolveSecretPermission } from '../shared/secretPermission.js'; const MAX_UPLOAD_BYTES = 1024 * 1024; const MAX_LONG_EDGE = 2048; const WEBP_QUALITY = 80; const WEBP_MIN_SAVING_RATIO = 0.97; -const resolveSecretPermission = (officerLevel: number): number => { - if (officerLevel >= 5) return 2; - if (officerLevel > 1) return 1; - return 0; -}; - -const assertBoardAccess = (nationId: number, officerLevel: number, isSecret: boolean) => { - if (nationId <= 0 || officerLevel <= 0) { +const assertBoardAccess = (permission: number, isSecret: boolean) => { + if (permission < 0) { throw new TRPCError({ code: 'FORBIDDEN', message: '국가에 소속되어있지 않습니다.' }); } - if (isSecret && resolveSecretPermission(officerLevel) < 2) { + if (isSecret && permission < 2) { throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다. 수뇌부가 아닙니다.' }); } }; +const getBoardActor = async (ctx: Parameters[0]) => { + const general = await getMyGeneral(ctx); + const nation = + general.nationId > 0 + ? await ctx.db.nation.findUnique({ + where: { id: general.nationId }, + select: { meta: true }, + }) + : null; + const permission = resolveSecretPermission(general, nation?.meta ?? {}, true); + return { general, permission }; +}; + const normalizeUploadPath = (value: string) => { if (!value.startsWith('/')) { return `/${value}`; @@ -92,111 +98,96 @@ const buildAvifBuffer = async (buffer: Buffer, resize: boolean): Promise return pipeline.avif({ quality: 60, effort: 4 }).toBuffer(); }; -type BoardPostRow = { - id: number; - nation_id: number; - is_secret: boolean; - author_general_id: number; - author_name: string; - title: string; - content_html: string; - created_at: Date; -}; - -type BoardCommentRow = { - id: number; - post_id: number; - author_general_id: number; - author_name: string; - content_text: string; - created_at: Date; -}; - export const boardRouter = router({ - getArticles: authedProcedure - .input(z.object({ isSecret: z.boolean() })) - .query(async ({ ctx, input }) => { - const me = await getMyGeneral(ctx); - assertBoardAccess(me.nationId, me.officerLevel, input.isSecret); + getAccess: authedProcedure.query(async ({ ctx }) => { + const { permission } = await getBoardActor(ctx); + return { + permission, + canMeeting: permission >= 0, + canSecret: permission >= 2, + }; + }), + getArticles: authedProcedure.input(z.object({ isSecret: z.boolean() })).query(async ({ ctx, input }) => { + const { general, permission } = await getBoardActor(ctx); + assertBoardAccess(permission, input.isSecret); - const posts = await ctx.db.$queryRaw(GamePrisma.sql` - SELECT - id, - nation_id, - is_secret, - author_general_id, - author_name, - title, - content_html, - created_at - FROM board_post - WHERE nation_id = ${me.nationId} AND is_secret = ${input.isSecret} - ORDER BY created_at DESC - LIMIT 100 - `); + const posts = await ctx.db.boardPost.findMany({ + where: { + nationId: general.nationId, + isSecret: input.isSecret, + }, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + take: 100, + include: { + comments: { + orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], + }, + }, + }); - if (posts.length === 0) { - return []; - } + if (posts.length === 0) { + return []; + } - const postIds = posts.map((post) => post.id); - const comments = await ctx.db.$queryRaw(GamePrisma.sql` - SELECT - id, - post_id, - author_general_id, - author_name, - content_text, - created_at - FROM board_comment - WHERE post_id IN (${GamePrisma.join(postIds)}) - ORDER BY created_at ASC - `); + const authors = await ctx.db.general.findMany({ + where: { + id: { + in: [...new Set(posts.map((post) => post.authorGeneralId))], + }, + }, + select: { + id: true, + picture: true, + imageServer: true, + }, + }); + const authorMap = new Map(authors.map((author) => [author.id, author])); - const commentMap = new Map(); - for (const comment of comments) { - const list = commentMap.get(comment.post_id) ?? []; - list.push(comment); - commentMap.set(comment.post_id, list); - } - - return posts.map((post) => ({ - id: post.id, - title: post.title, - contentHtml: post.content_html, - authorName: post.author_name, - createdAt: post.created_at.toISOString(), - comments: (commentMap.get(post.id) ?? []).map((comment) => ({ - id: comment.id, - authorName: comment.author_name, - content: comment.content_text, - createdAt: comment.created_at.toISOString(), - })), - })); - }), + return posts.map((post) => ({ + id: post.id, + title: post.title, + content: post.contentHtml, + authorName: post.authorName, + authorPicture: authorMap.get(post.authorGeneralId)?.picture ?? null, + authorImageServer: authorMap.get(post.authorGeneralId)?.imageServer ?? 0, + createdAt: post.createdAt.toISOString(), + comments: post.comments.map((comment) => ({ + id: comment.id, + authorName: comment.authorName, + content: comment.contentText, + createdAt: comment.createdAt.toISOString(), + })), + })); + }), writeArticle: authedProcedure .input( z.object({ isSecret: z.boolean(), title: z.string().trim().max(250), - contentHtml: z.string().trim().max(20000), + content: z.string().trim().max(20000), }) ) .mutation(async ({ ctx, input }) => { - const me = await getMyGeneral(ctx); - assertBoardAccess(me.nationId, me.officerLevel, input.isSecret); + const { general, permission } = await getBoardActor(ctx); + assertBoardAccess(permission, input.isSecret); - if (!input.title && !input.contentHtml) { - throw new TRPCError({ code: 'BAD_REQUEST', message: '제목 혹은 내용이 필요합니다.' }); + if (!input.title && !input.content) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '제목과 내용이 둘다 비어있습니다.' }); } - const rows = await ctx.db.$queryRaw<{ id: number }[]>(GamePrisma.sql` - INSERT INTO board_post (nation_id, is_secret, author_general_id, author_name, title, content_html) - VALUES (${me.nationId}, ${input.isSecret}, ${me.id}, ${me.name}, ${input.title}, ${input.contentHtml}) - RETURNING id - `); + const post = await ctx.db.boardPost.create({ + data: { + nationId: general.nationId, + isSecret: input.isSecret, + authorGeneralId: general.id, + authorName: general.name, + title: input.title, + contentHtml: input.content, + }, + select: { id: true }, + }); - return { id: rows[0]?.id ?? null }; + return { id: post.id }; }), writeComment: authedProcedure .input( @@ -206,101 +197,99 @@ export const boardRouter = router({ }) ) .mutation(async ({ ctx, input }) => { - const me = await getMyGeneral(ctx); + const { general, permission } = await getBoardActor(ctx); if (!input.content) { - throw new TRPCError({ code: 'BAD_REQUEST', message: '내용이 필요합니다.' }); + throw new TRPCError({ code: 'BAD_REQUEST', message: '내용이 비어있습니다.' }); } - const posts = await ctx.db.$queryRaw<{ id: number; nation_id: number; is_secret: boolean }[]>( - GamePrisma.sql` - SELECT id, nation_id, is_secret - FROM board_post - WHERE id = ${input.postId} - LIMIT 1 - ` - ); - - const post = posts[0]; + const post = await ctx.db.boardPost.findFirst({ + where: { + id: input.postId, + nationId: general.nationId, + }, + select: { + id: true, + isSecret: true, + }, + }); if (!post) { - throw new TRPCError({ code: 'NOT_FOUND', message: '게시물을 찾을 수 없습니다.' }); + throw new TRPCError({ code: 'NOT_FOUND', message: '게시물이 없습니다.' }); } - assertBoardAccess(me.nationId, me.officerLevel, post.is_secret); + assertBoardAccess(permission, post.isSecret); - if (post.nation_id !== me.nationId) { - throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' }); - } + const comment = await ctx.db.boardComment.create({ + data: { + postId: post.id, + nationId: general.nationId, + isSecret: post.isSecret, + authorGeneralId: general.id, + authorName: general.name, + contentText: input.content, + }, + select: { id: true }, + }); - const rows = await ctx.db.$queryRaw<{ id: number }[]>(GamePrisma.sql` - INSERT INTO board_comment (post_id, nation_id, is_secret, author_general_id, author_name, content_text) - VALUES (${post.id}, ${me.nationId}, ${post.is_secret}, ${me.id}, ${me.name}, ${input.content}) - RETURNING id - `); - - return { id: rows[0]?.id ?? null }; + return { id: comment.id }; }), - uploadImage: authedProcedure - .input(z.object({ dataUrl: z.string().min(1) })) - .mutation(async ({ ctx, input }) => { - const me = await getMyGeneral(ctx); - if (me.nationId <= 0 || me.officerLevel <= 0) { - throw new TRPCError({ code: 'FORBIDDEN', message: '국가에 소속되어있지 않습니다.' }); + uploadImage: authedProcedure.input(z.object({ dataUrl: z.string().min(1) })).mutation(async ({ ctx, input }) => { + const { permission } = await getBoardActor(ctx); + assertBoardAccess(permission, false); + + const buffer = parseDataUrl(input.dataUrl); + if (buffer.length > MAX_UPLOAD_BYTES) { + throw new TRPCError({ code: 'PAYLOAD_TOO_LARGE', message: '이미지 용량 제한(1MB)을 초과했습니다.' }); + } + + const metadata = await sharp(buffer, { animated: true }).metadata(); + if (!metadata.format || !metadata.width || !metadata.height) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지 정보를 확인할 수 없습니다.' }); + } + + const format = metadata.format; + const isAnimated = (metadata.pages ?? 1) > 1; + const needsResize = Math.max(metadata.width, metadata.height) > MAX_LONG_EDGE; + + const allowed = new Set(['png', 'jpeg', 'jpg', 'gif', 'webp', 'avif', 'heif', 'tiff', 'bmp']); + if (!allowed.has(format)) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '지원하지 않는 이미지 형식입니다.' }); + } + + let outputBuffer = buffer; + let outputFormat = format === 'avif' ? 'avif' : 'webp'; + + if (format === 'avif') { + if (needsResize) { + outputBuffer = await buildAvifBuffer(buffer, true); } - - const buffer = parseDataUrl(input.dataUrl); - if (buffer.length > MAX_UPLOAD_BYTES) { - throw new TRPCError({ code: 'PAYLOAD_TOO_LARGE', message: '이미지 용량 제한(1MB)을 초과했습니다.' }); - } - - const metadata = await sharp(buffer, { animated: true }).metadata(); - if (!metadata.format || !metadata.width || !metadata.height) { - throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지 정보를 확인할 수 없습니다.' }); - } - - const format = metadata.format; - const isAnimated = (metadata.pages ?? 1) > 1; - const needsResize = Math.max(metadata.width, metadata.height) > MAX_LONG_EDGE; - - const allowed = new Set(['png', 'jpeg', 'jpg', 'gif', 'webp', 'avif', 'heif', 'tiff', 'bmp']); - if (!allowed.has(format)) { - throw new TRPCError({ code: 'BAD_REQUEST', message: '지원하지 않는 이미지 형식입니다.' }); - } - - let outputBuffer = buffer; - let outputFormat = format === 'avif' ? 'avif' : 'webp'; - - if (format === 'avif') { - if (needsResize) { - outputBuffer = await buildAvifBuffer(buffer, true); - } + } else { + const webpBuffer = await buildWebpBuffer(buffer, { + animated: isAnimated || format === 'gif', + resize: needsResize, + }); + if (format === 'webp' && !needsResize && webpBuffer.length >= buffer.length * WEBP_MIN_SAVING_RATIO) { + outputBuffer = buffer; + outputFormat = 'webp'; } else { - const webpBuffer = await buildWebpBuffer(buffer, { - animated: isAnimated || format === 'gif', - resize: needsResize, - }); - if (format === 'webp' && !needsResize && webpBuffer.length >= buffer.length * WEBP_MIN_SAVING_RATIO) { - outputBuffer = buffer; - outputFormat = 'webp'; - } else { - outputBuffer = webpBuffer; - outputFormat = 'webp'; - } + outputBuffer = webpBuffer; + outputFormat = 'webp'; } + } - await fs.mkdir(ctx.uploadDir, { recursive: true }); - const filename = `${randomUUID()}.${outputFormat}`; - await fs.writeFile(path.join(ctx.uploadDir, filename), outputBuffer); + await fs.mkdir(ctx.uploadDir, { recursive: true }); + const filename = `${randomUUID()}.${outputFormat}`; + await fs.writeFile(path.join(ctx.uploadDir, filename), outputBuffer); - const outputMeta = await sharp(outputBuffer, { animated: true }).metadata(); - const url = buildPublicImageUrl(ctx.uploadPublicUrl, ctx.uploadPath, filename); + const outputMeta = await sharp(outputBuffer, { animated: true }).metadata(); + const url = buildPublicImageUrl(ctx.uploadPublicUrl, ctx.uploadPath, filename); - return { - url, - width: outputMeta.width ?? metadata.width, - height: outputMeta.height ?? metadata.height, - format: outputFormat, - animated: isAnimated, - size: outputBuffer.length, - }; - }), -}); \ No newline at end of file + return { + url, + width: outputMeta.width ?? metadata.width, + height: outputMeta.height ?? metadata.height, + format: outputFormat, + animated: isAnimated, + size: outputBuffer.length, + }; + }), +}); diff --git a/app/game-api/test/boardRouter.test.ts b/app/game-api/test/boardRouter.test.ts new file mode 100644 index 0000000..135b0ec --- /dev/null +++ b/app/game-api/test/boardRouter.test.ts @@ -0,0 +1,301 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; +import type { RedisConnector } from '@sammo-ts/infra'; + +import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js'; +import { InMemoryFlushStore } from '../src/auth/flushStore.js'; +import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js'; +import { appRouter } from '../src/router.js'; + +const buildGeneral = (overrides: Partial = {}): GeneralRow => ({ + id: 1, + userId: 'user-1', + name: '테스트장수', + nationId: 1, + cityId: 1, + troopId: 0, + npcState: 0, + affinity: null, + bornYear: 180, + deadYear: 300, + picture: '22.jpg', + imageServer: 0, + leadership: 50, + strength: 50, + intel: 50, + injury: 0, + experience: 0, + dedication: 0, + officerLevel: 1, + gold: 1000, + rice: 1000, + crew: 0, + crewTypeId: 0, + train: 0, + atmos: 0, + weaponCode: 'None', + bookCode: 'None', + horseCode: 'None', + itemCode: 'None', + turnTime: new Date('2026-01-01T00:00:00Z'), + recentWarTime: null, + age: 20, + startAge: 20, + personalCode: 'None', + specialCode: 'None', + special2Code: 'None', + lastTurn: {}, + meta: {}, + penalty: {}, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), + ...overrides, +}); + +const auth: GameSessionTokenPayload = { + version: 1, + profile: 'che:default', + issuedAt: '2026-01-01T00:00:00.000Z', + expiresAt: '2026-01-02T00:00:00.000Z', + sessionId: 'session-1', + user: { + id: 'user-1', + username: 'tester', + displayName: 'Tester', + roles: [], + }, + sanctions: {}, +}; + +const buildContext = (options: { + me?: GeneralRow; + nationMeta?: Record; + auth?: GameSessionTokenPayload | null; + posts?: Array<{ + id: number; + nationId: number; + isSecret: boolean; + authorGeneralId: number; + authorName: string; + title: string; + contentHtml: string; + createdAt: Date; + updatedAt: Date; + comments: Array<{ + id: number; + postId: number; + nationId: number; + isSecret: boolean; + authorGeneralId: number; + authorName: string; + contentText: string; + createdAt: Date; + }>; + }>; + targetPost?: { id: number; isSecret: boolean } | null; +}) => { + const me = options.me ?? buildGeneral(); + const boardPostFindMany = vi.fn(async () => options.posts ?? []); + const boardPostFindFirst = vi.fn(async () => options.targetPost ?? null); + const boardPostCreate = vi.fn(async () => ({ id: 31 })); + const boardCommentCreate = vi.fn(async () => ({ id: 41 })); + const db = { + general: { + findFirst: vi.fn(async ({ where }: { where: { userId: string } }) => + me.userId === where.userId ? me : null + ), + findMany: vi.fn(async () => [{ id: me.id, picture: me.picture, imageServer: me.imageServer }]), + }, + nation: { + findUnique: vi.fn(async ({ where }: { where: { id: number } }) => + where.id === me.nationId ? { meta: options.nationMeta ?? {} } : null + ), + }, + boardPost: { + findMany: boardPostFindMany, + findFirst: boardPostFindFirst, + create: boardPostCreate, + }, + boardComment: { + create: boardCommentCreate, + }, + }; + const accessTokenStore = new RedisAccessTokenStore( + { + get: async () => null, + set: async () => null, + }, + 'che:default' + ); + const context: GameApiContext = { + db: db as unknown as DatabaseClient, + redis: {} as RedisConnector['client'], + turnDaemon: {} as GameApiContext['turnDaemon'], + battleSim: {} as GameApiContext['battleSim'], + profile: { id: 'che', scenario: 'default', name: 'che:default' }, + auth: options.auth === undefined ? auth : options.auth, + uploadDir: 'uploads', + uploadPath: '/uploads', + uploadPublicUrl: null, + accessTokenStore, + flushStore: new InMemoryFlushStore(), + gameTokenSecret: 'test-secret', + }; + return { + context, + boardPostFindMany, + boardPostFindFirst, + boardPostCreate, + boardCommentCreate, + }; +}; + +describe('board router actor, nation, and secret permissions', () => { + it.each([ + { + label: 'ordinary member', + me: buildGeneral({ officerLevel: 1 }), + nationMeta: {}, + expected: { permission: 0, canMeeting: true, canSecret: false }, + }, + { + label: 'chief', + me: buildGeneral({ officerLevel: 5 }), + nationMeta: {}, + expected: { permission: 2, canMeeting: true, canSecret: true }, + }, + { + label: 'low-rank ambassador', + me: buildGeneral({ officerLevel: 1, meta: { permission: 'ambassador' } }), + nationMeta: {}, + expected: { permission: 4, canMeeting: true, canSecret: true }, + }, + { + label: 'auditor', + me: buildGeneral({ officerLevel: 1, meta: { permission: 'auditor' } }), + nationMeta: {}, + expected: { permission: 3, canMeeting: true, canSecret: true }, + }, + { + label: 'penalized chief', + me: buildGeneral({ officerLevel: 5, penalty: { noChief: true } }), + nationMeta: {}, + expected: { permission: 0, canMeeting: true, canSecret: false }, + }, + { + label: 'unaffiliated general', + me: buildGeneral({ nationId: 0, officerLevel: 0 }), + nationMeta: {}, + expected: { permission: -1, canMeeting: false, canSecret: false }, + }, + ])('reports legacy-compatible access for $label', async ({ me, nationMeta, expected }) => { + const fixture = buildContext({ me, nationMeta }); + await expect(appRouter.createCaller(fixture.context).board.getAccess()).resolves.toEqual(expected); + }); + + it('derives the article actor from the authenticated user and scopes the list to their nation', async () => { + const createdAt = new Date('2026-07-26T10:20:00Z'); + const fixture = buildContext({ + me: buildGeneral({ id: 7, userId: 'user-1', nationId: 3, officerLevel: 5 }), + posts: [ + { + id: 11, + nationId: 3, + isSecret: true, + authorGeneralId: 7, + authorName: '작성자', + title: '작전', + contentHtml: '내용', + createdAt, + updatedAt: createdAt, + comments: [], + }, + ], + }); + + await expect( + appRouter.createCaller(fixture.context).board.getArticles({ isSecret: true }) + ).resolves.toMatchObject([ + { + id: 11, + title: '작전', + content: '내용', + authorName: '작성자', + authorPicture: '22.jpg', + authorImageServer: 0, + }, + ]); + expect(fixture.boardPostFindMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { nationId: 3, isSecret: true }, + }) + ); + }); + + it('uses the session actor for writes and keeps empty-input wording compatible', async () => { + const fixture = buildContext({ + me: buildGeneral({ id: 7, userId: 'user-1', nationId: 3, officerLevel: 1 }), + }); + const caller = appRouter.createCaller(fixture.context); + + await expect(caller.board.writeArticle({ isSecret: false, title: '', content: '' })).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: '제목과 내용이 둘다 비어있습니다.', + }); + + await expect(caller.board.writeArticle({ isSecret: false, title: '알림', content: '내용' })).resolves.toEqual({ + id: 31, + }); + expect(fixture.boardPostCreate).toHaveBeenCalledWith({ + data: { + nationId: 3, + isSecret: false, + authorGeneralId: 7, + authorName: '테스트장수', + title: '알림', + contentHtml: '내용', + }, + select: { id: true }, + }); + }); + + it('does not reveal whether another nation owns a requested comment target', async () => { + const fixture = buildContext({ + me: buildGeneral({ nationId: 3, officerLevel: 5 }), + targetPost: null, + }); + await expect( + appRouter.createCaller(fixture.context).board.writeComment({ postId: 99, content: '답변' }) + ).rejects.toMatchObject({ + code: 'NOT_FOUND', + message: '게시물이 없습니다.', + }); + expect(fixture.boardPostFindFirst).toHaveBeenCalledWith({ + where: { id: 99, nationId: 3 }, + select: { id: true, isSecret: true }, + }); + expect(fixture.boardCommentCreate).not.toHaveBeenCalled(); + }); + + it('checks secret permission again when adding a comment', async () => { + const fixture = buildContext({ + me: buildGeneral({ officerLevel: 2 }), + targetPost: { id: 5, isSecret: true }, + }); + await expect( + appRouter.createCaller(fixture.context).board.writeComment({ postId: 5, content: '답변' }) + ).rejects.toMatchObject({ + code: 'FORBIDDEN', + message: '권한이 부족합니다. 수뇌부가 아닙니다.', + }); + expect(fixture.boardCommentCreate).not.toHaveBeenCalled(); + }); + + it('rejects unauthenticated board access', async () => { + const fixture = buildContext({ auth: null }); + await expect(appRouter.createCaller(fixture.context).board.getAccess()).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + }); +}); diff --git a/app/game-frontend/e2e/board.spec.ts b/app/game-frontend/e2e/board.spec.ts new file mode 100644 index 0000000..599d2e0 --- /dev/null +++ b/app/game-frontend/e2e/board.spec.ts @@ -0,0 +1,445 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; +import { readFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); +const imageRoots = [resolve(repositoryRoot, '../image'), resolve(repositoryRoot, '../../image')]; +const artifactRoot = process.env.BOARD_ARTIFACT_DIR; + +type Article = { + id: number; + title: string; + content: string; + authorName: string; + authorPicture: string | null; + authorImageServer: number; + createdAt: string; + comments: Array<{ + id: number; + authorName: string; + content: string; + createdAt: string; + }>; +}; + +type BoardFixture = { + permission: number; + canMeeting: boolean; + canSecret: boolean; + articles: Article[]; + failArticle?: boolean; + failComment?: boolean; + requests: string[]; +}; + +const response = (data: unknown) => ({ result: { data } }); +const errorResponse = (path: string, message: string) => ({ + error: { + message, + code: -32000, + data: { code: 'BAD_REQUEST', httpStatus: 400, path }, + }, +}); + +const operationNames = (route: Route): string[] => { + const url = new URL(route.request().url()); + return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(','); +}; + +const initialArticles = (): Article[] => [ + { + id: 11, + title: '첫 작전 회의', + content: '낙양을 지킵시다.\n병력은 서문에 배치합니다.', + authorName: '유비', + authorPicture: '22.jpg', + authorImageServer: 0, + createdAt: '2026-07-26T10:20:00.000Z', + comments: [ + { + id: 21, + authorName: '관우', + content: '확인했습니다.', + createdAt: '2026-07-26T10:25:00.000Z', + }, + ], + }, +]; + +const readImage = async (relative: string): Promise => { + for (const root of imageRoots) { + try { + return await readFile(resolve(root, relative)); + } catch { + // Main checkout and feature worktrees have different image-root parents. + } + } + throw new Error(`Reference image not found: ${relative}`); +}; + +const installImages = async (page: Page) => { + for (const filename of ['back_walnut.jpg', 'back_green.jpg', 'back_blue.jpg']) { + await page.route(`**/image/game/${filename}`, async (route) => { + await route.fulfill({ + status: 200, + contentType: 'image/jpeg', + body: await readImage(`game/${filename}`), + }); + }); + } + await page.route('**/image/icons/**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'image/jpeg', + body: await readImage('icons/22.jpg'), + }); + }); +}; + +const generalContext = { + general: { + id: 1, + name: '유비', + npcState: 0, + nationId: 1, + cityId: 1, + troopId: 0, + picture: '22.jpg', + imageServer: 0, + officerLevel: 1, + stats: { leadership: 80, strength: 70, intelligence: 75 }, + gold: 1000, + rice: 1000, + crew: 500, + train: 100, + atmos: 100, + injury: 0, + experience: 100, + dedication: 100, + items: { horse: 'None', weapon: 'None', book: 'None', item: 'None' }, + }, + city: null, + nation: null, + settings: {}, + penalties: {}, +}; + +const emptyMessages = { + private: [], + national: [], + public: [], + diplomacy: [], + sequence: -1, + hasMore: { private: false, national: false, public: false, diplomacy: false }, + latestRead: { private: 0, national: 0, public: 0, diplomacy: 0 }, + canRespondDiplomacy: false, +}; + +const installApi = async (page: Page, state: BoardFixture) => { + await installImages(page); + await page.addInitScript(() => { + window.localStorage.setItem('sammo-game-token', 'ga_board_playwright'); + window.localStorage.setItem('sammo-game-profile', 'che:default'); + }); + await page.route('**/che/api/trpc/**', async (route) => { + const operations = operationNames(route); + const results = operations.map((operation) => { + state.requests.push(operation); + if (operation === 'lobby.info') { + return response({ + year: 197, + month: 7, + userCnt: 2, + maxUserCnt: 50, + npcCnt: 0, + nationCnt: 1, + turnTerm: 60, + fictionMode: '가상', + starttime: '', + opentime: '', + turntime: '', + otherTextInfo: '', + isUnited: 0, + myGeneral: { name: '유비', picture: '22.jpg' }, + }); + } + if (operation === 'join.getConfig') return response({}); + if (operation === 'board.getAccess') { + return response({ + permission: state.permission, + canMeeting: state.canMeeting, + canSecret: state.canSecret, + }); + } + if (operation === 'board.getArticles') return response(state.articles); + if (operation === 'board.writeArticle') { + if (state.failArticle) { + state.failArticle = false; + return errorResponse(operation, '접속 제한입니다.'); + } + state.articles.unshift({ + id: 12, + title: '새 제목', + content: '새 내용', + authorName: '유비', + authorPicture: '22.jpg', + authorImageServer: 0, + createdAt: '2026-07-26T11:00:00.000Z', + comments: [], + }); + return response({ id: 12 }); + } + if (operation === 'board.writeComment') { + if (state.failComment) { + state.failComment = false; + return errorResponse(operation, '접속 제한입니다.'); + } + state.articles[0]?.comments.push({ + id: 22, + authorName: '유비', + content: '새 댓글', + createdAt: '2026-07-26T11:05:00.000Z', + }); + return response({ id: 22 }); + } + if (operation === 'general.me') return response(generalContext); + if (operation === 'world.getMapLayout') { + return response({ mapName: 'che', cityList: [], regionMap: {}, levelMap: {} }); + } + if (operation === 'world.getMap') { + return response({ + year: 197, + month: 7, + startYear: 190, + cityList: [], + nationList: [], + myCity: 1, + myNation: 1, + }); + } + if (operation === 'turns.getCommandTable') return response({ general: [], nation: [] }); + if (operation === 'messages.getRecent') return response(emptyMessages); + if (operation === 'turns.reserved.getGeneral') return response([]); + return errorResponse(operation, `Unhandled board fixture operation: ${operation}`); + }); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(results), + }); + }); +}; + +const gotoBoard = async (page: Page, path = 'board') => { + const articlesResponse = page.waitForResponse( + (result) => result.url().includes('/trpc/board.getArticles') && result.ok() + ); + await page.goto(path); + await articlesResponse; + await expect(page.locator('.article-frame')).toBeVisible(); +}; + +test('matches the ref meeting-room geometry, typography, textures, and controls', async ({ page }) => { + const state: BoardFixture = { + permission: 0, + canMeeting: true, + canSecret: false, + articles: initialArticles(), + requests: [], + }; + await installApi(page, state); + await page.setViewportSize({ width: 1000, height: 800 }); + await gotoBoard(page); + if (artifactRoot) { + await page.screenshot({ + path: resolve(artifactRoot, 'board-core-desktop.png'), + fullPage: true, + animations: 'disabled', + }); + } + + const geometry = await page.evaluate(() => { + const rect = (selector: string) => { + const box = document.querySelector(selector)!.getBoundingClientRect(); + return { x: box.x, y: box.y, width: box.width, height: box.height }; + }; + const pageStyle = getComputedStyle(document.querySelector('.legacy-board-page')!); + const articleText = getComputedStyle(document.querySelector('.article-text')!); + return { + container: rect('#container'), + topBar: rect('.top-back-bar'), + titleLabel: rect('.form-label'), + submitArticle: rect('#submitArticle'), + articleAuthor: rect('.article-header .author-name'), + articleDate: rect('.article-header .date'), + icon: rect('.general-icon'), + commentAuthor: rect('.comment-row .author-name'), + submitComment: rect('.submit-comment'), + bottomButton: rect('.bottom-bar .back-button'), + font: { + family: pageStyle.fontFamily, + size: pageStyle.fontSize, + lineHeight: pageStyle.lineHeight, + }, + whiteSpace: articleText.whiteSpace, + walnut: getComputedStyle(document.querySelector('#newArticle')!).backgroundImage, + green: getComputedStyle(document.querySelector('.article-header')!).backgroundImage, + blue: getComputedStyle(document.querySelector('.new-article-header')!).backgroundImage, + submitStyle: { + backgroundColor: getComputedStyle(document.querySelector('#submitArticle')!) + .backgroundColor, + borderColor: getComputedStyle(document.querySelector('#submitArticle')!).borderColor, + }, + }; + }); + + expect(geometry.container).toMatchObject({ width: 1000, height: 397.3125 }); + expect(geometry.topBar.height).toBe(32); + expect(geometry.titleLabel.width).toBeCloseTo(83.33, 1); + expect(geometry.titleLabel).toMatchObject({ y: 64.1875, height: 22.1875 }); + expect(geometry.submitArticle).toMatchObject({ y: 132.5625, height: 35.5 }); + expect(geometry.submitArticle.width).toBeCloseTo(149.16, 1); + expect(geometry.submitComment.width).toBeCloseTo(83.33, 1); + expect(geometry.articleAuthor.width).toBe(120); + expect(geometry.articleAuthor).toMatchObject({ y: 188.0625, height: 18.1875 }); + expect(geometry.articleDate.width).toBeCloseTo(83.33, 1); + expect(geometry.icon).toMatchObject({ x: 28, y: 206.25, width: 64, height: 64 }); + expect(geometry.commentAuthor.width).toBe(120); + expect(geometry.commentAuthor).toMatchObject({ y: 271.25, height: 20.1875 }); + expect(geometry.submitComment).toMatchObject({ y: 292.4375, height: 29.375 }); + expect(geometry.bottomButton).toMatchObject({ x: 0, y: 361.8125, width: 71, height: 35.5 }); + expect(geometry.font.family).toContain('Pretendard'); + expect(geometry.font).toMatchObject({ size: '14px', lineHeight: '18.2px' }); + expect(geometry.whiteSpace).toBe('pre'); + expect(geometry.walnut).toContain('back_walnut.jpg'); + expect(geometry.green).toContain('back_green.jpg'); + expect(geometry.blue).toContain('back_blue.jpg'); + expect(geometry.submitStyle).toEqual({ + backgroundColor: 'rgb(68, 68, 68)', + borderColor: 'rgb(61, 61, 61)', + }); + + const button = page.locator('#submitArticle'); + const before = await button.evaluate((element) => getComputedStyle(element).backgroundColor); + await button.hover(); + const hover = await button.evaluate((element) => getComputedStyle(element).backgroundColor); + await button.focus(); + await expect(button).toBeFocused(); + const focusOutline = await button.evaluate((element) => getComputedStyle(element).outline); + expect(hover).toBe(before); + expect(focusOutline).toContain('none'); +}); + +test('uses the ref 500px responsive form widths', async ({ page }) => { + const state: BoardFixture = { + permission: 2, + canMeeting: true, + canSecret: true, + articles: initialArticles(), + requests: [], + }; + await installApi(page, state); + await page.setViewportSize({ width: 500, height: 800 }); + await gotoBoard(page, 'board/secret'); + if (artifactRoot) { + await page.screenshot({ + path: resolve(artifactRoot, 'board-core-mobile-secret.png'), + fullPage: true, + animations: 'disabled', + }); + } + + const geometry = await page.evaluate(() => { + const width = (selector: string) => + document.querySelector(selector)!.getBoundingClientRect().width; + return { + container: width('#container'), + label: width('.form-label'), + submitArticle: width('#submitArticle'), + author: width('.article-header .author-name'), + date: width('.article-header .date'), + }; + }); + expect(geometry.container).toBe(500); + expect(geometry.label).toBeCloseTo(83.33, 1); + expect(geometry.submitArticle).toBeCloseTo(152.66, 1); + expect(geometry.author).toBe(120); + expect(geometry.date).toBeCloseTo(83.33, 1); + await expect(page.getByRole('heading', { name: '기밀실' })).toBeVisible(); +}); + +test('retains article and comment input after a failed mutation, then reloads after success', async ({ page }) => { + const state: BoardFixture = { + permission: 2, + canMeeting: true, + canSecret: true, + articles: initialArticles(), + failArticle: true, + failComment: true, + requests: [], + }; + await installApi(page, state); + await gotoBoard(page); + + await page.locator('#board-title').fill('새 제목'); + await page.locator('#board-content').fill('새 내용'); + page.once('dialog', async (dialog) => { + expect(dialog.message()).toBe('실패했습니다. :접속 제한입니다.'); + await dialog.accept(); + }); + await page.locator('#submitArticle').click(); + await expect(page.locator('#board-title')).toHaveValue('새 제목'); + await expect(page.locator('#board-content')).toHaveValue('새 내용'); + + await page.locator('#submitArticle').click(); + await expect(page.getByText('새 제목', { exact: true })).toBeVisible(); + await expect(page.locator('#board-title')).toHaveValue(''); + + const commentInput = page.locator('.comment-input').first(); + await commentInput.fill('새 댓글'); + page.once('dialog', async (dialog) => { + expect(dialog.message()).toBe('실패했습니다: 접속 제한입니다.'); + await dialog.accept(); + }); + await commentInput.press('Enter'); + await expect(commentInput).toHaveValue('새 댓글'); + + await commentInput.press('Enter'); + await expect(page.getByText('새 댓글', { exact: true })).toBeVisible(); +}); + +test('denies direct secret-room rendering and disables its in-game menu for an ordinary member', async ({ page }) => { + const state: BoardFixture = { + permission: 0, + canMeeting: true, + canSecret: false, + articles: initialArticles(), + requests: [], + }; + await installApi(page, state); + await page.goto('board/secret'); + await expect(page.getByRole('alert')).toHaveText('권한이 부족합니다. 수뇌부가 아닙니다.'); + await expect(page.locator('#newArticle')).toHaveCount(0); + expect(state.requests).not.toContain('board.getArticles'); + + state.requests.length = 0; + await page.goto(''); + await expect(page.getByRole('link', { name: '회의실', exact: true })).toBeVisible(); + await expect(page.locator('.header-actions [aria-disabled="true"]').filter({ hasText: '기밀실' })).toBeVisible(); + await expect(page.getByRole('link', { name: '기밀실', exact: true })).toHaveCount(0); +}); + +test('enables both in-game menu entries for a chief or delegated secret role', async ({ page }) => { + const state: BoardFixture = { + permission: 3, + canMeeting: true, + canSecret: true, + articles: initialArticles(), + requests: [], + }; + await installApi(page, state); + await page.goto(''); + await expect(page.getByRole('link', { name: '회의실', exact: true })).toBeVisible(); + await expect(page.getByRole('link', { name: '기밀실', exact: true })).toBeVisible(); +}); diff --git a/app/game-frontend/e2e/playwright.config.mjs b/app/game-frontend/e2e/playwright.config.mjs index eddbfbd..1e64361 100644 --- a/app/game-frontend/e2e/playwright.config.mjs +++ b/app/game-frontend/e2e/playwright.config.mjs @@ -6,15 +6,18 @@ const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../. export default defineConfig({ testDir: '.', - testMatch: 'troop.spec.ts', + testMatch: ['troop.spec.ts', 'board.spec.ts'], fullyParallel: false, workers: 1, timeout: 30_000, expect: { timeout: 5_000, }, - reporter: [['list'], ['html', { open: 'never', outputFolder: resolve(repositoryRoot, 'playwright-report') }]], - outputDir: resolve(repositoryRoot, 'test-results/troop'), + reporter: [ + ['list'], + ['html', { open: 'never', outputFolder: resolve(repositoryRoot, 'playwright-report/game-legacy') }], + ], + outputDir: resolve(repositoryRoot, 'test-results/game-legacy'), use: { baseURL: 'http://127.0.0.1:15120/che/', ...devices['Desktop Chrome'], diff --git a/app/game-frontend/package.json b/app/game-frontend/package.json index 4d24888..5350e81 100644 --- a/app/game-frontend/package.json +++ b/app/game-frontend/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "vue-tsc && vite build", "preview": "vite preview", - "test:e2e:troop": "playwright test --config e2e/playwright.config.mjs", + "test:e2e:troop": "playwright test troop.spec.ts --config e2e/playwright.config.mjs", + "test:e2e:board": "playwright test board.spec.ts --config e2e/playwright.config.mjs", "lint": "eslint .", "lint:fix": "eslint . --fix", "test": "node -e \"console.log('test not configured')\"", diff --git a/app/game-frontend/src/stores/mainDashboard.ts b/app/game-frontend/src/stores/mainDashboard.ts index ca49b20..042482c 100644 --- a/app/game-frontend/src/stores/mainDashboard.ts +++ b/app/game-frontend/src/stores/mainDashboard.ts @@ -23,6 +23,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { type MapLayout = Awaited>; type CommandTable = Awaited>; type MessageBundle = Awaited>; + type BoardAccess = Awaited>; type ReservedTurnView = Awaited>[number]; const loading = ref(false); @@ -36,6 +37,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { const mapLayout = ref(null); const commandTable = ref(null); const messages = ref(null); + const boardAccess = ref(null); const reservedGeneralTurns = ref(null); const reservedNationTurns = ref(null); @@ -133,6 +135,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { if (!context) { reservedGeneralTurns.value = null; reservedNationTurns.value = null; + boardAccess.value = null; loading.value = false; return; } @@ -144,12 +147,13 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { context.general.nationId > 0 && context.general.officerLevel >= 5 ? trpc.turns.reserved.getNation.query({ generalId: id }) : Promise.resolve(null); - const [layout, lobby, map, commands, messageData, generalTurns, nationTurns] = await Promise.all([ + const [layout, lobby, map, commands, messageData, access, generalTurns, nationTurns] = await Promise.all([ layoutPromise, trpc.lobby.info.query(), trpc.world.getMap.query({ generalId: id, showMe: true, useCache: true }), trpc.turns.getCommandTable.query({ generalId: id }), trpc.messages.getRecent.query({ generalId: id }), + trpc.board.getAccess.query(), generalTurnsPromise, nationTurnsPromise, ]); @@ -159,6 +163,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { worldMap.value = map; commandTable.value = commands; messages.value = messageData; + boardAccess.value = access; reservedGeneralTurns.value = generalTurns; reservedNationTurns.value = nationTurns; } catch (err) { @@ -479,6 +484,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { selectedCity, commandTable, messages, + boardAccess, reservedGeneralTurns, reservedNationTurns, messageDraftText, diff --git a/app/game-frontend/src/views/BoardView.vue b/app/game-frontend/src/views/BoardView.vue index a78b9f6..9cd663d 100644 --- a/app/game-frontend/src/views/BoardView.vue +++ b/app/game-frontend/src/views/BoardView.vue @@ -1,473 +1,528 @@