diff --git a/app/gateway-api/src/account/router.ts b/app/gateway-api/src/account/router.ts index 029ab3cf..97cc398c 100644 --- a/app/gateway-api/src/account/router.ts +++ b/app/gateway-api/src/account/router.ts @@ -15,8 +15,7 @@ import { WEB_PUSH_EVENT_TYPES } from '@sammo-ts/common'; const zSessionToken = z.string().min(1); const MAX_ICON_BYTES = 50 * 1024; const MAX_ACTIVE_ICONS = 5; -const ICON_UPLOAD_COOLDOWN_MS = 24 * 60 * 60 * 1000; -const ICON_RETIRE_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000; +const ICON_RETIRE_COOLDOWN_MS = 24 * 60 * 60 * 1000; const ALLOWED_ICON_FORMATS = new Set(['avif', 'webp', 'jpeg', 'png', 'gif']); const ICON_CONTENT_TYPES: Record = { avif: 'image/avif', @@ -48,16 +47,6 @@ const decodeImage = (input: string): Buffer => { return buffer; }; -const assertIconChangeAvailable = (user: UserRecord, now: Date): void => { - if ( - user.picture !== 'default.jpg' && - user.iconUpdatedAt && - new Date(user.iconUpdatedAt).getTime() > now.getTime() - ICON_UPLOAD_COOLDOWN_MS - ) { - throw new TRPCError({ code: 'TOO_MANY_REQUESTS', message: '아이콘 업로드는 24시간에 한 번만 가능합니다.' }); - } -}; - const hasActiveSanction = (sanctions: UserSanctions, now: Date): boolean => { const dates = [sanctions.bannedUntil, sanctions.mutedUntil, sanctions.suspendedUntil]; for (const value of dates) { @@ -212,9 +201,7 @@ export const accountRouter = router({ icons: icons.map((icon) => buildLibraryIcon(ctx, icon)), preferredPicture: resolveEffectiveAccountIcon(user).picture, maxActiveIcons: MAX_ACTIVE_ICONS, - nextUploadAt: user.iconUpdatedAt - ? new Date(new Date(user.iconUpdatedAt).getTime() + ICON_UPLOAD_COOLDOWN_MS).toISOString() - : null, + nextUploadAt: null, nextRetireAt: user.iconRetiredAt ? new Date(new Date(user.iconRetiredAt).getTime() + ICON_RETIRE_COOLDOWN_MS).toISOString() : null, @@ -279,7 +266,6 @@ export const accountRouter = router({ .mutation(async ({ ctx, input }) => { const user = await requireSessionUser(ctx, input.sessionToken); const now = new Date(); - assertIconChangeAvailable(user, now); const profiles = await listIconSyncProfiles(ctx, user.id); const buffer = decodeImage(input.imageData); const metadata = await sharp(buffer, { animated: true }).metadata(); @@ -306,14 +292,7 @@ export const accountRouter = router({ contentType: ICON_CONTENT_TYPES[extension]!, body: buffer, }); - const stored = await ctx.users.addIconForWindow( - user.id, - uploaded.picture, - 0, - now, - new Date(now.getTime() - ICON_UPLOAD_COOLDOWN_MS), - MAX_ACTIVE_ICONS - ); + const stored = await ctx.users.addIconForWindow(user.id, uploaded.picture, 0, now, MAX_ACTIVE_ICONS); if (!stored.ok) { if (stored.reason === 'LIMIT') { throw new TRPCError({ @@ -321,10 +300,7 @@ export const accountRouter = router({ message: '전용 아이콘은 최대 5개까지 등록할 수 있습니다.', }); } - throw new TRPCError({ - code: 'TOO_MANY_REQUESTS', - message: '아이콘 업로드는 24시간에 한 번만 가능합니다.', - }); + throw new TRPCError({ code: 'NOT_FOUND', message: '계정을 찾을 수 없습니다.' }); } const flushPublished = await publishIconFlush(ctx, user.id, 'account-icon-changed'); return { @@ -364,7 +340,7 @@ export const accountRouter = router({ if (result.reason === 'COOLDOWN') { throw new TRPCError({ code: 'TOO_MANY_REQUESTS', - message: '전용 아이콘은 7일에 한 번만 목록에서 내릴 수 있습니다.', + message: '전용 아이콘은 24시간에 한 개만 목록에서 내릴 수 있습니다.', }); } throw new TRPCError({ code: 'NOT_FOUND', message: '사용 가능한 내 전용 아이콘이 아닙니다.' }); @@ -382,21 +358,12 @@ export const accountRouter = router({ deleteIcon: procedure.input(z.object({ sessionToken: zSessionToken })).mutation(async ({ ctx, input }) => { const user = await requireSessionUser(ctx, input.sessionToken); const now = new Date(); - assertIconChangeAvailable(user, now); const profiles = await listIconSyncProfiles(ctx, user.id); - const revision = await ctx.users.updateIconForDay( - user.id, - 'default.jpg', - 0, - now, - new Date(now.getTime() - ICON_UPLOAD_COOLDOWN_MS), - false, - true - ); + const revision = await ctx.users.updateIconForDay(user.id, 'default.jpg', 0, now, now, false, true, false); if (!revision) { throw new TRPCError({ - code: 'TOO_MANY_REQUESTS', - message: '아이콘 변경은 24시간에 한 번만 가능합니다.', + code: 'NOT_FOUND', + message: '아이콘을 제거하지 못했습니다.', }); } const flushPublished = await publishIconFlush(ctx, user.id, 'account-icon-deleted'); diff --git a/app/gateway-api/src/auth/inMemoryUserRepository.ts b/app/gateway-api/src/auth/inMemoryUserRepository.ts index a0bbad68..b2c89173 100644 --- a/app/gateway-api/src/auth/inMemoryUserRepository.ts +++ b/app/gateway-api/src/auth/inMemoryUserRepository.ts @@ -434,13 +434,14 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass updatedAt: Date, dayStart: Date, consumeDailyQuota: boolean, - allowCutoffEquality = false + allowCutoffEquality = false, + enforceCooldown = true ): Promise { for (const user of usersByName.values()) { if (user.id !== userId) { continue; } - if (user.picture !== 'default.jpg' && user.iconUpdatedAt) { + if (enforceCooldown && user.picture !== 'default.jpg' && user.iconUpdatedAt) { const previousUpdate = new Date(user.iconUpdatedAt); if (allowCutoffEquality ? previousUpdate > dayStart : previousUpdate >= dayStart) { return null; @@ -468,12 +469,9 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass .filter((icon) => icon.userId === userId && (includeRetired || !icon.retiredAt)) .sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id)); }, - async addIconForWindow(userId, picture, imageServer, now, uploadCutoff, maxActive) { + async addIconForWindow(userId, picture, imageServer, now, maxActive) { const user = [...usersByName.values()].find((candidate) => candidate.id === userId); if (!user) return { ok: false, reason: 'NOT_FOUND' }; - if (user.iconUpdatedAt && new Date(user.iconUpdatedAt) > uploadCutoff) { - return { ok: false, reason: 'COOLDOWN' }; - } const active = [...iconsById.values()].filter((icon) => icon.userId === userId && !icon.retiredAt); if (active.length >= maxActive) return { ok: false, reason: 'LIMIT' }; const revision = nextRevision(user, now); diff --git a/app/gateway-api/src/auth/postgresUserRepository.ts b/app/gateway-api/src/auth/postgresUserRepository.ts index 75aceea3..cdd29658 100644 --- a/app/gateway-api/src/auth/postgresUserRepository.ts +++ b/app/gateway-api/src/auth/postgresUserRepository.ts @@ -607,7 +607,8 @@ export const createPostgresUserRepository = ( updatedAt: Date, dayStart: Date, consumeDailyQuota: boolean, - allowCutoffEquality = false + allowCutoffEquality = false, + enforceCooldown = true ): Promise { const rows = await prisma.$queryRaw>(GatewayPrisma.sql` UPDATE "app_user" @@ -623,7 +624,7 @@ export const createPostgresUserRepository = ( COALESCE("icon_revision", "icon_updated_at", "created_at") + INTERVAL '1 millisecond' ) WHERE "id" = ${userId} - AND ( + AND (NOT ${enforceCooldown} OR "picture" = 'default.jpg' OR "icon_updated_at" IS NULL OR "icon_updated_at" < ${dayStart} @@ -640,20 +641,16 @@ export const createPostgresUserRepository = ( }); return rows.map(mapIcon); }, - async addIconForWindow(userId, picture, imageServer, now, uploadCutoff, maxActive) { + async addIconForWindow(userId, picture, imageServer, now, maxActive) { return prisma.$transaction(async (tx) => { const users = await tx.$queryRaw< - Array<{ createdAt: Date; iconUpdatedAt: Date | null; iconRevision: Date | null }> + Array<{ createdAt: Date; iconRevision: Date | null }> >(GatewayPrisma.sql` - SELECT "created_at" AS "createdAt", "icon_updated_at" AS "iconUpdatedAt", - "icon_revision" AS "iconRevision" + SELECT "created_at" AS "createdAt", "icon_revision" AS "iconRevision" FROM "app_user" WHERE "id" = ${userId} FOR UPDATE `); const user = users[0]; if (!user) return { ok: false as const, reason: 'NOT_FOUND' as const }; - if (user.iconUpdatedAt && user.iconUpdatedAt > uploadCutoff) { - return { ok: false as const, reason: 'COOLDOWN' as const }; - } const activeCount = await tx.userIcon.count({ where: { userId, retiredAt: null } }); if (activeCount >= maxActive) return { ok: false as const, reason: 'LIMIT' as const }; const revision = new Date( diff --git a/app/gateway-api/src/auth/userRepository.ts b/app/gateway-api/src/auth/userRepository.ts index d53db27d..726aab9e 100644 --- a/app/gateway-api/src/auth/userRepository.ts +++ b/app/gateway-api/src/auth/userRepository.ts @@ -63,7 +63,7 @@ export interface SpecialAccountAccessGrantRecord { } export type AddUserIconResult = - { ok: true; icon: UserIconRecord; revision: string } | { ok: false; reason: 'COOLDOWN' | 'LIMIT' | 'NOT_FOUND' }; + { ok: true; icon: UserIconRecord; revision: string } | { ok: false; reason: 'LIMIT' | 'NOT_FOUND' }; export type RetireUserIconResult = | { ok: true; icon: UserIconRecord; revision: string; preferredChanged: boolean } @@ -233,7 +233,8 @@ export interface UserRepository { updatedAt: Date, dayStart: Date, consumeDailyQuota: boolean, - allowCutoffEquality?: boolean + allowCutoffEquality?: boolean, + enforceCooldown?: boolean ): Promise; listIcons(userId: string, includeRetired?: boolean): Promise; addIconForWindow( @@ -241,7 +242,6 @@ export interface UserRepository { picture: string, imageServer: number, now: Date, - uploadCutoff: Date, maxActive: number ): Promise; setPreferredIcon(userId: string, iconId: string, now: Date): Promise; diff --git a/app/gateway-api/test/accountIconDailyCas.integration.test.ts b/app/gateway-api/test/accountIconDailyCas.integration.test.ts index 3880bb36..7f16b300 100644 --- a/app/gateway-api/test/accountIconDailyCas.integration.test.ts +++ b/app/gateway-api/test/accountIconDailyCas.integration.test.ts @@ -156,7 +156,7 @@ integration('account icon daily PostgreSQL CAS', () => { }); }); - it('serializes the five-slot library and preserves retired rows', async () => { + it('serializes the five-slot library without an upload cooldown and preserves retired rows', async () => { const users = createPostgresUserRepository(db); const start = new Date('2026-08-03T00:00:00.000Z'); await db.userIcon.deleteMany({ where: { userId } }); @@ -166,35 +166,29 @@ integration('account icon daily PostgreSQL CAS', () => { }); for (let index = 0; index < 5; index += 1) { - const now = new Date(start.getTime() + index * 86_400_000); + const now = new Date(start.getTime() + index); await expect( - users.addIconForWindow( - userId, - `postgres-library-${index}.png`, - 1, - now, - new Date(now.getTime() - 86_400_000), - 5 - ) + users.addIconForWindow(userId, `postgres-library-${index}.png`, 1, now, 5) ).resolves.toMatchObject({ ok: true }); } await expect( - users.addIconForWindow( - userId, - 'postgres-library-sixth.png', - 1, - new Date(start.getTime() + 5 * 86_400_000), - new Date(start.getTime() + 4 * 86_400_000), - 5 - ) + users.addIconForWindow(userId, 'postgres-library-sixth.png', 1, new Date(start.getTime() + 5), 5) ).resolves.toEqual({ ok: false, reason: 'LIMIT' }); const icons = await users.listIcons(userId); - const retiredAt = new Date(start.getTime() + 6 * 86_400_000); + const retiredAt = new Date(start.getTime() + 6); await expect( - users.retireIconForWindow(userId, icons[0]!.id, retiredAt, new Date(retiredAt.getTime() - 7 * 86_400_000)) + users.retireIconForWindow(userId, icons[0]!.id, retiredAt, new Date(retiredAt.getTime() - 86_400_000)) ).resolves.toMatchObject({ ok: true }); - await expect(users.listIcons(userId)).resolves.toHaveLength(4); + const tooSoon = new Date(retiredAt.getTime() + 86_400_000 - 1); + await expect( + users.retireIconForWindow(userId, icons[1]!.id, tooSoon, new Date(tooSoon.getTime() - 86_400_000)) + ).resolves.toEqual({ ok: false, reason: 'COOLDOWN' }); + const allowedAt = new Date(retiredAt.getTime() + 86_400_000); + await expect( + users.retireIconForWindow(userId, icons[1]!.id, allowedAt, new Date(allowedAt.getTime() - 86_400_000)) + ).resolves.toMatchObject({ ok: true }); + await expect(users.listIcons(userId)).resolves.toHaveLength(3); await expect(users.listIcons(userId, true)).resolves.toContainEqual( expect.objectContaining({ picture: 'postgres-library-0.png', retiredAt: retiredAt.toISOString() }) ); diff --git a/app/gateway-api/test/authFlow.test.ts b/app/gateway-api/test/authFlow.test.ts index 28ab40ca..2c18ef32 100644 --- a/app/gateway-api/test/authFlow.test.ts +++ b/app/gateway-api/test/authFlow.test.ts @@ -1434,8 +1434,9 @@ describe('account self service', () => { expect(userIconUpload.upload).toHaveBeenCalledWith( expect.objectContaining({ contentType: 'image/png', body: png }) ); - await expect(caller.account.deleteIcon({ sessionToken: session.sessionToken })).rejects.toMatchObject({ - code: 'TOO_MANY_REQUESTS', + await expect(caller.account.deleteIcon({ sessionToken: session.sessionToken })).resolves.toMatchObject({ + ok: true, + iconUrl: null, }); } finally { await fs.rm(iconDir, { recursive: true, force: true }); @@ -1473,7 +1474,7 @@ describe('account self service', () => { } }); - it('atomically allows only one icon change per KST day and removes the losing file', async () => { + it('accepts concurrent uploads until the five-icon limit is reached', async () => { const iconDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-account-icon-race-')); try { const { caller, users, sessions } = buildCaller({ userIconDir: iconDir }); @@ -1501,14 +1502,64 @@ describe('account self service', () => { ) ); - expect(attempts.filter(({ status }) => status === 'fulfilled')).toHaveLength(1); - expect(attempts.filter(({ status }) => status === 'rejected')).toHaveLength(1); - expect(await users.listIcons(user.id)).toHaveLength(1); + expect(attempts.filter(({ status }) => status === 'fulfilled')).toHaveLength(2); + expect(await users.listIcons(user.id)).toHaveLength(2); } finally { await fs.rm(iconDir, { recursive: true, force: true }); } }); + it('limits only owned library retirement to one icon per rolling 24 hours', async () => { + const { caller, users, sessions } = buildCaller(); + const now = new Date('2026-08-01T00:00:00.000Z'); + const png = await sharp({ + create: { width: 64, height: 64, channels: 4, background: '#556677' }, + }) + .png() + .toBuffer(); + const imageData = `data:image/png;base64,${png.toString('base64')}`; + try { + vi.useFakeTimers(); + vi.setSystemTime(now); + const owner = await users.createUser({ username: 'icon-retirement-owner', password: 'password' }); + const other = await users.createUser({ username: 'icon-retirement-other', password: 'password' }); + const ownerSession = await sessions.createSession(owner); + const otherSession = await sessions.createSession(other); + const first = await caller.account.changeIcon({ sessionToken: ownerSession.sessionToken, imageData }); + const second = await caller.account.changeIcon({ sessionToken: ownerSession.sessionToken, imageData }); + + await expect( + caller.account.retireIcon({ + sessionToken: otherSession.sessionToken, + iconId: first.icon.id, + }) + ).rejects.toMatchObject({ code: 'NOT_FOUND' }); + await caller.account.retireIcon({ sessionToken: ownerSession.sessionToken, iconId: first.icon.id }); + expect(await caller.account.get({ sessionToken: ownerSession.sessionToken })).toMatchObject({ + nextUploadAt: null, + nextRetireAt: new Date(now.getTime() + 86_400_000).toISOString(), + }); + + vi.setSystemTime(new Date(now.getTime() + 86_400_000 - 1)); + const nextOwnerSession = await sessions.createSession(owner); + await expect( + caller.account.retireIcon({ + sessionToken: nextOwnerSession.sessionToken, + iconId: second.icon.id, + }) + ).rejects.toMatchObject({ code: 'TOO_MANY_REQUESTS' }); + vi.setSystemTime(new Date(now.getTime() + 86_400_000)); + await expect( + caller.account.retireIcon({ + sessionToken: nextOwnerSession.sessionToken, + iconId: second.icon.id, + }) + ).resolves.toMatchObject({ ok: true }); + } finally { + vi.useRealTimers(); + } + }); + it('flushes an account icon deletion with selectable running profiles', async () => { const { caller, users, sessions, flushPublisher } = buildCaller(); const user = await users.createUser({ @@ -1530,7 +1581,7 @@ describe('account self service', () => { expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(user.id, 'account-icon-deleted'); }); - it('uses a rolling 24-hour upload window and preserves delete-to-upload behavior', async () => { + it('allows a default-icon reset and another upload within the same day', async () => { const iconDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-account-icon-kst-')); const png = await sharp({ create: { @@ -1553,22 +1604,11 @@ describe('account self service', () => { await users.updateIcon(user.id, 'old.png', 1, new Date('2026-07-31T00:00:00.000Z')); const session = await sessions.createSession(user); - await expect(caller.account.deleteIcon({ sessionToken: session.sessionToken })).rejects.toMatchObject({ - code: 'TOO_MANY_REQUESTS', - }); - - vi.setSystemTime(new Date('2026-07-31T15:00:00.000Z')); - await expect(caller.account.deleteIcon({ sessionToken: session.sessionToken })).rejects.toMatchObject({ - code: 'TOO_MANY_REQUESTS', - }); - - vi.setSystemTime(new Date('2026-08-01T00:00:00.000Z')); - const nextSession = await sessions.createSession(user); - const deleted = await caller.account.deleteIcon({ sessionToken: nextSession.sessionToken }); - expect(deleted.revision).toBe('2026-08-01T00:00:00.000Z'); + const deleted = await caller.account.deleteIcon({ sessionToken: session.sessionToken }); + expect(deleted.revision).toBe('2026-07-31T14:59:59.001Z'); const changed = await caller.account.changeIcon({ - sessionToken: nextSession.sessionToken, + sessionToken: session.sessionToken, imageData: `data:image/png;base64,${png.toString('base64')}`, }); expect(new Date(changed.revision).getTime()).toBeGreaterThan(new Date(deleted.revision).getTime()); diff --git a/app/gateway-api/test/userIconLibrary.test.ts b/app/gateway-api/test/userIconLibrary.test.ts index b1e4dd47..c77852d0 100644 --- a/app/gateway-api/test/userIconLibrary.test.ts +++ b/app/gateway-api/test/userIconLibrary.test.ts @@ -5,33 +5,15 @@ import { createInMemoryUserRepository } from '../src/auth/inMemoryUserRepository const DAY_MS = 24 * 60 * 60 * 1000; describe('user icon library', () => { - it('keeps five immutable active icons and enforces the rolling upload window', async () => { + it('allows uploads without a cooldown while keeping five immutable active icons', async () => { const users = createInMemoryUserRepository(); const user = await users.createUser({ username: 'five-icons', password: 'password' }); const start = new Date('2026-08-01T00:00:00.000Z'); for (let index = 0; index < 5; index += 1) { - const now = new Date(start.getTime() + index * DAY_MS); - const stored = await users.addIconForWindow( - user.id, - `immutable-${index}.png`, - 1, - now, - new Date(now.getTime() - DAY_MS), - 5 - ); + const now = new Date(start.getTime() + index); + const stored = await users.addIconForWindow(user.id, `immutable-${index}.png`, 1, now, 5); expect(stored.ok).toBe(true); - if (index === 0) { - const blocked = await users.addIconForWindow( - user.id, - 'too-soon.png', - 1, - new Date(now.getTime() + DAY_MS - 1), - new Date(now.getTime() - 1), - 5 - ); - expect(blocked).toEqual({ ok: false, reason: 'COOLDOWN' }); - } } const icons = await users.listIcons(user.id); @@ -42,40 +24,19 @@ describe('user icon library', () => { 'immutable-3.png', 'immutable-4.png', ]); - const overLimit = await users.addIconForWindow( - user.id, - 'sixth.png', - 1, - new Date(start.getTime() + 5 * DAY_MS), - new Date(start.getTime() + 4 * DAY_MS), - 5 - ); + const overLimit = await users.addIconForWindow(user.id, 'sixth.png', 1, new Date(start.getTime() + 5), 5); expect(overLimit).toEqual({ ok: false, reason: 'LIMIT' }); }); - it('retires without deleting the durable record and allows retirement only every seven days', async () => { + it('retires without deleting the durable record and allows one retirement per rolling 24 hours', async () => { const users = createInMemoryUserRepository(); const user = await users.createUser({ username: 'retire-icons', password: 'password' }); const firstAt = new Date('2026-08-01T00:00:00.000Z'); - const first = await users.addIconForWindow( - user.id, - 'hall-of-fame.png', - 1, - firstAt, - new Date(firstAt.getTime() - DAY_MS), - 5 - ); + const first = await users.addIconForWindow(user.id, 'hall-of-fame.png', 1, firstAt, 5); expect(first.ok).toBe(true); if (!first.ok) return; - const secondAt = new Date(firstAt.getTime() + DAY_MS); - const second = await users.addIconForWindow( - user.id, - 'next.png', - 1, - secondAt, - new Date(secondAt.getTime() - DAY_MS), - 5 - ); + const secondAt = new Date(firstAt.getTime() + 1); + const second = await users.addIconForWindow(user.id, 'next.png', 1, secondAt, 5); expect(second.ok).toBe(true); if (!second.ok) return; @@ -83,7 +44,7 @@ describe('user icon library', () => { user.id, first.icon.id, secondAt, - new Date(secondAt.getTime() - 7 * DAY_MS) + new Date(secondAt.getTime() - DAY_MS) ); expect(retired.ok).toBe(true); expect(await users.listIcons(user.id)).toHaveLength(1); @@ -94,9 +55,13 @@ describe('user icon library', () => { const blocked = await users.retireIconForWindow( user.id, second.icon.id, - new Date(secondAt.getTime() + 7 * DAY_MS - 1), + new Date(secondAt.getTime() + DAY_MS - 1), new Date(secondAt.getTime() - 1) ); expect(blocked).toEqual({ ok: false, reason: 'COOLDOWN' }); + const allowedAt = new Date(secondAt.getTime() + DAY_MS); + await expect( + users.retireIconForWindow(user.id, second.icon.id, allowedAt, new Date(allowedAt.getTime() - DAY_MS)) + ).resolves.toMatchObject({ ok: true }); }); }); diff --git a/app/gateway-frontend/e2e/account-icon-sync.spec.ts b/app/gateway-frontend/e2e/account-icon-sync.spec.ts index 6783436b..2cae7b78 100644 --- a/app/gateway-frontend/e2e/account-icon-sync.spec.ts +++ b/app/gateway-frontend/e2e/account-icon-sync.spec.ts @@ -1,4 +1,5 @@ import { expect, test, type Page, type Route } from '@playwright/test'; +import { writeFile } from 'node:fs/promises'; const response = (data: unknown) => ({ result: { data } }); const errorResponse = (path: string, message: string) => ({ @@ -263,11 +264,34 @@ const installFixture = async (page: Page, options: FixtureOptions = {}) => { }; }; -test('chooses a preferred library icon and retires an icon only after confirmation', async ({ page }) => { +test('chooses a preferred library icon and retires an icon only after confirmation', async ({ page }, testInfo) => { const fixture = await installFixture(page); await page.goto('account'); await expect(page.locator('.account-icon-card')).toHaveCount(2); await expect(page.getByText('2 / 5개')).toBeVisible(); + await expect(page.getByText('목록에서 내리기는 24시간에 1개')).toBeVisible(); + await expect(page.getByText('업로드 횟수 제한 없음')).toHaveCount(0); + for (const [name, size] of [ + ['desktop', { width: 1280, height: 900 }], + ['mobile', { width: 390, height: 844 }], + ] as const) { + await page.setViewportSize(size); + const policy = page.locator('.icon-policy'); + await expect(policy).toBeVisible(); + const measurement = await policy.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, + fontFamily: style.fontFamily, + fontSize: style.fontSize, + lineHeight: style.lineHeight, + color: style.color, + }; + }); + await page.screenshot({ path: testInfo.outputPath(`account-icon-policy-${name}.png`) }); + await writeFile(testInfo.outputPath(`account-icon-policy-${name}.json`), JSON.stringify(measurement, null, 2)); + } await page.getByRole('button', { name: '대표로 설정' }).click(); await expect.poll(fixture.preferredIconCount).toBe(1); diff --git a/app/gateway-frontend/src/views/AccountView.vue b/app/gateway-frontend/src/views/AccountView.vue index 23422ef3..6f02203d 100644 --- a/app/gateway-frontend/src/views/AccountView.vue +++ b/app/gateway-frontend/src/views/AccountView.vue @@ -805,8 +805,8 @@ onBeforeUnmount(() => { 등록한 전용 아이콘이 없습니다.

- {{ account.icons.length }} / {{ account.maxActiveIcons }}개 · 업로드는 24시간에 1회 · - 목록에서 내리기는 7일에 1회 + {{ account.icons.length }} / {{ account.maxActiveIcons }}개 · 목록에서 내리기는 24시간에 + 1개