fix: wrap team creation in a transaction, add more-menu entry point, fix lint

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Bastian Wagner
2026-08-03 17:27:23 +02:00
parent b03198baa6
commit 6531f2553f
7 changed files with 185 additions and 24 deletions

View File

@@ -31,7 +31,7 @@ describe('TeamsController', () => {
const req = { user: { id: 42 } }; const req = { user: { id: 42 } };
const dto = { name: '1. Herren' }; const dto = { name: '1. Herren' };
controller.create(req as any, dto as any); void controller.create(req as any, dto as any);
expect(service.createNewTeam).toHaveBeenCalledWith(dto, 42); expect(service.createNewTeam).toHaveBeenCalledWith(dto, 42);
}); });

View File

@@ -30,6 +30,7 @@ describe('TeamsService#getOverviewStats theoretical balance', () => {
{ info: jest.fn(), debug: jest.fn(), warn: jest.fn() } as any, { info: jest.fn(), debug: jest.fn(), warn: jest.fn() } as any,
access as any, access as any,
{} as any, {} as any,
{} as any,
); );
}); });
@@ -288,6 +289,7 @@ describe('TeamsService#getTeamTransactionsJournal', () => {
{ info: jest.fn(), debug: jest.fn(), warn: jest.fn() } as any, { info: jest.fn(), debug: jest.fn(), warn: jest.fn() } as any,
access as any, access as any,
{} as any, {} as any,
{} as any,
); );
}); });
@@ -372,24 +374,46 @@ describe('TeamsService#createNewTeam', () => {
const settingsRepository = { create: jest.fn(), save: jest.fn() }; const settingsRepository = { create: jest.fn(), save: jest.fn() };
const usersRepository = { findOneBy: jest.fn() }; const usersRepository = { findOneBy: jest.fn() };
const logger = { info: jest.fn(), debug: jest.fn(), warn: jest.fn() }; const logger = { info: jest.fn(), debug: jest.fn(), warn: jest.fn() };
let manager: any;
let dataSource: any;
let service: TeamsService; let service: TeamsService;
beforeEach(() => { beforeEach(() => {
jest.resetAllMocks(); jest.resetAllMocks();
manager = {
getRepository: jest.fn((entity: { name: string }) => {
switch (entity.name) {
case 'Team':
return repository;
case 'Player':
return playerRepository;
case 'TeamRole':
return rolesRepository;
case 'TeamSetting':
return settingsRepository;
case 'User':
return usersRepository;
default:
throw new Error(`unexpected entity ${entity.name}`);
}
}),
};
dataSource = { transaction: jest.fn((work: any) => work(manager)) };
service = new TeamsService( service = new TeamsService(
repository as any,
playerRepository as any,
{} as any, {} as any,
rolesRepository as any, {} as any,
settingsRepository as any, {} as any,
{} as any,
{} as any,
{} as any, {} as any,
logger as any, logger as any,
{} as any, {} as any,
usersRepository as any, {} as any,
dataSource as any,
); );
}); });
it('creates the team, its default settings, and a captain membership for the creator', async () => { it('creates the team, its default settings, and a captain membership for the creator, all inside one transaction', async () => {
const savedTeam = { id: 7, name: '1. Herren' }; const savedTeam = { id: 7, name: '1. Herren' };
repository.create.mockReturnValue({ name: '1. Herren' }); repository.create.mockReturnValue({ name: '1. Herren' });
repository.save.mockResolvedValue(savedTeam); repository.save.mockResolvedValue(savedTeam);
@@ -406,6 +430,7 @@ describe('TeamsService#createNewTeam', () => {
const result = await service.createNewTeam({ name: '1. Herren' } as any, '42'); const result = await service.createNewTeam({ name: '1. Herren' } as any, '42');
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
expect(result).toBe(savedTeam); expect(result).toBe(savedTeam);
expect(rolesRepository.findOneBy).toHaveBeenCalledWith({ id: 3 }); expect(rolesRepository.findOneBy).toHaveBeenCalledWith({ id: 3 });
expect(usersRepository.findOneBy).toHaveBeenCalledWith({ id: 42 }); expect(usersRepository.findOneBy).toHaveBeenCalledWith({ id: 42 });
@@ -415,8 +440,27 @@ describe('TeamsService#createNewTeam', () => {
lastName: 'Muster', lastName: 'Muster',
team: savedTeam, team: savedTeam,
teamRole: { id: 3, name: 'captain' }, teamRole: { id: 3, name: 'captain' },
user: { id: 42, firstName: 'Alex', lastName: 'Muster' },
}), }),
); );
expect(playerRepository.save).toHaveBeenCalled(); expect(playerRepository.save).toHaveBeenCalled();
}); });
it('does not create the team at all if the captain-membership write fails', async () => {
const savedTeam = { id: 7, name: '1. Herren' };
repository.create.mockReturnValue({ name: '1. Herren' });
repository.save.mockResolvedValue(savedTeam);
settingsRepository.create.mockImplementation((s: unknown) => s);
settingsRepository.save.mockResolvedValue([]);
usersRepository.findOneBy.mockResolvedValue({ id: 42, firstName: 'Alex', lastName: 'Muster' });
rolesRepository.findOneBy.mockResolvedValue({ id: 3, name: 'captain' });
playerRepository.create.mockImplementation((p: unknown) => p);
playerRepository.save.mockRejectedValue(new Error('db down'));
await expect(
service.createNewTeam({ name: '1. Herren' } as any, '42'),
).rejects.toThrow('db down');
expect(logger.info).not.toHaveBeenCalled();
});
}); });

View File

@@ -10,7 +10,7 @@ import { TeamWalletTransaction } from 'src/team-wallet-transactions/entities/tea
import { Transaction } from 'src/transactions/entitites/transaction.entity'; import { Transaction } from 'src/transactions/entitites/transaction.entity';
import { TeamRolesEnum } from 'src/team-roles/team-roles.enum'; import { TeamRolesEnum } from 'src/team-roles/team-roles.enum';
import { User } from 'src/users/entities/user.entity'; import { User } from 'src/users/entities/user.entity';
import { Repository } from 'typeorm'; import { DataSource, EntityManager, Repository } from 'typeorm';
import { import {
TransactionsQueryDto, TransactionsQueryDto,
TransactionsSortableField, TransactionsSortableField,
@@ -50,6 +50,7 @@ export class TeamsService {
private access: TeamAccessService, private access: TeamAccessService,
@InjectRepository(User) @InjectRepository(User)
private usersRepository: Repository<User>, private usersRepository: Repository<User>,
private dataSource: DataSource,
) {} ) {}
async getOverview(teamId: string, actorUserId: string) { async getOverview(teamId: string, actorUserId: string) {
@@ -168,11 +169,16 @@ export class TeamsService {
} }
async createNewTeam(teamdto: CreateTeamDTO, userId: string) { async createNewTeam(teamdto: CreateTeamDTO, userId: string) {
const createTeam = this.repository.create(teamdto); const team = await this.dataSource.transaction(async (manager) => {
const teamRepository = manager.getRepository(Team);
const createTeam = teamRepository.create(teamdto);
const savedTeam = await teamRepository.save(createTeam);
const team = await this.repository.save(createTeam); await this.generateBasicTeamSettings(manager, savedTeam);
await this.generateBasicTeamSettings(team); await this.addCreatorAsCaptain(manager, savedTeam, userId);
await this.addCreatorAsCaptain(team, userId);
return savedTeam;
});
await this.logger.info({ await this.logger.info({
event: 'team_create', event: 'team_create',
@@ -182,13 +188,18 @@ export class TeamsService {
return team; return team;
} }
private async addCreatorAsCaptain(team: Team, userId: string): Promise<void> { private async addCreatorAsCaptain(
manager: EntityManager,
team: Team,
userId: string,
): Promise<void> {
const [user, captainRole] = await Promise.all([ const [user, captainRole] = await Promise.all([
this.usersRepository.findOneBy({ id: Number(userId) }), manager.getRepository(User).findOneBy({ id: Number(userId) }),
this.rolesRepository.findOneBy({ id: TeamRolesEnum.captain }), manager.getRepository(TeamRole).findOneBy({ id: TeamRolesEnum.captain }),
]); ]);
const player = this.playerRepository.create({ const playerRepository = manager.getRepository(Player);
const player = playerRepository.create({
firstName: user?.firstName ?? '', firstName: user?.firstName ?? '',
lastName: user?.lastName ?? '', lastName: user?.lastName ?? '',
team, team,
@@ -196,10 +207,14 @@ export class TeamsService {
user: user ?? undefined, user: user ?? undefined,
}); });
await this.playerRepository.save(player); await playerRepository.save(player);
} }
private async generateBasicTeamSettings(team: Team): Promise<void> { private async generateBasicTeamSettings(
manager: EntityManager,
team: Team,
): Promise<void> {
const settingsRepository = manager.getRepository(TeamSetting);
const settings: CreateTeamSettingDTO[] = Object.entries( const settings: CreateTeamSettingDTO[] = Object.entries(
TEAM_SETTING_DEFAULTS, TEAM_SETTING_DEFAULTS,
).map(([key, value]) => ({ ).map(([key, value]) => ({
@@ -208,8 +223,8 @@ export class TeamsService {
team, team,
})); }));
await this.settingsRepository.save( await settingsRepository.save(
settings.map((s) => this.settingsRepository.create(s)), settings.map((s) => settingsRepository.create(s)),
); );
} }

View File

@@ -5,6 +5,15 @@
</header> </header>
<section class="link-grid"> <section class="link-grid">
<button type="button" class="link-grid-action" (click)="createTeam()">
<mat-card
><mat-icon>add_circle</mat-icon>
<div>
<strong>Team erstellen</strong><span>Ein weiteres Team gründen</span>
</div>
<mat-icon>chevron_right</mat-icon></mat-card
>
</button>
@if (canOpenGuide()) { @if (canOpenGuide()) {
<a routerLink="guide" <a routerLink="guide"
><mat-card ><mat-card

View File

@@ -26,11 +26,23 @@ h1 {
gap: 14px; gap: 14px;
margin: 28px 0; margin: 28px 0;
} }
a { a,
.link-grid-action {
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
} }
a mat-card { .link-grid-action {
display: block;
width: 100%;
border: none;
background: none;
padding: 0;
font: inherit;
text-align: left;
cursor: pointer;
}
a mat-card,
.link-grid-action mat-card {
display: grid; display: grid;
grid-template-columns: auto 1fr auto; grid-template-columns: auto 1fr auto;
align-items: center; align-items: center;
@@ -41,19 +53,23 @@ a mat-card {
transform 150ms ease, transform 150ms ease,
box-shadow 150ms ease; box-shadow 150ms ease;
} }
a:hover mat-card { a:hover mat-card,
.link-grid-action:hover mat-card {
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: var(--mat-sys-level2); box-shadow: var(--mat-sys-level2);
} }
a div, a div,
.link-grid-action div,
.account-card div { .account-card div {
display: grid; display: grid;
gap: 3px; gap: 3px;
} }
a strong { a strong,
.link-grid-action strong {
font-size: 1.05rem; font-size: 1.05rem;
} }
a span, a span,
.link-grid-action span,
.account-card span { .account-card span {
color: var(--mat-sys-on-surface-variant); color: var(--mat-sys-on-surface-variant);
} }

View File

@@ -1,9 +1,15 @@
import { Component, signal } from '@angular/core'; import { Component, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { provideRouter, Router } from '@angular/router'; import { provideRouter, Router } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { MatDialog } from '@angular/material/dialog';
import { Subject } from 'rxjs';
import { AuthStore } from '../../../core/auth/auth-store'; import { AuthStore } from '../../../core/auth/auth-store';
import { HelpAccessService } from '../../../core/help/help-access'; import { HelpAccessService } from '../../../core/help/help-access';
import { MyTeamsStore } from '../../../core/team/my-teams-store';
import { TeamStore } from '../../../core/team/team-store'; import { TeamStore } from '../../../core/team/team-store';
import { environment } from '../../../../environments/environment';
import { More } from './more'; import { More } from './more';
@Component({ template: '' }) @Component({ template: '' })
@@ -15,6 +21,9 @@ describe('More', () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [More], imports: [More],
providers: [ providers: [
provideHttpClient(),
provideHttpClientTesting(),
{ provide: MatDialog, useValue: { open: vi.fn() } },
provideRouter([{ path: 'auth/login', component: LoginStub }]), provideRouter([{ path: 'auth/login', component: LoginStub }]),
{ {
provide: AuthStore, provide: AuthStore,
@@ -49,6 +58,9 @@ describe('More', () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [More], imports: [More],
providers: [ providers: [
provideHttpClient(),
provideHttpClientTesting(),
{ provide: MatDialog, useValue: { open: vi.fn() } },
provideRouter([]), provideRouter([]),
{ {
provide: AuthStore, provide: AuthStore,
@@ -88,6 +100,9 @@ describe('More', () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [More], imports: [More],
providers: [ providers: [
provideHttpClient(),
provideHttpClientTesting(),
{ provide: MatDialog, useValue: { open: vi.fn() } },
provideRouter([]), provideRouter([]),
{ {
provide: AuthStore, provide: AuthStore,
@@ -111,4 +126,45 @@ describe('More', () => {
expect(fixture.nativeElement.textContent).toContain('Berechtigungen'); expect(fixture.nativeElement.textContent).toContain('Berechtigungen');
}); });
it('opens the create-team dialog and navigates into the newly created team on success', async () => {
const dialogClosed = new Subject<
{ id: number; name: string; alias: string; balance: number } | undefined
>();
const dialog = { open: vi.fn(() => ({ afterClosed: () => dialogClosed.asObservable() })) };
await TestBed.configureTestingModule({
imports: [More],
providers: [
provideHttpClient(),
provideHttpClientTesting(),
provideRouter([]),
{
provide: AuthStore,
useValue: {
currentUser: signal({ id: 42, firstName: 'Alex', lastName: 'Muster', email: 'a@b.de' }),
clearSession: vi.fn(),
},
},
{ provide: HelpAccessService, useValue: { canOpenGuide: signal(false) } },
{ provide: TeamStore, useValue: { team: signal(null) } },
{ provide: MatDialog, useValue: dialog },
],
}).compileComponents();
const router = TestBed.inject(Router);
const navigateSpy = vi.spyOn(router, 'navigate');
const myTeamsStore = TestBed.inject(MyTeamsStore);
const refreshSpy = vi.spyOn(myTeamsStore, 'refresh');
const fixture = TestBed.createComponent(More);
fixture.detectChanges();
fixture.componentInstance['createTeam']();
expect(dialog.open).toHaveBeenCalled();
dialogClosed.next({ id: 9, name: '1. Herren', alias: 'a', balance: 0 });
expect(refreshSpy).toHaveBeenCalledWith(42);
expect(navigateSpy).toHaveBeenCalledWith(['/team', 9, 'overview']);
TestBed.inject(HttpTestingController).expectOne(`${environment.apiUrl}users/42/teams`).flush([]);
});
}); });

View File

@@ -2,11 +2,15 @@ import { Component, computed, inject } from '@angular/core';
import { Router, RouterLink } from '@angular/router'; import { Router, RouterLink } from '@angular/router';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { MatDialog } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { AuthStore } from '../../../core/auth/auth-store'; import { AuthStore } from '../../../core/auth/auth-store';
import { HelpAccessService } from '../../../core/help/help-access'; import { HelpAccessService } from '../../../core/help/help-access';
import { MyTeamsStore } from '../../../core/team/my-teams-store';
import { TeamPermissionsService } from '../../../core/team/team-permissions'; import { TeamPermissionsService } from '../../../core/team/team-permissions';
import { TeamStore } from '../../../core/team/team-store'; import { TeamStore } from '../../../core/team/team-store';
import { Team } from '../../../models/team.model';
import { CreateTeamDialog } from '../../team-select/create-team-dialog/create-team-dialog';
@Component({ @Component({
selector: 'app-more', selector: 'app-more',
@@ -16,7 +20,9 @@ import { TeamStore } from '../../../core/team/team-store';
}) })
export class More { export class More {
private readonly authStore = inject(AuthStore); private readonly authStore = inject(AuthStore);
private readonly dialog = inject(MatDialog);
private readonly helpAccess = inject(HelpAccessService); private readonly helpAccess = inject(HelpAccessService);
private readonly myTeamsStore = inject(MyTeamsStore);
private readonly permissions = inject(TeamPermissionsService); private readonly permissions = inject(TeamPermissionsService);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly teamStore = inject(TeamStore); private readonly teamStore = inject(TeamStore);
@@ -30,4 +36,19 @@ export class More {
this.authStore.clearSession(); this.authStore.clearSession();
void this.router.navigateByUrl('/auth/login'); void this.router.navigateByUrl('/auth/login');
} }
protected createTeam(): void {
this.dialog
.open(CreateTeamDialog)
.afterClosed()
.subscribe((team: Team | undefined) => {
if (!team) return;
const userId = this.authStore.currentUser()?.id;
if (userId) {
this.myTeamsStore.refresh(userId);
}
void this.router.navigate(['/team', team.id, 'overview']);
});
}
} }