계정 아이콘 등록과 목록 내리기 제한을 조정한다
This commit is contained in:
@@ -15,8 +15,7 @@ import { WEB_PUSH_EVENT_TYPES } from '@sammo-ts/common';
|
|||||||
const zSessionToken = z.string().min(1);
|
const zSessionToken = z.string().min(1);
|
||||||
const MAX_ICON_BYTES = 50 * 1024;
|
const MAX_ICON_BYTES = 50 * 1024;
|
||||||
const MAX_ACTIVE_ICONS = 5;
|
const MAX_ACTIVE_ICONS = 5;
|
||||||
const ICON_UPLOAD_COOLDOWN_MS = 24 * 60 * 60 * 1000;
|
const ICON_RETIRE_COOLDOWN_MS = 24 * 60 * 60 * 1000;
|
||||||
const ICON_RETIRE_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000;
|
|
||||||
const ALLOWED_ICON_FORMATS = new Set(['avif', 'webp', 'jpeg', 'png', 'gif']);
|
const ALLOWED_ICON_FORMATS = new Set(['avif', 'webp', 'jpeg', 'png', 'gif']);
|
||||||
const ICON_CONTENT_TYPES: Record<string, string> = {
|
const ICON_CONTENT_TYPES: Record<string, string> = {
|
||||||
avif: 'image/avif',
|
avif: 'image/avif',
|
||||||
@@ -48,16 +47,6 @@ const decodeImage = (input: string): Buffer => {
|
|||||||
return 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 hasActiveSanction = (sanctions: UserSanctions, now: Date): boolean => {
|
||||||
const dates = [sanctions.bannedUntil, sanctions.mutedUntil, sanctions.suspendedUntil];
|
const dates = [sanctions.bannedUntil, sanctions.mutedUntil, sanctions.suspendedUntil];
|
||||||
for (const value of dates) {
|
for (const value of dates) {
|
||||||
@@ -212,9 +201,7 @@ export const accountRouter = router({
|
|||||||
icons: icons.map((icon) => buildLibraryIcon(ctx, icon)),
|
icons: icons.map((icon) => buildLibraryIcon(ctx, icon)),
|
||||||
preferredPicture: resolveEffectiveAccountIcon(user).picture,
|
preferredPicture: resolveEffectiveAccountIcon(user).picture,
|
||||||
maxActiveIcons: MAX_ACTIVE_ICONS,
|
maxActiveIcons: MAX_ACTIVE_ICONS,
|
||||||
nextUploadAt: user.iconUpdatedAt
|
nextUploadAt: null,
|
||||||
? new Date(new Date(user.iconUpdatedAt).getTime() + ICON_UPLOAD_COOLDOWN_MS).toISOString()
|
|
||||||
: null,
|
|
||||||
nextRetireAt: user.iconRetiredAt
|
nextRetireAt: user.iconRetiredAt
|
||||||
? new Date(new Date(user.iconRetiredAt).getTime() + ICON_RETIRE_COOLDOWN_MS).toISOString()
|
? new Date(new Date(user.iconRetiredAt).getTime() + ICON_RETIRE_COOLDOWN_MS).toISOString()
|
||||||
: null,
|
: null,
|
||||||
@@ -279,7 +266,6 @@ export const accountRouter = router({
|
|||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const user = await requireSessionUser(ctx, input.sessionToken);
|
const user = await requireSessionUser(ctx, input.sessionToken);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
assertIconChangeAvailable(user, now);
|
|
||||||
const profiles = await listIconSyncProfiles(ctx, user.id);
|
const profiles = await listIconSyncProfiles(ctx, user.id);
|
||||||
const buffer = decodeImage(input.imageData);
|
const buffer = decodeImage(input.imageData);
|
||||||
const metadata = await sharp(buffer, { animated: true }).metadata();
|
const metadata = await sharp(buffer, { animated: true }).metadata();
|
||||||
@@ -306,14 +292,7 @@ export const accountRouter = router({
|
|||||||
contentType: ICON_CONTENT_TYPES[extension]!,
|
contentType: ICON_CONTENT_TYPES[extension]!,
|
||||||
body: buffer,
|
body: buffer,
|
||||||
});
|
});
|
||||||
const stored = await ctx.users.addIconForWindow(
|
const stored = await ctx.users.addIconForWindow(user.id, uploaded.picture, 0, now, MAX_ACTIVE_ICONS);
|
||||||
user.id,
|
|
||||||
uploaded.picture,
|
|
||||||
0,
|
|
||||||
now,
|
|
||||||
new Date(now.getTime() - ICON_UPLOAD_COOLDOWN_MS),
|
|
||||||
MAX_ACTIVE_ICONS
|
|
||||||
);
|
|
||||||
if (!stored.ok) {
|
if (!stored.ok) {
|
||||||
if (stored.reason === 'LIMIT') {
|
if (stored.reason === 'LIMIT') {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
@@ -321,10 +300,7 @@ export const accountRouter = router({
|
|||||||
message: '전용 아이콘은 최대 5개까지 등록할 수 있습니다.',
|
message: '전용 아이콘은 최대 5개까지 등록할 수 있습니다.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
throw new TRPCError({
|
throw new TRPCError({ code: 'NOT_FOUND', message: '계정을 찾을 수 없습니다.' });
|
||||||
code: 'TOO_MANY_REQUESTS',
|
|
||||||
message: '아이콘 업로드는 24시간에 한 번만 가능합니다.',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
const flushPublished = await publishIconFlush(ctx, user.id, 'account-icon-changed');
|
const flushPublished = await publishIconFlush(ctx, user.id, 'account-icon-changed');
|
||||||
return {
|
return {
|
||||||
@@ -364,7 +340,7 @@ export const accountRouter = router({
|
|||||||
if (result.reason === 'COOLDOWN') {
|
if (result.reason === 'COOLDOWN') {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'TOO_MANY_REQUESTS',
|
code: 'TOO_MANY_REQUESTS',
|
||||||
message: '전용 아이콘은 7일에 한 번만 목록에서 내릴 수 있습니다.',
|
message: '전용 아이콘은 24시간에 한 개만 목록에서 내릴 수 있습니다.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
throw new TRPCError({ code: 'NOT_FOUND', message: '사용 가능한 내 전용 아이콘이 아닙니다.' });
|
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 }) => {
|
deleteIcon: procedure.input(z.object({ sessionToken: zSessionToken })).mutation(async ({ ctx, input }) => {
|
||||||
const user = await requireSessionUser(ctx, input.sessionToken);
|
const user = await requireSessionUser(ctx, input.sessionToken);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
assertIconChangeAvailable(user, now);
|
|
||||||
const profiles = await listIconSyncProfiles(ctx, user.id);
|
const profiles = await listIconSyncProfiles(ctx, user.id);
|
||||||
const revision = await ctx.users.updateIconForDay(
|
const revision = await ctx.users.updateIconForDay(user.id, 'default.jpg', 0, now, now, false, true, false);
|
||||||
user.id,
|
|
||||||
'default.jpg',
|
|
||||||
0,
|
|
||||||
now,
|
|
||||||
new Date(now.getTime() - ICON_UPLOAD_COOLDOWN_MS),
|
|
||||||
false,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
if (!revision) {
|
if (!revision) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'TOO_MANY_REQUESTS',
|
code: 'NOT_FOUND',
|
||||||
message: '아이콘 변경은 24시간에 한 번만 가능합니다.',
|
message: '아이콘을 제거하지 못했습니다.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const flushPublished = await publishIconFlush(ctx, user.id, 'account-icon-deleted');
|
const flushPublished = await publishIconFlush(ctx, user.id, 'account-icon-deleted');
|
||||||
|
|||||||
@@ -434,13 +434,14 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass
|
|||||||
updatedAt: Date,
|
updatedAt: Date,
|
||||||
dayStart: Date,
|
dayStart: Date,
|
||||||
consumeDailyQuota: boolean,
|
consumeDailyQuota: boolean,
|
||||||
allowCutoffEquality = false
|
allowCutoffEquality = false,
|
||||||
|
enforceCooldown = true
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
for (const user of usersByName.values()) {
|
for (const user of usersByName.values()) {
|
||||||
if (user.id !== userId) {
|
if (user.id !== userId) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (user.picture !== 'default.jpg' && user.iconUpdatedAt) {
|
if (enforceCooldown && user.picture !== 'default.jpg' && user.iconUpdatedAt) {
|
||||||
const previousUpdate = new Date(user.iconUpdatedAt);
|
const previousUpdate = new Date(user.iconUpdatedAt);
|
||||||
if (allowCutoffEquality ? previousUpdate > dayStart : previousUpdate >= dayStart) {
|
if (allowCutoffEquality ? previousUpdate > dayStart : previousUpdate >= dayStart) {
|
||||||
return null;
|
return null;
|
||||||
@@ -468,12 +469,9 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass
|
|||||||
.filter((icon) => icon.userId === userId && (includeRetired || !icon.retiredAt))
|
.filter((icon) => icon.userId === userId && (includeRetired || !icon.retiredAt))
|
||||||
.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
|
.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);
|
const user = [...usersByName.values()].find((candidate) => candidate.id === userId);
|
||||||
if (!user) return { ok: false, reason: 'NOT_FOUND' };
|
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);
|
const active = [...iconsById.values()].filter((icon) => icon.userId === userId && !icon.retiredAt);
|
||||||
if (active.length >= maxActive) return { ok: false, reason: 'LIMIT' };
|
if (active.length >= maxActive) return { ok: false, reason: 'LIMIT' };
|
||||||
const revision = nextRevision(user, now);
|
const revision = nextRevision(user, now);
|
||||||
|
|||||||
@@ -607,7 +607,8 @@ export const createPostgresUserRepository = (
|
|||||||
updatedAt: Date,
|
updatedAt: Date,
|
||||||
dayStart: Date,
|
dayStart: Date,
|
||||||
consumeDailyQuota: boolean,
|
consumeDailyQuota: boolean,
|
||||||
allowCutoffEquality = false
|
allowCutoffEquality = false,
|
||||||
|
enforceCooldown = true
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const rows = await prisma.$queryRaw<Array<{ iconRevision: Date }>>(GatewayPrisma.sql`
|
const rows = await prisma.$queryRaw<Array<{ iconRevision: Date }>>(GatewayPrisma.sql`
|
||||||
UPDATE "app_user"
|
UPDATE "app_user"
|
||||||
@@ -623,7 +624,7 @@ export const createPostgresUserRepository = (
|
|||||||
COALESCE("icon_revision", "icon_updated_at", "created_at") + INTERVAL '1 millisecond'
|
COALESCE("icon_revision", "icon_updated_at", "created_at") + INTERVAL '1 millisecond'
|
||||||
)
|
)
|
||||||
WHERE "id" = ${userId}
|
WHERE "id" = ${userId}
|
||||||
AND (
|
AND (NOT ${enforceCooldown} OR
|
||||||
"picture" = 'default.jpg'
|
"picture" = 'default.jpg'
|
||||||
OR "icon_updated_at" IS NULL
|
OR "icon_updated_at" IS NULL
|
||||||
OR "icon_updated_at" < ${dayStart}
|
OR "icon_updated_at" < ${dayStart}
|
||||||
@@ -640,20 +641,16 @@ export const createPostgresUserRepository = (
|
|||||||
});
|
});
|
||||||
return rows.map(mapIcon);
|
return rows.map(mapIcon);
|
||||||
},
|
},
|
||||||
async addIconForWindow(userId, picture, imageServer, now, uploadCutoff, maxActive) {
|
async addIconForWindow(userId, picture, imageServer, now, maxActive) {
|
||||||
return prisma.$transaction(async (tx) => {
|
return prisma.$transaction(async (tx) => {
|
||||||
const users = await tx.$queryRaw<
|
const users = await tx.$queryRaw<
|
||||||
Array<{ createdAt: Date; iconUpdatedAt: Date | null; iconRevision: Date | null }>
|
Array<{ createdAt: Date; iconRevision: Date | null }>
|
||||||
>(GatewayPrisma.sql`
|
>(GatewayPrisma.sql`
|
||||||
SELECT "created_at" AS "createdAt", "icon_updated_at" AS "iconUpdatedAt",
|
SELECT "created_at" AS "createdAt", "icon_revision" AS "iconRevision"
|
||||||
"icon_revision" AS "iconRevision"
|
|
||||||
FROM "app_user" WHERE "id" = ${userId} FOR UPDATE
|
FROM "app_user" WHERE "id" = ${userId} FOR UPDATE
|
||||||
`);
|
`);
|
||||||
const user = users[0];
|
const user = users[0];
|
||||||
if (!user) return { ok: false as const, reason: 'NOT_FOUND' as const };
|
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 } });
|
const activeCount = await tx.userIcon.count({ where: { userId, retiredAt: null } });
|
||||||
if (activeCount >= maxActive) return { ok: false as const, reason: 'LIMIT' as const };
|
if (activeCount >= maxActive) return { ok: false as const, reason: 'LIMIT' as const };
|
||||||
const revision = new Date(
|
const revision = new Date(
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export interface SpecialAccountAccessGrantRecord {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type AddUserIconResult =
|
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 =
|
export type RetireUserIconResult =
|
||||||
| { ok: true; icon: UserIconRecord; revision: string; preferredChanged: boolean }
|
| { ok: true; icon: UserIconRecord; revision: string; preferredChanged: boolean }
|
||||||
@@ -233,7 +233,8 @@ export interface UserRepository {
|
|||||||
updatedAt: Date,
|
updatedAt: Date,
|
||||||
dayStart: Date,
|
dayStart: Date,
|
||||||
consumeDailyQuota: boolean,
|
consumeDailyQuota: boolean,
|
||||||
allowCutoffEquality?: boolean
|
allowCutoffEquality?: boolean,
|
||||||
|
enforceCooldown?: boolean
|
||||||
): Promise<string | null>;
|
): Promise<string | null>;
|
||||||
listIcons(userId: string, includeRetired?: boolean): Promise<UserIconRecord[]>;
|
listIcons(userId: string, includeRetired?: boolean): Promise<UserIconRecord[]>;
|
||||||
addIconForWindow(
|
addIconForWindow(
|
||||||
@@ -241,7 +242,6 @@ export interface UserRepository {
|
|||||||
picture: string,
|
picture: string,
|
||||||
imageServer: number,
|
imageServer: number,
|
||||||
now: Date,
|
now: Date,
|
||||||
uploadCutoff: Date,
|
|
||||||
maxActive: number
|
maxActive: number
|
||||||
): Promise<AddUserIconResult>;
|
): Promise<AddUserIconResult>;
|
||||||
setPreferredIcon(userId: string, iconId: string, now: Date): Promise<string | null>;
|
setPreferredIcon(userId: string, iconId: string, now: Date): Promise<string | null>;
|
||||||
|
|||||||
@@ -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 users = createPostgresUserRepository(db);
|
||||||
const start = new Date('2026-08-03T00:00:00.000Z');
|
const start = new Date('2026-08-03T00:00:00.000Z');
|
||||||
await db.userIcon.deleteMany({ where: { userId } });
|
await db.userIcon.deleteMany({ where: { userId } });
|
||||||
@@ -166,35 +166,29 @@ integration('account icon daily PostgreSQL CAS', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (let index = 0; index < 5; index += 1) {
|
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(
|
await expect(
|
||||||
users.addIconForWindow(
|
users.addIconForWindow(userId, `postgres-library-${index}.png`, 1, now, 5)
|
||||||
userId,
|
|
||||||
`postgres-library-${index}.png`,
|
|
||||||
1,
|
|
||||||
now,
|
|
||||||
new Date(now.getTime() - 86_400_000),
|
|
||||||
5
|
|
||||||
)
|
|
||||||
).resolves.toMatchObject({ ok: true });
|
).resolves.toMatchObject({ ok: true });
|
||||||
}
|
}
|
||||||
await expect(
|
await expect(
|
||||||
users.addIconForWindow(
|
users.addIconForWindow(userId, 'postgres-library-sixth.png', 1, new Date(start.getTime() + 5), 5)
|
||||||
userId,
|
|
||||||
'postgres-library-sixth.png',
|
|
||||||
1,
|
|
||||||
new Date(start.getTime() + 5 * 86_400_000),
|
|
||||||
new Date(start.getTime() + 4 * 86_400_000),
|
|
||||||
5
|
|
||||||
)
|
|
||||||
).resolves.toEqual({ ok: false, reason: 'LIMIT' });
|
).resolves.toEqual({ ok: false, reason: 'LIMIT' });
|
||||||
|
|
||||||
const icons = await users.listIcons(userId);
|
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(
|
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 });
|
).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(
|
await expect(users.listIcons(userId, true)).resolves.toContainEqual(
|
||||||
expect.objectContaining({ picture: 'postgres-library-0.png', retiredAt: retiredAt.toISOString() })
|
expect.objectContaining({ picture: 'postgres-library-0.png', retiredAt: retiredAt.toISOString() })
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1434,8 +1434,9 @@ describe('account self service', () => {
|
|||||||
expect(userIconUpload.upload).toHaveBeenCalledWith(
|
expect(userIconUpload.upload).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ contentType: 'image/png', body: png })
|
expect.objectContaining({ contentType: 'image/png', body: png })
|
||||||
);
|
);
|
||||||
await expect(caller.account.deleteIcon({ sessionToken: session.sessionToken })).rejects.toMatchObject({
|
await expect(caller.account.deleteIcon({ sessionToken: session.sessionToken })).resolves.toMatchObject({
|
||||||
code: 'TOO_MANY_REQUESTS',
|
ok: true,
|
||||||
|
iconUrl: null,
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
await fs.rm(iconDir, { recursive: true, force: true });
|
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-'));
|
const iconDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-account-icon-race-'));
|
||||||
try {
|
try {
|
||||||
const { caller, users, sessions } = buildCaller({ userIconDir: iconDir });
|
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 === 'fulfilled')).toHaveLength(2);
|
||||||
expect(attempts.filter(({ status }) => status === 'rejected')).toHaveLength(1);
|
expect(await users.listIcons(user.id)).toHaveLength(2);
|
||||||
expect(await users.listIcons(user.id)).toHaveLength(1);
|
|
||||||
} finally {
|
} finally {
|
||||||
await fs.rm(iconDir, { recursive: true, force: true });
|
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 () => {
|
it('flushes an account icon deletion with selectable running profiles', async () => {
|
||||||
const { caller, users, sessions, flushPublisher } = buildCaller();
|
const { caller, users, sessions, flushPublisher } = buildCaller();
|
||||||
const user = await users.createUser({
|
const user = await users.createUser({
|
||||||
@@ -1530,7 +1581,7 @@ describe('account self service', () => {
|
|||||||
expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(user.id, 'account-icon-deleted');
|
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 iconDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-account-icon-kst-'));
|
||||||
const png = await sharp({
|
const png = await sharp({
|
||||||
create: {
|
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'));
|
await users.updateIcon(user.id, 'old.png', 1, new Date('2026-07-31T00:00:00.000Z'));
|
||||||
const session = await sessions.createSession(user);
|
const session = await sessions.createSession(user);
|
||||||
|
|
||||||
await expect(caller.account.deleteIcon({ sessionToken: session.sessionToken })).rejects.toMatchObject({
|
const deleted = await caller.account.deleteIcon({ sessionToken: session.sessionToken });
|
||||||
code: 'TOO_MANY_REQUESTS',
|
expect(deleted.revision).toBe('2026-07-31T14:59:59.001Z');
|
||||||
});
|
|
||||||
|
|
||||||
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 changed = await caller.account.changeIcon({
|
const changed = await caller.account.changeIcon({
|
||||||
sessionToken: nextSession.sessionToken,
|
sessionToken: session.sessionToken,
|
||||||
imageData: `data:image/png;base64,${png.toString('base64')}`,
|
imageData: `data:image/png;base64,${png.toString('base64')}`,
|
||||||
});
|
});
|
||||||
expect(new Date(changed.revision).getTime()).toBeGreaterThan(new Date(deleted.revision).getTime());
|
expect(new Date(changed.revision).getTime()).toBeGreaterThan(new Date(deleted.revision).getTime());
|
||||||
|
|||||||
@@ -5,33 +5,15 @@ import { createInMemoryUserRepository } from '../src/auth/inMemoryUserRepository
|
|||||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
describe('user icon library', () => {
|
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 users = createInMemoryUserRepository();
|
||||||
const user = await users.createUser({ username: 'five-icons', password: 'password' });
|
const user = await users.createUser({ username: 'five-icons', password: 'password' });
|
||||||
const start = new Date('2026-08-01T00:00:00.000Z');
|
const start = new Date('2026-08-01T00:00:00.000Z');
|
||||||
|
|
||||||
for (let index = 0; index < 5; index += 1) {
|
for (let index = 0; index < 5; index += 1) {
|
||||||
const now = new Date(start.getTime() + index * DAY_MS);
|
const now = new Date(start.getTime() + index);
|
||||||
const stored = await users.addIconForWindow(
|
const stored = await users.addIconForWindow(user.id, `immutable-${index}.png`, 1, now, 5);
|
||||||
user.id,
|
|
||||||
`immutable-${index}.png`,
|
|
||||||
1,
|
|
||||||
now,
|
|
||||||
new Date(now.getTime() - DAY_MS),
|
|
||||||
5
|
|
||||||
);
|
|
||||||
expect(stored.ok).toBe(true);
|
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);
|
const icons = await users.listIcons(user.id);
|
||||||
@@ -42,40 +24,19 @@ describe('user icon library', () => {
|
|||||||
'immutable-3.png',
|
'immutable-3.png',
|
||||||
'immutable-4.png',
|
'immutable-4.png',
|
||||||
]);
|
]);
|
||||||
const overLimit = await users.addIconForWindow(
|
const overLimit = await users.addIconForWindow(user.id, 'sixth.png', 1, new Date(start.getTime() + 5), 5);
|
||||||
user.id,
|
|
||||||
'sixth.png',
|
|
||||||
1,
|
|
||||||
new Date(start.getTime() + 5 * DAY_MS),
|
|
||||||
new Date(start.getTime() + 4 * DAY_MS),
|
|
||||||
5
|
|
||||||
);
|
|
||||||
expect(overLimit).toEqual({ ok: false, reason: 'LIMIT' });
|
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 users = createInMemoryUserRepository();
|
||||||
const user = await users.createUser({ username: 'retire-icons', password: 'password' });
|
const user = await users.createUser({ username: 'retire-icons', password: 'password' });
|
||||||
const firstAt = new Date('2026-08-01T00:00:00.000Z');
|
const firstAt = new Date('2026-08-01T00:00:00.000Z');
|
||||||
const first = await users.addIconForWindow(
|
const first = await users.addIconForWindow(user.id, 'hall-of-fame.png', 1, firstAt, 5);
|
||||||
user.id,
|
|
||||||
'hall-of-fame.png',
|
|
||||||
1,
|
|
||||||
firstAt,
|
|
||||||
new Date(firstAt.getTime() - DAY_MS),
|
|
||||||
5
|
|
||||||
);
|
|
||||||
expect(first.ok).toBe(true);
|
expect(first.ok).toBe(true);
|
||||||
if (!first.ok) return;
|
if (!first.ok) return;
|
||||||
const secondAt = new Date(firstAt.getTime() + DAY_MS);
|
const secondAt = new Date(firstAt.getTime() + 1);
|
||||||
const second = await users.addIconForWindow(
|
const second = await users.addIconForWindow(user.id, 'next.png', 1, secondAt, 5);
|
||||||
user.id,
|
|
||||||
'next.png',
|
|
||||||
1,
|
|
||||||
secondAt,
|
|
||||||
new Date(secondAt.getTime() - DAY_MS),
|
|
||||||
5
|
|
||||||
);
|
|
||||||
expect(second.ok).toBe(true);
|
expect(second.ok).toBe(true);
|
||||||
if (!second.ok) return;
|
if (!second.ok) return;
|
||||||
|
|
||||||
@@ -83,7 +44,7 @@ describe('user icon library', () => {
|
|||||||
user.id,
|
user.id,
|
||||||
first.icon.id,
|
first.icon.id,
|
||||||
secondAt,
|
secondAt,
|
||||||
new Date(secondAt.getTime() - 7 * DAY_MS)
|
new Date(secondAt.getTime() - DAY_MS)
|
||||||
);
|
);
|
||||||
expect(retired.ok).toBe(true);
|
expect(retired.ok).toBe(true);
|
||||||
expect(await users.listIcons(user.id)).toHaveLength(1);
|
expect(await users.listIcons(user.id)).toHaveLength(1);
|
||||||
@@ -94,9 +55,13 @@ describe('user icon library', () => {
|
|||||||
const blocked = await users.retireIconForWindow(
|
const blocked = await users.retireIconForWindow(
|
||||||
user.id,
|
user.id,
|
||||||
second.icon.id,
|
second.icon.id,
|
||||||
new Date(secondAt.getTime() + 7 * DAY_MS - 1),
|
new Date(secondAt.getTime() + DAY_MS - 1),
|
||||||
new Date(secondAt.getTime() - 1)
|
new Date(secondAt.getTime() - 1)
|
||||||
);
|
);
|
||||||
expect(blocked).toEqual({ ok: false, reason: 'COOLDOWN' });
|
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 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||||
|
import { writeFile } from 'node:fs/promises';
|
||||||
|
|
||||||
const response = (data: unknown) => ({ result: { data } });
|
const response = (data: unknown) => ({ result: { data } });
|
||||||
const errorResponse = (path: string, message: string) => ({
|
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);
|
const fixture = await installFixture(page);
|
||||||
await page.goto('account');
|
await page.goto('account');
|
||||||
await expect(page.locator('.account-icon-card')).toHaveCount(2);
|
await expect(page.locator('.account-icon-card')).toHaveCount(2);
|
||||||
await expect(page.getByText('2 / 5개')).toBeVisible();
|
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 page.getByRole('button', { name: '대표로 설정' }).click();
|
||||||
await expect.poll(fixture.preferredIconCount).toBe(1);
|
await expect.poll(fixture.preferredIconCount).toBe(1);
|
||||||
|
|||||||
@@ -805,8 +805,8 @@ onBeforeUnmount(() => {
|
|||||||
<span v-if="account.icons.length === 0">등록한 전용 아이콘이 없습니다.</span>
|
<span v-if="account.icons.length === 0">등록한 전용 아이콘이 없습니다.</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="icon-policy">
|
<p class="icon-policy">
|
||||||
{{ account.icons.length }} / {{ account.maxActiveIcons }}개 · 업로드는 24시간에 1회 ·
|
{{ account.icons.length }} / {{ account.maxActiveIcons }}개 · 목록에서 내리기는 24시간에
|
||||||
목록에서 내리기는 7일에 1회
|
1개
|
||||||
</p>
|
</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
Reference in New Issue
Block a user