feat(teams): manage player active status and team-role with treasurer safeguard

Team managers (captain and above) can now deactivate/reactivate a player and
change their team-role from the player detail page. Deactivation zeroes the
open balance via an auditable adjustment transaction instead of overwriting
the balance field, and both actions are blocked if they would leave a team
without an active treasurer. Also hardens the existing PUT teams/:id/players
endpoint down to profile-only fields, fixing a typo bug and closing a gap
where any authenticated user could mutate a player's active/role/balance in
any team.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Bastian Wagner
2026-08-01 15:42:09 +02:00
parent 2424a8c025
commit 3551641a85
16 changed files with 830 additions and 35 deletions

View File

@@ -0,0 +1,85 @@
import 'reflect-metadata';
import { DataSource, DataSourceOptions } from 'typeorm';
import { Player } from '../src/players/entities/player.entity';
import { Team } from '../src/teams/entities/team.entity';
import { Transaction } from '../src/transactions/entitites/transaction.entity';
import { TransactionTypeEnum } from '../src/transactions/transaction-type.enum';
import { DEACTIVATION_ADJUSTMENT_NOTE_PREFIX } from '../src/teams/team-members.service';
// Eigene DataSource statt der geteilten `AppDataSource`: deren `migrations`-Glob
// (`src/database/migrations/**/*`) matcht auch `.spec.ts`-Dateien in diesem Ordner
// (z.B. `AddPlayerLookupIndexes.spec.ts`) und TypeORM requiret sie beim
// Metadaten-Aufbau -- das registriert `describe`/`it` NACH Testlauf-Start und lässt
// Jest mit "Cannot add a test after tests have started running" abbrechen. Diese
// Ausgleichsbuchung braucht keine Migrationen, nur Entities -- migrations: [] umgeht das.
describe('TeamMembers rollback atomicity (real MySQL)', () => {
let dataSource: DataSource;
let team: Team;
let player: Player;
const marker = `rollback-test-${Date.now()}`;
beforeAll(async () => {
dataSource = new DataSource({
type: process.env.DATABASE_TYPE,
url: process.env.DATABASE_URL,
host: process.env.DATABASE_HOST,
port: parseInt(process.env.DATABASE_PORT, 10) || 5432,
username: process.env.DATABASE_USERNAME,
password: process.env.DATABASE_PASSWORD,
database: process.env.DATABASE_NAME,
synchronize: false,
entities: [__dirname + '/../src/**/*.entity{.ts,.js}'],
} as DataSourceOptions);
await dataSource.initialize();
team = await dataSource.getRepository(Team).save({
name: marker,
alias: marker,
} as Team);
player = await dataSource.getRepository(Player).save({
firstName: 'Rollback',
lastName: 'Test',
team,
teamRole: { id: 4 }, // treasurer
balance: 42,
active: true,
} as unknown as Player);
});
afterAll(async () => {
await dataSource
.getRepository(Transaction)
.delete({ player: { id: player.id } });
await dataSource.getRepository(Player).delete({ id: player.id });
await dataSource.getRepository(Team).delete({ id: team.id });
await dataSource.destroy();
});
it('rolls back BOTH the inserted adjustment transaction AND the explicit balance write when a later error is thrown in the same manager transaction', async () => {
await expect(
dataSource.transaction(async (manager) => {
await manager.getRepository(Transaction).insert({
amount: -42,
date: new Date().toISOString(),
note: `${DEACTIVATION_ADJUSTMENT_NOTE_PREFIX} #${player.id}`,
player: { id: player.id } as Player,
type: { id: TransactionTypeEnum.credit },
});
await manager
.getRepository(Player)
.save({ ...player, balance: 0, active: false });
throw new Error('forced failure after adjustment transaction would have been created');
}),
).rejects.toThrow('forced failure');
const persistedTransaction = await dataSource.getRepository(Transaction).findOne({
where: { player: { id: player.id } },
});
expect(persistedTransaction).toBeNull();
const reloadedPlayer = await dataSource.getRepository(Player).findOne({
where: { id: player.id },
});
expect(reloadedPlayer?.active).toBe(true);
expect(Number(reloadedPlayer?.balance)).toBe(42);
});
});