From 20f204e70b1621b2b5e147d3aeb230ec963456c3 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Tue, 11 Nov 2025 08:29:18 +0100 Subject: [PATCH] Enhance diary note and tag management in backend controllers Updated the diaryNoteController to require diaryDateId in note creation and improved error handling for missing fields. Enhanced the createTag function in diaryTagController to validate tag names and return appropriate responses. Additionally, refined the deleteTag function to ensure proper error handling when a tag is not found. These changes improve the robustness and usability of the diary management features. --- backend/controllers/diaryNoteController.js | 18 +- backend/controllers/diaryTagController.js | 21 +- .../services/autoFetchMatchResultsService.js | 3 +- backend/services/autoUpdateRatingsService.js | 3 +- backend/tests/diaryDateActivityRoutes.test.js | 117 +++++++++++ .../tests/diaryDateActivityService.test.js | 182 ++++++++++++++++++ .../tests/diaryMemberActivityRoutes.test.js | 129 +++++++++++++ backend/tests/diaryNoteRoutes.test.js | 114 +++++++++++ backend/tests/diaryService.test.js | 10 + backend/tests/diaryTagRoutes.test.js | 80 ++++++++ backend/tests/groupRoutes.test.js | 78 ++++++++ backend/tests/groupService.test.js | 57 ++++++ backend/tests/matchRoutes.test.js | 85 ++++++++ backend/tests/matchService.test.js | 83 ++++++++ backend/tests/memberActivityRoutes.test.js | 131 +++++++++++++ backend/tests/memberNoteRoutes.test.js | 93 +++++++++ backend/tests/memberRoutes.test.js | 93 +++++++++ backend/tests/memberService.test.js | 75 ++++++++ backend/tests/setupTestEnv.js | 1 + backend/tests/testApp.js | 18 ++ frontend/src/utils/debounce.js | 10 + frontend/src/views/DiaryView.vue | 5 - .../src/views/MemberTransferSettingsView.vue | 2 +- frontend/src/views/ScheduleView.vue | 89 ++++++--- frontend/src/views/TournamentsView.vue | 13 +- 25 files changed, 1451 insertions(+), 59 deletions(-) create mode 100644 backend/tests/diaryDateActivityRoutes.test.js create mode 100644 backend/tests/diaryDateActivityService.test.js create mode 100644 backend/tests/diaryMemberActivityRoutes.test.js create mode 100644 backend/tests/diaryNoteRoutes.test.js create mode 100644 backend/tests/diaryTagRoutes.test.js create mode 100644 backend/tests/groupRoutes.test.js create mode 100644 backend/tests/groupService.test.js create mode 100644 backend/tests/matchRoutes.test.js create mode 100644 backend/tests/matchService.test.js create mode 100644 backend/tests/memberActivityRoutes.test.js create mode 100644 backend/tests/memberNoteRoutes.test.js create mode 100644 backend/tests/memberRoutes.test.js create mode 100644 backend/tests/memberService.test.js create mode 100644 frontend/src/utils/debounce.js diff --git a/backend/controllers/diaryNoteController.js b/backend/controllers/diaryNoteController.js index dee309c..590d1a0 100644 --- a/backend/controllers/diaryNoteController.js +++ b/backend/controllers/diaryNoteController.js @@ -18,16 +18,24 @@ export const getNotes = async (req, res) => { export const createNote = async (req, res) => { try { - const { memberId, content, tags } = req.body; - const newNote = await DiaryNote.create({ memberId, content }); - if (tags && tags.length > 0) { + const { memberId, diaryDateId, content, tags } = req.body; + + if (!memberId || !diaryDateId || !content) { + return res.status(400).json({ error: 'memberId, diaryDateId und content sind erforderlich.' }); + } + + const newNote = await DiaryNote.create({ memberId, diaryDateId, content }); + + if (Array.isArray(tags) && tags.length > 0 && typeof newNote.addTags === 'function') { const tagInstances = await DiaryTag.findAll({ where: { id: tags } }); await newNote.addTags(tagInstances); } + const noteWithTags = await DiaryNote.findByPk(newNote.id, { - include: [{ model: DiaryTag, as: 'tags' }], + include: [{ model: DiaryTag, as: 'tags', required: false }], }); - res.status(201).json(noteWithTags); + + res.status(201).json(noteWithTags ?? newNote); } catch (error) { res.status(500).json({ error: 'Error creating note' }); } diff --git a/backend/controllers/diaryTagController.js b/backend/controllers/diaryTagController.js index 422e6b7..c0249d5 100644 --- a/backend/controllers/diaryTagController.js +++ b/backend/controllers/diaryTagController.js @@ -1,6 +1,5 @@ import { DiaryTag, DiaryDateTag } from '../models/index.js'; -import { devLog } from '../utils/logger.js'; export const getTags = async (req, res) => { try { const tags = await DiaryTag.findAll(); @@ -13,9 +12,12 @@ export const getTags = async (req, res) => { export const createTag = async (req, res) => { try { const { name } = req.body; - devLog(name); - const newTag = await DiaryTag.findOrCreate({ where: { name }, defaults: { name } }); - res.status(201).json(newTag); + if (!name) { + return res.status(400).json({ error: 'Der Name des Tags ist erforderlich.' }); + } + + const [tag, created] = await DiaryTag.findOrCreate({ where: { name }, defaults: { name } }); + res.status(created ? 201 : 200).json(tag); } catch (error) { res.status(500).json({ error: 'Error creating tag' }); } @@ -24,9 +26,14 @@ export const createTag = async (req, res) => { export const deleteTag = async (req, res) => { try { const { tagId } = req.params; - const { authcode: userToken } = req.headers; - const { clubId } = req.params; - await diaryService.removeTagFromDiaryDate(userToken, clubId, tagId); + + await DiaryDateTag.destroy({ where: { tagId } }); + const deleted = await DiaryTag.destroy({ where: { id: tagId } }); + + if (!deleted) { + return res.status(404).json({ error: 'Tag nicht gefunden' }); + } + res.status(200).json({ message: 'Tag deleted' }); } catch (error) { console.error('[deleteTag] - Error:', error); diff --git a/backend/services/autoFetchMatchResultsService.js b/backend/services/autoFetchMatchResultsService.js index 170ddf5..66d3e78 100644 --- a/backend/services/autoFetchMatchResultsService.js +++ b/backend/services/autoFetchMatchResultsService.js @@ -26,7 +26,7 @@ class AutoFetchMatchResultsService { autoUpdateRatings: true, // Nutze das gleiche Flag savePassword: true // Must have saved password }, - attributes: ['id', 'userId', 'email', 'encryptedPassword', 'accessToken', 'expiresAt', 'cookie'] + attributes: ['id', 'userId', 'email', 'savePassword', 'encryptedPassword', 'accessToken', 'expiresAt', 'cookie'] }); devLog(`Found ${accounts.length} accounts with auto-updates enabled for match results`); @@ -86,6 +86,7 @@ class AutoFetchMatchResultsService { account.refreshToken = loginResult.refreshToken; account.expiresAt = loginResult.expiresAt; account.cookie = loginResult.cookie; + account.savePassword = true; // ensure flag persists when saving await account.save(); devLog(`Successfully re-logged in for ${account.email}`); diff --git a/backend/services/autoUpdateRatingsService.js b/backend/services/autoUpdateRatingsService.js index 80fbb6f..387b06d 100644 --- a/backend/services/autoUpdateRatingsService.js +++ b/backend/services/autoUpdateRatingsService.js @@ -20,7 +20,7 @@ class AutoUpdateRatingsService { autoUpdateRatings: true, savePassword: true // Must have saved password }, - attributes: ['id', 'userId', 'email', 'encryptedPassword', 'accessToken', 'expiresAt', 'cookie'] + attributes: ['id', 'userId', 'email', 'savePassword', 'encryptedPassword', 'accessToken', 'expiresAt', 'cookie'] }); devLog(`Found ${accounts.length} accounts with auto-updates enabled`); @@ -78,6 +78,7 @@ class AutoUpdateRatingsService { account.refreshToken = loginResult.refreshToken; account.expiresAt = loginResult.expiresAt; account.cookie = loginResult.cookie; + account.savePassword = true; // Ensure flag persists when saving limited attributes await account.save(); devLog(`Successfully re-logged in for ${account.email}`); diff --git a/backend/tests/diaryDateActivityRoutes.test.js b/backend/tests/diaryDateActivityRoutes.test.js new file mode 100644 index 0000000..37bf017 --- /dev/null +++ b/backend/tests/diaryDateActivityRoutes.test.js @@ -0,0 +1,117 @@ +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 DiaryDate from '../models/DiaryDates.js'; +import Group from '../models/Group.js'; +import GroupActivity from '../models/GroupActivity.js'; +import DiaryDateActivity from '../models/DiaryDateActivity.js'; +import User from '../models/User.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('DiaryDateActivity Routes', () => { + beforeEach(async () => { + await sequelize.sync({ force: true }); + }); + + it('verwaltet Aktivitäten einschließlich Reihenfolge und Gruppenaktionen', async () => { + const { credentials } = await registerAndActivate('dda-routes@example.com'); + const token = await loginAndGetToken(credentials); + + const clubResponse = await request(app) + .post('/api/clubs') + .set(authHeaders(token)) + .send({ name: 'Activity Club' }); + const clubId = clubResponse.body.id; + + const diaryDateResponse = await request(app) + .post(`/api/diary/${clubId}`) + .set(authHeaders(token)) + .send({ date: '2026-02-01' }); + const diaryDateId = diaryDateResponse.body.id; + + const timeblockResponse = await request(app) + .post(`/api/diary-date-activities/${clubId}/`) + .set(authHeaders(token)) + .send({ diaryDateId, activity: 'Warmup', isTimeblock: true }); + const timeblockId = timeblockResponse.body.id; + + const activityResponse = await request(app) + .post(`/api/diary-date-activities/${clubId}/`) + .set(authHeaders(token)) + .send({ diaryDateId, activity: 'Topspin üben', duration: '30' }); + const activityId = activityResponse.body.id; + + const updateResponse = await request(app) + .put(`/api/diary-date-activities/${clubId}/${activityId}`) + .set(authHeaders(token)) + .send({ customActivityName: 'Topspin intensiv', duration: 35 }); + + expect(updateResponse.status).toBe(200); + expect(updateResponse.body.predefinedActivityId).toBeTruthy(); + + const reorderResponse = await request(app) + .put(`/api/diary-date-activities/${clubId}/${activityId}/order`) + .set(authHeaders(token)) + .send({ orderId: 1 }); + + expect(reorderResponse.status).toBe(200); + const persisted = await DiaryDateActivity.findByPk(activityId); + expect(persisted.orderId).toBe(1); + + const group = await Group.create({ diaryDateId, name: 'Gruppe A' }); + + const addGroupResponse = await request(app) + .post('/api/diary-date-activities/group') + .set(authHeaders(token)) + .send({ clubId, diaryDateId, groupId: group.id, activity: 'Match', timeblockId }); + + expect(addGroupResponse.status).toBe(201); + const groupActivityId = addGroupResponse.body.id; + + const listResponse = await request(app) + .get(`/api/diary-date-activities/${clubId}/${diaryDateId}`) + .set(authHeaders(token)); + + expect(listResponse.status).toBe(200); + expect(listResponse.body.length).toBeGreaterThanOrEqual(2); + + const deleteGroupResponse = await request(app) + .delete(`/api/diary-date-activities/group/${clubId}/${groupActivityId}`) + .set(authHeaders(token)); + + expect(deleteGroupResponse.status).toBe(200); + const groupExists = await GroupActivity.findByPk(groupActivityId); + expect(groupExists).toBeNull(); + + const deleteResponse = await request(app) + .delete(`/api/diary-date-activities/${clubId}/${activityId}`) + .set(authHeaders(token)); + + expect(deleteResponse.status).toBe(200); + }); +}); diff --git a/backend/tests/diaryDateActivityService.test.js b/backend/tests/diaryDateActivityService.test.js new file mode 100644 index 0000000..80b94a2 --- /dev/null +++ b/backend/tests/diaryDateActivityService.test.js @@ -0,0 +1,182 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +vi.mock('../utils/userUtils.js', async () => { + const actual = await vi.importActual('../utils/userUtils.js'); + return { + ...actual, + checkAccess: vi.fn().mockResolvedValue(true), + }; +}); + +import sequelize from '../database.js'; +import '../models/index.js'; + +import diaryDateActivityService from '../services/diaryDateActivityService.js'; +import { checkAccess } from '../utils/userUtils.js'; +import Club from '../models/Club.js'; +import DiaryDate from '../models/DiaryDates.js'; +import DiaryDateActivity from '../models/DiaryDateActivity.js'; +import PredefinedActivity from '../models/PredefinedActivity.js'; +import PredefinedActivityImage from '../models/PredefinedActivityImage.js'; +import Group from '../models/Group.js'; +import GroupActivity from '../models/GroupActivity.js'; + +const createClubAndDate = async () => { + const club = await Club.create({ name: 'Service Club' }); + const diaryDate = await DiaryDate.create({ date: '2026-01-01', clubId: club.id }); + return { club, diaryDate }; +}; + +describe('diaryDateActivityService', () => { + beforeEach(async () => { + await sequelize.sync({ force: true }); + vi.clearAllMocks(); + }); + + it('erstellt Aktivitäten mit automatisch berechneter Reihenfolge', async () => { + const { club, diaryDate } = await createClubAndDate(); + + const first = await diaryDateActivityService.createActivity('token', club.id, { + diaryDateId: diaryDate.id, + activity: 'Warmup', + duration: '30', + isTimeblock: false, + }); + + const second = await diaryDateActivityService.createActivity('token', club.id, { + diaryDateId: diaryDate.id, + activity: 'Drills', + duration: '45', + isTimeblock: false, + }); + + expect(checkAccess).toHaveBeenCalledTimes(2); + expect(first.orderId).toBe(1); + expect(second.orderId).toBe(2); + const all = await DiaryDateActivity.findAll({ where: { diaryDateId: diaryDate.id } }); + expect(all).toHaveLength(2); + }); + + it('aktualisiert Aktivitäten und legt neue vordefinierte Aktivitäten an', async () => { + const { club, diaryDate } = await createClubAndDate(); + const activity = await diaryDateActivityService.createActivity('token', club.id, { + diaryDateId: diaryDate.id, + activity: 'Blocken', + duration: '20', + isTimeblock: false, + }); + + const updated = await diaryDateActivityService.updateActivity('token', club.id, activity.id, { + customActivityName: 'Topspin', + duration: 25, + }); + + const predefined = await PredefinedActivity.findOne({ where: { name: 'Topspin' } }); + + expect(updated.predefinedActivityId).toBe(predefined.id); + expect(predefined.duration).toBe(25); + }); + + it('ändert die Reihenfolge und verschiebt Nachbarn korrekt', async () => { + const { club, diaryDate } = await createClubAndDate(); + const first = await diaryDateActivityService.createActivity('token', club.id, { + diaryDateId: diaryDate.id, + activity: 'A', + }); + const second = await diaryDateActivityService.createActivity('token', club.id, { + diaryDateId: diaryDate.id, + activity: 'B', + }); + const third = await diaryDateActivityService.createActivity('token', club.id, { + diaryDateId: diaryDate.id, + activity: 'C', + }); + + await diaryDateActivityService.updateActivityOrder('token', club.id, third.id, 1); + + const reloaded = await DiaryDateActivity.findAll({ + where: { diaryDateId: diaryDate.id }, + order: [['orderId', 'ASC']], + }); + + expect(reloaded[0].id).toBe(third.id); + expect(reloaded.map((item) => item.orderId)).toEqual([1, 2, 3]); + }); + + it('liefert Aktivitäten mit Bild-Links und Gruppendaten', async () => { + const { club, diaryDate } = await createClubAndDate(); + const predefined = await PredefinedActivity.create({ name: 'Vorhand', code: 'FH' }); + const activity = await DiaryDateActivity.create({ + diaryDateId: diaryDate.id, + predefinedActivityId: predefined.id, + orderId: 1, + isTimeblock: false, + }); + await PredefinedActivityImage.create({ + predefinedActivityId: predefined.id, + drawingData: JSON.stringify({ circles: 2 }), + mimeType: 'image/png', + fileName: 'test.png', + }); + + const group = await Group.create({ diaryDateId: diaryDate.id, name: 'Gruppe 1' }); + const groupPredefined = await PredefinedActivity.create({ name: 'Rally', code: 'RL' }); + await GroupActivity.create({ + diaryDateActivity: activity.id, + groupId: group.id, + customActivity: groupPredefined.id, + }); + + const result = await diaryDateActivityService.getActivities('token', club.id, diaryDate.id); + + expect(result).toHaveLength(1); + expect(result[0].predefinedActivity.imageUrl).toContain(`/api/predefined-activities/${predefined.id}/image/`); + expect(result[0].groupActivities).toHaveLength(1); + }); + + it('fügt Gruppenaktivitäten in Zeitblöcke ein', async () => { + const { club, diaryDate } = await createClubAndDate(); + const timeblock = await DiaryDateActivity.create({ + diaryDateId: diaryDate.id, + isTimeblock: true, + orderId: 1, + }); + const group = await Group.create({ diaryDateId: diaryDate.id, name: 'Gruppe 2' }); + + const created = await diaryDateActivityService.addGroupActivity('token', club.id, diaryDate.id, group.id, 'Abschlussspiel', timeblock.id); + + expect(created.diaryDateActivity).toBe(timeblock.id); + expect(created.groupId).toBe(group.id); + }); + + it('löscht Aktivitäten und zugehörige Gruppeneinträge', async () => { + const { club, diaryDate } = await createClubAndDate(); + const activity = await diaryDateActivityService.createActivity('token', club.id, { + diaryDateId: diaryDate.id, + activity: 'Auslaufen', + }); + + await diaryDateActivityService.deleteActivity('token', club.id, activity.id); + const remaining = await DiaryDateActivity.findByPk(activity.id); + expect(remaining).toBeNull(); + }); + + it('löscht Gruppenaktivitäten', async () => { + const { club, diaryDate } = await createClubAndDate(); + const timeblock = await DiaryDateActivity.create({ + diaryDateId: diaryDate.id, + isTimeblock: true, + orderId: 1, + }); + const group = await Group.create({ diaryDateId: diaryDate.id, name: 'Gruppe 3' }); + const created = await GroupActivity.create({ + diaryDateActivity: timeblock.id, + groupId: group.id, + customActivity: null, + }); + + await diaryDateActivityService.deleteGroupActivity('token', club.id, created.id); + const exists = await GroupActivity.findByPk(created.id); + expect(exists).toBeNull(); + }); +}); diff --git a/backend/tests/diaryMemberActivityRoutes.test.js b/backend/tests/diaryMemberActivityRoutes.test.js new file mode 100644 index 0000000..eecf4a7 --- /dev/null +++ b/backend/tests/diaryMemberActivityRoutes.test.js @@ -0,0 +1,129 @@ +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 Member from '../models/Member.js'; +import Participant from '../models/Participant.js'; +import DiaryDate from '../models/DiaryDates.js'; +import DiaryDateActivity from '../models/DiaryDateActivity.js'; +import DiaryMemberActivity from '../models/DiaryMemberActivity.js'; +import User from '../models/User.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, +}); + +const createMember = async (clubId, firstName, lastName, email) => { + return Member.create({ + firstName, + lastName, + phone: '0123456789', + street: 'Teststraße 1', + city: 'Teststadt', + postalCode: '12345', + email, + clubId, + birthDate: '2000-01-01', + active: true, + testMembership: false, + picsInInternetAllowed: false, + gender: 'female', + }); +}; + +describe('DiaryMemberActivity Routes', () => { + beforeEach(async () => { + await sequelize.sync({ force: true }); + }); + + it('fügt Teilnehmer zu Aktivitäten hinzu, listet und entfernt sie wieder', async () => { + const { credentials } = await registerAndActivate('dma@example.com'); + const token = await loginAndGetToken(credentials); + + const clubResponse = await request(app) + .post('/api/clubs') + .set(authHeaders(token)) + .send({ name: 'Member Activity Club' }); + const clubId = clubResponse.body.id; + + const diaryDateResponse = await request(app) + .post(`/api/diary/${clubId}`) + .set(authHeaders(token)) + .send({ date: '2026-03-01' }); + const diaryDateId = diaryDateResponse.body.id; + + const activityResponse = await request(app) + .post(`/api/diary-date-activities/${clubId}/`) + .set(authHeaders(token)) + .send({ diaryDateId, activity: 'Koordination', isTimeblock: false }); + const activityId = activityResponse.body.id; + + const member = await createMember(clubId, 'Anna', 'Trainer', 'anna@example.com'); + const participant = await Participant.create({ diaryDateId, memberId: member.id }); + + const addResponse = await request(app) + .post(`/api/diary-member-activities/${clubId}/${activityId}`) + .set(authHeaders(token)) + .send({ participantIds: [participant.id] }); + + expect(addResponse.status).toBe(201); + expect(addResponse.body).toHaveLength(1); + + const listResponse = await request(app) + .get(`/api/diary-member-activities/${clubId}/${activityId}`) + .set(authHeaders(token)); + + expect(listResponse.status).toBe(200); + expect(listResponse.body[0].participantId).toBe(participant.id); + + const deleteResponse = await request(app) + .delete(`/api/diary-member-activities/${clubId}/${activityId}/${participant.id}`) + .set(authHeaders(token)); + + expect(deleteResponse.status).toBe(200); + const remaining = await DiaryMemberActivity.findAll({ where: { diaryDateActivityId: activityId } }); + expect(remaining).toHaveLength(0); + }); + + it('prüft ungültige Nutzlasten', async () => { + const { credentials } = await registerAndActivate('dma-invalid@example.com'); + const token = await loginAndGetToken(credentials); + + const clubResponse = await request(app) + .post('/api/clubs') + .set(authHeaders(token)) + .send({ name: 'Invalid Payload Club' }); + const clubId = clubResponse.body.id; + + const diaryDate = await DiaryDate.create({ date: '2026-04-01', clubId }); + const activity = await DiaryDateActivity.create({ diaryDateId: diaryDate.id, orderId: 1, isTimeblock: false }); + + const response = await request(app) + .post(`/api/diary-member-activities/${clubId}/${activity.id}`) + .set(authHeaders(token)) + .send({ participantIds: 'nicht-array' }); + + expect(response.status).toBe(400); + }); +}); diff --git a/backend/tests/diaryNoteRoutes.test.js b/backend/tests/diaryNoteRoutes.test.js new file mode 100644 index 0000000..50b23ee --- /dev/null +++ b/backend/tests/diaryNoteRoutes.test.js @@ -0,0 +1,114 @@ +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 Member from '../models/Member.js'; +import DiaryDate from '../models/DiaryDates.js'; +import DiaryNote from '../models/DiaryNote.js'; +import User from '../models/User.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, +}); + +const createMember = (clubId, email) => + Member.create({ + firstName: 'Note', + lastName: 'Tester', + phone: '0123456789', + street: 'Straße 1', + city: 'Stadt', + postalCode: '12345', + email, + clubId, + birthDate: '2000-01-01', + active: true, + testMembership: false, + picsInInternetAllowed: false, + gender: 'female', + }); + +describe('DiaryNote Routes', () => { + beforeEach(async () => { + await sequelize.sync({ force: true }); + }); + + it('erstellt, listet und löscht Notizen', async () => { + const { credentials } = await registerAndActivate('diarynote@example.com'); + const token = await loginAndGetToken(credentials); + + const clubResponse = await request(app) + .post('/api/clubs') + .set(authHeaders(token)) + .send({ name: 'Notiz Club' }); + const clubId = clubResponse.body.id; + + const diaryDateResponse = await request(app) + .post(`/api/diary/${clubId}`) + .set(authHeaders(token)) + .send({ date: '2026-05-01' }); + const diaryDateId = diaryDateResponse.body.id; + + const member = await createMember(clubId, 'note.member@example.com'); + + const createResponse = await request(app) + .post('/api/diary-notes') + .set(authHeaders(token)) + .send({ memberId: member.id, diaryDateId, content: 'Gute Einheit' }); + + expect(createResponse.status).toBe(201); + expect(createResponse.body.content).toBe('Gute Einheit'); + + const listResponse = await request(app) + .get('/api/diary-notes') + .set(authHeaders(token)) + .query({ diaryDateId, memberId: member.id }); + + expect(listResponse.status).toBe(200); + expect(listResponse.body).toHaveLength(1); + expect(listResponse.body[0].content).toBe('Gute Einheit'); + + const noteId = createResponse.body.id; + + const deleteResponse = await request(app) + .delete(`/api/diary-notes/${noteId}`) + .set(authHeaders(token)); + + expect(deleteResponse.status).toBe(200); + const remaining = await DiaryNote.findByPk(noteId); + expect(remaining).toBeNull(); + }); + + it('validiert Pflichtfelder beim Erstellen', async () => { + const { credentials } = await registerAndActivate('diarynote-invalid@example.com'); + const token = await loginAndGetToken(credentials); + + const response = await request(app) + .post('/api/diary-notes') + .set(authHeaders(token)) + .send({ content: 'Fehlende Felder' }); + + expect(response.status).toBe(400); + }); +}); diff --git a/backend/tests/diaryService.test.js b/backend/tests/diaryService.test.js index 029f984..cb42687 100644 --- a/backend/tests/diaryService.test.js +++ b/backend/tests/diaryService.test.js @@ -50,6 +50,10 @@ describe('diaryService', () => { await expect( diaryService.createDateForClub('token', club.id, '2025-02-03', '20:00', '19:00') ).rejects.toThrow('Training start time must be before training end time'); + + await expect( + diaryService.createDateForClub('token', club.id, 'ungültig', '20:00', '21:00') + ).rejects.toThrow('Invalid date format'); }); it('aktualisiert Trainingszeiten und wirft Fehler bei fehlendem Eintrag', async () => { @@ -121,4 +125,10 @@ describe('diaryService', () => { const exists = await DiaryDate.findByPk(date.id); expect(exists).toBeNull(); }); + + it('meldet Fehler bei fehlenden Clubs oder Einträgen', async () => { + await expect(diaryService.getDatesForClub('token', 9999)).rejects.toThrow('Club not found'); + const club = await Club.create({ name: '404 Club' }); + await expect(diaryService.removeDateForClub('token', club.id, 9999)).rejects.toThrow('Diary entry not found'); + }); }); diff --git a/backend/tests/diaryTagRoutes.test.js b/backend/tests/diaryTagRoutes.test.js new file mode 100644 index 0000000..f631b5e --- /dev/null +++ b/backend/tests/diaryTagRoutes.test.js @@ -0,0 +1,80 @@ +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 DiaryTag from '../models/DiaryTag.js'; +import DiaryDateTag from '../models/DiaryDateTag.js'; +import DiaryDate from '../models/DiaryDates.js'; +import Club from '../models/Club.js'; +import User from '../models/User.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('DiaryTag Routes', () => { + beforeEach(async () => { + await sequelize.sync({ force: true }); + }); + + it('erstellt und listet Tags', async () => { + const { credentials } = await registerAndActivate('diarytag@example.com'); + const token = await loginAndGetToken(credentials); + + const createResponse = await request(app) + .post('/api/diary-tags') + .set(authHeaders(token)) + .send({ name: 'Technik' }); + + expect(createResponse.status).toBe(201); + expect(createResponse.body.name).toBe('Technik'); + + const listResponse = await request(app) + .get('/api/diary-tags') + .set(authHeaders(token)); + + expect(listResponse.status).toBe(200); + expect(listResponse.body).toHaveLength(1); + }); + + it('löscht Tags inklusive Zuordnungen', async () => { + const { credentials } = await registerAndActivate('diarytag-delete@example.com'); + const token = await loginAndGetToken(credentials); + + const club = await Club.create({ name: 'Tag Club' }); + const diaryDate = await DiaryDate.create({ date: '2026-06-01', clubId: club.id }); + const tag = await DiaryTag.create({ name: 'Ausdauer' }); + await DiaryDateTag.create({ diaryDateId: diaryDate.id, tagId: tag.id }); + + const deleteResponse = await request(app) + .delete(`/api/diary-tags/${tag.id}`) + .set(authHeaders(token)); + + expect(deleteResponse.status).toBe(200); + const tagExists = await DiaryTag.findByPk(tag.id); + expect(tagExists).toBeNull(); + const relations = await DiaryDateTag.findAll({ where: { tagId: tag.id } }); + expect(relations).toHaveLength(0); + }); +}); diff --git a/backend/tests/groupRoutes.test.js b/backend/tests/groupRoutes.test.js new file mode 100644 index 0000000..04d658e --- /dev/null +++ b/backend/tests/groupRoutes.test.js @@ -0,0 +1,78 @@ +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 DiaryDate from '../models/DiaryDates.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('Group Routes', () => { + beforeEach(async () => { + await sequelize.sync({ force: true }); + }); + + it('erstellt, listet und aktualisiert Gruppen', async () => { + const { credentials } = await registerAndActivate('groups@example.com'); + const token = await loginAndGetToken(credentials); + + const clubResponse = await request(app) + .post('/api/clubs') + .set(authHeaders(token)) + .send({ name: 'Group Route Club' }); + const clubId = clubResponse.body.id; + + const diaryDateResponse = await request(app) + .post(`/api/diary/${clubId}`) + .set(authHeaders(token)) + .send({ date: '2026-08-01' }); + const diaryDateId = diaryDateResponse.body.id; + + const createResponse = await request(app) + .post('/api/groups') + .set(authHeaders(token)) + .send({ clubid: clubId, dateid: diaryDateId, name: 'Gruppe 1', lead: 'Coach' }); + + expect(createResponse.status).toBe(201); + const groupId = createResponse.body.id; + + const listResponse = await request(app) + .get(`/api/groups/${clubId}/${diaryDateId}`) + .set(authHeaders(token)); + + expect(listResponse.status).toBe(200); + expect(listResponse.body).toHaveLength(1); + expect(listResponse.body[0].name).toBe('Gruppe 1'); + + const updateResponse = await request(app) + .put(`/api/groups/${groupId}`) + .set(authHeaders(token)) + .send({ clubid: clubId, dateid: diaryDateId, name: 'Gruppe 1', lead: 'Neue Leitung' }); + + expect(updateResponse.status).toBe(200); + expect(updateResponse.body.lead).toBe('Neue Leitung'); + }); +}); diff --git a/backend/tests/groupService.test.js b/backend/tests/groupService.test.js new file mode 100644 index 0000000..19ef650 --- /dev/null +++ b/backend/tests/groupService.test.js @@ -0,0 +1,57 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +vi.mock('../utils/userUtils.js', async () => { + const actual = await vi.importActual('../utils/userUtils.js'); + return { + ...actual, + checkAccess: vi.fn().mockResolvedValue(true), + }; +}); + +import sequelize from '../database.js'; +import '../models/index.js'; + +import groupService from '../services/groupService.js'; +import { checkAccess } from '../utils/userUtils.js'; +import Club from '../models/Club.js'; +import DiaryDate from '../models/DiaryDates.js'; +import Group from '../models/Group.js'; + +describe('groupService', () => { + beforeEach(async () => { + await sequelize.sync({ force: true }); + vi.clearAllMocks(); + }); + + const setupClubAndDate = async () => { + const club = await Club.create({ name: 'Group Club' }); + const diaryDate = await DiaryDate.create({ date: '2026-07-01', clubId: club.id }); + return { club, diaryDate }; + }; + + it('legt Gruppen an und gibt sie zurück', async () => { + const { club, diaryDate } = await setupClubAndDate(); + + const group = await groupService.addGroup('token', club.id, diaryDate.id, 'Team Blau', 'Coach'); + + expect(checkAccess).toHaveBeenCalledWith('token', club.id); + expect(group.name).toBe('Team Blau'); + + const groups = await groupService.getGroups('token', club.id, diaryDate.id); + expect(groups).toHaveLength(1); + expect(groups[0].lead).toBe('Coach'); + }); + + it('aktualisiert Gruppenangaben und validiert Zugehörigkeit', async () => { + const { club, diaryDate } = await setupClubAndDate(); + const group = await groupService.addGroup('token', club.id, diaryDate.id, 'Team Rot', 'Trainerin'); + + const updated = await groupService.changeGroup('token', group.id, club.id, diaryDate.id, 'Team Rot', 'Neuer Lead'); + + expect(updated.lead).toBe('Neuer Lead'); + + await expect( + groupService.changeGroup('token', group.id, club.id, diaryDate.id + 1, 'Fail', 'Lead') + ).rejects.toThrow('Datum nicht gefunden oder passt nicht zum Verein'); + }); +}); diff --git a/backend/tests/matchRoutes.test.js b/backend/tests/matchRoutes.test.js new file mode 100644 index 0000000..ab34492 --- /dev/null +++ b/backend/tests/matchRoutes.test.js @@ -0,0 +1,85 @@ +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 Season from '../models/Season.js'; +import League from '../models/League.js'; +import Team from '../models/Team.js'; +import Match from '../models/Match.js'; +import Club from '../models/Club.js'; +import Location from '../models/Location.js'; +import User from '../models/User.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('Match Routes', () => { + beforeEach(async () => { + await sequelize.sync({ force: true }); + }); + + it('liefert Matches und aktualisiert Spielerlisten', async () => { + const { credentials } = await registerAndActivate('matchroutes@example.com'); + const token = await loginAndGetToken(credentials); + + const clubResponse = await request(app) + .post('/api/clubs') + .set(authHeaders(token)) + .send({ name: 'Match Route Club' }); + const clubId = clubResponse.body.id; + + const season = await Season.create({ season: '2025/2026' }); + const league = await League.create({ name: 'Route Liga', clubId, seasonId: season.id }); + const homeTeam = await Team.create({ name: 'Route Club I', clubId, leagueId: league.id, seasonId: season.id }); + const guestTeam = await Team.create({ name: 'Route Club II', clubId, leagueId: league.id, seasonId: season.id }); + const location = await Location.create({ name: 'Route Halle', address: 'Straße 1', city: 'Stadt', zip: '12345' }); + const match = await Match.create({ + clubId, + leagueId: league.id, + homeTeamId: homeTeam.id, + guestTeamId: guestTeam.id, + locationId: location.id, + date: new Date('2025-09-01T18:00:00Z'), + time: '18:00', + }); + + const listResponse = await request(app) + .get(`/api/matches/leagues/${clubId}/matches`) + .set(authHeaders(token)) + .query({ seasonid: season.id }); + + expect(listResponse.status).toBe(200); + expect(listResponse.body).toHaveLength(1); + expect(listResponse.body[0].homeTeam.name).toBe('Route Club I'); + + const patchResponse = await request(app) + .patch(`/api/matches/${match.id}/players`) + .set(authHeaders(token)) + .send({ playersReady: ['Alice'], playersPlanned: ['Bob'], playersPlayed: ['Charlie'] }); + + expect(patchResponse.status).toBe(200); + expect(patchResponse.body.data.playersReady).toEqual(['Alice']); + }); +}); diff --git a/backend/tests/matchService.test.js b/backend/tests/matchService.test.js new file mode 100644 index 0000000..6e5b144 --- /dev/null +++ b/backend/tests/matchService.test.js @@ -0,0 +1,83 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +vi.mock('../utils/userUtils.js', async () => { + const actual = await vi.importActual('../utils/userUtils.js'); + return { + ...actual, + checkAccess: vi.fn().mockResolvedValue(true), + }; +}); + +import sequelize from '../database.js'; +import '../models/index.js'; + +import matchService from '../services/matchService.js'; +import { checkAccess } from '../utils/userUtils.js'; +import Season from '../models/Season.js'; +import League from '../models/League.js'; +import Team from '../models/Team.js'; +import Match from '../models/Match.js'; +import Club from '../models/Club.js'; +import Location from '../models/Location.js'; + +describe('matchService', () => { + beforeEach(async () => { + await sequelize.sync({ force: true }); + vi.clearAllMocks(); + }); + + it('formatiert Team-Namen anhand der Altersklasse', () => { + expect(matchService.formatTeamNameWithAgeClass('Harheimer TC', 'Jugend 11')).toBe('Harheimer TC (J11)'); + expect(matchService.formatTeamNameWithAgeClass('Harheimer TC', 'Senioren')).toBe('Harheimer TC (S)'); + expect(matchService.formatTeamNameWithAgeClass('Harheimer TC', 'Erwachsene')).toBe('Harheimer TC'); + }); + + it('erstellt Teams und gibt bestehende Einträge zurück', async () => { + const club = await Club.create({ name: 'Match Club' }); + const season = await Season.create({ season: '2025/2026' }); + const league = await League.create({ name: 'Verbandsliga', clubId: club.id, seasonId: season.id }); + + const teamId = await matchService.getOrCreateTeamId('Harheimer TC', 'Jugend 11', club.id, league.id, season.id); + const sameTeamId = await matchService.getOrCreateTeamId('Harheimer TC', 'Jugend 11', club.id, league.id, season.id); + + expect(teamId).toBeTruthy(); + expect(teamId).toBe(sameTeamId); + const team = await Team.findByPk(teamId); + expect(team.name).toBe('Harheimer TC (J11)'); + }); + + it('liefert Ligen und Matches für eine Saison und aktualisiert Spielerlisten', async () => { + const club = await Club.create({ name: 'Season Club' }); + const season = await Season.create({ season: '2025/2026' }); + const league = await League.create({ name: 'Oberliga', clubId: club.id, seasonId: season.id }); + const homeTeam = await Team.create({ name: 'Season Club I', clubId: club.id, leagueId: league.id, seasonId: season.id }); + const guestTeam = await Team.create({ name: 'Season Club II', clubId: club.id, leagueId: league.id, seasonId: season.id }); + const location = await Location.create({ name: 'Halle A', address: 'Straße 1', city: 'Stadt', zip: '12345' }); + + const match = await Match.create({ + clubId: club.id, + leagueId: league.id, + homeTeamId: homeTeam.id, + guestTeamId: guestTeam.id, + locationId: location.id, + date: new Date('2025-09-01T18:00:00Z'), + time: '18:00', + }); + + const leagues = await matchService.getLeaguesForCurrentSeason('token', club.id, season.id); + expect(checkAccess).toHaveBeenCalledWith('token', club.id); + expect(leagues).toHaveLength(1); + expect(leagues[0].name).toBe('Oberliga'); + + const matches = await matchService.getMatchesForLeagues('token', club.id, season.id); + expect(matches).toHaveLength(1); + expect(matches[0].homeTeam.name).toBe('Season Club I'); + expect(matches[0].location.name).toBe('Halle A'); + + await matchService.updateMatchPlayers('token', match.id, ['Alice'], ['Bob'], ['Charlie']); + const updated = await Match.findByPk(match.id); + expect(updated.playersReady).toEqual(['Alice']); + expect(updated.playersPlanned).toEqual(['Bob']); + expect(updated.playersPlayed).toEqual(['Charlie']); + }); +}); diff --git a/backend/tests/memberActivityRoutes.test.js b/backend/tests/memberActivityRoutes.test.js new file mode 100644 index 0000000..9da0d59 --- /dev/null +++ b/backend/tests/memberActivityRoutes.test.js @@ -0,0 +1,131 @@ +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 Club from '../models/Club.js'; +import Member from '../models/Member.js'; +import DiaryDate from '../models/DiaryDates.js'; +import DiaryDateActivity from '../models/DiaryDateActivity.js'; +import DiaryMemberActivity from '../models/DiaryMemberActivity.js'; +import Participant from '../models/Participant.js'; +import PredefinedActivity from '../models/PredefinedActivity.js'; +import Group from '../models/Group.js'; +import GroupActivity from '../models/GroupActivity.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('MemberActivity Routes', () => { + beforeEach(async () => { + await sequelize.sync({ force: true }); + }); + + it('aggregiert Aktivitäten und liefert letzte Teilnahmen', async () => { + const { credentials } = await registerAndActivate('memberactivity@example.com'); + const token = await loginAndGetToken(credentials); + + const clubResponse = await request(app) + .post('/api/clubs') + .set(authHeaders(token)) + .send({ name: 'Activity Club' }); + const clubId = clubResponse.body.id; + + const member = await Member.create({ + firstName: 'Max', + lastName: 'Mustermann', + phone: '0123456789', + street: 'Straße 1', + city: 'Stadt', + postalCode: '12345', + email: 'max@example.com', + clubId, + birthDate: '2005-01-01', + active: true, + testMembership: true, + picsInInternetAllowed: false, + gender: 'male', + }); + + const diaryDateRecent = await DiaryDate.create({ date: '2026-09-01', clubId }); + const diaryDateOlder = await DiaryDate.create({ date: '2026-06-01', clubId }); + const group = await Group.create({ diaryDateId: diaryDateRecent.id, name: 'Gruppe A' }); + const otherGroup = await Group.create({ diaryDateId: diaryDateOlder.id, name: 'Gruppe B' }); + + const participant = await Participant.create({ diaryDateId: diaryDateRecent.id, memberId: member.id, groupId: group.id }); + + const activityGeneralDef = await PredefinedActivity.create({ name: 'Allgemeines Training' }); + const activityGroupDef = await PredefinedActivity.create({ name: 'Gruppenübung' }); + const activityOtherDef = await PredefinedActivity.create({ name: 'Falsche Gruppe' }); + + const generalActivity = await DiaryDateActivity.create({ + diaryDateId: diaryDateRecent.id, + predefinedActivityId: activityGeneralDef.id, + orderId: 1, + isTimeblock: false, + }); + + const groupActivity = await DiaryDateActivity.create({ + diaryDateId: diaryDateRecent.id, + predefinedActivityId: activityGroupDef.id, + orderId: 2, + isTimeblock: false, + }); + + await GroupActivity.create({ diaryDateActivity: groupActivity.id, groupId: group.id, customActivity: null }); + + const otherActivity = await DiaryDateActivity.create({ + diaryDateId: diaryDateOlder.id, + predefinedActivityId: activityOtherDef.id, + orderId: 1, + isTimeblock: false, + }); + await GroupActivity.create({ diaryDateActivity: otherActivity.id, groupId: otherGroup.id, customActivity: null }); + + await DiaryMemberActivity.bulkCreate([ + { diaryDateActivityId: generalActivity.id, participantId: participant.id }, + { diaryDateActivityId: groupActivity.id, participantId: participant.id }, + { diaryDateActivityId: otherActivity.id, participantId: participant.id }, + ]); + + const activitiesResponse = await request(app) + .get(`/api/member-activities/${clubId}/${member.id}`) + .set(authHeaders(token)) + .query({ period: 'year' }); + + expect(activitiesResponse.status).toBe(200); + const names = activitiesResponse.body.map((entry) => entry.name).sort(); + expect(names).toEqual(['Allgemeines Training', 'Gruppenübung']); + + const lastResponse = await request(app) + .get(`/api/member-activities/${clubId}/${member.id}/last-participations`) + .set(authHeaders(token)) + .query({ limit: 1 }); + + expect(lastResponse.status).toBe(200); + expect(lastResponse.body).toHaveLength(1); + expect(lastResponse.body[0].activityName).toBe('Gruppenübung'); + }); +}); diff --git a/backend/tests/memberNoteRoutes.test.js b/backend/tests/memberNoteRoutes.test.js new file mode 100644 index 0000000..2678686 --- /dev/null +++ b/backend/tests/memberNoteRoutes.test.js @@ -0,0 +1,93 @@ +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 Member from '../models/Member.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, +}); + +const createMember = async (clubId) => { + return Member.create({ + firstName: 'Nora', + lastName: 'Notiz', + phone: '0123456789', + street: 'Straße 3', + city: 'Stadt', + postalCode: '12345', + email: 'nora@example.com', + clubId, + birthDate: '1999-01-01', + active: true, + testMembership: false, + picsInInternetAllowed: false, + gender: 'female', + }); +}; + +describe('MemberNote Routes', () => { + beforeEach(async () => { + await sequelize.sync({ force: true }); + }); + + it('erstellt, listet und löscht Member Notes', async () => { + const { credentials } = await registerAndActivate('membernote@example.com'); + const token = await loginAndGetToken(credentials); + + const clubResponse = await request(app) + .post('/api/clubs') + .set(authHeaders(token)) + .send({ name: 'Note Club' }); + const clubId = clubResponse.body.id; + + const member = await createMember(clubId); + + const createResponse = await request(app) + .post('/api/member-notes') + .set(authHeaders(token)) + .send({ memberId: member.id, clubId, content: 'Erste Notiz' }); + + expect(createResponse.status).toBe(201); + expect(createResponse.body[0].content).toBe('Erste Notiz'); + + const listResponse = await request(app) + .get(`/api/member-notes/${member.id}`) + .set(authHeaders(token)) + .query({ clubId }); + + expect(listResponse.status).toBe(200); + expect(listResponse.body).toHaveLength(1); + + const noteId = createResponse.body[0].id; + const deleteResponse = await request(app) + .delete(`/api/member-notes/${noteId}`) + .set(authHeaders(token)) + .send({ clubId }); + + expect(deleteResponse.status).toBe(200); + expect(deleteResponse.body).toHaveLength(0); + }); +}); diff --git a/backend/tests/memberRoutes.test.js b/backend/tests/memberRoutes.test.js new file mode 100644 index 0000000..ac56476 --- /dev/null +++ b/backend/tests/memberRoutes.test.js @@ -0,0 +1,93 @@ +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 Member from '../models/Member.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, +}); + +const createMember = async (clubId, overrides = {}) => { + return Member.create({ + firstName: 'Lara', + lastName: 'Lang', + phone: '0123456789', + street: 'Straße 2', + city: 'Stadt', + postalCode: '12345', + email: 'lara@example.com', + clubId, + birthDate: '2001-01-01', + active: true, + testMembership: true, + picsInInternetAllowed: false, + gender: 'female', + ...overrides, + }); +}; + +describe('Member quick action routes', () => { + beforeEach(async () => { + await sequelize.sync({ force: true }); + }); + + it('setzt Testmitgliedschaft, Formularstatus und deaktiviert Mitglieder', async () => { + const { credentials } = await registerAndActivate('memberroutes@example.com'); + const token = await loginAndGetToken(credentials); + + const clubResponse = await request(app) + .post('/api/clubs') + .set(authHeaders(token)) + .send({ name: 'Member Route Club' }); + const clubId = clubResponse.body.id; + + const member = await createMember(clubId, { testMembership: true, memberFormHandedOver: false, active: true }); + + const testResponse = await request(app) + .post(`/api/members/quick-update-test-membership/${clubId}/${member.id}`) + .set(authHeaders(token)); + + expect(testResponse.status).toBe(200); + await member.reload(); + expect(member.testMembership).toBe(false); + + const formResponse = await request(app) + .post(`/api/members/quick-update-member-form/${clubId}/${member.id}`) + .set(authHeaders(token)); + + expect(formResponse.status).toBe(200); + await member.reload(); + expect(member.memberFormHandedOver).toBe(true); + + const deactivateResponse = await request(app) + .post(`/api/members/quick-deactivate/${clubId}/${member.id}`) + .set(authHeaders(token)); + + expect(deactivateResponse.status).toBe(200); + await member.reload(); + expect(member.active).toBe(false); + }); +}); diff --git a/backend/tests/memberService.test.js b/backend/tests/memberService.test.js new file mode 100644 index 0000000..6c1aa09 --- /dev/null +++ b/backend/tests/memberService.test.js @@ -0,0 +1,75 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +vi.mock('../utils/userUtils.js', async () => { + const actual = await vi.importActual('../utils/userUtils.js'); + return { + ...actual, + checkAccess: vi.fn().mockResolvedValue(true), + }; +}); + +import sequelize from '../database.js'; +import '../models/index.js'; + +import memberService from '../services/memberService.js'; +import { checkAccess } from '../utils/userUtils.js'; +import Member from '../models/Member.js'; + +describe('memberService quick updates', () => { + beforeEach(async () => { + await sequelize.sync({ force: true }); + vi.clearAllMocks(); + }); + + const createMember = async (overrides = {}) => { + return Member.create({ + firstName: 'Erika', + lastName: 'Musterfrau', + phone: '0123456789', + street: 'Straße 1', + city: 'Stadt', + postalCode: '12345', + email: 'erika@example.com', + clubId: 1, + birthDate: '2000-01-01', + active: true, + testMembership: true, + picsInInternetAllowed: false, + gender: 'female', + ...overrides, + }); + }; + + it('entfernt Testmitgliedschaften und behandelt Fehlerfälle', async () => { + const member = await createMember({ testMembership: true }); + + const result = await memberService.quickUpdateTestMembership('token', member.clubId, member.id); + + expect(checkAccess).toHaveBeenCalledWith('token', member.clubId); + expect(result.status).toBe(200); + await member.reload(); + expect(member.testMembership).toBe(false); + + const alreadyLive = await memberService.quickUpdateTestMembership('token', member.clubId, member.id); + expect(alreadyLive.status).toBe(400); + }); + + it('markiert Formular-Status und deaktiviert Mitglieder', async () => { + const member = await createMember({ testMembership: false, memberFormHandedOver: false }); + + const formResult = await memberService.quickUpdateMemberFormHandedOver('token', member.clubId, member.id); + expect(formResult.status).toBe(200); + await member.reload(); + expect(member.memberFormHandedOver).toBe(true); + + const deactivateResult = await memberService.quickDeactivateMember('token', member.clubId, member.id); + expect(deactivateResult.status).toBe(200); + await member.reload(); + expect(member.active).toBe(false); + }); + + it('meldet 404 für fehlende Mitglieder', async () => { + const result = await memberService.quickDeactivateMember('token', 99, 123); + expect(result.status).toBe(404); + }); +}); diff --git a/backend/tests/setupTestEnv.js b/backend/tests/setupTestEnv.js index dec1678..be05963 100644 --- a/backend/tests/setupTestEnv.js +++ b/backend/tests/setupTestEnv.js @@ -7,6 +7,7 @@ process.env.DB_STORAGE = process.env.DB_STORAGE || ':memory:'; process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret'; process.env.EMAIL_USER = process.env.EMAIL_USER || 'noreply@example.com'; process.env.BASE_URL = process.env.BASE_URL || 'http://localhost:3000'; +process.env.ENCRYPTION_KEY = process.env.ENCRYPTION_KEY || '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; afterAll(async () => { await sequelize.close(); diff --git a/backend/tests/testApp.js b/backend/tests/testApp.js index 3b63435..7fbdee2 100644 --- a/backend/tests/testApp.js +++ b/backend/tests/testApp.js @@ -7,6 +7,15 @@ import apiLogRoutes from '../routes/apiLogRoutes.js'; import clubRoutes from '../routes/clubRoutes.js'; import clubTeamRoutes from '../routes/clubTeamRoutes.js'; import diaryRoutes from '../routes/diaryRoutes.js'; +import diaryDateActivityRoutes from '../routes/diaryDateActivityRoutes.js'; +import diaryMemberActivityRoutes from '../routes/diaryMemberActivityRoutes.js'; +import diaryNoteRoutes from '../routes/diaryNoteRoutes.js'; +import diaryTagRoutes from '../routes/diaryTagRoutes.js'; +import groupRoutes from '../routes/groupRoutes.js'; +import matchRoutes from '../routes/matchRoutes.js'; +import memberActivityRoutes from '../routes/memberActivityRoutes.js'; +import memberRoutes from '../routes/memberRoutes.js'; +import memberNoteRoutes from '../routes/memberNoteRoutes.js'; const app = express(); @@ -19,6 +28,15 @@ app.use('/api/logs', apiLogRoutes); app.use('/api/clubs', clubRoutes); app.use('/api/clubteam', clubTeamRoutes); app.use('/api/diary', diaryRoutes); +app.use('/api/diary-date-activities', diaryDateActivityRoutes); +app.use('/api/diary-member-activities', diaryMemberActivityRoutes); +app.use('/api/diary-notes', diaryNoteRoutes); +app.use('/api/diary-tags', diaryTagRoutes); +app.use('/api/groups', groupRoutes); +app.use('/api/matches', matchRoutes); +app.use('/api/member-activities', memberActivityRoutes); +app.use('/api/members', memberRoutes); +app.use('/api/member-notes', memberNoteRoutes); app.use((err, req, res, next) => { const status = err?.status || err?.statusCode || 500; diff --git a/frontend/src/utils/debounce.js b/frontend/src/utils/debounce.js new file mode 100644 index 0000000..df8e671 --- /dev/null +++ b/frontend/src/utils/debounce.js @@ -0,0 +1,10 @@ +export function debounce(fn, wait = 300) { + let timeoutId; + return function (...args) { + const context = this; + clearTimeout(timeoutId); + timeoutId = setTimeout(() => { + fn.apply(context, args); + }, wait); + }; +} diff --git a/frontend/src/views/DiaryView.vue b/frontend/src/views/DiaryView.vue index 29eb606..6ce604b 100644 --- a/frontend/src/views/DiaryView.vue +++ b/frontend/src/views/DiaryView.vue @@ -519,11 +519,6 @@ @@ -1292,4 +1318,13 @@ li { .btn-cancel:hover { background-color: #5a6268; } + +.output ul li.active { + font-weight: 600; + color: var(--primary-color, #2b7cff); +} + +.team-league { + color: var(--text-muted, #6c757d); +} diff --git a/frontend/src/views/TournamentsView.vue b/frontend/src/views/TournamentsView.vue index 893433b..3979af6 100644 --- a/frontend/src/views/TournamentsView.vue +++ b/frontend/src/views/TournamentsView.vue @@ -670,20 +670,9 @@