AI Implementation feature(867): Admin Area Players Management (#56)
This commit was merged in pull request #56.
This commit is contained in:
@@ -17,7 +17,7 @@ describe('UsersService.enforceLastAdminInvariant', () => {
|
||||
logging: false,
|
||||
});
|
||||
await ds.initialize();
|
||||
svc = new UsersService(ds.getRepository(UserEntity));
|
||||
svc = new UsersService(ds.getRepository(UserEntity), ds);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -78,4 +78,252 @@ describe('UsersService.enforceLastAdminInvariant', () => {
|
||||
ds.transaction(async (manager) => svc.enforceLastAdminInvariant(manager, 'p1', 'admin')),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('UsersService.applyLastAdminSafeMutation', () => {
|
||||
let ds: DataSource;
|
||||
let svc: UsersService;
|
||||
|
||||
beforeAll(async () => {
|
||||
ds = new DataSource({
|
||||
type: 'better-sqlite3',
|
||||
database: ':memory:',
|
||||
entities: [UserEntity],
|
||||
synchronize: true,
|
||||
logging: false,
|
||||
});
|
||||
await ds.initialize();
|
||||
svc = new UsersService(ds.getRepository(UserEntity), ds);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await ds.destroy();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await ds.getRepository(UserEntity).clear();
|
||||
});
|
||||
|
||||
it('demotes a non-last admin and persists the new role', async () => {
|
||||
const repo = ds.getRepository(UserEntity);
|
||||
await repo.save([
|
||||
repo.create({ id: 'a1', username: 'a1', passwordHash: 'x', role: 'admin', status: 'enabled', createdAt: new Date().toISOString() }),
|
||||
repo.create({ id: 'a2', username: 'a2', passwordHash: 'x', role: 'admin', status: 'enabled', createdAt: new Date().toISOString() }),
|
||||
]);
|
||||
await svc.applyLastAdminSafeMutation('a1', {
|
||||
demotesOrDelete: true,
|
||||
updatedRole: 'player',
|
||||
mutate: async (manager, target) => {
|
||||
target.role = 'player';
|
||||
await manager.save(target);
|
||||
return target;
|
||||
},
|
||||
});
|
||||
const after = await repo.findOne({ where: { id: 'a1' } });
|
||||
expect(after?.role).toBe('player');
|
||||
});
|
||||
|
||||
it('deletes a non-last admin atomically', async () => {
|
||||
const repo = ds.getRepository(UserEntity);
|
||||
await repo.save([
|
||||
repo.create({ id: 'a1', username: 'a1', passwordHash: 'x', role: 'admin', status: 'enabled', createdAt: new Date().toISOString() }),
|
||||
repo.create({ id: 'a2', username: 'a2', passwordHash: 'x', role: 'admin', status: 'enabled', createdAt: new Date().toISOString() }),
|
||||
]);
|
||||
await svc.applyLastAdminSafeMutation('a1', {
|
||||
demotesOrDelete: true,
|
||||
mutate: async (manager) => {
|
||||
await manager.delete(UserEntity, { id: 'a1' });
|
||||
return null;
|
||||
},
|
||||
});
|
||||
const all = await repo.find();
|
||||
expect(all.map((u) => u.id)).toEqual(['a2']);
|
||||
});
|
||||
|
||||
it('rejects demoting the only admin with 409 LAST_ADMIN', async () => {
|
||||
const repo = ds.getRepository(UserEntity);
|
||||
await repo.save(
|
||||
repo.create({ id: 'a1', username: 'a1', passwordHash: 'x', role: 'admin', status: 'enabled', createdAt: new Date().toISOString() }),
|
||||
);
|
||||
await expect(
|
||||
svc.applyLastAdminSafeMutation('a1', {
|
||||
demotesOrDelete: true,
|
||||
updatedRole: 'player',
|
||||
mutate: async (manager, target) => {
|
||||
target.role = 'player';
|
||||
await manager.save(target);
|
||||
return target;
|
||||
},
|
||||
}),
|
||||
).rejects.toMatchObject({ response: { code: ERROR_CODES.LAST_ADMIN } });
|
||||
const after = await repo.findOne({ where: { id: 'a1' } });
|
||||
expect(after?.role).toBe('admin');
|
||||
});
|
||||
|
||||
it('rejects deleting the only admin with 409 LAST_ADMIN', async () => {
|
||||
const repo = ds.getRepository(UserEntity);
|
||||
await repo.save(
|
||||
repo.create({ id: 'a1', username: 'a1', passwordHash: 'x', role: 'admin', status: 'enabled', createdAt: new Date().toISOString() }),
|
||||
);
|
||||
await expect(
|
||||
svc.applyLastAdminSafeMutation('a1', {
|
||||
demotesOrDelete: true,
|
||||
mutate: async (manager) => {
|
||||
await manager.delete(UserEntity, { id: 'a1' });
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
).rejects.toMatchObject({ response: { code: ERROR_CODES.LAST_ADMIN } });
|
||||
const after = await repo.find();
|
||||
expect(after.length).toBe(1);
|
||||
});
|
||||
|
||||
it('returns NOT_FOUND for an unknown id', async () => {
|
||||
await expect(
|
||||
svc.applyLastAdminSafeMutation('00000000-0000-0000-0000-000000000000', {
|
||||
demotesOrDelete: false,
|
||||
mutate: async () => null,
|
||||
}),
|
||||
).rejects.toMatchObject({ response: { code: ERROR_CODES.NOT_FOUND } });
|
||||
});
|
||||
|
||||
it('retries on a transient SQLITE_BUSY lock conflict and succeeds', async () => {
|
||||
const repo = ds.getRepository(UserEntity);
|
||||
await repo.save([
|
||||
repo.create({ id: 'a1', username: 'a1', passwordHash: 'x', role: 'admin', status: 'enabled', createdAt: new Date().toISOString() }),
|
||||
repo.create({ id: 'a2', username: 'a2', passwordHash: 'x', role: 'admin', status: 'enabled', createdAt: new Date().toISOString() }),
|
||||
]);
|
||||
|
||||
const realCreate = ds.createQueryRunner.bind(ds);
|
||||
let busyThrowCount = 0;
|
||||
const busy = Object.assign(new Error('SQLITE_BUSY: database is locked'), { code: 'SQLITE_BUSY' });
|
||||
(ds as unknown as { createQueryRunner: () => unknown }).createQueryRunner = (() => {
|
||||
busyThrowCount += 1;
|
||||
if (busyThrowCount === 1) throw busy;
|
||||
return realCreate();
|
||||
}) as typeof ds.createQueryRunner;
|
||||
|
||||
try {
|
||||
const result = await svc.applyLastAdminSafeMutation('a1', {
|
||||
demotesOrDelete: true,
|
||||
updatedRole: 'player',
|
||||
mutate: async (manager, target) => {
|
||||
target.role = 'player';
|
||||
await manager.save(target);
|
||||
return target;
|
||||
},
|
||||
});
|
||||
expect(busyThrowCount).toBeGreaterThanOrEqual(2);
|
||||
expect(result.role).toBe('player');
|
||||
} finally {
|
||||
(ds as unknown as { createQueryRunner: typeof ds.createQueryRunner }).createQueryRunner = realCreate;
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects with LAST_ADMIN when SQLITE_BUSY retries are exhausted', async () => {
|
||||
const repo = ds.getRepository(UserEntity);
|
||||
await repo.save([
|
||||
repo.create({ id: 'a1', username: 'a1', passwordHash: 'x', role: 'admin', status: 'enabled', createdAt: new Date().toISOString() }),
|
||||
repo.create({ id: 'a2', username: 'a2', passwordHash: 'x', role: 'admin', status: 'enabled', createdAt: new Date().toISOString() }),
|
||||
]);
|
||||
|
||||
const busy = Object.assign(new Error('SQLITE_BUSY: database is locked'), { code: 'SQLITE_BUSY' });
|
||||
const realCreate = ds.createQueryRunner.bind(ds);
|
||||
(ds as unknown as { createQueryRunner: () => unknown }).createQueryRunner = (() => {
|
||||
throw busy;
|
||||
}) as typeof ds.createQueryRunner;
|
||||
|
||||
try {
|
||||
await expect(
|
||||
svc.applyLastAdminSafeMutation('a1', {
|
||||
demotesOrDelete: true,
|
||||
updatedRole: 'player',
|
||||
mutate: async () => null,
|
||||
}),
|
||||
).rejects.toMatchObject({ response: { code: ERROR_CODES.LAST_ADMIN } });
|
||||
} finally {
|
||||
(ds as unknown as { createQueryRunner: typeof ds.createQueryRunner }).createQueryRunner = realCreate;
|
||||
}
|
||||
});
|
||||
|
||||
it('two concurrent demote calls cannot both reduce the admin count below 1', async () => {
|
||||
const repo = ds.getRepository(UserEntity);
|
||||
await repo.save([
|
||||
repo.create({ id: 'a1', username: 'a1', passwordHash: 'x', role: 'admin', status: 'enabled', createdAt: new Date().toISOString() }),
|
||||
repo.create({ id: 'a2', username: 'a2', passwordHash: 'x', role: 'admin', status: 'enabled', createdAt: new Date().toISOString() }),
|
||||
]);
|
||||
|
||||
// Run sequentially so the second call's precondition check observes
|
||||
// the first call's committed state. better-sqlite3 single-connection
|
||||
// concurrency is unreliable for racing two `dataSource.transaction()`
|
||||
// calls in the same Node process; the actual multi-instance
|
||||
// guarantee comes from the SQLite file lock + the BEFORE triggers
|
||||
// migration that this test does not install. The two-step sequential
|
||||
// exercise verifies that the second call's pre-check rejects when
|
||||
// the first has already demoted the second-to-last admin.
|
||||
await svc.applyLastAdminSafeMutation('a1', {
|
||||
demotesOrDelete: true,
|
||||
updatedRole: 'player',
|
||||
mutate: async (manager, target) => {
|
||||
target.role = 'player';
|
||||
await manager.save(target);
|
||||
return target;
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
svc.applyLastAdminSafeMutation('a2', {
|
||||
demotesOrDelete: true,
|
||||
updatedRole: 'player',
|
||||
mutate: async (manager, target) => {
|
||||
target.role = 'player';
|
||||
await manager.save(target);
|
||||
return target;
|
||||
},
|
||||
}),
|
||||
).rejects.toMatchObject({ response: { code: ERROR_CODES.LAST_ADMIN } });
|
||||
|
||||
const afterCount = await repo.count({ where: { role: 'admin' } });
|
||||
expect(afterCount).toBe(1);
|
||||
const after = await repo.find();
|
||||
const a2 = after.find((u) => u.id === 'a2');
|
||||
expect(a2?.role).toBe('admin');
|
||||
});
|
||||
|
||||
it('concurrent role-toggle + delete cannot reduce the admin count below 1', async () => {
|
||||
const repo = ds.getRepository(UserEntity);
|
||||
await repo.save([
|
||||
repo.create({ id: 'a1', username: 'a1', passwordHash: 'x', role: 'admin', status: 'enabled', createdAt: new Date().toISOString() }),
|
||||
repo.create({ id: 'a2', username: 'a2', passwordHash: 'x', role: 'admin', status: 'enabled', createdAt: new Date().toISOString() }),
|
||||
]);
|
||||
|
||||
// Sequential: first demote, then attempt to delete the last admin.
|
||||
// better-sqlite3 single-connection concurrency cannot reliably race
|
||||
// two top-level transactions in a single Node process; the
|
||||
// multi-instance guarantee is provided by the SQLite file lock plus
|
||||
// the BEFORE UPDATE/DELETE triggers (installed in production by the
|
||||
// AddUserLastAdminTriggers migration).
|
||||
await svc.applyLastAdminSafeMutation('a1', {
|
||||
demotesOrDelete: true,
|
||||
updatedRole: 'player',
|
||||
mutate: async (manager, target) => {
|
||||
target.role = 'player';
|
||||
await manager.save(target);
|
||||
return target;
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
svc.applyLastAdminSafeMutation('a2', {
|
||||
demotesOrDelete: true,
|
||||
mutate: async (manager) => {
|
||||
await manager.delete(UserEntity, { id: 'a2' });
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
).rejects.toMatchObject({ response: { code: ERROR_CODES.LAST_ADMIN } });
|
||||
|
||||
const afterCount = await repo.count({ where: { role: 'admin' } });
|
||||
expect(afterCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user