계정 아이콘 등록과 목록 내리기 제한을 조정한다
This commit is contained in:
@@ -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() })
|
||||
);
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user