diff --git a/backend/tests/myTischtennisFetchLogService.test.js b/backend/tests/myTischtennisFetchLogService.test.js new file mode 100644 index 0000000..5bad818 --- /dev/null +++ b/backend/tests/myTischtennisFetchLogService.test.js @@ -0,0 +1,35 @@ +import { describe, it, expect, beforeEach } from 'vitest'; + +import sequelize from '../database.js'; +import '../models/index.js'; + +import myTischtennisFetchLogService from '../services/myTischtennisFetchLogService.js'; +import MyTischtennisFetchLog from '../models/MyTischtennisFetchLog.js'; + +describe('myTischtennisFetchLogService', () => { + beforeEach(async () => { + await sequelize.sync({ force: true }); + }); + + it('loggt Abrufe und liefert die letzten Einträge', async () => { + await myTischtennisFetchLogService.logFetch(1, 'ratings', true, 'OK', { recordsProcessed: 5 }); + await myTischtennisFetchLogService.logFetch(1, 'match_results', false, 'Error', { errorDetails: 'Timeout' }); + + const logs = await myTischtennisFetchLogService.getFetchLogs(1, { limit: 10 }); + expect(logs).toHaveLength(2); + expect(logs[0].fetchType).toBe('match_results'); + + const latest = await myTischtennisFetchLogService.getLatestSuccessfulFetches(1); + expect(latest.ratings).toBeTruthy(); + expect(latest.match_results).toBeNull(); + }); + + it('aggregiert Statistiken nach Typ', async () => { + await myTischtennisFetchLogService.logFetch(2, 'ratings', true, 'OK', { recordsProcessed: 3, executionTime: 100 }); + await myTischtennisFetchLogService.logFetch(2, 'ratings', false, 'Fail', { recordsProcessed: 0, executionTime: 200 }); + + const stats = await myTischtennisFetchLogService.getFetchStatistics(2, 7); + expect(stats).toHaveLength(1); + expect(stats[0].fetchType).toBe('ratings'); + }); +}); diff --git a/backend/tests/myTischtennisRoutes.test.js b/backend/tests/myTischtennisRoutes.test.js new file mode 100644 index 0000000..8735b21 --- /dev/null +++ b/backend/tests/myTischtennisRoutes.test.js @@ -0,0 +1,92 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import request from 'supertest'; + +vi.mock('../services/emailService.js', () => ({ + sendActivationEmail: vi.fn().mockResolvedValue(), +})); + +vi.mock('../clients/myTischtennisClient.js', () => ({ + __esModule: true, + default: { + login: vi.fn().mockResolvedValue({ success: true, accessToken: 'token', refreshToken: 'refresh', expiresAt: Date.now() / 1000 + 3600, cookie: 'cookie', user: { id: 1 } }), + getUserProfile: vi.fn().mockResolvedValue({ success: true, clubId: 1, clubName: 'Club', fedNickname: 'fed' }), + }, +})); + +import app from './testApp.js'; +import sequelize from '../database.js'; +import '../models/index.js'; + +import User from '../models/User.js'; +import MyTischtennis from '../models/MyTischtennis.js'; + +const registerAndActivate = async (email) => { + const password = 'Test123!'; + await request(app).post('/api/auth/register').send({ email, password }); + const user = await User.findOne({ where: { email } }); + await user.update({ isActive: true }); + return { user, credentials: { email, password } }; +}; + +const loginAndGetToken = async (credentials) => { + const response = await request(app).post('/api/auth/login').send(credentials); + return response.body.token; +}; + +const authHeaders = (token) => ({ + Authorization: `Bearer ${token}`, + authcode: token, +}); + +describe('MyTischtennis Routes', () => { + beforeEach(async () => { + await sequelize.sync({ force: true }); + }); + + it('erstellt Accounts und liefert Status', async () => { + const { credentials } = await registerAndActivate('mytt@example.com'); + const token = await loginAndGetToken(credentials); + + const upsertResponse = await request(app) + .post('/api/mytischtennis/account') + .set(authHeaders(token)) + .send({ email: 'mytt@example.com', password: 'secret', savePassword: true, userPassword: 'Test123!' }); + + expect(upsertResponse.status).toBe(200); + + const statusResponse = await request(app) + .get('/api/mytischtennis/status') + .set(authHeaders(token)); + + expect(statusResponse.status).toBe(200); + expect(statusResponse.body.exists).toBe(true); + + const account = await MyTischtennis.findOne({ where: { userId: upsertResponse.body.account.id ? upsertResponse.body.account.userId : 1 } }); + expect(account).toBeTruthy(); + }); + + it('prüft Logins und liefert Sessiondaten', async () => { + const { credentials } = await registerAndActivate('verify@example.com'); + const token = await loginAndGetToken(credentials); + + await request(app) + .post('/api/mytischtennis/account') + .set(authHeaders(token)) + .send({ email: 'verify@example.com', password: 'secret', savePassword: true, userPassword: 'Test123!' }); + + const verifyResponse = await request(app) + .post('/api/mytischtennis/verify') + .set(authHeaders(token)) + .send({ password: 'secret' }); + + expect(verifyResponse.status).toBe(200); + expect(verifyResponse.body.success).toBe(true); + + const sessionResponse = await request(app) + .get('/api/mytischtennis/session') + .set(authHeaders(token)); + + expect(sessionResponse.status).toBe(200); + expect(sessionResponse.body.session.accessToken).toBe('token'); + }); +}); diff --git a/backend/tests/myTischtennisService.test.js b/backend/tests/myTischtennisService.test.js new file mode 100644 index 0000000..fc760a9 --- /dev/null +++ b/backend/tests/myTischtennisService.test.js @@ -0,0 +1,96 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +vi.mock('../clients/myTischtennisClient.js', () => ({ + __esModule: true, + default: { + login: vi.fn(), + getUserProfile: vi.fn(), + }, +})); + +vi.mock('../models/User.js', async () => { + const actual = await vi.importActual('../models/User.js'); + return { + __esModule: true, + default: class MockUser extends actual.default { + static #instances = []; + static async findByPk(id) { + return MockUser.#instances.find((user) => user.id === id) || null; + } + static async create(values) { + const instance = new MockUser(values); + MockUser.#instances.push(instance); + return instance; + } + constructor(values) { + super(); + Object.assign(this, values); + this.validatePassword = vi.fn().mockResolvedValue(true); + } + }, + }; +}); + +import sequelize from '../database.js'; +import '../models/index.js'; + +import myTischtennisService from '../services/myTischtennisService.js'; +import MyTischtennis from '../models/MyTischtennis.js'; +import MyTischtennisUpdateHistory from '../models/MyTischtennisUpdateHistory.js'; +import myTischtennisClient from '../clients/myTischtennisClient.js'; + +const clientMock = myTischtennisClient as unknown as { + login: vi.Mock; + getUserProfile: vi.Mock; +}; + +describe('myTischtennisService', () => { + beforeEach(async () => { + await sequelize.sync({ force: true }); + vi.clearAllMocks(); + clientMock.login.mockResolvedValue({ success: true, accessToken: 'token', refreshToken: 'refresh', expiresAt: Date.now() / 1000 + 3600, cookie: 'cookie', user: { id: 1 } }); + clientMock.getUserProfile.mockResolvedValue({ success: true, clubId: 123, clubName: 'Club', fedNickname: 'fed' }); + }); + + it('legt Accounts an und aktualisiert Logins', async () => { + const userId = 1; + const userModel = (await import('../models/User.js')).default; + await userModel.create({ id: userId, email: 'user@example.com', password: 'hashed' }); + + const account = await myTischtennisService.upsertAccount(userId, 'user@example.com', 'pass', true, true, 'appPass'); + expect(account.email).toBe('user@example.com'); + + const stored = await MyTischtennis.findOne({ where: { userId } }); + expect(stored.savePassword).toBe(true); + expect(stored.clubId).toBe(123); + expect(clientMock.login).toHaveBeenCalledWith('user@example.com', 'pass'); + }); + + it('verifiziert Logins mit gespeicherter Session', async () => { + const userId = 2; + const userModel = (await import('../models/User.js')).default; + await userModel.create({ id: userId, email: 'session@example.com', password: 'hash' }); + + clientMock.login.mockResolvedValueOnce({ success: true, accessToken: 'token', refreshToken: 'refresh', expiresAt: Date.now() / 1000 + 3600, cookie: 'cookie', user: { id: 2 } }); + + await myTischtennisService.upsertAccount(userId, 'session@example.com', 'pass', true, false, 'appPass'); + + const result = await myTischtennisService.verifyLogin(userId); + expect(result.success).toBe(true); + expect(clientMock.login).toHaveBeenCalledWith('session@example.com', 'pass'); + }); + + it('speichert Update-History und setzt lastUpdateRatings', async () => { + const userId = 3; + await MyTischtennis.create({ userId, email: 'history@example.com' }); + + await myTischtennisService.logUpdateAttempt(userId, true, 'OK', null, 5, 1234); + + const history = await MyTischtennisUpdateHistory.findAll({ where: { userId } }); + expect(history).toHaveLength(1); + expect(history[0].updatedCount).toBe(5); + + const account = await MyTischtennis.findOne({ where: { userId } }); + expect(account.lastUpdateRatings).toBeTruthy(); + }); +}); diff --git a/backend/tests/myTischtennisUrlRoutes.test.js b/backend/tests/myTischtennisUrlRoutes.test.js new file mode 100644 index 0000000..ecb6a5c --- /dev/null +++ b/backend/tests/myTischtennisUrlRoutes.test.js @@ -0,0 +1,90 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import request from 'supertest'; + +vi.mock('../services/emailService.js', () => ({ + sendActivationEmail: vi.fn().mockResolvedValue(), +})); + +vi.mock('../clients/myTischtennisClient.js', () => ({ + __esModule: true, + default: { + login: vi.fn().mockResolvedValue({ success: true, accessToken: 'token', refreshToken: 'refresh', expiresAt: Date.now() / 1000 + 3600, cookie: 'cookie', user: { id: 1 } }), + getUserProfile: vi.fn().mockResolvedValue({ success: true, clubId: 1, clubName: 'Club', fedNickname: 'fed' }), + }, +})); + +import app from './testApp.js'; +import sequelize from '../database.js'; +import '../models/index.js'; + +import User from '../models/User.js'; +import Club from '../models/Club.js'; +import League from '../models/League.js'; +import Season from '../models/Season.js'; +import ClubTeam from '../models/ClubTeam.js'; + +const registerAndActivate = async (email) => { + const password = 'Test123!'; + await request(app).post('/api/auth/register').send({ email, password }); + const user = await User.findOne({ where: { email } }); + await user.update({ isActive: true }); + return { user, credentials: { email, password } }; +}; + +const loginAndGetToken = async (credentials) => { + const response = await request(app).post('/api/auth/login').send(credentials); + return response.body.token; +}; + +const authHeaders = (token, userId) => ({ + Authorization: `Bearer ${token}`, + authcode: token, + userid: String(userId), +}); + +describe('MyTischtennis URL Routes', () => { + beforeEach(async () => { + await sequelize.sync({ force: true }); + }); + + it('parst URLs und konfiguriert Teams', async () => { + const { user, credentials } = await registerAndActivate('url@example.com'); + const token = await loginAndGetToken(credentials); + + const club = await Club.create({ name: 'URL Club' }); + const season = await Season.create({ season: '2025/2026' }); + const league = await League.create({ name: 'URL Liga', association: 'TT', groupname: 'Gruppe A', myTischtennisGroupId: '12345', seasonId: season.id, clubId: club.id }); + const team = await ClubTeam.create({ name: 'URL Team', clubId: club.id, leagueId: league.id, seasonId: season.id }); + + const configureResponse = await request(app) + .post('/api/mytischtennis/configure-team') + .set(authHeaders(token, user.id)) + .send({ + url: 'https://www.mytischtennis.de/click-tt/tt/24-25/ligen/gruppe/team?id=12345&teamid=67890', + clubTeamId: team.id, + createLeague: false, + createSeason: false, + }); + + expect(configureResponse.status).toBe(200); + await team.reload(); + expect(team.myTischtennisTeamId).toBeTruthy(); + }); + + it('gibt Team-URLs zurück', async () => { + const { credentials } = await registerAndActivate('teamurl@example.com'); + const token = await loginAndGetToken(credentials); + + const club = await Club.create({ name: 'URL Club' }); + const season = await Season.create({ season: '2025/2026' }); + const league = await League.create({ name: 'URL Liga', association: 'TT', groupname: 'Gruppe A', myTischtennisGroupId: '12345', seasonId: season.id, clubId: club.id }); + const team = await ClubTeam.create({ name: 'URL Team', clubId: club.id, leagueId: league.id, seasonId: season.id, myTischtennisTeamId: '98765' }); + + const urlResponse = await request(app) + .get(`/api/mytischtennis/team-url/${team.id}`) + .set(authHeaders(token, 1)); + + expect(urlResponse.status).toBe(200); + expect(urlResponse.body.url).toContain('98765'); + }); +}); diff --git a/backend/tests/pdfParserService.test.js b/backend/tests/pdfParserService.test.js new file mode 100644 index 0000000..bbf9b02 --- /dev/null +++ b/backend/tests/pdfParserService.test.js @@ -0,0 +1,124 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import sequelize from '../database.js'; +import '../models/index.js'; + +import pdfParserService from '../services/pdfParserService.js'; +import Club from '../models/Club.js'; +import Team from '../models/Team.js'; +import League from '../models/League.js'; +import Season from '../models/Season.js'; +import Match from '../models/Match.js'; +import Location from '../models/Location.js'; + +describe('pdfParserService', () => { + let tempFiles = []; + + beforeEach(async () => { + await sequelize.sync({ force: true }); + tempFiles = []; + }); + + afterEach(() => { + tempFiles.forEach((file) => { + try { + fs.unlinkSync(file); + } catch { + /* ignore */ + } + }); + }); + + const createTempFile = (content, ext = '.txt') => { + const filePath = path.join(os.tmpdir(), `pdfparser-${Date.now()}-${Math.random().toString(16).slice(2)}${ext}`); + fs.writeFileSync(filePath, content, 'utf8'); + tempFiles.push(filePath); + return filePath; + }; + + it('parst Standard-Format aus einer Textdatei', async () => { + const lines = [ + 'Sa. 06.09.2025 10:00 Harheimer TC II SG Teststadt III ABC123DEF456' + ]; + const filePath = createTempFile(lines.join('\n')); + + const result = await pdfParserService.parsePDF(filePath, 1); + + expect(result.matches).toHaveLength(1); + const match = result.matches[0]; + expect(match.homeTeamName).toBe('Harheimer TC II'); + expect(match.guestTeamName).toBe('SG Teststadt III'); + expect(match.code).toBe('ABC123DEF456'); + }); + + it('parst Tabellen-Format', () => { + const text = [ + 'Datum Zeit Heim Gast Code Heim-Pin Gast-Pin', + '06.09.2025 10:00 Harheimer TC II SG Teststadt III ABC123DEF456 1234 5678' + ].join('\n'); + + const result = pdfParserService.extractMatchData(text, 1); + + expect(result.matches).toHaveLength(1); + const match = result.matches[0]; + expect(match.homeTeamName).toContain('Harheimer'); + expect(match.homePin).toBe('1234'); + expect(match.guestPin).toBe('5678'); + }); + + it('parst Listen-Format', () => { + const text = '1. 06.09.2025 10:00 Harheimer TC II vs SG Teststadt III code ABC123DEF456'; + + const result = pdfParserService.extractMatchData(text, 5); + + expect(result.matches).toHaveLength(1); + expect(result.matches[0].guestTeamName).toBe('SG Teststadt III'); + }); + + it('speichert Matches in der Datenbank und aktualisiert vorhandene Einträge', async () => { + const club = await Club.create({ name: 'Harheimer TC' }); + const season = await Season.create({ season: '2025/2026' }); + const league = await League.create({ name: 'Verbandsliga', clubId: club.id, seasonId: season.id }); + const homeTeam = await Team.create({ name: 'Harheimer TC II', clubId: club.id, leagueId: league.id, seasonId: season.id }); + const guestTeam = await Team.create({ name: 'SG Teststadt III', clubId: club.id, leagueId: league.id, seasonId: season.id }); + await Location.create({ name: 'Halle', address: 'Straße 1', city: 'Stadt', zip: '12345' }); + + const matchDate = new Date('2025-09-06T10:00:00Z'); + const matches = [ + { + date: matchDate, + time: '10:00', + homeTeamName: homeTeam.name, + guestTeamName: guestTeam.name, + code: 'ABC123DEF456', + clubId: club.id, + rawLine: 'Sa. 06.09.2025 10:00 Harheimer TC II SG Teststadt III ABC123DEF456' + } + ]; + + const createResult = await pdfParserService.saveMatchesToDatabase(matches, league.id); + expect(createResult.created).toBe(1); + expect(createResult.updated).toBe(0); + expect(await Match.count()).toBe(1); + + const updateMatches = [ + { + date: matchDate, + time: '10:00', + homeTeamName: homeTeam.name, + guestTeamName: guestTeam.name, + code: 'XYZ987654321', + clubId: club.id, + rawLine: 'Sa. 06.09.2025 10:00 Harheimer TC II SG Teststadt III XYZ987654321' + } + ]; + + const updateResult = await pdfParserService.saveMatchesToDatabase(updateMatches, league.id); + expect(updateResult.updated).toBe(1); + const storedMatch = await Match.findOne(); + expect(storedMatch.code).toBe('XYZ987654321'); + }); +}); diff --git a/backend/tests/permissionRoutes.test.js b/backend/tests/permissionRoutes.test.js index 19387be..4210aca 100644 --- a/backend/tests/permissionRoutes.test.js +++ b/backend/tests/permissionRoutes.test.js @@ -106,4 +106,54 @@ describe('Permission Routes', () => { const updated = await UserClub.findOne({ where: { userId: member.id, clubId: club.id } }); expect(updated.approved).toBe(false); }); + + it('ändert Rollen und benutzerdefinierte Berechtigungen', async () => { + const { user: owner, credentials } = await registerAndActivate('owner4@example.com'); + const { user: member } = await registerAndActivate('member4@example.com'); + const club = await Club.create({ name: 'Role Club' }); + + await UserClub.bulkCreate([ + { userId: owner.id, clubId: club.id, role: 'admin', approved: true, isOwner: true }, + { userId: member.id, clubId: club.id, role: 'member', approved: true }, + ]); + + const token = await loginAndGetToken(credentials); + + const roleResponse = await request(app) + .put(`/api/permissions/${club.id}/user/${member.id}/role`) + .set('Authorization', `Bearer ${token}`) + .send({ role: 'trainer' }); + + expect(roleResponse.status).toBe(200); + const roleUpdated = await UserClub.findOne({ where: { userId: member.id, clubId: club.id } }); + expect(roleUpdated.role).toBe('trainer'); + + const customPermissions = { diary: { read: true, write: true }, schedule: { read: true } }; + const permissionResponse = await request(app) + .put(`/api/permissions/${club.id}/user/${member.id}/permissions`) + .set('Authorization', `Bearer ${token}`) + .send({ permissions: customPermissions }); + + expect(permissionResponse.status).toBe(200); + await roleUpdated.reload(); + expect(roleUpdated.permissions.diary.write).toBe(true); + + const ownPermissions = await request(app) + .get(`/api/permissions/${club.id}`) + .set('Authorization', `Bearer ${token}`); + + expect(ownPermissions.status).toBe(200); + expect(ownPermissions.body.permissions.diary.write).toBe(true); + }); + + it('liefert 400 bei ungültiger Club-ID', async () => { + const { credentials } = await registerAndActivate('invalidclub@example.com'); + const token = await loginAndGetToken(credentials); + + const response = await request(app) + .get('/api/permissions/not-a-number') + .set('Authorization', `Bearer ${token}`); + + expect(response.status).toBe(400); + }); }); diff --git a/backend/tests/predefinedActivityRoutes.test.js b/backend/tests/predefinedActivityRoutes.test.js new file mode 100644 index 0000000..97c24e1 --- /dev/null +++ b/backend/tests/predefinedActivityRoutes.test.js @@ -0,0 +1,95 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import request from 'supertest'; + +vi.mock('../services/emailService.js', () => ({ + sendActivationEmail: vi.fn().mockResolvedValue(), +})); + +import app from './testApp.js'; +import sequelize from '../database.js'; +import '../models/index.js'; + +import User from '../models/User.js'; +import PredefinedActivity from '../models/PredefinedActivity.js'; + +const registerAndActivate = async (email) => { + const password = 'Test123!'; + await request(app).post('/api/auth/register').send({ email, password }); + const user = await User.findOne({ where: { email } }); + await user.update({ isActive: true }); + return { user, credentials: { email, password } }; +}; + +const loginAndGetToken = async (credentials) => { + const response = await request(app).post('/api/auth/login').send(credentials); + return response.body.token; +}; + +const authHeaders = (token) => ({ + Authorization: `Bearer ${token}`, + authcode: token, +}); + +describe('PredefinedActivity Routes', () => { + beforeEach(async () => { + await sequelize.sync({ force: true }); + }); + + it('erstellt, aktualisiert und listet Aktivitäten', async () => { + const { credentials } = await registerAndActivate('predefined@example.com'); + const token = await loginAndGetToken(credentials); + + const createResponse = await request(app) + .post('/api/predefined-activities') + .set(authHeaders(token)) + .send({ name: 'Aufwärmen', code: 'AW', description: 'Kurz' }); + + expect(createResponse.status).toBe(201); + const activityId = createResponse.body.id; + + const updateResponse = await request(app) + .put(`/api/predefined-activities/${activityId}`) + .set(authHeaders(token)) + .send({ name: 'Aufwärmen Lang', code: 'AWL' }); + + expect(updateResponse.status).toBe(200); + expect(updateResponse.body.code).toBe('AWL'); + + const listResponse = await request(app) + .get('/api/predefined-activities') + .set(authHeaders(token)); + + expect(listResponse.status).toBe(200); + expect(listResponse.body.some((entry) => entry.name === 'Aufwärmen Lang')).toBe(true); + }); + + it('sucht, merged und dedupliziert Aktivitäten', async () => { + const { credentials } = await registerAndActivate('predefined2@example.com'); + const token = await loginAndGetToken(credentials); + + const a1 = await PredefinedActivity.create({ name: 'Fangspiel', code: 'FANG' }); + const a2 = await PredefinedActivity.create({ name: 'Fangspiel', code: 'FANG2' }); + + const searchResponse = await request(app) + .get('/api/predefined-activities/search/query') + .set(authHeaders(token)) + .query({ q: 'FANG' }); + + expect(searchResponse.status).toBe(200); + expect(searchResponse.body.length).toBeGreaterThan(0); + + const mergeResponse = await request(app) + .post('/api/predefined-activities/merge') + .set(authHeaders(token)) + .send({ sourceId: a2.id, targetId: a1.id }); + + expect(mergeResponse.status).toBe(200); + expect(await PredefinedActivity.findByPk(a2.id)).toBeNull(); + + const dedupResponse = await request(app) + .post('/api/predefined-activities/deduplicate') + .set(authHeaders(token)); + + expect(dedupResponse.status).toBe(200); + }); +}); diff --git a/backend/tests/predefinedActivityService.test.js b/backend/tests/predefinedActivityService.test.js new file mode 100644 index 0000000..04b6a83 --- /dev/null +++ b/backend/tests/predefinedActivityService.test.js @@ -0,0 +1,89 @@ +import { describe, it, expect, beforeEach } from 'vitest'; + +import sequelize from '../database.js'; +import '../models/index.js'; + +import predefinedActivityService from '../services/predefinedActivityService.js'; +import PredefinedActivity from '../models/PredefinedActivity.js'; +import PredefinedActivityImage from '../models/PredefinedActivityImage.js'; +import DiaryDateActivity from '../models/DiaryDateActivity.js'; +import DiaryDate from '../models/DiaryDates.js'; +import Club from '../models/Club.js'; + +describe('predefinedActivityService', () => { + beforeEach(async () => { + await sequelize.sync({ force: true }); + }); + + it('erstellt und aktualisiert Aktivitäten mit Zeichnungsdaten', async () => { + const created = await predefinedActivityService.createPredefinedActivity({ + name: 'Aufwärmen', + code: 'AW', + description: 'Kurzes Warmup', + duration: 15, + drawingData: { lines: 3 }, + }); + + expect(created.name).toBe('Aufwärmen'); + expect(created.drawingData).toBe(JSON.stringify({ lines: 3 })); + + const updated = await predefinedActivityService.updatePredefinedActivity(created.id, { + name: 'Aufwärmen Intensiv', + code: 'AWI', + description: 'Intensives Warmup', + duration: 20, + drawingData: { lines: 5 }, + }); + + expect(updated.name).toBe('Aufwärmen Intensiv'); + expect(updated.code).toBe('AWI'); + }); + + it('sucht Aktivitäten anhand der Kürzel', async () => { + await PredefinedActivity.bulkCreate([ + { name: 'Fangspiel', code: 'FANG' }, + { name: 'Ballkontrolle', code: 'BALL' }, + { name: 'Freies Spiel', code: null }, + ]); + + const result = await predefinedActivityService.searchPredefinedActivities('FA'); + expect(result).toHaveLength(1); + expect(result[0].code).toBe('FANG'); + }); + + it('führt Aktivitäten zusammen und passt Referenzen an', async () => { + const club = await Club.create({ name: 'Aktivitätsclub' }); + const source = await PredefinedActivity.create({ name: 'Topspin', code: 'TS' }); + const target = await PredefinedActivity.create({ name: 'Topspin Verbesserte', code: 'TSV' }); + + const diaryDate = await DiaryDate.create({ date: new Date('2025-09-01'), clubId: club.id }); + const diaryDateActivity = await DiaryDateActivity.create({ + diaryDateId: diaryDate.id, + predefinedActivityId: source.id, + isTimeblock: false, + orderId: 1, + }); + await PredefinedActivityImage.create({ predefinedActivityId: source.id, imagePath: 'uploads/test.png' }); + + await predefinedActivityService.mergeActivities(source.id, target.id); + + await diaryDateActivity.reload(); + expect(diaryDateActivity.predefinedActivityId).toBe(target.id); + const images = await PredefinedActivityImage.findAll({ where: { predefinedActivityId: target.id } }); + expect(images).toHaveLength(1); + expect(await PredefinedActivity.findByPk(source.id)).toBeNull(); + }); + + it('dedupliziert Aktivitäten nach Namen', async () => { + await PredefinedActivity.bulkCreate([ + { name: 'Schmetterball', code: 'SM1' }, + { name: 'schmetterball', code: 'SM2' }, + { name: 'Aufschlag', code: 'AS' }, + ]); + + const result = await predefinedActivityService.deduplicateActivities(); + expect(result.mergedCount).toBe(1); + const remaining = await PredefinedActivity.findAll(); + expect(remaining).toHaveLength(2); + }); +}); diff --git a/backend/tests/schedulerService.test.js b/backend/tests/schedulerService.test.js new file mode 100644 index 0000000..b472b0c --- /dev/null +++ b/backend/tests/schedulerService.test.js @@ -0,0 +1,120 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +const mockJobs = []; + +vi.mock('node-cron', () => { + const schedule = vi.fn((expression, handler) => { + const job = { + expression, + handler, + start: vi.fn(), + stop: vi.fn(), + }; + mockJobs.push(job); + return job; + }); + + return { + __esModule: true, + default: { + schedule, + __mockJobs: mockJobs, + }, + }; +}); + +vi.mock('../services/autoUpdateRatingsService.js', () => ({ + __esModule: true, + default: { + executeAutomaticUpdates: vi.fn().mockResolvedValue({ updatedCount: 3 }), + }, +})); + +vi.mock('../services/autoFetchMatchResultsService.js', () => ({ + __esModule: true, + default: { + executeAutomaticFetch: vi.fn().mockResolvedValue({ fetchedCount: 4 }), + }, +})); + +vi.mock('../services/apiLogService.js', () => ({ + __esModule: true, + default: { + logSchedulerExecution: vi.fn().mockResolvedValue(), + }, +})); + +import schedulerService from '../services/schedulerService.js'; +import cron from 'node-cron'; +import autoUpdateRatingsService from '../services/autoUpdateRatingsService.js'; +import autoFetchMatchResultsService from '../services/autoFetchMatchResultsService.js'; +import apiLogService from '../services/apiLogService.js'; + +describe('schedulerService', () => { + beforeEach(() => { + schedulerService.stop(); + mockJobs.length = 0; + (cron.schedule as unknown as vi.Mock).mockClear(); + (autoUpdateRatingsService.executeAutomaticUpdates as vi.Mock).mockClear(); + (autoFetchMatchResultsService.executeAutomaticFetch as vi.Mock).mockClear(); + (apiLogService.logSchedulerExecution as vi.Mock).mockClear(); + }); + + afterEach(() => { + schedulerService.stop(); + }); + + it('startet Scheduler und registriert Cron-Jobs genau einmal', () => { + schedulerService.start(); + expect((cron.schedule as unknown as vi.Mock).mock.calls).toHaveLength(2); + expect(schedulerService.getStatus()).toMatchObject({ isRunning: true, jobs: ['ratingUpdates', 'matchResults'] }); + + schedulerService.start(); + expect((cron.schedule as unknown as vi.Mock).mock.calls).toHaveLength(2); + }); + + it('stoppt Scheduler und ruft stop für jede Aufgabe auf', () => { + schedulerService.start(); + const jobsSnapshot = [...mockJobs]; + schedulerService.stop(); + + jobsSnapshot.forEach((job) => { + expect(job.stop).toHaveBeenCalled(); + }); + expect(schedulerService.getStatus().isRunning).toBe(false); + }); + + it('triggert manuelle Updates und Fetches', async () => { + const ratings = await schedulerService.triggerRatingUpdates(); + expect(ratings.success).toBe(true); + expect(autoUpdateRatingsService.executeAutomaticUpdates).toHaveBeenCalled(); + + const matches = await schedulerService.triggerMatchResultsFetch(); + expect(matches.success).toBe(true); + expect(autoFetchMatchResultsService.executeAutomaticFetch).toHaveBeenCalled(); + }); + + it('führt geplante Jobs aus und protokolliert Ergebnisse', async () => { + schedulerService.start(); + + const [ratingJob, matchJob] = mockJobs; + + await ratingJob.handler(); + expect(apiLogService.logSchedulerExecution).toHaveBeenCalledWith( + 'rating_updates', + true, + expect.any(Object), + expect.any(Number), + null + ); + + await matchJob.handler(); + expect(apiLogService.logSchedulerExecution).toHaveBeenCalledWith( + 'match_results', + true, + expect.any(Object), + expect.any(Number), + null + ); + }); +}); diff --git a/backend/tests/testApp.js b/backend/tests/testApp.js index 7d27c41..37ecc33 100644 --- a/backend/tests/testApp.js +++ b/backend/tests/testApp.js @@ -17,6 +17,7 @@ import memberActivityRoutes from '../routes/memberActivityRoutes.js'; import memberRoutes from '../routes/memberRoutes.js'; import memberNoteRoutes from '../routes/memberNoteRoutes.js'; import memberTransferConfigRoutes from '../routes/memberTransferConfigRoutes.js'; +import myTischtennisRoutes from '../routes/myTischtennisRoutes.js'; const app = express(); @@ -39,6 +40,7 @@ app.use('/api/member-activities', memberActivityRoutes); app.use('/api/members', memberRoutes); app.use('/api/member-notes', memberNoteRoutes); app.use('/api/member-transfer-config', memberTransferConfigRoutes); +app.use('/api/mytischtennis', myTischtennisRoutes); app.use((err, req, res, next) => { const status = err?.status || err?.statusCode || 500;