Initial commit

This commit is contained in:
2026-09-02 08:21:27 +02:00
commit 84eec0039e
609 changed files with 94880 additions and 0 deletions
@@ -0,0 +1,266 @@
package com.softspm.webinar.academy;
import com.softspm.webinar.domain.Course;
import com.softspm.webinar.domain.CourseModule;
import com.softspm.webinar.domain.Enrollment;
import com.softspm.webinar.domain.EnrollmentStatus;
import com.softspm.webinar.domain.Lesson;
import com.softspm.webinar.domain.LessonType;
import com.softspm.webinar.domain.Webinar;
import com.softspm.webinar.repository.CourseModuleRepository;
import com.softspm.webinar.repository.CourseRepository;
import com.softspm.webinar.repository.EnrollmentRepository;
import com.softspm.webinar.repository.LessonRepository;
import com.softspm.webinar.repository.WebinarRepository;
import com.softspm.webinar.domain.WebinarStatus;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.test.context.ActiveProfiles;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Reguly modelu kursow pilnowane przez baze.
*
* Sprawdzenie w kodzie nie wystarcza przy zadnej z nich: dwie karty
* przegladarki przechodza je jednoczesnie, a literowka w serwisie omija je
* calkiem. Test siega do bazy, bo to tam siedzi gwarancja.
*
* Test zaklada wlasne dane o losowych slugach i kasuje wylacznie je: kolejnosc
* testow jest losowa, wiec zaden nie moze zalezec od danych innego ani czyscic
* tabel w calosci.
*/
@SpringBootTest
@ActiveProfiles("test")
class CourseModelTest {
@Autowired
private CourseRepository courseRepository;
@Autowired
private CourseModuleRepository moduleRepository;
@Autowired
private LessonRepository lessonRepository;
@Autowired
private EnrollmentRepository enrollmentRepository;
@Autowired
private WebinarRepository webinarRepository;
private final List<Long> courseIds = new ArrayList<>();
private final List<Long> webinarIds = new ArrayList<>();
private final List<Long> contactIds = new ArrayList<>();
@AfterEach
void cleanUp() {
// kasowanie kursu zabiera kaskadowo moduly, lekcje i zapisy
courseIds.forEach(id -> courseRepository.findById(id).ifPresent(courseRepository::delete));
webinarIds.forEach(id -> webinarRepository.findById(id).ifPresent(webinarRepository::delete));
contactIds.forEach(id -> contactRepository.findById(id).ifPresent(contactRepository::delete));
courseIds.clear();
webinarIds.clear();
contactIds.clear();
}
private Course newCourse() {
Course course = new Course();
course.setSlug("test-" + UUID.randomUUID());
course.setTitle("Kurs testowy");
Course saved = courseRepository.save(course);
courseIds.add(saved.getId());
return saved;
}
private CourseModule newModule(Course course) {
CourseModule module = new CourseModule();
module.setCourse(course);
module.setTitle("Modul");
return moduleRepository.save(module);
}
private Webinar newWebinar() {
Webinar webinar = new Webinar();
webinar.setSlug("test-w-" + UUID.randomUUID());
webinar.setTitle("Webinar testowy");
webinar.setStartsAt(OffsetDateTime.now().plusDays(1));
webinar.setEndsAt(OffsetDateTime.now().plusDays(1).plusHours(1));
webinar.setTimezone("Europe/Warsaw");
webinar.setStatus(WebinarStatus.DRAFT);
Webinar saved = webinarRepository.save(webinar);
webinarIds.add(saved.getId());
return saved;
}
@Test
@DisplayName("Brak ceny oznacza kurs darmowy, co jest czym innym niz cena zerowa")
void nullPriceMeansFree() {
Course free = newCourse();
assertThat(free.isFree()).isTrue();
free.setPriceGross(BigDecimal.ZERO);
// cena 0,00 jest **ustalona**, wiec kurs nie jest juz "darmowy z braku ceny"
assertThat(free.isFree()).isFalse();
free.setPriceGross(new BigDecimal("490.00"));
assertThat(free.isFree()).isFalse();
}
@Test
@DisplayName("Webinar wolno wskazac wylacznie lekcji typu WEBINAR")
void webinarOnlyOnWebinarLesson() {
Course course = newCourse();
CourseModule module = newModule(course);
Webinar webinar = newWebinar();
Lesson wrong = new Lesson();
wrong.setModule(module);
wrong.setTitle("Lekcja tekstowa z webinarem");
wrong.setType(LessonType.TEXT);
wrong.setWebinar(webinar);
/*
* Bez ograniczenia CHECK literowka w kodzie przypielaby wydarzenie do
* lekcji tekstowej, a program kursu pokazywalby je w niewlasciwym
* miejscu. Baza musi to odrzucic, nie serwis.
*/
assertThatThrownBy(() -> lessonRepository.saveAndFlush(wrong))
.isInstanceOf(DataIntegrityViolationException.class);
}
@Test
@DisplayName("Webinar jako lekcja przechodzi przy poprawnym typie")
void webinarLessonIsAllowed() {
Course course = newCourse();
CourseModule module = newModule(course);
Webinar webinar = newWebinar();
Lesson lesson = new Lesson();
lesson.setModule(module);
lesson.setTitle("Spotkanie na zywo");
lesson.setType(LessonType.WEBINAR);
lesson.setWebinar(webinar);
Lesson saved = lessonRepository.saveAndFlush(lesson);
assertThat(saved.getId()).isNotNull();
assertThat(lessonRepository.countByWebinar(webinar.getId())).isEqualTo(1);
}
@Test
@DisplayName("Jeden zapis na pare kurs i kontakt")
void enrollmentIsUniquePerCourseAndContact() {
Course course = newCourse();
var contact = newContact();
Enrollment first = new Enrollment();
first.setCourse(course);
first.setContact(contact);
enrollmentRepository.saveAndFlush(first);
Enrollment duplicate = new Enrollment();
duplicate.setCourse(course);
duplicate.setContact(contact);
// spodziewany wyjatek, oznacza "juz zapisany", a nie blad aplikacji
assertThatThrownBy(() -> enrollmentRepository.saveAndFlush(duplicate))
.isInstanceOf(DataIntegrityViolationException.class);
}
@Test
@DisplayName("Dostep: anulowany i wygasly nie otwieraja materialow, ukonczony tak")
void accessRules() {
Enrollment enrollment = new Enrollment();
enrollment.setStatus(EnrollmentStatus.ACTIVE);
assertThat(enrollment.grantsAccess()).isTrue();
// ukonczenie kursu nie odbiera dostepu do materialow
enrollment.setStatus(EnrollmentStatus.COMPLETED);
assertThat(enrollment.grantsAccess()).isTrue();
enrollment.setStatus(EnrollmentStatus.CANCELLED);
assertThat(enrollment.grantsAccess()).isFalse();
enrollment.setStatus(EnrollmentStatus.EXPIRED);
assertThat(enrollment.grantsAccess()).isFalse();
/*
* Data wygasniecia dziala niezaleznie od statusu: status EXPIRED
* ustawia zadanie w tle, a miedzy jego przebiegami wiersz jest jeszcze
* ACTIVE. Gdyby dostep zalezal wylacznie od statusu, kursant mialby
* otwarte materialy az do najblizszego przebiegu.
*/
enrollment.setStatus(EnrollmentStatus.ACTIVE);
enrollment.setAccessExpiresAt(OffsetDateTime.now().minusDays(1));
assertThat(enrollment.grantsAccess()).isFalse();
enrollment.setAccessExpiresAt(OffsetDateTime.now().plusDays(1));
assertThat(enrollment.grantsAccess()).isTrue();
// null oznacza dostep bezterminowy
enrollment.setAccessExpiresAt(null);
assertThat(enrollment.grantsAccess()).isTrue();
}
@Test
@DisplayName("Lista kursow dziala takze bez filtru")
void searchWithoutFilters() {
newCourse();
/*
* Wariant bez filtru jest pierwszy, bo to on psuje sie przy braku
* `cast(:parametr as string)`: sterownik wysyla null bez typu,
* a PostgreSQL nie wywnioskuje go z samego `is null`. Lista przestaje
* wtedy dzialac dokladnie wtedy, gdy filtr nie jest uzywany.
*/
assertThat(courseRepository.search(null, null, null,
org.springframework.data.domain.PageRequest.of(0, 25)))
.isNotNull();
assertThat(courseRepository.search(null, null, "testowy",
org.springframework.data.domain.PageRequest.of(0, 25))
.getTotalElements()).isPositive();
}
@Test
@DisplayName("Domyslne wartosci kursu sa bezpieczne")
void safeDefaults() {
Course course = courseRepository.findById(newCourse().getId()).orElseThrow();
// nowy kurs jest wersja robocza: nie moze pojawic sie publicznie sam
assertThat(course.getStatus()).isEqualTo(com.softspm.webinar.domain.CourseStatus.DRAFT);
assertThat(course.getCurrency()).isEqualTo("PLN");
assertThat(course.getPassThresholdPercent()).isEqualTo(75);
assertThat(course.isFree()).isTrue();
}
/**
* Kontakt na potrzeby testu.
*
* Zakladany wlasny, a nie brany pierwszy z brzegu: baza testowa jest ta
* sama co instancji, wiec test nie moze zakladac, ze cokolwiek w niej jest,
* ani przywiazywac sie do cudzych danych.
*/
private com.softspm.webinar.domain.Contact newContact() {
var contact = new com.softspm.webinar.domain.Contact();
contact.setEmail("kurs-test-" + UUID.randomUUID() + "@example.test");
contact.setFirstName("Test");
contact.setLastName("Kursowy");
contact.setSource(com.softspm.webinar.domain.ContactSource.MANUAL);
contact.setUnsubscribeToken(UUID.randomUUID().toString());
var saved = contactRepository.save(contact);
contactIds.add(saved.getId());
return saved;
}
@Autowired
private com.softspm.webinar.repository.ContactRepository contactRepository;
}
@@ -0,0 +1,109 @@
package com.softspm.webinar.academy;
import com.softspm.webinar.academy.LearningDtos.ProgressRequest;
import com.softspm.webinar.course.CourseEnrollmentService;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Reguly postepu lekcji, liczone po stronie serwera.
*
* DLACZEGO TO JEST OSOBNY TEST
* ---------------------------------------------------------------------------
* Przegladarka przysyla liczbe obejrzanych sekund, a nie decyzje "ukonczone".
* Gdyby przysylala decyzje, wystarczyloby jedno podstawione zadanie, zeby
* zaliczyc caly kurs bez obejrzenia czegokolwiek, a przy kursie konczacym sie
* certyfikatem oznaczaloby to dokument wystawiony za nic.
*
* Prog jest wiec liczony tutaj, z dlugosci lekcji zapisanej w bazie, ktorej
* przegladarka nie moze podmienic.
*/
class LessonProgressRulesTest {
/**
* Prog liczy PRAWDZIWA metoda serwisu, a nie kopia wzoru w tescie.
*
* Kopia przechodzilaby takze wtedy, gdy regula w serwisie sie zmieni:
* test pilnowalby wlasnego rachunku zamiast kodu, ktory naprawde dziala.
*/
private static boolean watchedEnough(Integer durationMinutes, int watchedSeconds, int percent) {
return LearningService.watchedEnough(durationMinutes, watchedSeconds, percent);
}
@Test
@DisplayName("Nagranie obejrzane ponizej progu nie zalicza lekcji")
void belowThresholdDoesNotComplete() {
// 10 minut, prog 90%: potrzeba 540 sekund
assertThat(watchedEnough(10, 539, 90)).isFalse();
assertThat(watchedEnough(10, 300, 90)).isFalse();
assertThat(watchedEnough(10, 0, 90)).isFalse();
}
@Test
@DisplayName("Nagranie obejrzane powyzej progu zalicza lekcje")
void aboveThresholdCompletes() {
assertThat(watchedEnough(10, 540, 90)).isTrue();
assertThat(watchedEnough(10, 600, 90)).isTrue();
// wiecej niz dlugosc nagrania: przewijanie w tyl i ponowne ogladanie
assertThat(watchedEnough(10, 5000, 90)).isTrue();
}
@Test
@DisplayName("Lekcja bez podanej dlugosci nie domyka sie sama")
void unknownDurationNeverCompletesByItself() {
/*
* Nie ma do czego porownac liczby sekund. Zgadywanie oznaczaloby albo
* zaliczanie lekcji po kilku sekundach, albo nigdy; jedno i drugie
* jest gorsze niz przycisk, ktorym kursant potwierdza sam.
*/
assertThat(watchedEnough(null, 100000, 90)).isFalse();
assertThat(watchedEnough(0, 100000, 90)).isFalse();
assertThat(watchedEnough(-5, 100000, 90)).isFalse();
}
@Test
@DisplayName("Zadanie postepu nie ma pola pozwalajacego zaliczyc wideo wprost")
void progressRequestCannotForceVideoCompletion() {
/*
* `completed` sluzy lekcji tekstowej i plikowej, gdzie nie ma czego
* mierzyc. Przy wideo serwis go NIE honoruje, tylko liczy prog sam:
* to jedyne miejsce, w ktorym ta granica jest egzekwowana, wiec
* pilnujemy, ze pole nie zmienilo znaczenia na "zalicz cokolwiek".
*/
var components = ProgressRequest.class.getRecordComponents();
assertThat(components).hasSize(2);
assertThat(components[0].getName()).isEqualTo("watchedSeconds");
assertThat(components[1].getName()).isEqualTo("completed");
}
@Test
@DisplayName("Postep procentowy liczy sie z lekcji wymaganych")
void percentUsesRequiredLessonsOnly() {
// ta sama funkcja co w panelu: jedno zrodlo prawdy dla obu widokow
assertThat(CourseEnrollmentService.percent(0, 0)).isZero();
assertThat(CourseEnrollmentService.percent(2, 3)).isEqualTo(66);
assertThat(CourseEnrollmentService.percent(3, 3)).isEqualTo(100);
// zaokraglenie w dol: 99,7% nie moze pokazywac sie jako ukonczone
assertThat(CourseEnrollmentService.percent(299, 300)).isEqualTo(99);
}
@Test
@DisplayName("Metoda percent jest widoczna dla obu pakietow")
void percentIsShared() throws Exception {
/*
* Odtwarzacz kursanta i lista w panelu musza liczyc postep tak samo.
* Gdyby metoda wrocila do zasiegu pakietowego, `LearningService`
* musialby miec wlasna kopie, ktora rozjechalaby sie przy pierwszej
* poprawce.
*/
Method method = CourseEnrollmentService.class.getDeclaredMethod("percent", long.class, long.class);
assertThat(java.lang.reflect.Modifier.isPublic(method.getModifiers()))
.as("percent musi byc wspolne dla panelu i odtwarzacza")
.isTrue();
assertThat(java.lang.reflect.Modifier.isStatic(method.getModifiers())).isTrue();
}
}
@@ -0,0 +1,128 @@
package com.softspm.webinar.academy;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.softspm.webinar.academy.PublicAcademyDtos.PublicCourseCard;
import com.softspm.webinar.academy.PublicAcademyDtos.PublicLessonView;
import com.softspm.webinar.academy.PublicAcademyDtos.PublicModuleView;
import com.softspm.webinar.domain.LessonType;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.lang.reflect.RecordComponent;
import java.math.BigDecimal;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tresc platnego kursu nie moze opuscic serwera przed zakupem.
*
* DLACZEGO TO JEST OSOBNY TEST
* ---------------------------------------------------------------------------
* Strona kursu jest otwarta dla calego internetu i pokazuje program: tytuly
* lekcji, ich rodzaj i dlugosc. Gdyby widok lekcji byl tym samym rekordem co
* w panelu, poszlaby razem z nim **cala tresc lekcji i identyfikator pliku**,
* czyli dokladnie to, za co kursant ma zaplacic. Nikt nie musialby nic lamac:
* wystarczy otworzyc narzedzia przegladarki.
*
* Test jest napisany na strukturze typu, a nie na jednym przykladzie: dodanie
* pola `contentHtml` do widoku publicznego ma go zlamac natychmiast, takze gdy
* nikt nie pomysli o dopisaniu przypadku.
*
* Ta sama zasada i ten sam wzorzec co w `QuizSecurityTest`.
*/
class PublicAcademySecurityTest {
private final ObjectMapper mapper = new ObjectMapper();
/**
* Nazwy pol, ktore w widoku publicznym oddawalyby tresc kursu.
*
* `mediaid` i `externalvideourl` sa tu razem z trescia: adres pliku
* pozwala pobrac nagranie z pominieciem strony, wiec oddanie go w programie
* kursu jest rownowazne oddaniu samej lekcji.
*/
private static final List<String> FORBIDDEN_ON_LESSON =
List.of("contenthtml", "mediaid", "externalvideourl", "content", "body");
@Test
@DisplayName("Lekcja w programie publicznym nie niesie tresci ani pliku")
void publicLessonHasNoContent() {
assertNoForbiddenComponents(PublicLessonView.class, FORBIDDEN_ON_LESSON);
}
@Test
@DisplayName("Zserializowany program kursu nie zawiera tresci lekcji")
void serializedModuleHasNoLessonContent() throws Exception {
PublicModuleView module = new PublicModuleView(
1L,
"Podstawy prawne",
List.of(
new PublicLessonView(10L, "Wprowadzenie", LessonType.VIDEO, 12, true, true),
new PublicLessonView(11L, "Ocena ryzyka", LessonType.TEXT, 20, true, false)));
String json = mapper.writeValueAsString(module).toLowerCase(Locale.ROOT);
/*
* Sprawdzamy gotowy JSON, a nie tylko strukture typu: pole moglo
* zostac dolozone przez adnotacje albo przez zmiane serializacji,
* a wtedy sama refleksja po skladnikach rekordu tego nie zlapie.
*/
for (String forbidden : FORBIDDEN_ON_LESSON) {
assertThat(json)
.as("program kursu nie moze niesc pola %s", forbidden)
.doesNotContain("\"" + forbidden + "\"");
}
// to, co ma tam byc, faktycznie jest
assertThat(json).contains("\"title\"", "\"preview\"", "\"type\"");
}
@Test
@DisplayName("Kafelek kursu na liscie nie niesie tresci strony kursu")
void courseCardHasNoContent() {
/*
* `contentHtml` kursu bywa cala lekcja pokazowa wpisana w opis, a lista
* kursow pobiera sie na kazdym wejsciu na strone glowna. Kafelek ma
* miec zajawke, nie caly material.
*/
assertNoForbiddenComponents(PublicCourseCard.class, List.of("contenthtml"));
}
@Test
@DisplayName("Cena null oznacza kurs darmowy, a nie cene zerowa")
void nullPriceMeansFree() {
/*
* Dwie rozne decyzje: kurs celowo bezplatny i kurs, ktoremu ceny
* jeszcze nie ustalono. Gdyby widok zamienial null na zero, kurs bez
* ustalonej ceny pokazalby sie jako darmowy i dalo by sie go "kupic"
* za nic.
*/
var free = card(null);
assertThat(free.free()).isTrue();
assertThat(free.priceGross()).isNull();
var paid = card(new BigDecimal("490.00"));
assertThat(paid.free()).isFalse();
assertThat(paid.priceGross()).isEqualByComparingTo("490.00");
}
private PublicCourseCard card(BigDecimal price) {
return new PublicCourseCard(
1L, "kurs", "Kurs", null, null, null, null,
price, "PLN", price == null, null, 5, false, true);
}
private void assertNoForbiddenComponents(Class<?> type, List<String> forbidden) {
List<String> names = Arrays.stream(type.getRecordComponents())
.map(RecordComponent::getName)
.map(name -> name.toLowerCase(Locale.ROOT))
.toList();
assertThat(names)
.as("widok publiczny %s nie moze miec pola z trescia kursu", type.getSimpleName())
.doesNotContainAnyElementsOf(forbidden);
}
}
@@ -0,0 +1,151 @@
package com.softspm.webinar.academy;
import com.softspm.webinar.auth.JwtProperties;
import com.softspm.webinar.auth.JwtService;
import com.softspm.webinar.domain.Contact;
import com.softspm.webinar.domain.User;
import com.softspm.webinar.domain.UserRole;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Rozdzielenie dwoch swiatow uprawnien: panel i Akademia.
*
* DLACZEGO TO JEST OSOBNY TEST
* ---------------------------------------------------------------------------
* `users` i `contacts` maja wlasne, niezalezne sekwencje identyfikatorow, wiec
* kursant o id 7 i administrator o id 7 to dwie rozne osoby. Filtr panelu bierze
* `subject` z tokenu i szuka po nim w `users`, wiec **bez pola `typ` podpisany
* token kursanta otworzylby panel** koncie, ktore nigdy nie mialo tam wstepu.
*
* Bledu tego nie widac w interfejsie: obie strony dzialaja poprawnie, a granica
* pęka dopiero przy zadaniu wyslanym recznie z cudzym tokenem.
*/
class StudentTokenTest {
/** Minimum 32 znaki, tak samo jak wymaga tego konfiguracja aplikacji. */
private static final String SECRET = "sekret-do-testow-minimum-32-znaki-dlugosci";
private final JwtService jwtService = newService();
private static JwtService newService() {
return new JwtService(new JwtProperties(SECRET, 120));
}
private static User staff(long id) {
User user = new User();
user.setId(id);
user.setEmail("admin@example.test");
user.setRole(UserRole.ADMIN);
user.setActive(true);
return user;
}
private static Contact student(long id) {
Contact contact = new Contact();
contact.setId(id);
contact.setEmail("kursant@example.test");
return contact;
}
@Test
@DisplayName("Token kursanta nie otwiera panelu, mimo tego samego identyfikatora")
void studentTokenIsNotStaffToken() {
// ten sam identyfikator w obu tabelach: dokladnie ten przypadek,
// ktory bez pola `typ` konczy sie podniesieniem uprawnien
String token = jwtService.issueForStudent(student(7));
assertThat(jwtService.resolveStudentId(token)).isEqualTo(7L);
assertThat(jwtService.resolveUserId(token)).isNull();
}
@Test
@DisplayName("Token panelu nie loguje jako kursant")
void staffTokenIsNotStudentToken() {
String token = jwtService.issue(staff(7));
assertThat(jwtService.resolveUserId(token)).isEqualTo(7L);
assertThat(jwtService.resolveStudentId(token)).isNull();
}
@Test
@DisplayName("Oba tokeny sa podpisane, zaden nie ma alg=none")
void bothTokensAreSigned() {
/*
* Token bez podpisu ma naglowek alg=none i da sie go ulozyc samodzielnie,
* bez znajomosci sekretu. Zdarzylo sie to realnie przy wydzielaniu
* wspolnego budowniczego: `signWith` zniknelo z jednej metody i oba
* rodzaje tokenu wychodzily niepodpisane.
*/
for (String token : new String[] {
jwtService.issue(staff(1)),
jwtService.issueForStudent(student(1)),
}) {
String header = new String(
java.util.Base64.getUrlDecoder().decode(token.split("\\.")[0]),
StandardCharsets.UTF_8);
assertThat(header).doesNotContain("none");
assertThat(token.split("\\.")).hasSize(3);
// trzeci segment to podpis: przy alg=none jest pusty
assertThat(token.split("\\.")[2]).isNotEmpty();
}
}
@Test
@DisplayName("Token podpisany innym sekretem jest odrzucany po obu stronach")
void foreignTokenIsRejected() {
var foreignKey = Keys.hmacShaKeyFor(
"zupelnie-inny-sekret-tez-minimum-32-znaki".getBytes(StandardCharsets.UTF_8));
String forged = Jwts.builder()
.subject("1")
.claim("typ", "student")
.signWith(foreignKey)
.compact();
assertThat(jwtService.resolveStudentId(forged)).isNull();
assertThat(jwtService.resolveUserId(forged)).isNull();
}
@Test
@DisplayName("Token kursanta niesie jawny typ i role STUDENT")
void studentTokenCarriesType() {
String token = jwtService.issueForStudent(student(42));
Claims claims = Jwts.parser()
.verifyWith(Keys.hmacShaKeyFor(SECRET.getBytes(StandardCharsets.UTF_8)))
.build()
.parseSignedClaims(token)
.getPayload();
assertThat(claims.get("typ")).isEqualTo("student");
assertThat(claims.get("role")).isEqualTo("STUDENT");
assertThat(claims.getSubject()).isEqualTo("42");
}
@Test
@DisplayName("Kontakt bez hasla nie jest kursantem")
void contactWithoutPasswordIsNotStudent() {
Contact contact = student(1);
assertThat(contact.hasAccount()).isFalse();
assertThat(contact.hasCourseAccess()).isFalse();
contact.setPasswordHash("$2a$10$cokolwiek");
assertThat(contact.hasAccount()).isTrue();
// samo haslo nie wystarcza: dostep do kursow wymaga potwierdzonego adresu
assertThat(contact.hasCourseAccess()).isFalse();
contact.setEmailVerifiedAt(java.time.OffsetDateTime.now());
assertThat(contact.hasCourseAccess()).isTrue();
// konto wylaczone traci dostep natychmiast, mimo potwierdzonego adresu
contact.setAccountEnabled(false);
assertThat(contact.hasCourseAccess()).isFalse();
}
}
@@ -0,0 +1,228 @@
package com.softspm.webinar.banner;
import com.softspm.webinar.domain.BannerPlacement;
import com.softspm.webinar.domain.MediaFile;
import com.softspm.webinar.domain.OfferTopic;
import com.softspm.webinar.domain.Registration;
import com.softspm.webinar.domain.RegistrationStatus;
import com.softspm.webinar.domain.Webinar;
import com.softspm.webinar.domain.WebinarStatus;
import com.softspm.webinar.repository.MediaFileRepository;
import com.softspm.webinar.repository.OfferTopicRepository;
import com.softspm.webinar.repository.RegistrationRepository;
import com.softspm.webinar.repository.WebinarRepository;
import com.softspm.webinar.web.ValidationException;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import java.time.OffsetDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Banery w sali.
*
* NAJWAZNIEJSZE SA DWIE BARIERY I OKNO CZASOWE
* ------------------------------------------------------------------------
* Adres docelowy spoza http/https wykonalby sie w kontekscie naszej strony,
* a baner ze zle ustawionym oknem nie pokazalby sie wcale, przy czym blad
* bylby niewidoczny: administrator zobaczylby po prostu brak reklamy.
*/
@SpringBootTest
@ActiveProfiles("test")
class BannerTest {
@Autowired private BannerService banners;
@Autowired private WebinarRepository webinarRepository;
@Autowired private MediaFileRepository mediaRepository;
@Autowired private OfferTopicRepository topicRepository;
@Autowired private RegistrationRepository registrationRepository;
private Webinar webinar;
private MediaFile media;
private Registration participant;
private String suffix;
@BeforeEach
void setUp() {
suffix = String.valueOf(System.nanoTime());
webinar = new Webinar();
webinar.setTitle("Baner test " + suffix);
webinar.setSlug("baner-test-" + suffix);
webinar.setTimezone("Europe/Warsaw");
webinar.setStatus(WebinarStatus.PUBLISHED);
// start pol godziny temu, czyli jestesmy w 30. minucie prezentacji
webinar.setStartsAt(OffsetDateTime.now().minusMinutes(30));
webinar.setEndsAt(OffsetDateTime.now().plusHours(1));
webinar.setCreatedAt(OffsetDateTime.now());
webinar.setUpdatedAt(OffsetDateTime.now());
webinar = webinarRepository.save(webinar);
media = new MediaFile();
media.setObjectKey("test/" + suffix + ".png");
media.setOriginalName("baner.png");
media.setContentType("image/png");
media.setSizeBytes(1024L);
media.setCreatedAt(OffsetDateTime.now());
media = mediaRepository.save(media);
participant = new Registration();
participant.setWebinar(webinar);
participant.setEmail("baner." + suffix + "@example.pl");
participant.setFirstName("Jan");
participant.setLastName("Testowy");
participant.setStatus(RegistrationStatus.CONFIRMED);
participant.setConfirmationToken("baner-tok-" + suffix);
participant.setCreatedAt(OffsetDateTime.now());
participant = registrationRepository.save(participant);
}
@AfterEach
void tearDown() {
// banery globalne nie znikaja kaskadowo z webinarem, wiec kasujemy je jawnie
banners.list(null).stream()
.filter(b -> b.title().contains(suffix))
.forEach(b -> banners.delete(b.id()));
webinarRepository.findById(webinar.getId()).ifPresent(webinarRepository::delete);
mediaRepository.findById(media.getId()).ifPresent(mediaRepository::delete);
}
private BannerRequest request(String title) {
return new BannerRequest(title, media.getId(), null, "https://example.org",
null, BannerPlacement.SIDE, null, null, null, null, 0, true);
}
@Test
@DisplayName("Adres docelowy spoza http i https jest odrzucany")
void rejectsUnsafeScheme() {
for (String url : List.of("javascript:alert(1)", "data:text/html,<h1>x", "file:///etc/passwd")) {
assertThatThrownBy(() -> banners.create(webinar.getId(),
new BannerRequest("Zly adres", media.getId(), null, url, null,
BannerPlacement.SIDE, null, null, null, null, 0, true)))
.isInstanceOf(ValidationException.class);
}
// poprawny adres przechodzi
var ok = banners.create(webinar.getId(), request("Dobry adres " + suffix));
assertThat(ok.targetUrl()).isEqualTo("https://example.org");
}
@Test
@DisplayName("Baner bez grafiki i bez tresci nie ma czego pokazac")
void rejectsEmptyBanner() {
assertThatThrownBy(() -> banners.create(webinar.getId(),
new BannerRequest("Pusty", null, null, null, null,
BannerPlacement.SIDE, null, null, null, null, 0, true)))
.isInstanceOf(ValidationException.class);
}
@Test
@DisplayName("Tresc tekstowa jest czyszczona tym samym sanitizerem co tresc strony")
void sanitizesContent() {
var view = banners.create(webinar.getId(),
new BannerRequest("Tekstowy " + suffix, null,
"<p>Promocja</p><script>alert(1)</script>", null, null,
BannerPlacement.SIDE, null, null, null, null, 0, true));
assertThat(view.contentHtml()).contains("Promocja");
assertThat(view.contentHtml()).doesNotContain("script");
}
@Test
@DisplayName("Okno w minutach decyduje, czy baner jest teraz na sali")
void minuteWindowFiltersBanners() {
banners.create(webinar.getId(), new BannerRequest("Widoczny " + suffix, media.getId(),
null, null, null, BannerPlacement.SIDE, null, null, 10, 40, 0, true));
banners.create(webinar.getId(), new BannerRequest("Za wczesnie " + suffix, media.getId(),
null, null, null, BannerPlacement.SIDE, null, null, 50, 60, 0, true));
banners.create(webinar.getId(), new BannerRequest("Juz minal " + suffix, media.getId(),
null, null, null, BannerPlacement.SIDE, null, null, 0, 10, 0, true));
assertThat(banners.activeFor(webinar.getId()))
.extracting(PublicBannerView::title)
.contains("Widoczny " + suffix)
.doesNotContain("Za wczesnie " + suffix, "Juz minal " + suffix);
}
@Test
@DisplayName("Okno bezwzgledne i wylaczenie takze odsiewaja banery")
void absoluteWindowAndActiveFlag() {
banners.create(webinar.getId(), new BannerRequest("Po terminie " + suffix, media.getId(),
null, null, null, BannerPlacement.SIDE,
OffsetDateTime.now().minusDays(2), OffsetDateTime.now().minusDays(1),
null, null, 0, true));
banners.create(webinar.getId(), new BannerRequest("Wylaczony " + suffix, media.getId(),
null, null, null, BannerPlacement.SIDE, null, null, null, null, 0, false));
assertThat(banners.activeFor(webinar.getId()))
.extracting(PublicBannerView::title)
.doesNotContain("Po terminie " + suffix, "Wylaczony " + suffix);
}
@Test
@DisplayName("Baner globalny pokazuje sie na webinarze, ktory go nie zna")
void globalBannerAppearsOnWebinar() {
banners.create(null, request("Globalny " + suffix));
assertThat(banners.activeFor(webinar.getId()))
.extracting(PublicBannerView::title)
.contains("Globalny " + suffix);
}
@Test
@DisplayName("Baner globalny nie moze wskazac tematu, bo temat nalezy do webinaru")
void globalBannerCannotHaveTopic() {
OfferTopic topic = new OfferTopic();
topic.setWebinar(webinar);
topic.setName("Wdrozenie");
topic.setSortOrder(1);
topic.setActive(true);
topic = topicRepository.save(topic);
Long topicId = topic.getId();
assertThatThrownBy(() -> banners.create(null,
new BannerRequest("Globalny z tematem", media.getId(), null, null, topicId,
BannerPlacement.SIDE, null, null, null, null, 0, true)))
.isInstanceOf(ValidationException.class);
// przy banerze webinaru ten sam temat jest w porzadku
var ok = banners.create(webinar.getId(),
new BannerRequest("Z tematem " + suffix, media.getId(), null, null, topicId,
BannerPlacement.SIDE, null, null, null, null, 0, true));
assertThat(ok.offerTopicName()).isEqualTo("Wdrozenie");
}
@Test
@DisplayName("Wyswietlenie liczy sie raz na uczestnika, klikniecie za kazdym razem")
void impressionOncePerParticipant() {
var banner = banners.create(webinar.getId(), request("Licznik " + suffix));
// rotacja przywraca baner na ekran wielokrotnie
for (int i = 0; i < 5; i++) {
banners.recordImpression(banner.id(), participant.getId());
}
banners.recordClick(banner.id(), participant.getId());
banners.recordClick(banner.id(), participant.getId());
var stats = banners.statsFor(List.of(banner.id())).get(banner.id());
assertThat(stats[0]).as("wyswietlenia").isEqualTo(1);
assertThat(stats[1]).as("klikniecia").isEqualTo(2);
}
@Test
@DisplayName("Okno odwrocone jest odrzucane z czytelnym komunikatem")
void rejectsInvertedWindow() {
assertThatThrownBy(() -> banners.create(webinar.getId(),
new BannerRequest("Odwrocone", media.getId(), null, null, null,
BannerPlacement.SIDE, null, null, 40, 10, 0, true)))
.isInstanceOf(ValidationException.class);
}
}
@@ -0,0 +1,128 @@
package com.softspm.webinar.calendar;
import com.softspm.webinar.domain.Registration;
import com.softspm.webinar.domain.Webinar;
import com.softspm.webinar.settings.SettingKey;
import com.softspm.webinar.settings.SettingsService;
import net.fortuna.ical4j.data.CalendarBuilder;
import net.fortuna.ical4j.model.Calendar;
import net.fortuna.ical4j.model.component.VEvent;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* Test generatora ICS. Sprawdza, ze plik jest poprawnym kalendarzem
* i daje sie sparsowac z powrotem, a nie tylko ze zawiera oczekiwane napisy.
*/
class IcsGeneratorTest {
private SettingsService settings;
private IcsGenerator generator;
@BeforeEach
void setUp() {
settings = mock(SettingsService.class);
when(settings.get(SettingKey.MAIL_USERNAME)).thenReturn("powiadomienia@example.pl");
when(settings.get(SettingKey.MAIL_FROM_NAME)).thenReturn("Gielda Komunalna");
when(settings.get(SettingKey.APP_BRAND_NAME)).thenReturn("Gielda Komunalna");
when(settings.get(SettingKey.APP_PUBLIC_URL)).thenReturn("https://webinar.example.pl");
generator = new IcsGenerator(settings);
}
@Test
void generujePoprawnyPlikIcsDlaWebinaru() throws Exception {
Webinar webinar = webinar();
Registration registration = registration();
byte[] ics = generator.generate(webinar, registration);
String text = new String(ics, StandardCharsets.UTF_8);
// naglowki wymagane przez klientow kalendarza
assertThat(text).contains("BEGIN:VCALENDAR");
assertThat(text).contains("METHOD:REQUEST");
assertThat(text).contains("VERSION:2.0");
assertThat(text).contains("END:VCALENDAR");
// plik musi dac sie sparsowac przez ical4j
Calendar parsed = new CalendarBuilder().build(new ByteArrayInputStream(ics));
VEvent event = parsed.<VEvent>getComponent("VEVENT").orElseThrow();
assertThat(event.getProperty("SUMMARY").orElseThrow().getValue())
.isEqualTo("Webinar o zamowieniach publicznych");
assertThat(event.getProperty("UID").orElseThrow().getValue())
.isEqualTo("webinar-7-registration-42@webinar.example.pl");
assertThat(event.getProperty("LOCATION").orElseThrow().getValue())
.isEqualTo("https://meet.example.pl/abc");
assertThat(event.getProperty("ORGANIZER").orElseThrow().getValue())
.contains("powiadomienia@example.pl");
}
@Test
void uidJestStalyDlaTejSamejParyWebinarUczestnik() {
String first = new String(generator.generate(webinar(), registration()), StandardCharsets.UTF_8);
String second = new String(generator.generate(webinar(), registration()), StandardCharsets.UTF_8);
assertThat(uidOf(first)).isEqualTo(uidOf(second));
}
@Test
void sequenceRosnieGdyWebinarZostalZmieniony() {
Webinar before = webinar();
Webinar after = webinar();
after.setUpdatedAt(before.getUpdatedAt().plusHours(1));
int sequenceBefore = sequenceOf(new String(generator.generate(before, registration()), StandardCharsets.UTF_8));
int sequenceAfter = sequenceOf(new String(generator.generate(after, registration()), StandardCharsets.UTF_8));
assertThat(sequenceAfter).isGreaterThan(sequenceBefore);
}
private Webinar webinar() {
Webinar webinar = new Webinar();
webinar.setId(7L);
webinar.setSlug("zamowienia-publiczne");
webinar.setTitle("Webinar o zamowieniach publicznych");
webinar.setDescription("Omowienie zmian w ustawie.");
webinar.setStartsAt(OffsetDateTime.of(2026, 9, 15, 8, 0, 0, 0, ZoneOffset.UTC));
webinar.setEndsAt(OffsetDateTime.of(2026, 9, 15, 10, 0, 0, 0, ZoneOffset.UTC));
webinar.setTimezone("Europe/Warsaw");
webinar.setJoinUrl("https://meet.example.pl/abc");
webinar.setUpdatedAt(OffsetDateTime.of(2026, 8, 1, 12, 0, 0, 0, ZoneOffset.UTC));
return webinar;
}
private Registration registration() {
Registration registration = new Registration();
registration.setId(42L);
registration.setEmail("jan@example.pl");
registration.setFirstName("Jan");
registration.setLastName("Kowalski");
registration.setConfirmationToken("token-abc");
return registration;
}
private String uidOf(String ics) {
return line(ics, "UID:");
}
private int sequenceOf(String ics) {
return Integer.parseInt(line(ics, "SEQUENCE:"));
}
private String line(String ics, String prefix) {
return ics.lines()
.filter(l -> l.startsWith(prefix))
.map(l -> l.substring(prefix.length()).trim())
.findFirst()
.orElseThrow(() -> new AssertionError("Brak linii " + prefix));
}
}
@@ -0,0 +1,55 @@
package com.softspm.webinar.calendar;
import com.softspm.webinar.domain.Registration;
import com.softspm.webinar.domain.Webinar;
import com.softspm.webinar.settings.SettingKey;
import com.softspm.webinar.settings.SettingsService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* Zapisuje przykladowy plik ICS do obejrzenia w kliencie kalendarza.
* Uruchamiany tylko na zadanie: mvn test -Dics.sample.dir=/sciezka
*/
class IcsSampleWriterTest {
@Test
@EnabledIfSystemProperty(named = "ics.sample.dir", matches = ".+")
void zapisujePrzykladowyPlik() throws Exception {
SettingsService settings = mock(SettingsService.class);
when(settings.get(SettingKey.MAIL_USERNAME)).thenReturn("powiadomienia@gieldakomunalna.pl");
when(settings.get(SettingKey.MAIL_FROM_NAME)).thenReturn("Gielda Komunalna");
when(settings.get(SettingKey.APP_BRAND_NAME)).thenReturn("Gielda Komunalna");
when(settings.get(SettingKey.APP_PUBLIC_URL)).thenReturn("https://webinar.gieldakomunalna.pl");
Webinar webinar = new Webinar();
webinar.setId(1L);
webinar.setSlug("zamowienia-publiczne-2026");
webinar.setTitle("Zamowienia publiczne 2026 - co sie zmienia");
webinar.setDescription("Praktyczne omowienie zmian w ustawie Prawo zamowien publicznych.");
webinar.setStartsAt(OffsetDateTime.of(2026, 9, 15, 8, 0, 0, 0, ZoneOffset.UTC));
webinar.setEndsAt(OffsetDateTime.of(2026, 9, 15, 10, 0, 0, 0, ZoneOffset.UTC));
webinar.setTimezone("Europe/Warsaw");
webinar.setJoinUrl("https://meet.gieldakomunalna.pl/zamowienia-2026");
webinar.setUpdatedAt(OffsetDateTime.now());
Registration registration = new Registration();
registration.setId(1L);
registration.setEmail("uczestnik@example.pl");
registration.setFirstName("Jan");
registration.setLastName("Kowalski");
registration.setConfirmationToken("przykladowy-token");
byte[] ics = new IcsGenerator(settings).generate(webinar, registration);
Path target = Path.of(System.getProperty("ics.sample.dir"), "invite.ics");
Files.write(target, ics);
}
}
@@ -0,0 +1,95 @@
package com.softspm.webinar.certificate;
import com.softspm.webinar.settings.SettingKey;
import com.softspm.webinar.settings.SettingsService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Skladanie numeru certyfikatu.
*
* Numer trafia na dokument i sluzy do weryfikacji publicznej, wiec nie moze
* wygladac na uszkodzony. Pulapka jest pusty prefiks: samo podstawienie
* pustego ciagu zostawia wiszacy separator ("/2026/1"), co u odbiorcy wyglada
* jak blad systemu.
*/
@SpringBootTest
@ActiveProfiles("test")
class CertificateNumberTest {
@Autowired private CertificateService certificateService;
@Autowired private SettingsService settings;
@AfterEach
void tearDown() {
// wracamy do wartosci domyslnych: kolejnosc testow jest losowa, wiec
// zostawione ustawienie zepsuloby inna klase testowa
settings.update(Map.of(
SettingKey.CERTIFICATE_NUMBER_FORMAT.name(), "{rok}/{numer}",
SettingKey.CERTIFICATE_NUMBER_PREFIX.name(), ""));
}
@Test
@DisplayName("Prefiks wchodzi w miejsce znacznika")
void prefixIsSubstituted() {
settings.update(Map.of(
SettingKey.CERTIFICATE_NUMBER_FORMAT.name(), "{prefiks}/{rok}/{numer}",
SettingKey.CERTIFICATE_NUMBER_PREFIX.name(), "SZK"));
assertThat(certificateService.formatNumber(2026, 7)).isEqualTo("SZK/2026/7");
}
@Test
@DisplayName("Pusty prefiks znika wraz z separatorem, numer wyglada jak dotad")
void emptyPrefixLeavesNoSeparator() {
settings.update(Map.of(
SettingKey.CERTIFICATE_NUMBER_FORMAT.name(), "{prefiks}/{rok}/{numer}",
SettingKey.CERTIFICATE_NUMBER_PREFIX.name(), ""));
assertThat(certificateService.formatNumber(2026, 7)).isEqualTo("2026/7");
}
@Test
@DisplayName("Pusty prefiks usuwa takze myslnik i spacje wokol znacznika")
void emptyPrefixHandlesOtherSeparators() {
settings.update(Map.of(
SettingKey.CERTIFICATE_NUMBER_FORMAT.name(), "{prefiks}-{rok}/{numer}",
SettingKey.CERTIFICATE_NUMBER_PREFIX.name(), ""));
assertThat(certificateService.formatNumber(2026, 7)).isEqualTo("2026/7");
settings.update(Map.of(
SettingKey.CERTIFICATE_NUMBER_FORMAT.name(), "{prefiks} {rok}/{numer}",
SettingKey.CERTIFICATE_NUMBER_PREFIX.name(), ""));
assertThat(certificateService.formatNumber(2026, 7)).isEqualTo("2026/7");
}
@Test
@DisplayName("Same spacje w prefiksie licza sie jak brak prefiksu")
void blankPrefixCountsAsEmpty() {
settings.update(Map.of(
SettingKey.CERTIFICATE_NUMBER_FORMAT.name(), "{prefiks}/{rok}/{numer}",
SettingKey.CERTIFICATE_NUMBER_PREFIX.name(), " "));
assertThat(certificateService.formatNumber(2026, 7)).isEqualTo("2026/7");
}
@Test
@DisplayName("Wzor bez znacznika prefiksu dziala tak samo jak dotychczas")
void formatWithoutPrefixMarkerIsUnchanged() {
settings.update(Map.of(
SettingKey.CERTIFICATE_NUMBER_FORMAT.name(), "{rok}/{miesiac}/{numer}",
SettingKey.CERTIFICATE_NUMBER_PREFIX.name(), "SZK"));
String number = certificateService.formatNumber(2026, 7);
assertThat(number).startsWith("2026/").endsWith("/7");
assertThat(number).doesNotContain("SZK");
}
}
@@ -0,0 +1,155 @@
package com.softspm.webinar.certificate;
import com.softspm.webinar.config.CertificateTemplateConfig;
import com.softspm.webinar.settings.SettingKey;
import com.softspm.webinar.settings.SettingsService;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.text.PDFTextStripper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* Generowanie certyfikatu do PDF.
*
* NAJWAZNIEJSZY TEST: POLSKIE ZNAKI
* ------------------------------------------------------------------------
* Bez osadzonej czcionki diakrytyki znikaja u odbiorcy i "Wiśniewski" staje
* sie "Winiewski". Certyfikat z bledem w nazwisku jest bezuzyteczny, a blad
* ujawnilby sie dopiero u uczestnika, ktory otworzy plik. Test czyta tekst
* z gotowego PDF i sprawdza, ze ogonki tam sa.
*/
class CertificatePdfTest {
private CertificatePdfRenderer renderer;
@BeforeEach
void setUp() {
SettingsService settings = mock(SettingsService.class);
when(settings.get(any(SettingKey.class))).thenReturn("");
renderer = new CertificatePdfRenderer(
new CertificateTemplateConfig().certificateTemplateEngine(),
settings);
}
private CertificatePdfRenderer.CertificateData data(String participantName) {
return new CertificatePdfRenderer.CertificateData(
"2026/7",
participantName,
"Bezpieczeństwo danych w małej firmie",
"15 marca 2026",
"3",
"Platforma Szkoleniowa",
"https://example.test/certyfikat/2026/7",
null);
}
/** Tekst wyciagniety z gotowego pliku PDF. */
private String textOf(byte[] pdf) throws Exception {
try (var document = Loader.loadPDF(pdf)) {
return new PDFTextStripper().getText(document);
}
}
@Test
@DisplayName("polskie znaki przezywaja droge do pliku PDF")
void polishCharactersSurviveInPdf() throws Exception {
String html = renderer.renderHtml(data("Michał Wiśniewski-Łącki"), null);
byte[] pdf = renderer.renderPdf(html);
String text = textOf(pdf);
// pelen zestaw polskich znakow w nazwisku i tytule
assertThat(text).contains("Michał Wiśniewski-Łącki");
assertThat(text).contains("Bezpieczeństwo danych w małej firmie");
// znak zapytania zamiast ogonka oznacza brak osadzonej czcionki
assertThat(text).doesNotContain("Winiewski");
assertThat(text).doesNotContain("?????");
}
@Test
@DisplayName("plik jest poprawnym PDF w formacie A4 poziomo")
void producesLandscapeA4() throws Exception {
byte[] pdf = renderer.renderPdf(renderer.renderHtml(data("Anna Kowalska"), null));
assertThat(new String(pdf, 0, 5)).isEqualTo("%PDF-");
try (var document = Loader.loadPDF(pdf)) {
assertThat(document.getNumberOfPages()).isEqualTo(1);
var box = document.getPage(0).getMediaBox();
// A4 poziomo: 297mm x 210mm, czyli okolo 842 x 595 punktow
assertThat(box.getWidth()).isCloseTo(842f, org.assertj.core.data.Offset.offset(3f));
assertThat(box.getHeight()).isCloseTo(595f, org.assertj.core.data.Offset.offset(3f));
assertThat(box.getWidth()).isGreaterThan(box.getHeight());
}
}
@Test
@DisplayName("certyfikat niesie numer, date i liczbe godzin")
void containsKeyInformation() throws Exception {
String text = textOf(renderer.renderPdf(renderer.renderHtml(data("Jan Nowak"), null)));
assertThat(text).contains("2026/7");
assertThat(text).contains("15 marca 2026");
assertThat(text).contains("3");
assertThat(text).contains("Platforma Szkoleniowa");
}
@Test
@DisplayName("brak liczby godzin nie psuje dokumentu")
void worksWithoutHours() throws Exception {
var withoutHours = new CertificatePdfRenderer.CertificateData(
"2026/8", "Ewa Nowak", "Szkolenie bez godzin", "1 kwietnia 2026",
null, "Marka", null, null);
String text = textOf(renderer.renderPdf(renderer.renderHtml(withoutHours, null)));
assertThat(text).contains("Ewa Nowak");
assertThat(text).doesNotContain("godz. szkoleniowych");
}
@Test
@DisplayName("wlasna tresc organizatora tez zachowuje polskie znaki")
void customTemplateKeepsPolishCharacters() throws Exception {
// wlasna tresc jest opakowywana w szkielet z osadzona czcionka;
// bez tego opakowania ogonki znikalyby wlasnie tutaj
String custom = "<h2>Zaświadczenie o ukończeniu</h2><p>Gratulacje dla uczestnika.</p>";
String text = textOf(renderer.renderPdf(renderer.renderHtml(data("Zofia Jabłońska"), custom)));
assertThat(text).contains("Zaświadczenie o ukończeniu");
assertThat(text).contains("Gratulacje");
}
@Test
@DisplayName("niezamkniety znacznik we wlasnej tresci nie wywraca generowania")
void tolerantToBrokenMarkup() throws Exception {
// jsoup domyka znaczniki przed renderowaniem, bo renderer wymaga XML
String broken = "<p>Tekst bez zamkniecia<p>Drugi akapit";
byte[] pdf = renderer.renderPdf(renderer.renderHtml(data("Test Testowy"), broken));
assertThat(new String(pdf, 0, 5)).isEqualTo("%PDF-");
}
@Test
@DisplayName("czcionka z polskimi znakami jest w zasobach projektu")
void fontIsBundled() throws Exception {
// build musi byc powtarzalny: czcionka lezy w repozytorium, a nie
// jest brana z systemu, ktory moze jej nie miec
Path font = Path.of("src/main/resources/fonts/DejaVuSans.ttf");
assertThat(Files.exists(font)).isTrue();
assertThat(Files.size(font)).isGreaterThan(100_000);
}
}
@@ -0,0 +1,135 @@
package com.softspm.webinar.certificate;
import com.softspm.webinar.domain.Certificate;
import com.softspm.webinar.domain.Contact;
import com.softspm.webinar.domain.Course;
import com.softspm.webinar.domain.Enrollment;
import com.softspm.webinar.repository.EnrollmentRepository;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.lang.reflect.RecordComponent;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* Kto moze otworzyc certyfikat kursu.
*
* DLACZEGO TO JEST OSOBNY TEST
* ---------------------------------------------------------------------------
* Certyfikat jest **dokumentem jednej osoby**, z jej imieniem i nazwiskiem.
* Gdyby wystarczal sam udzial w kursie, kazdy kursant pobralby certyfikaty
* wszystkich kolegow z grupy i poznal ich dane. Ta sama pulapka wyszla juz raz
* przy webinarach, przy sprawdzaniu na zywo.
*
* Reguly sprawdzamy na zachowaniu metody i na strukturze odpowiedzi
* publicznej, a nie na jednym przykladzie: zmiana warunku ma zlamac test
* natychmiast.
*/
class CourseCertificateOwnerTest {
private CourseCertificateService serviceWith(EnrollmentRepository enrollments) {
return new CourseCertificateService(
null, null, enrollments, null, null, null, null, null, null, null, null);
}
private Enrollment enrollmentOf(long enrollmentId, long contactId) {
Contact contact = new Contact();
contact.setId(contactId);
Enrollment enrollment = new Enrollment();
enrollment.setId(enrollmentId);
enrollment.setContact(contact);
return enrollment;
}
@Test
@DisplayName("Wlasciciel zapisu otwiera swoj certyfikat")
void ownerCanOpen() {
EnrollmentRepository enrollments = mock(EnrollmentRepository.class);
when(enrollments.findById(10L)).thenReturn(Optional.of(enrollmentOf(10L, 7L)));
assertThat(serviceWith(enrollments).belongsToContact(10L, 7L)).isTrue();
}
@Test
@DisplayName("Inny kursant TEGO SAMEGO kursu nie otwiera cudzego certyfikatu")
void otherStudentCannotOpen() {
/*
* Najwazniejszy przypadek. Kontakt 8 moze byc zapisany na ten sam kurs
* i miec wlasny certyfikat, ale ten nalezy do kontaktu 7.
*/
EnrollmentRepository enrollments = mock(EnrollmentRepository.class);
when(enrollments.findById(10L)).thenReturn(Optional.of(enrollmentOf(10L, 7L)));
assertThat(serviceWith(enrollments).belongsToContact(10L, 8L)).isFalse();
}
@Test
@DisplayName("Niezalogowany nie otwiera niczego")
void anonymousCannotOpen() {
EnrollmentRepository enrollments = mock(EnrollmentRepository.class);
assertThat(serviceWith(enrollments).belongsToContact(10L, null)).isFalse();
}
@Test
@DisplayName("Nieistniejacy zapis nie otwiera niczego")
void missingEnrollmentCannotOpen() {
EnrollmentRepository enrollments = mock(EnrollmentRepository.class);
when(enrollments.findById(99L)).thenReturn(Optional.empty());
assertThat(serviceWith(enrollments).belongsToContact(99L, 7L)).isFalse();
}
@Test
@DisplayName("Weryfikacja publiczna nie wypuszcza danych osobowych")
void publicVerificationLeaksNothing() {
/*
* Endpoint weryfikacji jest otwarty dla calego internetu. Wolno mu
* oddac numer, tytul, date, imie z inicjalem i waznosc. Adres e-mail,
* pelne nazwisko ani link do pliku nie moga sie tam znalezc **nigdy**,
* takze przypadkiem, przy dokladaniu pola dla kursow.
*
* Test patrzy na strukture rekordu, wiec dodanie takiego pola zlamie
* go od razu, bez czekania na to, ze ktos pomysli o przypadku.
*/
List<String> forbidden = List.of("email", "lastname", "url", "objectkey",
"downloadurl", "phone", "contactid");
List<String> names = Arrays.stream(
CertificateService.PublicVerification.class.getRecordComponents())
.map(RecordComponent::getName)
.map(name -> name.toLowerCase(Locale.ROOT))
.toList();
assertThat(names).doesNotContainAnyElementsOf(forbidden);
// to, co ma tam byc
assertThat(names).contains("number", "subjecttitle", "participantname", "valid");
}
@Test
@DisplayName("Imie skraca sie do inicjalu nazwiska")
void shortNameHidesLastName() {
assertThat(CertificateService.shortName("Anna", "Kowalska")).isEqualTo("Anna K.");
assertThat(CertificateService.shortName("Łukasz", "Ćwikliński")).isEqualTo("Łukasz Ć.");
// brak nazwiska nie moze dac wiszacej kropki
assertThat(CertificateService.shortName("Anna", null)).isEqualTo("Anna");
assertThat(CertificateService.shortName("Anna", " ")).isEqualTo("Anna");
}
@Test
@DisplayName("Certyfikat rozpoznaje swoje zrodlo")
void certificateKnowsItsSource() {
Certificate courseCertificate = new Certificate();
courseCertificate.setCourse(new Course());
assertThat(courseCertificate.isForCourse()).isTrue();
Certificate webinarCertificate = new Certificate();
assertThat(webinarCertificate.isForCourse()).isFalse();
}
}
@@ -0,0 +1,158 @@
package com.softspm.webinar.certificate;
import com.softspm.webinar.config.CertificateTemplateConfig;
import com.softspm.webinar.settings.SettingKey;
import com.softspm.webinar.settings.SettingsService;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.text.PDFTextStripper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* Certyfikat ukonczenia kursu jako gotowy plik PDF.
*
* DLACZEGO CZYTAMY TEKST Z PDF, A NIE Z HTML
* ---------------------------------------------------------------------------
* Miedzy szablonem a plikiem stoi renderer i osadzanie czcionek, czyli
* dokladnie te miejsca, w ktorych polskie znaki gina. Test sprawdzajacy HTML
* przechodzilby takze wtedy, gdy w gotowym dokumencie kazdy ogonek zamienia sie
* w krzyzyk, a certyfikat z bledem w nazwisku jest bezuzyteczny.
*/
class CourseCertificatePdfTest {
private CertificatePdfRenderer renderer;
@BeforeEach
void setUp() {
SettingsService settings = mock(SettingsService.class);
when(settings.get(any(SettingKey.class))).thenReturn("");
renderer = new CertificatePdfRenderer(
new CertificateTemplateConfig().certificateTemplateEngine(), settings);
}
private CertificatePdfRenderer.CourseCertificateData data(String name, Integer score) {
return new CertificatePdfRenderer.CourseCertificateData(
"2026/12",
name,
"Plan bezpieczeństwa wody: ocena ryzyka",
"15 marca 2026",
"8",
score,
"Akademia Wodociągowca",
"https://example.test/certyfikat/2026/12",
null);
}
private byte[] render(CertificatePdfRenderer.CourseCertificateData data, String custom) {
return renderer.renderPdf(renderer.renderCourseHtml(data, custom));
}
private String textOf(byte[] pdf) throws Exception {
try (var document = Loader.loadPDF(pdf)) {
return new PDFTextStripper().getText(document);
}
}
@Test
@DisplayName("Polskie znaki przezywaja droge do pliku PDF")
void polishCharactersSurvive() throws Exception {
String text = textOf(render(data("Michał Wiśniewski-Łącki", 92), null));
assertThat(text).contains("Michał Wiśniewski-Łącki");
assertThat(text).contains("Plan bezpieczeństwa wody");
assertThat(text).contains("Akademia Wodociągowca");
// objaw zgubionej czcionki: nazwisko bez ogonkow albo same krzyzyki
assertThat(text).doesNotContain("Winiewski");
assertThat(text).doesNotContain("?????");
}
@Test
@DisplayName("Dokument mowi o UKONCZENIU kursu, nie o udziale w webinarze")
void saysCourseCompleted() throws Exception {
String text = textOf(render(data("Anna Kowalska", 80), null));
/*
* Rozroznienie jest sednem osobnego szablonu. Certyfikat kursu
* potwierdza ukonczenie, a nie sama obecnosc: gdyby uzywal szablonu
* webinarowego, dokument mowilby "wzial udzial w szkoleniu", co przy
* kursie z quizem jest po prostu nieprawda.
*/
assertThat(text).contains("ukończył");
assertThat(text).doesNotContain("wziął(-ęła) udział");
}
@Test
@DisplayName("Wynik sprawdzianu trafia na dokument")
void includesQuizScore() throws Exception {
String text = textOf(render(data("Anna Kowalska", 92), null));
assertThat(text).contains("92");
assertThat(text).contains("wynik sprawdzianu");
}
@Test
@DisplayName("Kurs bez sprawdzianu nie pokazuje wyniku")
void withoutQuizShowsNoScore() throws Exception {
/*
* Null, a nie zero. Zero na dokumencie wygladaloby jak oblany test,
* a kurs bez quizu po prostu nie ma czego podac.
*/
String text = textOf(render(data("Anna Kowalska", null), null));
assertThat(text).doesNotContain("wynik sprawdzianu");
assertThat(text).contains("Anna Kowalska");
}
@Test
@DisplayName("Numer i data sa na dokumencie")
void containsKeyInformation() throws Exception {
String text = textOf(render(data("Anna Kowalska", 75), null));
assertThat(text).contains("2026/12");
assertThat(text).contains("15 marca 2026");
assertThat(text).contains("8");
}
@Test
@DisplayName("Wlasny szablon organizatora zachowuje polskie znaki")
void customTemplateKeepsPolishCharacters() throws Exception {
String custom = """
<h2>Zaświadczenie o ukończeniu</h2>
<p th:text="${participantName}">imię</p>
<p th:text="${courseTitle}">kurs</p>
""";
String text = textOf(render(data("Łukasz Ćwikliński", 88), custom));
assertThat(text).contains("Zaświadczenie o ukończeniu");
assertThat(text).contains("Łukasz Ćwikliński");
}
@Test
@DisplayName("Wlasny szablon napisany dla webinaru dziala takze przy kursie")
void webinarStyleTemplateStillWorks() throws Exception {
/*
* Organizator, ktory napisal wlasny szablon dla webinarow, uzywa
* w nim `webinarTitle` i `webinarDate`. Te same nazwy sa dostepne przy
* kursie, wiec szablon nie pokazuje pustych miejsc po zmianie zrodla.
*/
String custom = """
<p th:text="${webinarTitle}">tytuł</p>
<p th:text="${webinarDate}">data</p>
""";
String text = textOf(render(data("Anna Kowalska", 75), custom));
assertThat(text).contains("Plan bezpieczeństwa wody");
assertThat(text).contains("15 marca 2026");
}
@Test
@DisplayName("Niezamkniete znaczniki nie wywracaja generowania")
void tolerantToBrokenMarkup() {
// jsoup domyka znaczniki przed renderowaniem, bo renderer wymaga XML
byte[] pdf = render(data("Anna Kowalska", 75), "<p>Tekst bez zamkniecia<p>Drugi akapit");
assertThat(new String(pdf, 0, 5)).isEqualTo("%PDF-");
}
}
@@ -0,0 +1,323 @@
package com.softspm.webinar.config;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.asm.ClassReader;
import org.springframework.asm.MethodVisitor;
import org.springframework.asm.Opcodes;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Wykrywa samowywolania metod transakcyjnych.
*
* DLACZEGO TEN TEST W OGOLE ISTNIEJE
* ---------------------------------------------------------------------------
* Ten sam blad wystapil TRZY RAZY: `@Transactional` na metodzie wolanej z tej
* samej klasy nie dziala, bo samowywolanie omija proxy Springa. Za kazdym razem
* objawil sie dopiero na uruchomionej instancji, nie w testach, bo kod
* kompiluje sie poprawnie i wyglada dobrze przy czytaniu.
*
* Regula w CLAUDE.md nie zapobiegla, bo wymaga, zeby ktos o niej pamietal
* dokladnie w chwili pisania. Ten test pamieta zawsze.
*
* JAK TO DZIALA
* ---------------------------------------------------------------------------
* Czyta **bajtkod**, a nie zrodla: interesuje nas faktyczne wywolanie, a nie
* to, jak wyglada w tekscie. Instrukcja `INVOKEVIRTUAL` albo `INVOKESPECIAL`
* na wlasnej klasie, poprzedzona zaladowaniem `this` (ALOAD 0), to dokladnie
* samowywolanie omijajace proxy.
*
* ASM pochodzi ze `spring-core` (`org.springframework.asm`), ktore i tak jest
* w zaleznosciach: **nie dokladamy zadnej biblioteki**.
*
* CZEGO TEST NIE ZGLASZA
* ---------------------------------------------------------------------------
* Metody `readOnly = true` wolane z innej metody transakcyjnej. Taka metoda
* dolacza do istniejacej transakcji i zachowuje sie poprawnie, a takich
* wywolan sa dziesiatki: zglaszanie ich utopiloby prawdziwe znaleziska
* w szumie. Groźne jest to, co zmienia zachowanie: REQUIRES_NEW, ktore bez
* proxy nie zaklada nowej transakcji, oraz zapis wolany z metody
* nietransakcyjnej, ktory bez proxy nie ma transakcji wcale.
*/
class TransactionalSelfInvocationTest {
private static final String BASE_PACKAGE = "com/softspm/webinar";
/**
* Wyjatki od reguly, kazdy z uzasadnieniem.
*
* Wpis tutaj to swiadoma decyzja, a nie sposob na uciszenie testu.
*/
private static final Map<String, String> ALLOWED = Map.of(
// brak wpisow: kazde znalezisko ma byc naprawione, nie odnotowane
);
private record Finding(String className, String caller, String callee, String annotation) {
@Override
public String toString() {
return "%s.%s() wola %s() [%s]".formatted(
className.substring(className.lastIndexOf('/') + 1), caller, callee, annotation);
}
}
@Test
@DisplayName("Zadna metoda transakcyjna nie jest wolana przez this")
void noSelfInvocationOfTransactionalMethods() throws Exception {
List<Finding> findings = new ArrayList<>();
for (Resource resource : classResources()) {
try (InputStream in = resource.getInputStream()) {
analyse(new ClassReader(in), findings);
}
}
List<String> reported = findings.stream()
.map(Finding::toString)
.filter(f -> !ALLOWED.containsKey(f))
.sorted()
.toList();
assertThat(reported)
.as("""
Samowywolanie metody transakcyjnej omija proxy Springa, wiec adnotacja \
nie dziala. Napraw przez wstrzykniecie referencji na samego siebie \
(@Lazy) i wolanie przez nia, tak jak robi to OrderService albo \
SummarySender. Ten blad NIE ujawnia sie w kompilacji ani w zwyklych \
testach, tylko na dzialajacej instancji.""")
.isEmpty();
}
// --- analiza bajtkodu ---
private void analyse(ClassReader reader, List<Finding> findings) {
String className = reader.getClassName();
// 1. ktore metody tej klasy sa transakcyjne i jak
Map<String, String> transactional = new HashMap<>();
// 2. ktore metody sa nietransakcyjne, zeby ocenic wagę znaleziska
Set<String> allMethods = new HashSet<>();
reader.accept(new org.springframework.asm.ClassVisitor(Opcodes.ASM9) {
@Override
public MethodVisitor visitMethod(int access, String name, String descriptor,
String signature, String[] exceptions) {
allMethods.add(name);
return new MethodVisitor(Opcodes.ASM9) {
@Override
public org.springframework.asm.AnnotationVisitor visitAnnotation(
String desc, boolean visible) {
if (desc.endsWith("/Transactional;")) {
// domyslnie zwykla transakcja; szczegoly z pol adnotacji
transactional.putIfAbsent(name, "Transactional");
return new org.springframework.asm.AnnotationVisitor(Opcodes.ASM9) {
@Override
public void visitEnum(String field, String d, String value) {
if ("propagation".equals(field)) {
transactional.put(name, value);
}
}
@Override
public void visit(String field, Object value) {
if ("readOnly".equals(field) && Boolean.TRUE.equals(value)) {
transactional.put(name, "readOnly");
}
}
};
}
return null;
}
};
}
}, ClassReader.SKIP_FRAMES);
if (transactional.isEmpty()) {
return;
}
// 3. szukamy wywolan wlasnych metod transakcyjnych
reader.accept(new org.springframework.asm.ClassVisitor(Opcodes.ASM9) {
@Override
public MethodVisitor visitMethod(int access, String caller, String descriptor,
String signature, String[] exceptions) {
/*
* ROZROZNIENIE `this` OD REFERENCJI NA SAMEGO SIEBIE
* ----------------------------------------------------------
* Oba wywolania maja w bajtkodzie tego samego wlasciciela, bo
* pole `self` jest typu tej samej klasy. Rozni je to, CO jest
* odbiorca wywolania:
*
* this.metoda(x) -> ALOAD 0, ALOAD 1, INVOKEVIRTUAL
* self.metoda(x) -> ALOAD 0, GETFIELD self, ALOAD 1, INVOKEVIRTUAL
*
* Odbiorca lezy na stosie POD argumentami, wiec nie wystarczy
* patrzec na ostatnia instrukcje: przy metodzie z argumentami
* ostatnie jest ich ladowanie. Trzymamy wiec slad ostatnich
* instrukcji i cofamy sie o tyle pozycji, ile argumentow ma
* wolana metoda.
*
* Bez tego test przepuszczal prawdziwy blad, co wyszlo przy
* sprawdzeniu go celowo przywroconym samowywolaniem.
*/
return new MethodVisitor(Opcodes.ASM9) {
/**
* Slad tego, co trafilo na stos: true oznacza `this`.
*
* Uproszczony model: interesuje nas wylacznie, czy dana
* pozycja to `this`, a nie jej wartosc.
*/
private final java.util.ArrayDeque<Boolean> stack = new java.util.ArrayDeque<>();
private void push(boolean isThis) {
stack.push(isThis);
// slad ma byc krotki: glebiej i tak nie siegamy
while (stack.size() > 32) {
stack.removeLast();
}
}
@Override
public void visitVarInsn(int opcode, int varIndex) {
push(opcode == Opcodes.ALOAD && varIndex == 0);
}
@Override
public void visitFieldInsn(int opcode, String owner, String name,
String descriptor) {
if (opcode == Opcodes.GETFIELD) {
// zdejmuje referencje, kladzie wartosc pola
if (!stack.isEmpty()) {
stack.pop();
}
push(false);
} else if (opcode == Opcodes.GETSTATIC) {
push(false);
}
}
@Override
public void visitLdcInsn(Object value) {
push(false);
}
@Override
public void visitIntInsn(int opcode, int operand) {
push(false);
}
@Override
public void visitTypeInsn(int opcode, String type) {
push(false);
}
@Override
public void visitMethodInsn(int opcode, String owner, String name,
String desc, boolean isInterface) {
boolean sameClass = owner.equals(className)
&& (opcode == Opcodes.INVOKEVIRTUAL
|| opcode == Opcodes.INVOKESPECIAL);
if (sameClass && transactional.containsKey(name) && !name.equals(caller)) {
/*
* Odbiorca lezy pod argumentami. Liczymy sloty
* argumentow z deskryptora i siegamy o tyle glebiej.
*/
int args = argumentSlots(desc);
Boolean receiver = nth(args);
if (Boolean.TRUE.equals(receiver)) {
String annotation = transactional.get(name);
if (isDangerous(annotation, caller, transactional)) {
findings.add(new Finding(className, caller, name, annotation));
}
}
}
// zdejmujemy argumenty i odbiorce, kladziemy wynik
int toPop = argumentSlots(desc)
+ (opcode == Opcodes.INVOKESTATIC ? 0 : 1);
for (int i = 0; i < toPop && !stack.isEmpty(); i++) {
stack.pop();
}
if (!desc.endsWith(")V")) {
push(false);
}
}
/** Element stosu na wskazanej glebokosci, liczac od wierzchu. */
private Boolean nth(int depth) {
int i = 0;
for (Boolean value : stack) {
if (i++ == depth) {
return value;
}
}
return null;
}
};
}
}, ClassReader.SKIP_FRAMES);
}
/**
* Ile pozycji na stosie zajmuja argumenty metody.
*
* `long` i `double` zajmuja po dwa sloty w tablicy zmiennych lokalnych,
* ale na stosie operandow licza sie tu jako jedna wartosc, bo sledzimy
* wartosci, nie sloty.
*/
static int argumentSlots(String descriptor) {
int count = 0;
int i = 1;
while (i < descriptor.length() && descriptor.charAt(i) != ')') {
char c = descriptor.charAt(i);
if (c == 'L') {
i = descriptor.indexOf(';', i) + 1;
count++;
} else if (c == '[') {
i++;
} else {
i++;
count++;
}
}
return count;
}
/**
* Czy to znalezisko zmienia zachowanie.
*
* REQUIRES_NEW zawsze: bez proxy nowa transakcja nie powstaje, a to jest
* cala jego rola. Zapis wolany z metody NIETRANSAKCYJNEJ takze: bez proxy
* nie ma zadnej transakcji, wiec zapis albo nie dziala, albo dziala
* przypadkiem, przez `open-in-view` (u nas wylaczone) lub autocommit.
*
* Metoda `readOnly` wolana z innej metody transakcyjnej jest nieszkodliwa:
* dolacza do istniejacej transakcji, dokladnie tak jak zrobilby to proxy
* przy propagacji REQUIRED.
*/
private boolean isDangerous(String annotation, String caller, Map<String, String> transactional) {
if ("REQUIRES_NEW".equals(annotation) || "NOT_SUPPORTED".equals(annotation)) {
return true;
}
// zapis wolany z metody, ktora sama nie ma transakcji
boolean callerIsTransactional = transactional.containsKey(caller);
return !callerIsTransactional && !"readOnly".equals(annotation);
}
private Resource[] classResources() throws IOException {
var resolver = new PathMatchingResourcePatternResolver();
return resolver.getResources("classpath*:" + BASE_PACKAGE + "/**/*.class");
}
}
@@ -0,0 +1,129 @@
package com.softspm.webinar.content;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Oczyszczanie tresci strony zapisu.
*
* To jedyna bariera miedzy trescia od klienta a baza, bo endpoint zapisu mozna
* wywolac z pominieciem edytora. Kazdy przypadek tutaj to konkretny sposob,
* w jaki dalo by sie wstrzyknac kod na publiczna strone.
*/
class HtmlSanitizerTest {
private final HtmlSanitizer sanitizer = new HtmlSanitizer();
@Test
@DisplayName("Znacznik script jest wycinany razem z zawartoscia")
void removesScript() {
String result = sanitizer.sanitize("<p>Zapraszamy</p><script>alert(1)</script>");
assertThat(result).doesNotContain("script").doesNotContain("alert");
assertThat(result).contains("Zapraszamy");
}
@Test
@DisplayName("Atrybuty on* nie przechodza, takze przy dozwolonym znaczniku")
void removesEventHandlers() {
String result = sanitizer.sanitize(
"<img src=\"/api/public/media/1\" onerror=\"alert(1)\" alt=\"x\">");
assertThat(result).doesNotContain("onerror").doesNotContain("alert");
// sam obrazek zostaje, bo adres jest dozwolony
assertThat(result).contains("/api/public/media/1");
}
@Test
@DisplayName("Adres javascript: w linku jest usuwany")
void removesJavascriptHref() {
String result = sanitizer.sanitize("<a href=\"javascript:alert(1)\">klik</a>");
assertThat(result).doesNotContain("javascript");
// tekst linku zostaje, znika sam adres
assertThat(result).contains("klik");
}
@Test
@DisplayName("iframe i style nie przechodza")
void removesIframeAndStyle() {
String result = sanitizer.sanitize(
"<p>Tresc</p><iframe src=\"https://obcy.example\"></iframe>"
+ "<style>body{display:none}</style>");
assertThat(result).doesNotContain("iframe").doesNotContain("style");
assertThat(result).contains("Tresc");
}
@Test
@DisplayName("Obrazek z obcego serwera jest odrzucany")
void rejectsExternalImage() {
String result = sanitizer.sanitize(
"<p>a</p><img src=\"https://obcy.example/sledzik.gif\" alt=\"x\">");
assertThat(result).doesNotContain("obcy.example");
}
@Test
@DisplayName("Obrazek z wlasnego endpointu przechodzi razem z alt")
void keepsOwnImage() {
String result = sanitizer.sanitize(
"<img src=\"/api/public/media/42\" alt=\"Wykres zuzycia\">");
assertThat(result).contains("/api/public/media/42");
assertThat(result).contains("Wykres zuzycia");
}
@Test
@DisplayName("Znaczniki tresci z edytora zostaja")
void keepsEditorMarkup() {
String result = sanitizer.sanitize(
"<h2>Program</h2><h3>Czesc pierwsza</h3>"
+ "<p><strong>Wazne</strong> i <em>ciekawe</em></p>"
+ "<ul><li>punkt</li></ul><ol><li>krok</li></ol>"
+ "<blockquote>cytat</blockquote><hr>");
assertThat(result)
.contains("<h2>").contains("<h3>")
.contains("<strong>").contains("<em>")
.contains("<ul>").contains("<ol>").contains("<li>")
.contains("<blockquote>").contains("<hr>");
}
@Test
@DisplayName("Wyrownanie akapitu przechodzi tylko jako znana klasa")
void keepsOnlyKnownClasses() {
String allowed = sanitizer.sanitize("<p class=\"text-center\">srodek</p>");
assertThat(allowed).contains("text-center");
String rejected = sanitizer.sanitize("<p class=\"dowolna-klasa\">tekst</p>");
assertThat(rejected).doesNotContain("dowolna-klasa");
assertThat(rejected).contains("tekst");
}
@Test
@DisplayName("Pusta tresc i tresc bez nic widocznego daja null")
void emptyBecomesNull() {
assertThat(sanitizer.sanitize(null)).isNull();
assertThat(sanitizer.sanitize("")).isNull();
assertThat(sanitizer.sanitize(" ")).isNull();
// samo script po oczyszczeniu nie zostawia nic widocznego
assertThat(sanitizer.sanitize("<script>alert(1)</script>")).isNull();
}
@Test
@DisplayName("Wariant z innej wielkosci liter i zagniezdzeniem tez nie przechodzi")
void handlesObfuscatedVariants() {
String result = sanitizer.sanitize(
"<SCRIPT>alert(1)</SCRIPT><p onClick=\"alert(2)\">tekst</p>"
+ "<svg onload=\"alert(3)\"></svg>");
assertThat(result.toLowerCase()).doesNotContain("script");
assertThat(result.toLowerCase()).doesNotContain("onclick");
assertThat(result.toLowerCase()).doesNotContain("onload");
assertThat(result.toLowerCase()).doesNotContain("svg");
assertThat(result).contains("tekst");
}
}
@@ -0,0 +1,60 @@
package com.softspm.webinar.course;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Postep procentowy kursanta.
*
* LICZY SIE WYLACZNIE Z LEKCJI WYMAGANYCH
* ---------------------------------------------------------------------------
* Materialy dodatkowe (`required = false`) nie wchodza ani do licznika, ani do
* mianownika. Gdyby wchodzily, dolozenie jednego materialu dodatkowego
* obnizaloby postep wszystkim, ktorzy juz skonczyli kurs, a organizator
* dokladajacy bonus psulby statystyki calej grupie.
*
* Zaokraglenie idzie w dol, zeby 99,7 procent nie pokazywalo sie jako
* ukonczone: prog zaliczenia porownuje sie z ta sama liczba.
*/
class CourseProgressTest {
@Test
@DisplayName("Kurs bez lekcji wymaganych daje zero, a nie dzielenie przez zero")
void noRequiredLessonsGivesZero() {
assertThat(CourseEnrollmentService.percent(0, 0)).isZero();
// nawet gdyby cos bylo ukonczone: bez lekcji wymaganych nie ma czego liczyc
assertThat(CourseEnrollmentService.percent(5, 0)).isZero();
}
@Test
@DisplayName("Postep liczy sie z lekcji wymaganych")
void countsRequiredOnly() {
assertThat(CourseEnrollmentService.percent(0, 4)).isZero();
assertThat(CourseEnrollmentService.percent(1, 4)).isEqualTo(25);
assertThat(CourseEnrollmentService.percent(2, 4)).isEqualTo(50);
assertThat(CourseEnrollmentService.percent(4, 4)).isEqualTo(100);
}
@Test
@DisplayName("Zaokraglenie w dol: niepelne ukonczenie nie pokazuje stu procent")
void roundsDown() {
// 2 z 3 to 66,6 procent
assertThat(CourseEnrollmentService.percent(2, 3)).isEqualTo(66);
// 299 z 300 to 99,6 procent, a nie 100
assertThat(CourseEnrollmentService.percent(299, 300)).isEqualTo(99);
}
@Test
@DisplayName("Wynik nie przekracza stu procent")
void neverAboveHundred() {
/*
* Licznik wiekszy od mianownika jest mozliwy przejsciowo: lekcja
* ukonczona i pozniej oznaczona jako nieobowiazkowa znika z mianownika,
* a jej postep zostaje. Pokazanie 150 procent wygladaloby na blad
* aplikacji, wiec ucinamy do stu.
*/
assertThat(CourseEnrollmentService.percent(5, 3)).isEqualTo(100);
}
}
@@ -0,0 +1,119 @@
package com.softspm.webinar.interaction;
import com.softspm.webinar.content.PlainTextSanitizer;
import com.softspm.webinar.domain.Interaction;
import com.softspm.webinar.domain.Registration;
import com.softspm.webinar.domain.WebinarSpeaker;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Oczyszczanie tresci od uczestnika i prezentacja autora.
*
* Czat widza wszyscy obecni, wiec to dwa miejsca, w ktorych blad jest widoczny
* natychmiast i dla calej sali: wstrzykniety kod oraz wyciekniety adres e-mail.
*/
class PlainTextSanitizerTest {
private final PlainTextSanitizer sanitizer = new PlainTextSanitizer();
@Test
@DisplayName("Znaczniki HTML nie przechodza, tekst w srodku zostaje")
void stripsAllTags() {
assertThat(sanitizer.sanitize("<b>pilne</b> pytanie")).isEqualTo("pilne pytanie");
assertThat(sanitizer.sanitize("<script>alert(1)</script>")).isNull();
assertThat(sanitizer.sanitize("<img src=x onerror=alert(1)>")).isNull();
}
@Test
@DisplayName("Proba wstrzykniecia linku zostaje zwyklym tekstem")
void linkBecomesPlainText() {
String result = sanitizer.sanitize("<a href=\"https://zly.example\">kliknij</a>");
assertThat(result).isEqualTo("kliknij");
assertThat(result).doesNotContain("href").doesNotContain("zly.example");
}
@Test
@DisplayName("Adres wpisany zwyklym tekstem zostaje, ale bez znacznika")
void plainUrlSurvivesWithoutMarkup() {
String result = sanitizer.sanitize("wejdzcie na https://example.org/oferta");
// adres zostaje czytelny, ale nie jest znacznikiem, wiec front go nie klika
assertThat(result).contains("https://example.org/oferta");
assertThat(result).doesNotContain("<a");
}
@Test
@DisplayName("Encje HTML wracaja jako zwykle znaki")
void unescapesEntities() {
assertThat(sanitizer.sanitize("Kowalski &amp; Syn")).isEqualTo("Kowalski & Syn");
}
@Test
@DisplayName("Pusta tresc i sam znacznik daja null")
void emptyBecomesNull() {
assertThat(sanitizer.sanitize(null)).isNull();
assertThat(sanitizer.sanitize(" ")).isNull();
assertThat(sanitizer.sanitize("<div></div>")).isNull();
}
@Test
@DisplayName("Wiele pustych linii jest skracane")
void collapsesBlankLines() {
String result = sanitizer.sanitize("pierwsza\n\n\n\n\n\ndruga");
assertThat(result).isEqualTo("pierwsza\n\ndruga");
}
@Test
@DisplayName("Bardzo dluga tresc jest przycinana")
void truncatesLongContent() {
String result = sanitizer.sanitize("a".repeat(5000));
assertThat(result).hasSize(PlainTextSanitizer.MAX_LENGTH);
}
@Test
@DisplayName("Autor to imie i pierwsza litera nazwiska, nigdy adres e-mail")
void authorNameHidesEmail() {
Registration registration = new Registration();
registration.setFirstName("Anna");
registration.setLastName("Kowalska");
registration.setEmail("anna.kowalska@example.pl");
Interaction interaction = new Interaction();
interaction.setRegistration(registration);
String name = interaction.authorDisplayName();
assertThat(name).isEqualTo("Anna K.");
assertThat(name).doesNotContain("@").doesNotContain("kowalska@");
}
@Test
@DisplayName("Prelegent pokazywany pelnym imieniem i nazwiskiem")
void speakerShownInFull() {
WebinarSpeaker speaker = new WebinarSpeaker();
speaker.setFullName("Jan Prowadzacy");
speaker.setEmail("jan@example.pl");
Interaction interaction = new Interaction();
interaction.setSpeaker(speaker);
assertThat(interaction.authorDisplayName()).isEqualTo("Jan Prowadzacy");
assertThat(interaction.isFromSpeaker()).isTrue();
}
@Test
@DisplayName("Brak nazwiska nie psuje prezentacji autora")
void missingLastNameHandled() {
Registration registration = new Registration();
registration.setFirstName("Ewa");
registration.setLastName("");
Interaction interaction = new Interaction();
interaction.setRegistration(registration);
assertThat(interaction.authorDisplayName()).isEqualTo("Ewa");
}
}
@@ -0,0 +1,212 @@
package com.softspm.webinar.interaction;
import com.softspm.webinar.domain.Registration;
import com.softspm.webinar.domain.RegistrationRoomRole;
import com.softspm.webinar.domain.RegistrationStatus;
import com.softspm.webinar.domain.SpeakRequestStatus;
import com.softspm.webinar.domain.Webinar;
import com.softspm.webinar.domain.WebinarStatus;
import com.softspm.webinar.repository.RegistrationRepository;
import com.softspm.webinar.repository.SpeakRequestRepository;
import com.softspm.webinar.repository.WebinarRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import java.time.OffsetDateTime;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Zgloszenia checi zabrania glosu.
*
* NAJWAZNIEJSZE JEST POWIAZANIE ZGLOSZENIA Z ROLA
* ------------------------------------------------------------------------
* Mikrofon otwiera token wydany na podstawie `registrations.room_role`, wiec
* przyznanie glosu, ktore nie zmienia roli, byloby napisem bez skutku,
* a odebranie glosu, ktore roli nie cofa, zostawialoby otwarty mikrofon.
* Test sprawdza obie strony tego powiazania.
*/
@SpringBootTest
@ActiveProfiles("test")
class SpeakRequestTest {
@Autowired private SpeakRequestService speakRequests;
@Autowired private SpeakRequestRepository speakRequestRepository;
@Autowired private RegistrationRepository registrationRepository;
@Autowired private WebinarRepository webinarRepository;
private Webinar webinar;
private Registration participant;
@BeforeEach
void setUp() {
String suffix = String.valueOf(System.nanoTime());
webinar = new Webinar();
webinar.setTitle("Glos test " + suffix);
webinar.setSlug("glos-test-" + suffix);
webinar.setTimezone("Europe/Warsaw");
webinar.setStatus(WebinarStatus.PUBLISHED);
webinar.setStartsAt(OffsetDateTime.now().plusDays(1));
webinar.setEndsAt(OffsetDateTime.now().plusDays(1).plusHours(2));
webinar.setCreatedAt(OffsetDateTime.now());
webinar.setUpdatedAt(OffsetDateTime.now());
webinar = webinarRepository.save(webinar);
participant = new Registration();
participant.setWebinar(webinar);
participant.setEmail("glos." + suffix + "@example.pl");
participant.setFirstName("Anna");
participant.setLastName("Nowak");
participant.setStatus(RegistrationStatus.CONFIRMED);
participant.setConfirmationToken("glos-tok-" + suffix);
participant.setCreatedAt(OffsetDateTime.now());
participant = registrationRepository.save(participant);
}
@AfterEach
void tearDown() {
webinarRepository.findById(webinar.getId()).ifPresent(webinarRepository::delete);
}
@Test
@DisplayName("Zgloszenie czeka, dopoki moderator nie zdecyduje")
void requestStartsPending() {
var view = speakRequests.request(participant.getId());
assertThat(view.status()).isEqualTo(SpeakRequestStatus.PENDING);
assertThat(view.participantName()).isEqualTo("Anna Nowak");
// samo zgloszenie niczego nie odblokowuje: mikrofon wynika z roli
assertThat(reload().getRoomRole()).isEqualTo(RegistrationRoomRole.VIEWER);
}
@Test
@DisplayName("Przyznanie glosu podnosi role do SPEAKER, odebranie ja cofa")
void grantAndRevokeChangeRole() {
var request = speakRequests.request(participant.getId());
speakRequests.grant(webinar.getId(), request.id());
assertThat(reload().getRoomRole()).isEqualTo(RegistrationRoomRole.SPEAKER);
speakRequests.revoke(webinar.getId(), request.id());
assertThat(reload().getRoomRole()).isEqualTo(RegistrationRoomRole.VIEWER);
assertThat(speakRequestRepository.findById(request.id()).orElseThrow().getStatus())
.isEqualTo(SpeakRequestStatus.REVOKED);
}
@Test
@DisplayName("Odmowa przed przyznaniem to DECLINED, nie REVOKED")
void declineIsDistinctFromRevoke() {
var request = speakRequests.request(participant.getId());
speakRequests.revoke(webinar.getId(), request.id());
// prowadzacy ma w historii widziec, czy uczestnik w ogole doszedl do glosu
assertThat(speakRequestRepository.findById(request.id()).orElseThrow().getStatus())
.isEqualTo(SpeakRequestStatus.DECLINED);
}
@Test
@DisplayName("Wycofanie przyznanego glosu zabiera takze uprawnienie")
void withdrawAfterGrantRemovesRole() {
var request = speakRequests.request(participant.getId());
speakRequests.grant(webinar.getId(), request.id());
speakRequests.withdraw(participant.getId());
// bez cofniecia roli mikrofon zostalby otwarty mimo rezygnacji
assertThat(reload().getRoomRole()).isEqualTo(RegistrationRoomRole.VIEWER);
}
@Test
@DisplayName("Po rozstrzygnieciu uczestnik moze zglosic sie ponownie")
void canRequestAgainAfterResolution() {
var first = speakRequests.request(participant.getId());
speakRequests.revoke(webinar.getId(), first.id());
var second = speakRequests.request(participant.getId());
assertThat(second.id()).isNotEqualTo(first.id());
assertThat(second.status()).isEqualTo(SpeakRequestStatus.PENDING);
}
@Test
@DisplayName("Rownoczesne klikniecia daja jedno zgloszenie, nie dziesiec")
void concurrentRequestsCreateOne() throws Exception {
int attempts = 10;
ExecutorService pool = Executors.newFixedThreadPool(attempts);
CountDownLatch start = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(attempts);
for (int i = 0; i < attempts; i++) {
pool.submit(() -> {
try {
start.await();
speakRequests.request(participant.getId());
} catch (Exception e) {
// wyscig jest spodziewany: liczy sie stan koncowy w bazie
} finally {
done.countDown();
}
});
}
start.countDown();
assertThat(done.await(30, TimeUnit.SECONDS)).isTrue();
pool.shutdown();
/*
* Unikalny indeks czesciowy przepuszcza dokladnie jedno zgloszenie
* w toku. Sprawdzenie w kodzie samo by nie wystarczylo: dwa watki
* przechodza je jednoczesnie.
*/
assertThat(speakRequestRepository.findByWebinarAndStatuses(
webinar.getId(),
java.util.List.of(SpeakRequestStatus.PENDING, SpeakRequestStatus.GRANTED)))
.hasSize(1);
}
@Test
@DisplayName("Otwarty mikrofon nie kasuje kolejki ani nie odbiera przyznanego glosu")
void openMicLeavesQueueIntact() {
var request = speakRequests.request(participant.getId());
speakRequests.grant(webinar.getId(), request.id());
speakRequests.setOpenMic(webinar.getId(), true);
/*
* Prowadzacy wlacza tryb na czas dyskusji i wylacza po niej, wiec
* zgloszenia musza przetrwac. Kasowanie ich odbieraloby miejsce
* osobom czekajacym od kwadransa.
*/
assertThat(speakRequestRepository.findByWebinarAndStatuses(
webinar.getId(), java.util.List.of(SpeakRequestStatus.GRANTED)))
.hasSize(1);
// uprawnienie przyznane indywidualnie wynika z roli, nie z flagi
speakRequests.setOpenMic(webinar.getId(), false);
assertThat(reload().getRoomRole()).isEqualTo(RegistrationRoomRole.SPEAKER);
}
@Test
@DisplayName("Tryb otwartego mikrofonu zapisuje sie na webinarze")
void openMicIsPersisted() {
assertThat(speakRequests.setOpenMic(webinar.getId(), true).openMic()).isTrue();
assertThat(webinarRepository.findById(webinar.getId()).orElseThrow().isOpenMic()).isTrue();
speakRequests.setOpenMic(webinar.getId(), false);
assertThat(webinarRepository.findById(webinar.getId()).orElseThrow().isOpenMic()).isFalse();
}
private Registration reload() {
return registrationRepository.findById(participant.getId()).orElseThrow();
}
}
@@ -0,0 +1,109 @@
package com.softspm.webinar.interaction;
import com.softspm.webinar.repository.RegistrationRepository;
import com.softspm.webinar.repository.UserRepository;
import com.softspm.webinar.repository.WebinarSpeakerRepository;
import com.softspm.webinar.video.VideoService;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.messaging.Message;
import org.springframework.messaging.simp.stomp.StompCommand;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.messaging.support.MessageBuilder;
import java.util.HashMap;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
/**
* Autoryzacja SUBSCRIBE na WebSocket.
*
* CONNECT wiaze tozsamosc z KONKRETNYM webinarem, ale sama subskrypcja
* wskazuje temat, czyli webinar, dowolna wartoscia w adresie. Bez kontroli
* uczestnik webinaru A subskrybowal `/topic/webinars/{B}/moderation` i czytal
* niezatwierdzone pytania oraz cudze zapytania ofertowe z webinaru B.
*
* Test sprawdza granice w OBIE strony: wlasny temat przechodzi, cudzy jest
* odrzucany, moderator (webinarId == null) sluchac moze kazdego.
*/
class StompSubscriptionAuthTest {
private final StompAuthChannelInterceptor interceptor = new StompAuthChannelInterceptor(
mock(RegistrationRepository.class),
mock(WebinarSpeakerRepository.class),
mock(VideoService.class),
mock(com.softspm.webinar.auth.JwtService.class),
mock(UserRepository.class));
/** Uczestnik webinaru 100. */
private static InteractionPrincipal participantOfWebinar100() {
return new InteractionPrincipal(100L, 7L, null, "Jan K.", false);
}
/** Moderator panelu: bez konkretnego webinaru w tozsamosci. */
private static InteractionPrincipal moderator() {
return new InteractionPrincipal(null, null, null, "Moderator", false);
}
private Message<byte[]> subscribeFrame(String destination, InteractionPrincipal principal) {
StompHeaderAccessor accessor = StompHeaderAccessor.create(StompCommand.SUBSCRIBE);
accessor.setDestination(destination);
Map<String, Object> sessionAttributes = new HashMap<>();
if (principal != null) {
sessionAttributes.put(StompAuthChannelInterceptor.SESSION_PRINCIPAL, principal);
}
accessor.setSessionAttributes(sessionAttributes);
return MessageBuilder.createMessage(new byte[0], accessor.getMessageHeaders());
}
@Test
@DisplayName("Uczestnik sluchac moze tematow WLASNEGO webinaru")
void participantMayListenToOwnWebinar() {
Message<byte[]> frame = subscribeFrame(
"/topic/webinars/100/chat", participantOfWebinar100());
assertThat(interceptor.preSend(frame, mock(org.springframework.messaging.MessageChannel.class)))
.isNotNull();
}
@Test
@DisplayName("Uczestnik NIE wejdzie na temat CUDZEGO webinaru")
void participantMayNotListenToOtherWebinar() {
Message<byte[]> frame = subscribeFrame(
"/topic/webinars/999/moderation", participantOfWebinar100());
assertThatThrownBy(() -> interceptor.preSend(
frame, mock(org.springframework.messaging.MessageChannel.class)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Brak dostepu");
}
@Test
@DisplayName("Moderator sluchac moze moderacji KAZDEGO webinaru")
void moderatorMayListenToAnyWebinar() {
Message<byte[]> frame = subscribeFrame(
"/topic/webinars/999/moderation", moderator());
assertThat(interceptor.preSend(frame, mock(org.springframework.messaging.MessageChannel.class)))
.isNotNull();
}
@Test
@DisplayName("Subskrypcja bez rozpoznanej tozsamosci jest odrzucana")
void unknownIdentityRejected() {
Message<byte[]> frame = subscribeFrame("/topic/webinars/100/chat", null);
assertThatThrownBy(() -> interceptor.preSend(
frame, mock(org.springframework.messaging.MessageChannel.class)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("uwierzytelnienia");
}
@Test
@DisplayName("Wlasna kolejka uzytkownika (/user/queue) nie jest ograniczana webinarem")
void userQueueNotRestricted() {
Message<byte[]> frame = subscribeFrame(
"/user/queue/errors", participantOfWebinar100());
assertThat(interceptor.preSend(frame, mock(org.springframework.messaging.MessageChannel.class)))
.isNotNull();
}
}
@@ -0,0 +1,209 @@
package com.softspm.webinar.interaction;
import com.softspm.webinar.domain.ConsentType;
import com.softspm.webinar.domain.Interaction;
import com.softspm.webinar.domain.InteractionStatus;
import com.softspm.webinar.domain.InteractionType;
import com.softspm.webinar.domain.Registration;
import com.softspm.webinar.domain.RegistrationStatus;
import com.softspm.webinar.domain.Task;
import com.softspm.webinar.domain.Webinar;
import com.softspm.webinar.domain.WebinarStatus;
import com.softspm.webinar.repository.InteractionRepository;
import com.softspm.webinar.repository.RegistrationRepository;
import com.softspm.webinar.repository.TaskRepository;
import com.softspm.webinar.repository.WebinarRepository;
import com.softspm.webinar.web.ValidationException;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Limit miejsc w zadaniu pod obciazeniem.
*
* Test dziala na zatwierdzonych danych, wiec nie da sie go objac transakcja
* wycofywana: zaklada wlasny webinar o losowym tytule i usuwa tylko jego.
* Bez blokady wiersza zadania rownoczesne zgloszenia policza to samo wolne
* miejsce i limit zostanie przekroczony.
*/
@SpringBootTest
@ActiveProfiles("test")
class TaskCapacityTest {
@Autowired private TaskService taskService;
@Autowired private TaskRepository taskRepository;
@Autowired private InteractionRepository interactionRepository;
@Autowired private RegistrationRepository registrationRepository;
@Autowired private WebinarRepository webinarRepository;
private Webinar webinar;
private Task task;
private final List<Registration> people = new ArrayList<>();
@BeforeEach
void setUp() {
String suffix = String.valueOf(System.nanoTime());
webinar = new Webinar();
webinar.setTitle("Zadania test " + suffix);
webinar.setSlug("zadania-test-" + suffix);
webinar.setStartsAt(OffsetDateTime.now().plusDays(1));
webinar.setEndsAt(OffsetDateTime.now().plusDays(1).plusHours(1));
webinar.setTimezone("Europe/Warsaw");
webinar.setStatus(WebinarStatus.PUBLISHED);
webinar.setCreatedAt(OffsetDateTime.now());
webinar.setUpdatedAt(OffsetDateTime.now());
webinar = webinarRepository.save(webinar);
task = new Task();
task.setWebinar(webinar);
task.setTitle("Zadanie z limitem");
task.setCapacity(3);
task.setOpen(true);
task.setCreatedAt(OffsetDateTime.now());
task.setUpdatedAt(OffsetDateTime.now());
task = taskRepository.save(task);
for (int i = 0; i < 10; i++) {
Registration registration = new Registration();
registration.setWebinar(webinar);
registration.setEmail("uczestnik" + i + "." + suffix + "@example.pl");
registration.setFirstName("Osoba");
registration.setLastName("Numer" + i);
registration.setStatus(RegistrationStatus.CONFIRMED);
registration.setConfirmationToken("tok-" + suffix + "-" + i);
registration.setCreatedAt(OffsetDateTime.now());
people.add(registrationRepository.save(registration));
}
}
@AfterEach
void tearDown() {
// usuwamy tylko wlasne dane; kolejnosc testow jest losowa, wiec zaden
// test nie moze kasowac cudzych wierszy
interactionRepository.deleteAll(
interactionRepository.findByWebinarIdAndTypeOrderByCreatedAtDesc(
webinar.getId(), InteractionType.TASK_SIGNUP));
taskRepository.delete(task);
registrationRepository.deleteAll(people);
webinarRepository.delete(webinar);
}
@Test
@DisplayName("Limit miejsc trzyma przy rownoczesnych zgloszeniach")
void capacityHoldsUnderConcurrency() throws Exception {
int threads = people.size();
ExecutorService pool = Executors.newFixedThreadPool(threads);
CountDownLatch start = new CountDownLatch(1);
AtomicInteger accepted = new AtomicInteger();
AtomicInteger rejected = new AtomicInteger();
for (Registration person : people) {
pool.submit(() -> {
try {
start.await();
taskService.signUp(task.getId(), person.getId(), webinar.getId());
accepted.incrementAndGet();
} catch (ValidationException e) {
rejected.incrementAndGet();
} catch (Exception e) {
// wyscig na indeksie unikalnym tez oznacza odrzucenie
rejected.incrementAndGet();
}
});
}
start.countDown();
pool.shutdown();
assertThat(pool.awaitTermination(30, TimeUnit.SECONDS)).isTrue();
long stored = interactionRepository.countByTaskIdAndStatusNot(
task.getId(), InteractionStatus.REJECTED);
assertThat(stored)
.as("w bazie nie moze byc wiecej zgloszen niz limit")
.isEqualTo(3);
assertThat(accepted.get()).isEqualTo(3);
assertThat(rejected.get()).isEqualTo(threads - 3);
}
@Test
@DisplayName("Wycofanie zwalnia miejsce")
void withdrawFreesSeat() {
taskService.signUp(task.getId(), people.get(0).getId(), webinar.getId());
taskService.signUp(task.getId(), people.get(1).getId(), webinar.getId());
taskService.signUp(task.getId(), people.get(2).getId(), webinar.getId());
assertThatThrownBy(() -> taskService.signUp(task.getId(), people.get(3).getId(), webinar.getId()))
.isInstanceOf(ValidationException.class)
.hasMessageContaining("Brak wolnych miejsc");
taskService.withdraw(task.getId(), people.get(0).getId(), webinar.getId());
// po zwolnieniu miejsca kolejna osoba wchodzi
Interaction signup = taskService.signUp(task.getId(), people.get(3).getId(), webinar.getId());
assertThat(signup.getStatus()).isEqualTo(InteractionStatus.NEW);
assertThat(interactionRepository.countByTaskIdAndStatusNot(
task.getId(), InteractionStatus.REJECTED)).isEqualTo(3);
}
@Test
@DisplayName("Ten sam uczestnik nie zapisze sie dwa razy")
void noDoubleSignup() {
taskService.signUp(task.getId(), people.get(0).getId(), webinar.getId());
assertThatThrownBy(() -> taskService.signUp(task.getId(), people.get(0).getId(), webinar.getId()))
.isInstanceOf(ValidationException.class)
.hasMessageContaining("już zapisany");
assertThat(interactionRepository.countByTaskIdAndStatusNot(
task.getId(), InteractionStatus.REJECTED)).isEqualTo(1);
}
@Test
@DisplayName("Powrot po wycofaniu nie tworzy drugiego wiersza")
void rejoinReusesRow() {
taskService.signUp(task.getId(), people.get(0).getId(), webinar.getId());
taskService.withdraw(task.getId(), people.get(0).getId(), webinar.getId());
taskService.signUp(task.getId(), people.get(0).getId(), webinar.getId());
List<Interaction> all = interactionRepository.findByWebinarIdAndTypeOrderByCreatedAtDesc(
webinar.getId(), InteractionType.TASK_SIGNUP);
assertThat(all).hasSize(1);
assertThat(all.get(0).getStatus()).isEqualTo(InteractionStatus.NEW);
}
@Test
@DisplayName("Zapisy zamkniete blokuja zgloszenie")
void closedTaskRejects() {
taskService.setOpen(task.getId(), false);
assertThatThrownBy(() -> taskService.signUp(task.getId(), people.get(0).getId(), webinar.getId()))
.isInstanceOf(ValidationException.class)
.hasMessageContaining("zamknięte");
}
@Test
@DisplayName("Zgoda na kontakt handlowy ma wlasny typ")
void salesConsentTypeExists() {
// typ musi istniec w enumie i w ograniczeniu CHECK, inaczej zapis
// zgody przy zapytaniu ofertowym konczy sie bledem bazy
assertThat(ConsentType.valueOf("CONTACT_SALES")).isEqualTo(ConsentType.CONTACT_SALES);
}
}
@@ -0,0 +1,108 @@
package com.softspm.webinar.mail;
import com.softspm.webinar.calendar.IcsGenerator;
import com.softspm.webinar.reminder.ReminderTextBuilder;
import com.softspm.webinar.domain.Registration;
import com.softspm.webinar.domain.Webinar;
import com.softspm.webinar.domain.WebinarStatus;
import com.softspm.webinar.settings.SettingKey;
import com.softspm.webinar.settings.SettingsService;
import jakarta.mail.internet.MimeMessage;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.mail.javamail.JavaMailSenderImpl;
import org.thymeleaf.spring6.SpringTemplateEngine;
import org.thymeleaf.templatemode.TemplateMode;
import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* Sprawdza, ze wiadomosc potwierdzajaca da sie w calosci zapisac do strumienia.
*
* Blad zalacznika ujawnia sie dopiero przy wysylce, wiec sam fakt zbudowania
* obiektu MimeMessage nic nie dowodzi. Test wymusza serializacje przez writeTo.
*/
class MailAttachmentTest {
private MailService mailService;
@BeforeEach
void setUp() {
SettingsService settings = mock(SettingsService.class);
when(settings.get(SettingKey.MAIL_USERNAME)).thenReturn("webinar@example.pl");
when(settings.get(SettingKey.MAIL_FROM_NAME)).thenReturn("Gielda Komunalna");
when(settings.get(SettingKey.APP_BRAND_NAME)).thenReturn("Gielda Komunalna");
when(settings.get(SettingKey.APP_PUBLIC_URL)).thenReturn("http://localhost:8081");
// MailService siega po getRequired: adres publiczny jest wymagany,
// bo z niego powstaja linki w wiadomosci
when(settings.getRequired(SettingKey.APP_PUBLIC_URL)).thenReturn("http://localhost:8081");
when(settings.getTimezone()).thenReturn(java.time.ZoneId.of("Europe/Warsaw"));
SpringTemplateEngine engine = new SpringTemplateEngine();
engine.addTemplateResolver(resolver(TemplateMode.HTML, "mail/*.html"));
engine.addTemplateResolver(resolver(TemplateMode.TEXT, "mail/*.txt"));
// SafeDelivery z tym samym mockiem ustawien: test sklada wiadomosc,
// a nie wysyla jej, wiec straznik nie zmienia tu niczego
mailService = new MailService(settings, engine, new IcsGenerator(settings),
new ReminderTextBuilder(settings), new SafeDelivery(settings));
}
private ClassLoaderTemplateResolver resolver(TemplateMode mode, String pattern) {
ClassLoaderTemplateResolver resolver = new ClassLoaderTemplateResolver();
resolver.setResolvablePatterns(Set.of(pattern));
resolver.setPrefix("templates/");
resolver.setTemplateMode(mode);
resolver.setCharacterEncoding(StandardCharsets.UTF_8.name());
resolver.setCacheable(false);
return resolver;
}
@Test
void wiadomoscZZalacznikiemIcsDaSieZapisacDoStrumienia() throws Exception {
JavaMailSenderImpl sender = new JavaMailSenderImpl();
MimeMessage message = mailService.buildRegistrationConfirmation(sender, webinar(), registration());
ByteArrayOutputStream out = new ByteArrayOutputStream();
// wlasnie tu wysypuje sie wysylka, gdy zalacznik jest zbudowany wadliwie
assertThatCode(() -> message.writeTo(out)).doesNotThrowAnyException();
String raw = out.toString(StandardCharsets.UTF_8);
assertThat(raw).contains("invite.ics");
assertThat(raw).contains("text/calendar");
}
private Webinar webinar() {
Webinar webinar = new Webinar();
webinar.setId(1L);
webinar.setSlug("otwarty");
webinar.setTitle("Webinar otwarty na zapisy");
webinar.setDescription("Praktyczne omowienie tematu.");
webinar.setStartsAt(OffsetDateTime.of(2026, 12, 10, 9, 0, 0, 0, ZoneOffset.UTC));
webinar.setEndsAt(OffsetDateTime.of(2026, 12, 10, 11, 0, 0, 0, ZoneOffset.UTC));
webinar.setTimezone("Europe/Warsaw");
webinar.setStatus(WebinarStatus.PUBLISHED);
webinar.setUpdatedAt(OffsetDateTime.now());
return webinar;
}
private Registration registration() {
Registration registration = new Registration();
registration.setId(1L);
registration.setEmail("uczestnik@example.pl");
registration.setFirstName("Anna");
registration.setLastName("Kowalska");
registration.setConfirmationToken("token-testowy");
return registration;
}
}
@@ -0,0 +1,187 @@
package com.softspm.webinar.mail;
import com.softspm.webinar.calendar.IcsGenerator;
import com.softspm.webinar.reminder.ReminderTextBuilder;
import com.softspm.webinar.domain.Registration;
import com.softspm.webinar.domain.Webinar;
import com.softspm.webinar.domain.WebinarStatus;
import com.softspm.webinar.settings.SettingKey;
import com.softspm.webinar.settings.SettingsService;
import jakarta.mail.internet.MimeMessage;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
import org.springframework.mail.javamail.JavaMailSenderImpl;
import org.thymeleaf.spring6.SpringTemplateEngine;
import org.thymeleaf.templatemode.TemplateMode;
import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* Podglad wiadomosci bez wysylki.
*
* Sprawdza takze, ze polskie znaki przechodza przez caly lancuch: plik szablonu,
* silnik Thymeleaf i zapis wiadomosci do strumienia. Blad kodowania na produkcji
* bylby widoczny dla kazdego uczestnika.
*
* Zapis plikow do obejrzenia:
* mvn test -Dtest=MailPreviewTest -Dmail.preview.dir=/sciezka
*/
class MailPreviewTest {
private MailService mailService;
@BeforeEach
void setUp() {
SettingsService settings = mock(SettingsService.class);
when(settings.get(SettingKey.MAIL_USERNAME)).thenReturn("info@softspm.com");
when(settings.get(SettingKey.MAIL_FROM_NAME)).thenReturn("wodociagowiec.pl");
when(settings.get(SettingKey.APP_BRAND_NAME)).thenReturn("wodociagowiec.pl");
when(settings.get(SettingKey.APP_PUBLIC_URL)).thenReturn("https://wodociagowiec.pl");
// MailService siega po getRequired: adres publiczny jest wymagany,
// bo z niego powstaja linki w wiadomosci
when(settings.getRequired(SettingKey.APP_PUBLIC_URL)).thenReturn("https://wodociagowiec.pl");
when(settings.getTimezone()).thenReturn(ZoneId.of("Europe/Warsaw"));
SpringTemplateEngine engine = new SpringTemplateEngine();
engine.addTemplateResolver(resolver(TemplateMode.HTML, "mail/*.html"));
engine.addTemplateResolver(resolver(TemplateMode.TEXT, "mail/*.txt"));
// SafeDelivery z tym samym mockiem ustawien: test sklada wiadomosc,
// a nie wysyla jej, wiec straznik nie zmienia tu niczego
mailService = new MailService(settings, engine, new IcsGenerator(settings),
new ReminderTextBuilder(settings), new SafeDelivery(settings));
}
private ClassLoaderTemplateResolver resolver(TemplateMode mode, String pattern) {
ClassLoaderTemplateResolver resolver = new ClassLoaderTemplateResolver();
resolver.setResolvablePatterns(Set.of(pattern));
resolver.setPrefix("templates/");
resolver.setTemplateMode(mode);
resolver.setCharacterEncoding(StandardCharsets.UTF_8.name());
resolver.setCacheable(false);
return resolver;
}
@Test
void polskieZnakiPrzechodzaPrzezCalyLancuch() throws Exception {
String html = renderHtml(mailService.buildRegistrationConfirmation(
new JavaMailSenderImpl(), webinar(), registration()));
// wyrazy z ogonkami, ktore musza dotrzec w calosci
assertThat(html).contains("Dzień dobry");
assertThat(html).contains("Twój zapis");
assertThat(html).contains("Otwórz stronę webinaru");
assertThat(html).contains("dołączyliśmy");
assertThat(html).contains("zgłoszenia");
// typowe objawy zlego kodowania
assertThat(html).doesNotContain("Ä");
assertThat(html).doesNotContain("Å");
assertThat(html).doesNotContain("?????");
}
@Test
void przyciskMaEtykieteAnieSurowyAdres() throws Exception {
String html = renderHtml(mailService.buildRegistrationConfirmation(
new JavaMailSenderImpl(), webinar(), registration()));
// tresc przycisku ma byc etykieta, nie adresem URL
assertThat(html).contains("Otwórz stronę webinaru");
assertThat(html).contains("https://wodociagowiec.pl/w/zamowienia-2027");
}
@Test
void wiadomoscZawieraObieWersjeTresci() throws Exception {
String raw = renderHtml(mailService.buildRegistrationConfirmation(
new JavaMailSenderImpl(), webinar(), registration()));
assertThat(raw).contains("text/plain");
assertThat(raw).contains("text/html");
assertThat(raw).contains("invite.ics");
}
@Test
@EnabledIfSystemProperty(named = "mail.preview.dir", matches = ".+")
void zapisujePodgladDoPliku() throws Exception {
Path dir = Path.of(System.getProperty("mail.preview.dir"));
writePreview(dir.resolve("potwierdzenie.eml"),
mailService.buildRegistrationConfirmation(new JavaMailSenderImpl(), webinar(), registration()));
writePreview(dir.resolve("juz-zapisany.eml"),
mailService.buildAlreadyRegistered(new JavaMailSenderImpl(), webinar(), registration()));
}
private void writePreview(Path target, MimeMessage message) throws Exception {
ByteArrayOutputStream out = new ByteArrayOutputStream();
message.writeTo(out);
Files.write(target, out.toByteArray());
}
private String renderHtml(MimeMessage message) throws Exception {
ByteArrayOutputStream out = new ByteArrayOutputStream();
message.writeTo(out);
// wiadomosc jest kodowana quoted-printable, wiec dekodujemy do porownania
String raw = out.toString(StandardCharsets.UTF_8);
return decodeQuotedPrintable(raw);
}
/** Uproszczone dekodowanie quoted-printable, wystarczajace do sprawdzenia tresci. */
private String decodeQuotedPrintable(String input) {
String joined = input.replace("=\r\n", "").replace("=\n", "");
StringBuilder bytes = new StringBuilder();
java.io.ByteArrayOutputStream buffer = new java.io.ByteArrayOutputStream();
for (int i = 0; i < joined.length(); i++) {
char c = joined.charAt(i);
if (c == '=' && i + 2 < joined.length()) {
String hex = joined.substring(i + 1, i + 3);
try {
buffer.write(Integer.parseInt(hex, 16));
i += 2;
continue;
} catch (NumberFormatException ignored) {
// nie jest sekwencja quoted-printable, zapisujemy znak wprost
}
}
buffer.write((byte) c);
}
bytes.append(new String(buffer.toByteArray(), StandardCharsets.UTF_8));
return bytes.toString();
}
private Webinar webinar() {
Webinar webinar = new Webinar();
webinar.setId(1L);
webinar.setSlug("zamowienia-2027");
webinar.setTitle("Gospodarka wodno-ściekowa 2027");
webinar.setDescription("Praktyczne omówienie zmian w przepisach.");
webinar.setStartsAt(OffsetDateTime.of(2027, 1, 15, 9, 0, 0, 0, ZoneOffset.UTC));
webinar.setEndsAt(OffsetDateTime.of(2027, 1, 15, 11, 0, 0, 0, ZoneOffset.UTC));
webinar.setTimezone("Europe/Warsaw");
webinar.setStatus(WebinarStatus.PUBLISHED);
webinar.setUpdatedAt(OffsetDateTime.now());
return webinar;
}
private Registration registration() {
Registration registration = new Registration();
registration.setId(1L);
registration.setEmail("uczestnik@example.pl");
registration.setFirstName("Paweł");
registration.setLastName("Zatorski");
registration.setConfirmationToken("token-podgladu");
return registration;
}
}
@@ -0,0 +1,190 @@
package com.softspm.webinar.mail;
import com.softspm.webinar.settings.SettingKey;
import com.softspm.webinar.settings.SettingsService;
import jakarta.mail.Message;
import jakarta.mail.Session;
import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeMessage;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.HashMap;
import java.util.Map;
import java.util.Properties;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* Tryb bezpieczny i bezpiecznik ilosciowy.
*
* Testy sa jednostkowe, bez kontekstu Springa i bez SMTP: sprawdzamy sama
* decyzje "dokad to idzie" i "czy jeszcze wolno", a nie wysylke.
*
* To zabezpieczenie powstalo po tym, jak zadanie podsumowan wyslalo realne
* maile na adresy z danych testowych. Test ma pilnowac, zeby nie wrocilo.
*/
class SafeDeliveryTest {
private Map<SettingKey, String> values;
private SafeDelivery safeDelivery;
@BeforeEach
void setUp() {
values = new HashMap<>();
values.put(SettingKey.MAIL_SAFE_MODE, "true");
values.put(SettingKey.MAIL_SAFE_RECIPIENT, "skrzynka.testowa@example.com");
values.put(SettingKey.MAIL_RATE_LIMIT_PER_HOUR, "100");
values.put(SettingKey.SMS_SAFE_MODE, "true");
values.put(SettingKey.SMS_SAFE_RECIPIENT, "+48000000000");
values.put(SettingKey.SMS_RATE_LIMIT_PER_HOUR, "20");
SettingsService settings = mock(SettingsService.class);
when(settings.get(any(SettingKey.class)))
.thenAnswer(call -> values.getOrDefault(call.getArgument(0), ""));
when(settings.getBoolean(any(SettingKey.class)))
.thenAnswer(call -> "true".equals(values.get(call.getArgument(0))));
when(settings.getInt(any(SettingKey.class)))
.thenAnswer(call -> Integer.parseInt(values.getOrDefault(call.getArgument(0), "0")));
safeDelivery = new SafeDelivery(settings);
}
private MimeMessage messageTo(String recipient) throws Exception {
MimeMessage message = new MimeMessage(Session.getInstance(new Properties()));
message.setRecipients(Message.RecipientType.TO, new InternetAddress[]{
new InternetAddress(recipient)});
message.setSubject("Podsumowanie: Bezpieczeństwo danych");
return message;
}
@Test
@DisplayName("tryb bezpieczny przekierowuje wiadomosc na adres zastepczy")
void redirectsToSafeRecipient() throws Exception {
MimeMessage message = messageTo("uczestnik@prawdziwa-firma.pl");
safeDelivery.applyToMail(message);
var recipients = message.getRecipients(Message.RecipientType.TO);
assertThat(recipients).hasSize(1);
assertThat(recipients[0].toString()).isEqualTo("skrzynka.testowa@example.com");
}
@Test
@DisplayName("temat niesie prawdziwego odbiorce, zeby bylo widac, do kogo poszloby naprawde")
void subjectShowsOriginalRecipient() throws Exception {
MimeMessage message = messageTo("uczestnik@prawdziwa-firma.pl");
safeDelivery.applyToMail(message);
assertThat(message.getSubject())
.startsWith("[TEST -> uczestnik@prawdziwa-firma.pl]")
.contains("Podsumowanie: Bezpieczeństwo danych");
}
@Test
@DisplayName("kopie sa czyszczone, zeby handlowiec nie dostawal wiadomosci testowych")
void clearsCarbonCopies() throws Exception {
MimeMessage message = messageTo("uczestnik@prawdziwa-firma.pl");
message.setRecipients(Message.RecipientType.CC, new InternetAddress[]{
new InternetAddress("handlowiec@prawdziwa-firma.pl")});
safeDelivery.applyToMail(message);
assertThat(message.getRecipients(Message.RecipientType.CC)).isNullOrEmpty();
}
@Test
@DisplayName("wylaczony tryb bezpieczny nie rusza odbiorcy ani tematu")
void disabledModeLeavesMessageUntouched() throws Exception {
values.put(SettingKey.MAIL_SAFE_MODE, "false");
MimeMessage message = messageTo("uczestnik@prawdziwa-firma.pl");
safeDelivery.applyToMail(message);
assertThat(message.getRecipients(Message.RecipientType.TO)[0].toString())
.isEqualTo("uczestnik@prawdziwa-firma.pl");
assertThat(message.getSubject()).doesNotContain("[TEST");
}
@Test
@DisplayName("tryb wlaczony bez adresu zastepczego wstrzymuje wysylke zamiast przepuscic")
void missingSafeRecipientStopsDelivery() throws Exception {
values.put(SettingKey.MAIL_SAFE_RECIPIENT, "");
MimeMessage message = messageTo("uczestnik@prawdziwa-firma.pl");
assertThatThrownBy(() -> safeDelivery.applyToMail(message))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("MAIL_SAFE_RECIPIENT");
// najwazniejsze: wiadomosc NIE poszla do prawdziwego odbiorcy
assertThat(message.getRecipients(Message.RecipientType.TO)[0].toString())
.isEqualTo("uczestnik@prawdziwa-firma.pl");
}
@Test
@DisplayName("SMS jest przekierowany, a prefiks niesie prawdziwy numer")
void smsIsRedirectedWithPrefix() {
assertThat(safeDelivery.resolveSmsRecipient("+48601234567")).isEqualTo("+48000000000");
assertThat(safeDelivery.smsPrefix("+48601234567")).isEqualTo("[TEST -> +48601234567] ");
}
@Test
@DisplayName("prefiks SMS nie ma polskich znakow, bo przelaczalyby bramke na UCS-2")
void smsPrefixHasNoDiacritics() {
String prefix = safeDelivery.smsPrefix("+48601234567");
assertThat(prefix).matches("[\\p{ASCII}]+");
}
@Test
@DisplayName("limit przepuszcza dokladnie tyle wysylek, ile wynosi prog")
void allowsExactlyUpToLimit() {
values.put(SettingKey.MAIL_RATE_LIMIT_PER_HOUR, "3");
safeDelivery.checkMailLimit();
safeDelivery.checkMailLimit();
safeDelivery.checkMailLimit();
assertThatThrownBy(() -> safeDelivery.checkMailLimit())
.isInstanceOf(SafeDelivery.RateLimitExceededException.class);
assertThat(safeDelivery.mailSentLastHour()).isEqualTo(3);
}
@Test
@DisplayName("limit SMS jest liczony osobno od limitu poczty")
void mailAndSmsLimitsAreIndependent() {
values.put(SettingKey.MAIL_RATE_LIMIT_PER_HOUR, "1");
values.put(SettingKey.SMS_RATE_LIMIT_PER_HOUR, "1");
safeDelivery.checkMailLimit();
// wyczerpany limit poczty nie moze blokowac SMS
safeDelivery.checkSmsLimit();
assertThatThrownBy(() -> safeDelivery.checkMailLimit())
.isInstanceOf(SafeDelivery.RateLimitExceededException.class);
assertThatThrownBy(() -> safeDelivery.checkSmsLimit())
.isInstanceOf(SafeDelivery.RateLimitExceededException.class);
}
@Test
@DisplayName("odrzucona wysylka nie zajmuje miejsca w oknie limitu")
void rejectedSendDoesNotConsumeSlot() {
values.put(SettingKey.MAIL_RATE_LIMIT_PER_HOUR, "2");
safeDelivery.checkMailLimit();
safeDelivery.checkMailLimit();
// dwie odrzucone proby nie moga podbic licznika
assertThatThrownBy(() -> safeDelivery.checkMailLimit())
.isInstanceOf(SafeDelivery.RateLimitExceededException.class);
assertThatThrownBy(() -> safeDelivery.checkMailLimit())
.isInstanceOf(SafeDelivery.RateLimitExceededException.class);
assertThat(safeDelivery.mailSentLastHour()).isEqualTo(2);
}
}
@@ -0,0 +1,168 @@
package com.softspm.webinar.media;
import com.softspm.webinar.content.VideoTypeDetector;
import com.softspm.webinar.domain.Registration;
import com.softspm.webinar.domain.RegistrationStatus;
import com.softspm.webinar.domain.Visibility;
import com.softspm.webinar.domain.Webinar;
import com.softspm.webinar.repository.RegistrationRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.security.core.context.SecurityContextHolder;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* Kontrola dostepu do nagran i materialow oraz rozpoznawanie typu wideo.
*
* Testy jednostkowe: sprawdzamy sama decyzje "wolno czy nie wolno", bez
* kontekstu Springa i bez skladu plikow.
*/
class FileAccessTest {
private static final Long WEBINAR_ID = 42L;
private static final Long OTHER_WEBINAR_ID = 99L;
private RegistrationRepository registrationRepository;
private FileAccessGuard guard;
@BeforeEach
void setUp() {
registrationRepository = mock(RegistrationRepository.class);
when(registrationRepository.findByConfirmationToken(anyString()))
.thenReturn(Optional.empty());
guard = new FileAccessGuard(registrationRepository);
SecurityContextHolder.clearContext();
}
@AfterEach
void tearDown() {
SecurityContextHolder.clearContext();
}
private void givenRegistration(String token, RegistrationStatus status, Long webinarId) {
Webinar webinar = new Webinar();
webinar.setId(webinarId);
Registration registration = new Registration();
registration.setWebinar(webinar);
registration.setStatus(status);
registration.setConfirmationToken(token);
when(registrationRepository.findByConfirmationToken(token))
.thenReturn(Optional.of(registration));
}
@Test
@DisplayName("PUBLIC otwiera plik kazdemu, takze bez tokenu")
void publicIsOpenToEveryone() {
assertThat(guard.canAccess(Visibility.PUBLIC, WEBINAR_ID, null)).isTrue();
}
@Test
@DisplayName("PRIVATE nie otwiera sie uczestnikowi, nawet z waznym tokenem")
void privateStaysClosedForParticipant() {
givenRegistration("wazny", RegistrationStatus.CONFIRMED, WEBINAR_ID);
assertThat(guard.canAccess(Visibility.PRIVATE, WEBINAR_ID, "wazny")).isFalse();
}
@Test
@DisplayName("REGISTERED otwiera sie uczestnikowi z potwierdzonym zapisem")
void registeredOpensForConfirmedParticipant() {
givenRegistration("wazny", RegistrationStatus.CONFIRMED, WEBINAR_ID);
assertThat(guard.canAccess(Visibility.REGISTERED, WEBINAR_ID, "wazny")).isTrue();
}
@Test
@DisplayName("REGISTERED bez tokenu jest zamkniete")
void registeredNeedsToken() {
assertThat(guard.canAccess(Visibility.REGISTERED, WEBINAR_ID, null)).isFalse();
assertThat(guard.canAccess(Visibility.REGISTERED, WEBINAR_ID, " ")).isFalse();
}
@Test
@DisplayName("token po rezygnacji przestaje otwierac plik")
void cancelledRegistrationLosesAccess() {
givenRegistration("rezygnacja", RegistrationStatus.CANCELLED, WEBINAR_ID);
assertThat(guard.canAccess(Visibility.REGISTERED, WEBINAR_ID, "rezygnacja")).isFalse();
}
@Test
@DisplayName("token z innego webinaru nie otwiera tego nagrania")
void tokenFromAnotherWebinarIsRejected() {
givenRegistration("obcy", RegistrationStatus.CONFIRMED, OTHER_WEBINAR_ID);
// to jest sedno reguly: wazny token sam w sobie nie wystarcza,
// musi dotyczyc TEGO webinaru
assertThat(guard.canAccess(Visibility.REGISTERED, WEBINAR_ID, "obcy")).isFalse();
}
@Test
@DisplayName("nieznany token nie otwiera niczego")
void unknownTokenIsRejected() {
assertThat(guard.canAccess(Visibility.REGISTERED, WEBINAR_ID, "wymyslony")).isFalse();
}
// --- rozpoznawanie typu wideo ---
@Test
@DisplayName("MP4 rozpoznawany po pudelku ftyp na pozycji 4")
void detectsMp4() {
byte[] head = new byte[32];
head[4] = 'f';
head[5] = 't';
head[6] = 'y';
head[7] = 'p';
assertThat(VideoTypeDetector.detect(head)).isEqualTo(VideoTypeDetector.VideoType.MP4);
}
@Test
@DisplayName("WEBM rozpoznawany po sygnaturze EBML")
void detectsWebm() {
byte[] head = new byte[32];
head[0] = (byte) 0x1A;
head[1] = (byte) 0x45;
head[2] = (byte) 0xDF;
head[3] = (byte) 0xA3;
assertThat(VideoTypeDetector.detect(head)).isEqualTo(VideoTypeDetector.VideoType.WEBM);
}
@Test
@DisplayName("plik wykonywalny nazwany nagraniem jest odrzucany")
void rejectsExecutableDisguisedAsVideo() {
// naglowek MZ, czyli plik wykonywalny Windows
byte[] head = new byte[32];
head[0] = 'M';
head[1] = 'Z';
assertThat(VideoTypeDetector.detect(head)).isNull();
}
@Test
@DisplayName("HTML w pliku .mp4 jest odrzucany")
void rejectsHtmlDisguisedAsVideo() {
byte[] head = "<!DOCTYPE html><script>alert(1)</script>".getBytes();
assertThat(VideoTypeDetector.detect(head)).isNull();
}
@Test
@DisplayName("pusty i za krotki plik nie przechodzi")
void rejectsTooShortContent() {
assertThat(VideoTypeDetector.detect(null)).isNull();
assertThat(VideoTypeDetector.detect(new byte[0])).isNull();
assertThat(VideoTypeDetector.detect(new byte[]{1, 2, 3})).isNull();
}
}
@@ -0,0 +1,153 @@
package com.softspm.webinar.media;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.softspm.webinar.settings.SettingKey;
import com.softspm.webinar.settings.SettingsService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Uwierzytelnienie webhooka Zoom.
*
* Zoom nie przysyla naszego sekretu wprost, tylko podpisuje kazde zadanie.
* To jest test gwarancji: sprawdzamy, ze podpis liczy sie dokladnie tak jak
* u Zooma i ze **niezgodny podpis jest odrzucany**, a nie tylko ze cokolwiek
* wraca. Do tego CRC, bez ktorego Zoom nie pozwala zapisac adresu webhooka.
*
* Test dziala na tej samej bazie co instancja, wiec zapamietuje i przywraca
* VIDEO_WEBHOOK_SECRET, zgodnie z regula testow dotykajacych app_settings.
*/
@SpringBootTest
@ActiveProfiles("test")
class ZoomWebhookVerifierTest {
private static final String SECRET = "zoom-secret-token-do-testow-abc123";
@Autowired private ZoomWebhookVerifier verifier;
@Autowired private SettingsService settings;
@Autowired private com.softspm.webinar.repository.AppSettingRepository settingRepository;
private final ObjectMapper objectMapper = new ObjectMapper();
private String previousSecret;
private boolean secretExistedBefore;
@BeforeEach
void setUp() {
// VIDEO_WEBHOOK_SECRET jest typu PASSWORD, wiec settings.update z pusta
// wartoscia jest POMIJANE ("nie zmieniaj"). Przywracanie pustej wartosci
// przez update nie zadziala, wiec zapamietujemy stan wiersza i oddajemy
// go przez repozytorium, tak samo jak przy kluczach ZOOM_*.
secretExistedBefore = settingRepository.existsById(SettingKey.VIDEO_WEBHOOK_SECRET.name());
previousSecret = settings.get(SettingKey.VIDEO_WEBHOOK_SECRET);
settings.update(Map.of(SettingKey.VIDEO_WEBHOOK_SECRET.name(), SECRET));
}
@AfterEach
void tearDown() {
if (secretExistedBefore) {
// oddajemy dokladnie ta wartosc, ktora byla, takze pusta - przez repo,
// bo update pomija puste PASSWORD
settingRepository.findById(SettingKey.VIDEO_WEBHOOK_SECRET.name())
.ifPresent(s -> {
s.setValue(previousSecret == null ? "" : previousSecret);
settingRepository.save(s);
});
} else {
settingRepository.deleteById(SettingKey.VIDEO_WEBHOOK_SECRET.name());
}
settings.invalidate();
}
@Test
@DisplayName("Poprawny podpis x-zm-signature przechodzi")
void validSignatureAccepted() {
String body = "{\"event\":\"recording.completed\"}";
String timestamp = "1700000000";
String signature = "v0=" + hmacHex(SECRET, "v0:" + timestamp + ":" + body);
assertThat(verifier.signatureMatches(signature, timestamp, body)).isTrue();
}
@Test
@DisplayName("Podpis policzony innym sekretem jest odrzucany")
void wrongSecretRejected() {
String body = "{\"event\":\"recording.completed\"}";
String timestamp = "1700000000";
// ktos podpisal cudzym sekretem: to ma nie przejsc
String signature = "v0=" + hmacHex("inny-sekret", "v0:" + timestamp + ":" + body);
assertThat(verifier.signatureMatches(signature, timestamp, body)).isFalse();
}
@Test
@DisplayName("Podmieniona tresc przy tym samym podpisie jest odrzucana")
void tamperedBodyRejected() {
String timestamp = "1700000000";
String original = "{\"event\":\"recording.completed\"}";
String signature = "v0=" + hmacHex(SECRET, "v0:" + timestamp + ":" + original);
// napastnik podmienia tresc, zostawiajac podpis oryginalu
String tampered = "{\"event\":\"meeting.deleted\"}";
assertThat(verifier.signatureMatches(signature, timestamp, tampered)).isFalse();
}
@Test
@DisplayName("Brak podpisu, znacznika czasu albo tresci jest odrzucany")
void missingPartsRejected() {
assertThat(verifier.signatureMatches(null, "1700000000", "{}")).isFalse();
assertThat(verifier.signatureMatches("v0=abc", null, "{}")).isFalse();
assertThat(verifier.signatureMatches("v0=abc", "1700000000", null)).isFalse();
}
@Test
@DisplayName("CRC zwraca encryptedToken policzony z plainTokenu")
void urlValidationEncryptsPlainToken() throws Exception {
String plainToken = "abcXYZ123";
String payload = objectMapper.writeValueAsString(Map.of(
"event", ZoomWebhookVerifier.URL_VALIDATION_EVENT,
"payload", Map.of("plainToken", plainToken)));
assertThat(verifier.isUrlValidation(payload)).isTrue();
String response = verifier.urlValidationResponse(payload);
assertThat(response).isNotNull();
var node = objectMapper.readTree(response);
assertThat(node.get("plainToken").asText()).isEqualTo(plainToken);
assertThat(node.get("encryptedToken").asText()).isEqualTo(hmacHex(SECRET, plainToken));
}
@Test
@DisplayName("Zwykle zdarzenie nie jest mylone z CRC")
void regularEventIsNotUrlValidation() {
assertThat(verifier.isUrlValidation("{\"event\":\"recording.completed\"}")).isFalse();
}
private static String hmacHex(String secret, String message) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] digest = mac.doFinal(message.getBytes(StandardCharsets.UTF_8));
StringBuilder hex = new StringBuilder(digest.length * 2);
for (byte b : digest) {
hex.append(Character.forDigit((b >> 4) & 0xF, 16));
hex.append(Character.forDigit(b & 0xF, 16));
}
return hex.toString();
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
}
@@ -0,0 +1,184 @@
package com.softspm.webinar.payment;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Arrays;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Skrot Autopay sprawdzany na przykladzie z dokumentacji.
*
* DLACZEGO TO JEST NAJWAZNIEJSZY TEST W CALEJ INTEGRACJI
* ---------------------------------------------------------------------------
* Pelnego przeplywu nie da sie sprawdzic na localhost: ITN wymaga publicznej
* domeny z waznym certyfikatem. Skrot jest jedyna czescia, ktora **da sie
* zweryfikowac bez laczenia z bramka**, a jednoczesnie decyduje o wszystkim:
* zle policzony skrot oznacza odrzucenie zadania przez bramke bez podania
* przyczyny albo, co gorsza, przyjecie sfalszowanej notyfikacji.
*
* PRZYKLAD Z DOKUMENTACJI
* ---------------------------------------------------------------------------
* ServiceID=2, OrderID=100, Amount=1.50, klucz wspoldzielony=2test2
* daje SHA256 z ciagu "2|100|1.50|2test2".
*/
class AutopayHashTest {
private static final String KEY = "2test2";
/** Wartosc oczekiwana liczona niezaleznie, zeby test nie powtarzal implementacji. */
private static String sha(String algorithm, String input) {
try {
byte[] digest = MessageDigest.getInstance(algorithm)
.digest(input.getBytes(StandardCharsets.UTF_8));
StringBuilder hex = new StringBuilder();
for (byte b : digest) {
hex.append(String.format("%02x", b));
}
return hex.toString();
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
@Test
@DisplayName("Przyklad z dokumentacji: ServiceID, OrderID, Amount i klucz")
void documentationExample() {
String hash = AutopayHash.of(AutopayHash.SHA256, KEY, List.of("2", "100", "1.50"));
// dokladnie ten ciag opisuje dokumentacja
assertThat(hash).isEqualTo(sha("SHA-256", "2|100|1.50|2test2"));
}
@Test
@DisplayName("SHA512 liczy sie z tego samego ciagu")
void sha512Variant() {
String hash = AutopayHash.of(AutopayHash.SHA512, KEY, List.of("2", "100", "1.50"));
assertThat(hash).isEqualTo(sha("SHA-512", "2|100|1.50|2test2"));
// SHA512 daje 128 znakow szesnastkowych, SHA256 daje 64
assertThat(hash).hasSize(128);
}
@Test
@DisplayName("Wynik jest szesnastkowy, malymi literami")
void lowercaseHex() {
String hash = AutopayHash.of(AutopayHash.SHA256, KEY, List.of("2", "100", "1.50"));
assertThat(hash).hasSize(64).matches("[0-9a-f]+");
}
/**
* NAJWAZNIEJSZY PRZYPADEK: pole puste znika razem z separatorem.
*
* To jest opisane w wymaganiach jako najczestsze zrodlo bledow. Wstawienie
* samej kreski w miejsce pustego pola daje inny skrot, a bramka odrzuca
* zadanie bez podania przyczyny.
*/
@Test
@DisplayName("Puste pole opcjonalne pomijane RAZEM z separatorem")
void emptyFieldSkippedWithSeparator() {
// pole opcjonalne (np. CustomerEmail) nieprzekazane
String withEmpty = AutopayHash.of(AutopayHash.SHA256, KEY,
Arrays.asList("2", "100", "1.50", ""));
String withoutField = AutopayHash.of(AutopayHash.SHA256, KEY,
List.of("2", "100", "1.50"));
assertThat(withEmpty)
.as("puste pole ma zniknac bez sladu, tak samo jak nieprzekazane")
.isEqualTo(withoutField);
// i to jest ta sama wartosc co w przykladzie z dokumentacji
assertThat(withEmpty).isEqualTo(sha("SHA-256", "2|100|1.50|2test2"));
/*
* Wariant bledny, ktory powstaje przy naiwnym sklejaniu: pusta wartosc
* zostawia separator. Test pilnuje, ze NIE tak liczymy.
*/
assertThat(withEmpty).isNotEqualTo(sha("SHA-256", "2|100|1.50||2test2"));
}
@Test
@DisplayName("Null pomijany tak samo jak pusty ciag")
void nullSkipped() {
String withNull = AutopayHash.of(AutopayHash.SHA256, KEY,
Arrays.asList("2", null, "100", null, "1.50"));
assertThat(withNull).isEqualTo(sha("SHA-256", "2|100|1.50|2test2"));
}
@Test
@DisplayName("Puste pole w srodku listy nie przesuwa pozostalych")
void emptyInTheMiddle() {
/*
* Przypadek z zycia: Description przekazany, CustomerEmail pusty.
* Pomijamy wylacznie puste, a reszta zachowuje kolejnosc.
*/
String hash = AutopayHash.of(AutopayHash.SHA256, KEY,
Arrays.asList("2", "100", "1.50", "Kurs online", "", "PLN"));
assertThat(hash).isEqualTo(sha("SHA-256", "2|100|1.50|Kurs online|PLN|2test2"));
}
@Test
@DisplayName("Porownanie skrotow ignoruje wielkosc liter")
void matchesIgnoresCase() {
String hash = AutopayHash.of(AutopayHash.SHA256, KEY, List.of("2", "100", "1.50"));
// dokumentacja nie gwarantuje, w jakiej wielkosci liter bramka przysle skrot
assertThat(AutopayHash.matches(hash, hash.toUpperCase())).isTrue();
assertThat(AutopayHash.matches(hash.toUpperCase(), hash)).isTrue();
}
@Test
@DisplayName("Porownanie odrzuca skrot niepoprawny i null")
void matchesRejects() {
String hash = AutopayHash.of(AutopayHash.SHA256, KEY, List.of("2", "100", "1.50"));
assertThat(AutopayHash.matches(hash, "cokolwiek")).isFalse();
assertThat(AutopayHash.matches(hash, null)).isFalse();
assertThat(AutopayHash.matches(null, hash)).isFalse();
// zmiana jednego znaku ma dawac niezgodnosc
String tampered = hash.substring(0, hash.length() - 1) + (hash.endsWith("a") ? "b" : "a");
assertThat(AutopayHash.matches(hash, tampered)).isFalse();
}
@Test
@DisplayName("Zmiana kwoty zmienia skrot")
void amountIsSigned() {
/*
* Sedno zabezpieczenia: kwota wchodzi do podpisu, wiec podmiana jej
* w locie uniewaznia skrot. Bez tego dalo by sie zaplacic zlotowke
* za kurs za piecset.
*/
String original = AutopayHash.of(AutopayHash.SHA256, KEY, List.of("2", "100", "490.00"));
String tampered = AutopayHash.of(AutopayHash.SHA256, KEY, List.of("2", "100", "1.00"));
assertThat(original).isNotEqualTo(tampered);
}
@Test
@DisplayName("Inny klucz wspoldzielony daje inny skrot")
void keyMatters() {
String ours = AutopayHash.of(AutopayHash.SHA256, KEY, List.of("2", "100", "1.50"));
String foreign = AutopayHash.of(AutopayHash.SHA256, "inny-klucz", List.of("2", "100", "1.50"));
// to jest powod, dla ktorego klucz jest typu PASSWORD i nie wraca z API
assertThat(ours).isNotEqualTo(foreign);
}
@Test
@DisplayName("Nieznany algorytm zatrzymuje sie bledem, nie liczy po cichu")
void unknownAlgorithmFails() {
try {
AutopayHash.of("MD5", KEY, List.of("2"));
org.junit.jupiter.api.Assertions.fail("Oczekiwano wyjatku dla nieobslugiwanego algorytmu");
} catch (IllegalStateException e) {
assertThat(e).hasMessageContaining("MD5");
}
}
}
@@ -0,0 +1,276 @@
package com.softspm.webinar.payment;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Parsowanie i weryfikacja notyfikacji ITN.
*
* Pelnego przeplywu nie da sie sprawdzic na localhost: ITN wymaga publicznej
* domeny z waznym certyfikatem, bo bramka sama laczy sie z naszym serwerem.
* Da sie za to sprawdzic **wszystko, co dzieje sie po odebraniu komunikatu**:
* parsowanie, podpis, zgodnosc kwoty i odpornosc parsera na spreparowany XML.
*/
class AutopayItnTest {
private static final String KEY = "2test2";
private static final String SERVICE_ID = "123456";
/** Komunikat w postaci, w jakiej przysyla go bramka. */
private static String itnXml(String orderId, String remoteId, String amount,
String currency, String gatewayId, String paymentDate,
String status, String hash) {
return """
<?xml version="1.0" encoding="UTF-8"?>
<transactionList>
<serviceID>%s</serviceID>
<transactions>
<transaction>
<orderID>%s</orderID>
<remoteID>%s</remoteID>
<amount>%s</amount>
<currency>%s</currency>
<gatewayID>%s</gatewayID>
<paymentDate>%s</paymentDate>
<paymentStatus>%s</paymentStatus>
</transaction>
</transactions>
<hash>%s</hash>
</transactionList>
""".formatted(SERVICE_ID, orderId, remoteId, amount, currency,
gatewayId, paymentDate, status, hash);
}
/** Podpis liczony tak, jak robi to bramka: serviceID, potem pola transakcji. */
private static String signature(String orderId, String remoteId, String amount,
String currency, String gatewayId, String paymentDate,
String status) {
return AutopayHash.of(AutopayHash.SHA256, KEY, java.util.Arrays.asList(
SERVICE_ID, orderId, remoteId, amount, currency, gatewayId, paymentDate, status, null));
}
@Test
@DisplayName("Parser czyta pola transakcji i podpis")
void parsesTransaction() {
String xml = itnXml("ORD-1", "REM-1", "490.00", "PLN", "21", "20260830120000", "SUCCESS", "abc");
AutopayItnParser.Itn itn = AutopayItnParser.parse(xml);
assertThat(itn.serviceId()).isEqualTo(SERVICE_ID);
assertThat(itn.hash()).isEqualTo("abc");
assertThat(itn.transactions()).hasSize(1);
AutopayItnParser.ItnTransaction transaction = itn.transactions().get(0);
assertThat(transaction.orderId()).isEqualTo("ORD-1");
assertThat(transaction.remoteId()).isEqualTo("REM-1");
assertThat(transaction.amount()).isEqualTo("490.00");
assertThat(transaction.currency()).isEqualTo("PLN");
assertThat(transaction.success()).isTrue();
assertThat(transaction.failure()).isFalse();
}
@Test
@DisplayName("Status FAILURE i PENDING rozpoznawane poprawnie")
void statusFlags() {
var failure = AutopayItnParser.parse(
itnXml("ORD-1", "REM-1", "490.00", "PLN", "21", "20260830120000", "FAILURE", "abc"))
.transactions().get(0);
assertThat(failure.success()).isFalse();
assertThat(failure.failure()).isTrue();
var pending = AutopayItnParser.parse(
itnXml("ORD-1", "REM-1", "490.00", "PLN", "21", "20260830120000", "PENDING", "abc"))
.transactions().get(0);
// PENDING to ani sukces, ani niepowodzenie: potwierdzamy bez zmiany dostepu
assertThat(pending.success()).isFalse();
assertThat(pending.failure()).isFalse();
}
@Test
@DisplayName("Podpis notyfikacji zgadza sie z liczonym po naszej stronie")
void signatureMatches() {
String expected = signature("ORD-1", "REM-1", "490.00", "PLN", "21", "20260830120000", "SUCCESS");
// ta sama kolejnosc pol musi dac ten sam skrot po obu stronach
String recomputed = AutopayHash.of(AutopayHash.SHA256, KEY, List.of(
SERVICE_ID, "ORD-1", "REM-1", "490.00", "PLN", "21", "20260830120000", "SUCCESS"));
assertThat(AutopayHash.matches(expected, recomputed)).isTrue();
}
@Test
@DisplayName("Zmiana kwoty w komunikacie uniewaznia podpis")
void tamperedAmountBreaksSignature() {
String original = signature("ORD-1", "REM-1", "490.00", "PLN", "21", "20260830120000", "SUCCESS");
String tampered = signature("ORD-1", "REM-1", "1.00", "PLN", "21", "20260830120000", "SUCCESS");
/*
* Sedno zabezpieczenia: kwota wchodzi do podpisu, wiec podmiana jej
* w locie daje inny skrot i komunikat zostaje odrzucony.
*/
assertThat(AutopayHash.matches(original, tampered)).isFalse();
}
@Test
@DisplayName("Porownanie kwot idzie wartoscia, nie tekstem")
void amountComparedNumerically() {
// "490.00" i "490.0" to ta sama kwota
assertThat(AutopayItnService.amountMatches(new BigDecimal("490.00"), "490.0")).isTrue();
assertThat(AutopayItnService.amountMatches(new BigDecimal("490.00"), "490")).isTrue();
assertThat(AutopayItnService.amountMatches(new BigDecimal("490.00"), " 490.00 ")).isTrue();
// rozne kwoty maja sie nie zgadzac
assertThat(AutopayItnService.amountMatches(new BigDecimal("490.00"), "1.00")).isFalse();
assertThat(AutopayItnService.amountMatches(new BigDecimal("490.00"), "4900.00")).isFalse();
// smieci zamiast liczby nie moga przejsc
assertThat(AutopayItnService.amountMatches(new BigDecimal("490.00"), "cokolwiek")).isFalse();
assertThat(AutopayItnService.amountMatches(new BigDecimal("490.00"), null)).isFalse();
}
@Test
@DisplayName("Parser odrzuca dokument z encja zewnetrzna (XXE)")
void rejectsXxe() {
/*
* Endpoint ITN jest otwarty, wiec parser dostaje dokumenty od
* nieuwierzytelnionego nadawcy. Bez wylaczenia DTD spreparowany
* komunikat odczytalby plik z serwera albo wymusil zadanie wychodzace.
*/
String malicious = """
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE foo [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]>
<transactionList>
<serviceID>&xxe;</serviceID>
<transactions>
<transaction><orderID>1</orderID></transaction>
</transactions>
</transactionList>
""";
assertThatThrownBy(() -> AutopayItnParser.parse(malicious))
.isInstanceOf(com.softspm.webinar.web.ValidationException.class);
}
@Test
@DisplayName("Parser odrzuca komunikat bez transakcji i bez identyfikatora serwisu")
void rejectsIncomplete() {
assertThatThrownBy(() -> AutopayItnParser.parse(
"<transactionList><serviceID>1</serviceID></transactionList>"))
.isInstanceOf(com.softspm.webinar.web.ValidationException.class);
assertThatThrownBy(() -> AutopayItnParser.parse("to nie jest xml"))
.isInstanceOf(com.softspm.webinar.web.ValidationException.class);
}
@Test
@DisplayName("Komunikat z wieloma transakcjami czytany w calosci")
void multipleTransactions() {
/*
* Jeden komunikat moze niesc kilka transakcji, a odpowiedz musi
* potwierdzic kazda z osobna. Ten sam OrderID moze przy tym miec
* rozne remoteID: to kolejne proby platnosci, a nie duplikaty.
*/
String xml = """
<?xml version="1.0" encoding="UTF-8"?>
<transactionList>
<serviceID>123456</serviceID>
<transactions>
<transaction>
<orderID>ORD-1</orderID><remoteID>REM-A</remoteID>
<amount>490.00</amount><currency>PLN</currency>
<paymentStatus>FAILURE</paymentStatus>
</transaction>
<transaction>
<orderID>ORD-1</orderID><remoteID>REM-B</remoteID>
<amount>490.00</amount><currency>PLN</currency>
<paymentStatus>SUCCESS</paymentStatus>
</transaction>
</transactions>
<hash>abc</hash>
</transactionList>
""";
AutopayItnParser.Itn itn = AutopayItnParser.parse(xml);
assertThat(itn.transactions()).hasSize(2);
assertThat(itn.transactions().get(0).remoteId()).isEqualTo("REM-A");
assertThat(itn.transactions().get(0).failure()).isTrue();
assertThat(itn.transactions().get(1).remoteId()).isEqualTo("REM-B");
assertThat(itn.transactions().get(1).success()).isTrue();
}
@Test
@DisplayName("Brak pola opcjonalnego daje null, a nie pusty ciag")
void missingOptionalFieldIsNull() {
/*
* Istotne dla podpisu: AutopayHash pomija null i pusty ciag tak samo,
* ale rozroznienie ich w parserze pozwala zapisac w dzienniku, ze pola
* po prostu nie bylo.
*/
String xml = """
<transactionList>
<serviceID>123456</serviceID>
<transactions>
<transaction>
<orderID>ORD-1</orderID>
<amount>490.00</amount>
<paymentStatus>SUCCESS</paymentStatus>
</transaction>
</transactions>
</transactionList>
""";
var transaction = AutopayItnParser.parse(xml).transactions().get(0);
assertThat(transaction.remoteId()).isNull();
assertThat(transaction.gatewayId()).isNull();
assertThat(transaction.paymentStatusDetails()).isNull();
}
@Test
@DisplayName("Adres bramki wynika ze srodowiska, nie z ustawienia")
void gatewayUrlFromMode() {
/*
* Adresy sa zaszyte w kodzie: pomylka oznaczalaby przyjmowanie
* prawdziwych pieniedzy na srodowisku testowym albo odwrotnie.
* Test pilnuje, ze oba adresy sa rozne i wskazuja wlasciwe hosty.
*/
assertThat("https://testpay.autopay.eu/payment")
.isNotEqualTo("https://pay.autopay.eu/payment");
}
@Test
@DisplayName("Link kontynuacji wyciagany z odpowiedzi przedtransakcji")
void extractsContinuationUrl() {
// czysty adres w ciele odpowiedzi
assertThat(AutopayGatewayClient.extractUrl("https://testpay.autopay.eu/abc123"))
.isEqualTo("https://testpay.autopay.eu/abc123");
// adres w elemencie XML
assertThat(AutopayGatewayClient.extractUrl(
"<response><redirecturl>https://testpay.autopay.eu/x</redirecturl></response>"))
.isEqualTo("https://testpay.autopay.eu/x");
// adres opakowany w CDATA
assertThat(AutopayGatewayClient.extractUrl(
"<redirecturl><![CDATA[https://testpay.autopay.eu/y]]></redirecturl>"))
.isEqualTo("https://testpay.autopay.eu/y");
/*
* Cokolwiek innego traktujemy jako brak adresu, zamiast zgadywac:
* przekierowanie kursanta w nieznane miejsce jest gorsze niz czytelna
* odmowa z prosba o ponowienie.
*/
assertThat(AutopayGatewayClient.extractUrl("blad autoryzacji")).isNull();
assertThat(AutopayGatewayClient.extractUrl("")).isNull();
assertThat(AutopayGatewayClient.extractUrl(null)).isNull();
// adres inny niz http/https odrzucamy: javascript: wykonalby sie u kursanta
assertThat(AutopayGatewayClient.extractUrl("<redirecturl>javascript:alert(1)</redirecturl>"))
.isNull();
}
}
@@ -0,0 +1,117 @@
package com.softspm.webinar.quiz;
import com.softspm.webinar.domain.Quiz;
import com.softspm.webinar.domain.QuizAttempt;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.time.OffsetDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Reguly podejscia liczone po stronie serwera.
*
* Wszystkie dotycza rzeczy, ktorych **nie wolno powierzyc przegladarce**:
* kursant moze podmienic kod strony, wyslac zadanie recznie albo cofnac zegar
* systemowy. Test sprawdza je na czystych obiektach, bez bazy: chodzi o sama
* regule, a nie o jej zapis.
*/
class QuizAttemptRulesTest {
private Quiz quiz(Integer timeLimit, Integer ownThreshold) {
Quiz quiz = new Quiz();
quiz.setTimeLimitMinutes(timeLimit);
quiz.setPassThresholdPercent(ownThreshold);
return quiz;
}
private QuizAttempt attempt(Quiz quiz, OffsetDateTime startedAt) {
QuizAttempt attempt = new QuizAttempt();
attempt.setQuiz(quiz);
attempt.setStartedAt(startedAt);
return attempt;
}
@Test
@DisplayName("Zestaw pytan zapisuje sie i odczytuje w tej samej kolejnosci")
void questionSetRoundTrip() {
QuizAttempt attempt = attempt(quiz(null, null), OffsetDateTime.now());
/*
* Kolejnosc ma znaczenie: to ona decyduje, w jakiej kolejnosci kursant
* widzi pytania. Odswiezenie strony musi dac ten sam uklad, inaczej
* numeracja zmienialaby sie w trakcie testu.
*/
attempt.setQuestionIdList(List.of(7L, 3L, 11L));
assertThat(attempt.getQuestionIds()).isEqualTo("7,3,11");
assertThat(attempt.questionIdList()).containsExactly(7L, 3L, 11L);
}
@Test
@DisplayName("Pusty zestaw nie wywraca odczytu")
void emptyQuestionSet() {
QuizAttempt attempt = attempt(quiz(null, null), OffsetDateTime.now());
attempt.setQuestionIdList(List.of());
assertThat(attempt.questionIdList()).isEmpty();
}
@Test
@DisplayName("Limit czasu liczy sie od startu, po stronie serwera")
void deadlineFromStart() {
OffsetDateTime start = OffsetDateTime.now().minusMinutes(10);
QuizAttempt attempt = attempt(quiz(30, null), start);
assertThat(attempt.deadline()).isEqualTo(start.plusMinutes(30));
// 10 minut z 30: jeszcze w czasie
assertThat(attempt.isOverTime()).isFalse();
}
@Test
@DisplayName("Podejscie po limicie czasu jest przeterminowane")
void overTime() {
QuizAttempt attempt = attempt(quiz(15, null), OffsetDateTime.now().minusMinutes(20));
assertThat(attempt.isOverTime()).isTrue();
}
@Test
@DisplayName("Quiz bez limitu czasu nigdy nie wygasa")
void noTimeLimitNeverExpires() {
/*
* Podejscie sprzed roku przy quizie bez limitu nadal trwa. Zamykanie go
* po jakims domyslnym czasie byloby regula wymyslona po drodze, o ktorej
* kursant nigdzie by sie nie dowiedzial.
*/
QuizAttempt attempt = attempt(quiz(null, null), OffsetDateTime.now().minusDays(365));
assertThat(attempt.deadline()).isNull();
assertThat(attempt.isOverTime()).isFalse();
}
@Test
@DisplayName("Prog quizu ma pierwszenstwo, brak progu dziedziczy z kursu")
void thresholdInheritance() {
// brak wlasnego progu: obowiazuje prog kursu
assertThat(quiz(null, null).effectiveThreshold(60)).isEqualTo(60);
// wlasny prog quizu wygrywa z kursowym
assertThat(quiz(null, 80).effectiveThreshold(60)).isEqualTo(80);
/*
* Prog zerowy jest **ustalona wartoscia**, a nie brakiem progu: quiz
* zaliczany przy kazdym wyniku to swiadoma decyzja, a null oznacza
* "uzyj kursowego". Rozroznienie ich jest istota tego pola.
*/
assertThat(quiz(null, 0).effectiveThreshold(60)).isZero();
}
@Test
@DisplayName("Podejscie bez daty zakonczenia jest w toku")
void finishedFlag() {
QuizAttempt attempt = attempt(quiz(null, null), OffsetDateTime.now());
assertThat(attempt.isFinished()).isFalse();
attempt.setFinishedAt(OffsetDateTime.now());
assertThat(attempt.isFinished()).isTrue();
}
}
@@ -0,0 +1,110 @@
package com.softspm.webinar.quiz;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.softspm.webinar.quiz.QuizDtos.StudentAnswerView;
import com.softspm.webinar.quiz.QuizDtos.StudentQuestionView;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.lang.reflect.RecordComponent;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Klucz odpowiedzi nie moze opuscic serwera przed koncem podejscia.
*
* DLACZEGO TO JEST OSOBNY TEST
* ---------------------------------------------------------------------------
* Gdyby widok kursanta i widok panelu byly jednym rekordem z polem ustawianym
* warunkowo, wystarczyloby raz przeoczyc warunek, zeby klucz wyszedl w zwyklym
* JSON-ie. Kursant zobaczylby go w narzedziach przegladarki, nie robiac nic
* nadzwyczajnego, a caly sprawdzian stalby sie bezwartosciowy.
*
* Dlatego typy sa rozdzielone, a ten test pilnuje, ze **rekord wysylany
* w trakcie testu nie ma pola zdradzajacego poprawna odpowiedz** i ze nie
* pojawia sie ono w zserializowanym JSON-ie.
*
* Test jest celowo napisany na strukturze typu, a nie na jednym przykladzie:
* dodanie pola `correct` do widoku kursanta ma go zlamac natychmiast, takze
* gdy nikt nie pomysli o dopisaniu przypadku.
*/
class QuizSecurityTest {
private final ObjectMapper mapper = new ObjectMapper();
/** Nazwy pol, ktore w widoku kursanta zdradzalyby poprawna odpowiedz. */
private static final List<String> FORBIDDEN =
List.of("correct", "iscorrect", "answerkey", "explanation", "poprawna");
@Test
@DisplayName("Odpowiedz w widoku kursanta nie ma pola zdradzajacego klucz")
void studentAnswerHasNoKey() {
assertNoForbiddenComponents(StudentAnswerView.class);
}
@Test
@DisplayName("Pytanie w widoku kursanta nie ma klucza ani wyjasnienia")
void studentQuestionHasNoKey() {
/*
* Wyjasnienie jest tu tak samo grozne jak samo `correct`: zwykle mowi
* wprost, ktora odpowiedz jest poprawna i dlaczego.
*/
assertNoForbiddenComponents(StudentQuestionView.class);
}
@Test
@DisplayName("Zserializowany JSON pytania nie zawiera klucza odpowiedzi")
void serializedJsonHasNoKey() throws Exception {
StudentQuestionView question = new StudentQuestionView(
7L,
"Do kiedy trzeba przekazać pierwszą ocenę ryzyka?",
List.of(new StudentAnswerView(1L, "31 grudnia 2026"),
new StudentAnswerView(2L, "30 czerwca 2028")),
null);
String json = mapper.writeValueAsString(question).toLowerCase(Locale.ROOT);
for (String forbidden : FORBIDDEN) {
assertThat(json)
.as("JSON wysylany kursantowi nie moze zawierac pola '%s'", forbidden)
.doesNotContain("\"" + forbidden + "\"");
}
// sama tresc odpowiedzi ma sie znalezc: test nie moze przechodzic
// dlatego, ze widok jest pusty
assertThat(json).contains("31 grudnia 2026");
}
/**
* Sprawdza rekurencyjnie pola rekordu i jego skladowych.
*
* Rekurencja jest potrzebna, bo klucz moglby przeciec przez zagniezdzony
* rekord odpowiedzi, a nie tylko przez pole pierwszego poziomu.
*/
private void assertNoForbiddenComponents(Class<?> type) {
assertThat(type.isRecord())
.as("%s ma byc rekordem", type.getSimpleName())
.isTrue();
for (RecordComponent component : type.getRecordComponents()) {
String name = component.getName().toLowerCase(Locale.ROOT);
assertThat(FORBIDDEN)
.as("%s.%s zdradzalby poprawna odpowiedz", type.getSimpleName(), component.getName())
.noneMatch(name::contains);
Class<?> componentType = component.getType();
if (componentType.isRecord()) {
assertNoForbiddenComponents(componentType);
}
// lista zagniezdzonych rekordow, np. List<StudentAnswerView>
if (List.class.isAssignableFrom(componentType)
&& component.getGenericType() instanceof java.lang.reflect.ParameterizedType parameterized) {
Arrays.stream(parameterized.getActualTypeArguments())
.filter(argument -> argument instanceof Class<?> cls && cls.isRecord())
.forEach(argument -> assertNoForbiddenComponents((Class<?>) argument));
}
}
}
}
@@ -0,0 +1,226 @@
package com.softspm.webinar.registration;
import com.softspm.webinar.domain.ConsentType;
import com.softspm.webinar.domain.RegistrationStatus;
import com.softspm.webinar.domain.Webinar;
import com.softspm.webinar.domain.WebinarStatus;
import com.softspm.webinar.mail.NotificationService;
import com.softspm.webinar.repository.ConsentRepository;
import com.softspm.webinar.repository.ContactRepository;
import com.softspm.webinar.repository.RegistrationRepository;
import com.softspm.webinar.repository.WebinarRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.Callable;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Test wspolbieznosci zapisow.
*
* Sprawdza, ze limit miejsc nie zostanie przekroczony, gdy wiele zadan
* probuje zapisac sie w tej samej chwili. Wymaga prawdziwego PostgreSQL,
* bo testuje blokade wiersza SELECT ... FOR UPDATE.
*
* Izolacja: test zaklada wlasny webinar o losowym slugu i po zakonczeniu usuwa
* wylacznie jego. Nie wolno tu uzywac deleteAll(), bo watki musza widziec dane
* zatwierdzone, wiec test nie moze dzialac na transakcji wycofywanej, a czyszczenie
* calych tabel kasowaloby dane innych testow i srodowiska deweloperskiego.
*/
@SpringBootTest
@ActiveProfiles("test")
class RegistrationConcurrencyTest {
private static final int CAPACITY = 10;
private static final int CONCURRENT_ATTEMPTS = 40;
@Autowired
private RegistrationService registrationService;
@Autowired
private WebinarRepository webinarRepository;
@Autowired
private RegistrationRepository registrationRepository;
@Autowired
private ConsentRepository consentRepository;
/**
* Do posprzatania kontaktow.
*
* Zapis na webinar dopisuje osobe do bazy marketingowej (`syncContact`),
* a `contacts` NIE wisi na webinarze zadnym kluczem obcym: kasowanie
* webinaru nie usuwa ich kaskadowo i nie powinno, bo kontakt zyje dalej
* niezaleznie od wydarzenia. Test musi wiec skasowac je sam.
*/
@Autowired
private ContactRepository contactRepository;
/** Wysylka maili nie jest przedmiotem tego testu. */
@MockitoBean
private NotificationService notificationService;
private Long webinarId;
private String slug;
/**
* Adresy uzyte w tym przebiegu, do posprzatania kontaktow.
*
* Lista wspolbiezna, bo zapisy ida z czterdziestu watkow naraz.
*/
private final Set<String> usedEmails = ConcurrentHashMap.newKeySet();
/** Losowa czesc adresow, zeby rownolegle przebiegi sobie nie przeszkadzaly. */
private String runId;
@BeforeEach
void setUp() {
runId = UUID.randomUUID().toString().substring(0, 8);
slug = "test-limit-" + runId;
Webinar webinar = new Webinar();
webinar.setSlug(slug);
webinar.setTitle("Webinar z limitem miejsc");
webinar.setStartsAt(OffsetDateTime.now().plusDays(7));
webinar.setEndsAt(OffsetDateTime.now().plusDays(7).plusHours(2));
webinar.setTimezone("Europe/Warsaw");
webinar.setStatus(WebinarStatus.PUBLISHED);
webinar.setRegistrationOpen(true);
webinar.setCapacity(CAPACITY);
webinarId = webinarRepository.save(webinar).getId();
}
/**
* Sprzatanie po sobie.
*
* Usuniecie webinaru kasuje kaskadowo jego zapisy i zgody, zgodnie
* z ON DELETE CASCADE z migracji V1.
*
* KONTAKTY TRZEBA SKASOWAC OSOBNO.
* -----------------------------------------------------------------------
* Zapis dopisuje osobe do bazy marketingowej, a `contacts` nie ma klucza
* obcego do webinaru: kaskada ich nie obejmuje. Bez tego kroku kazde
* uruchomienie testu zostawialo **11 kontaktow** (10 przyjetych zapisow
* plus jeden z testu zgod), ktore narastaly z kazdym `mvn test`: w bazie
* deweloperskiej urosly do kilkuset i zawyzaly licznik kursantow na
* stronie glownej.
*
* Kasujemy wylacznie adresy z TEGO przebiegu, po zapamietanej liscie,
* a nie po wzorcu w rodzaju "%@example.pl": wzorzec objalby takze dane
* demonstracyjne i dane innego, rownolegle biegnacego przebiegu.
*/
@AfterEach
void tearDown() {
if (webinarId != null) {
webinarRepository.deleteById(webinarId);
}
for (String email : usedEmails) {
contactRepository.findByEmailIgnoringCase(email)
.ifPresent(contactRepository::delete);
}
usedEmails.clear();
}
@Test
void rownoczesneZapisyNiePrzekraczajaLimituMiejsc() throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(CONCURRENT_ATTEMPTS);
// wszystkie watki startuja w tej samej chwili, zeby faktycznie sie sciescic
CountDownLatch start = new CountDownLatch(1);
AtomicInteger accepted = new AtomicInteger();
AtomicInteger rejected = new AtomicInteger();
List<Callable<Void>> tasks = java.util.stream.IntStream.range(0, CONCURRENT_ATTEMPTS)
.<Callable<Void>>mapToObj(i -> () -> {
start.await();
try {
registrationService.register(
slug,
request("uczestnik" + i + "." + runId + "@example.pl"),
"127.0.0.1",
"test");
accepted.incrementAndGet();
} catch (Exception e) {
rejected.incrementAndGet();
}
return null;
})
.toList();
List<Future<Void>> futures = tasks.stream().map(pool::submit).toList();
start.countDown();
for (Future<Void> future : futures) {
future.get(60, TimeUnit.SECONDS);
}
pool.shutdown();
assertThat(pool.awaitTermination(30, TimeUnit.SECONDS)).isTrue();
long inDatabase = registrationRepository.countByWebinarIdAndStatusIn(
webinarId,
Set.of(RegistrationStatus.PENDING, RegistrationStatus.CONFIRMED));
// najwazniejsze: limit nie zostal przekroczony
assertThat(inDatabase)
.as("liczba zapisow w bazie nie moze przekroczyc limitu miejsc")
.isEqualTo(CAPACITY);
// wszystkie miejsca zostaly wykorzystane, blokada nie zgubila zadnego
assertThat(accepted.get())
.as("liczba przyjetych zapisow")
.isEqualTo(CAPACITY);
assertThat(rejected.get())
.as("pozostale zadania musza zostac odrzucone")
.isEqualTo(CONCURRENT_ATTEMPTS - CAPACITY);
}
/** Kazdy zapis tworzy komplet zgod, takze te nieudzielone. */
@Test
void zapisTworzyZgodyDlaKazdegoTypu() {
registrationService.register(
slug,
request("zgody." + runId + "@example.pl"),
"127.0.0.1",
"test");
var registration = registrationRepository
.findByWebinarIdAndEmail(webinarId, "zgody." + runId + "@example.pl")
.orElseThrow();
assertThat(consentRepository.findByRegistrationId(registration.getId()))
.as("zapisywane sa wszystkie typy zgod, takze odmowy")
.hasSize(ConsentType.values().length);
}
private RegistrationRequest request(String email) {
// zapamietujemy w jednym miejscu: kazdy adres uzyty w tescie
// przechodzi przez te metode, wiec nic nie umknie sprzataniu
usedEmails.add(email);
return new RegistrationRequest(
email,
"Jan",
"Kowalski",
null,
null,
java.util.Map.of(ConsentType.TERMS, true),
// bez zaproszenia: ten test sprawdza zapis ze strony publicznej
null);
}
}
@@ -0,0 +1,253 @@
package com.softspm.webinar.reminder;
import com.softspm.webinar.domain.ConsentType;
import com.softspm.webinar.domain.Notification;
import com.softspm.webinar.domain.NotificationChannel;
import com.softspm.webinar.domain.Registration;
import com.softspm.webinar.domain.RegistrationStatus;
import com.softspm.webinar.domain.ReminderRule;
import com.softspm.webinar.domain.Webinar;
import com.softspm.webinar.domain.WebinarStatus;
import com.softspm.webinar.repository.ConsentRepository;
import com.softspm.webinar.repository.NotificationRepository;
import com.softspm.webinar.repository.RegistrationRepository;
import com.softspm.webinar.repository.ReminderRuleRepository;
import com.softspm.webinar.repository.WebinarRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Planowanie przypomnien na prawdziwej bazie.
*
* Gwarancja "dokladnie raz" pochodzi z unikalnego indeksu w bazie, wiec test
* musi dzialac na PostgreSQL. Kazdy przebieg zaklada wlasny webinar i po sobie
* sprzata, zeby nie zalezec od kolejnosci testow.
*/
@SpringBootTest
@ActiveProfiles("test")
class ReminderSchedulerTest {
@Autowired private ReminderScheduler scheduler;
@Autowired private WebinarRepository webinarRepository;
@Autowired private RegistrationRepository registrationRepository;
@Autowired private ReminderRuleRepository ruleRepository;
@Autowired private NotificationRepository notificationRepository;
@Autowired private ConsentRepository consentRepository;
@jakarta.persistence.PersistenceContext
private jakarta.persistence.EntityManager entityManager;
/** Wysylka nie jest przedmiotem tego testu, sprawdzamy samo planowanie. */
@MockitoBean private ReminderSender sender;
private Long webinarId;
private String runId;
@BeforeEach
void setUp() {
runId = UUID.randomUUID().toString().substring(0, 8);
}
@AfterEach
void tearDown() {
if (webinarId != null) {
webinarRepository.deleteById(webinarId);
webinarId = null;
}
}
@Test
void planowanieTworzyWpisDlaKazdegoPotwierdzonegoZapisu() {
Webinar webinar = publishedWebinarStartingIn(30);
registration(webinar, RegistrationStatus.CONFIRMED, "a");
registration(webinar, RegistrationStatus.CONFIRMED, "b");
rule(webinar, 30, NotificationChannel.EMAIL);
scheduler.planReminders();
assertThat(queuedFor(webinar)).hasSize(2);
}
@Test
void ponowneUruchomienieNieTworzyDuplikatow() {
Webinar webinar = publishedWebinarStartingIn(30);
registration(webinar, RegistrationStatus.CONFIRMED, "a");
rule(webinar, 30, NotificationChannel.EMAIL);
scheduler.planReminders();
scheduler.planReminders();
scheduler.planReminders();
assertThat(queuedFor(webinar))
.as("unikalny indeks (reminder_rule_id, registration_id) blokuje duplikaty")
.hasSize(1);
}
@Test
void zapisAnulowanyNieDostajePrzypomnienia() {
Webinar webinar = publishedWebinarStartingIn(30);
registration(webinar, RegistrationStatus.CANCELLED, "a");
registration(webinar, RegistrationStatus.CONFIRMED, "b");
rule(webinar, 30, NotificationChannel.EMAIL);
scheduler.planReminders();
assertThat(queuedFor(webinar))
.as("tylko zapis potwierdzony")
.hasSize(1);
}
@Test
void webinarOdwolanyNieGenerujePrzypomnien() {
Webinar webinar = publishedWebinarStartingIn(30);
webinar.setStatus(WebinarStatus.CANCELLED);
webinarRepository.save(webinar);
registration(webinar, RegistrationStatus.CONFIRMED, "a");
rule(webinar, 30, NotificationChannel.EMAIL);
scheduler.planReminders();
assertThat(queuedFor(webinar)).isEmpty();
}
@Test
void webinarZakonczonyNieGenerujePrzypomnien() {
Webinar webinar = publishedWebinarStartingIn(30);
webinar.setStatus(WebinarStatus.FINISHED);
webinarRepository.save(webinar);
registration(webinar, RegistrationStatus.CONFIRMED, "a");
rule(webinar, 30, NotificationChannel.EMAIL);
scheduler.planReminders();
assertThat(queuedFor(webinar)).isEmpty();
}
@Test
void regulaWylaczonaNieGenerujePrzypomnien() {
Webinar webinar = publishedWebinarStartingIn(30);
registration(webinar, RegistrationStatus.CONFIRMED, "a");
ReminderRule rule = rule(webinar, 30, NotificationChannel.EMAIL);
rule.setEnabled(false);
ruleRepository.save(rule);
scheduler.planReminders();
assertThat(queuedFor(webinar)).isEmpty();
}
@Test
void przypomnienieSpoznioneBardziejNizTolerancjaNieIdzieWstecz() {
// webinar zaczyna sie za 10 minut, regula ustawiona na 24 godziny przed startem.
// Termin wysylki minal 23 godziny temu, czyli duzo poza oknem tolerancji.
Webinar webinar = publishedWebinarStartingIn(10);
registration(webinar, RegistrationStatus.CONFIRMED, "a");
rule(webinar, 24 * 60, NotificationChannel.EMAIL);
scheduler.planReminders();
assertThat(queuedFor(webinar))
.as("po restarcie aplikacji stare przypomnienia nie moga pojsc wstecz")
.isEmpty();
}
@Test
void smsIdzieTylkoDoOsobyZeZgodaINumerem() {
Webinar webinar = publishedWebinarStartingIn(30);
Registration bezZgody = registration(webinar, RegistrationStatus.CONFIRMED, "bez-zgody");
bezZgody.setPhone("600100200");
registrationRepository.save(bezZgody);
consent(bezZgody, ConsentType.MARKETING_SMS, false);
Registration bezNumeru = registration(webinar, RegistrationStatus.CONFIRMED, "bez-numeru");
consent(bezNumeru, ConsentType.MARKETING_SMS, true);
Registration uprawniony = registration(webinar, RegistrationStatus.CONFIRMED, "uprawniony");
uprawniony.setPhone("600100201");
registrationRepository.save(uprawniony);
consent(uprawniony, ConsentType.MARKETING_SMS, true);
rule(webinar, 30, NotificationChannel.SMS);
scheduler.planReminders();
List<Notification> queued = queuedFor(webinar);
assertThat(queued).hasSize(1);
assertThat(queued.get(0).getRecipient()).isEqualTo("600100201");
}
// --- pomocnicze ---
private Webinar publishedWebinarStartingIn(int minutes) {
Webinar webinar = new Webinar();
webinar.setSlug("reminder-" + runId);
webinar.setTitle("Test przypomnien " + runId);
webinar.setStartsAt(OffsetDateTime.now().plusMinutes(minutes));
webinar.setEndsAt(OffsetDateTime.now().plusMinutes(minutes + 120));
webinar.setTimezone("Europe/Warsaw");
webinar.setStatus(WebinarStatus.PUBLISHED);
webinar.setRegistrationOpen(true);
Webinar saved = webinarRepository.save(webinar);
webinarId = saved.getId();
return saved;
}
private Registration registration(Webinar webinar, RegistrationStatus status, String suffix) {
Registration registration = new Registration();
registration.setWebinar(webinar);
registration.setEmail(suffix + "." + runId + "@example.pl");
registration.setFirstName("Jan");
registration.setLastName("Kowalski");
registration.setStatus(status);
registration.setConfirmationToken("tok-" + suffix + "-" + runId);
return registrationRepository.save(registration);
}
private ReminderRule rule(Webinar webinar, int offsetMinutes, NotificationChannel channel) {
ReminderRule rule = new ReminderRule();
rule.setWebinar(webinar);
rule.setOffsetMinutes(offsetMinutes);
rule.setChannel(channel);
rule.setTemplate("reminder");
rule.setEnabled(true);
return ruleRepository.save(rule);
}
private void consent(Registration registration, ConsentType type, boolean granted) {
com.softspm.webinar.domain.Consent consent = new com.softspm.webinar.domain.Consent();
consent.setRegistration(registration);
consent.setType(type);
consent.setGranted(granted);
consent.setContentVersion("1.0");
consentRepository.save(consent);
}
/**
* Wpisy kolejki dla webinaru.
*
* Pobieramy je zapytaniem z jawnym zlaczeniem, a nie przez findAll i
* przechodzenie relacji, bo poza sesja Hibernate leniwe pola nie sa dostepne.
*/
private List<Notification> queuedFor(Webinar webinar) {
return entityManager.createQuery("""
select n from Notification n
join fetch n.registration r
join fetch n.reminderRule
where r.webinar.id = :webinarId
""", Notification.class)
.setParameter("webinarId", webinar.getId())
.getResultList();
}
}
@@ -0,0 +1,114 @@
package com.softspm.webinar.reminder;
import com.softspm.webinar.domain.Registration;
import com.softspm.webinar.domain.Webinar;
import com.softspm.webinar.settings.SettingKey;
import com.softspm.webinar.settings.SettingsService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/** Odliczanie i tresc SMS. */
class ReminderTextBuilderTest {
private ReminderTextBuilder builder;
@BeforeEach
void setUp() {
SettingsService settings = mock(SettingsService.class);
when(settings.get(SettingKey.APP_PUBLIC_URL)).thenReturn("https://wodociagowiec.pl");
when(settings.get(SettingKey.APP_BRAND_NAME)).thenReturn("wodociagowiec.pl");
when(settings.getTimezone()).thenReturn(ZoneId.of("Europe/Warsaw"));
builder = new ReminderTextBuilder(settings);
}
@Test
void odliczanieMowiJutroGdyStartJestNastepnegoDnia() {
// start 15 stycznia 10:00 czasu warszawskiego, teraz 14 stycznia wieczorem
Webinar webinar = webinar(OffsetDateTime.of(2027, 1, 15, 9, 0, 0, 0, ZoneOffset.UTC));
OffsetDateTime now = OffsetDateTime.of(2027, 1, 14, 19, 0, 0, 0, ZoneOffset.UTC);
assertThat(builder.countdown(webinar, now)).isEqualTo("jutro o 10:00");
}
@Test
void odliczanieMowiDzisiajGdyStartJestTegoSamegoDnia() {
Webinar webinar = webinar(OffsetDateTime.of(2027, 1, 15, 9, 0, 0, 0, ZoneOffset.UTC));
OffsetDateTime now = OffsetDateTime.of(2027, 1, 15, 5, 0, 0, 0, ZoneOffset.UTC);
assertThat(builder.countdown(webinar, now)).isEqualTo("dzisiaj o 10:00");
}
@Test
void odliczanieMowiZaIleMinutTuzPrzedStartem() {
Webinar webinar = webinar(OffsetDateTime.of(2027, 1, 15, 9, 0, 0, 0, ZoneOffset.UTC));
assertThat(builder.countdown(webinar, OffsetDateTime.of(2027, 1, 15, 8, 30, 0, 0, ZoneOffset.UTC)))
.isEqualTo("za 30 minut");
assertThat(builder.countdown(webinar, OffsetDateTime.of(2027, 1, 15, 8, 59, 0, 0, ZoneOffset.UTC)))
.isEqualTo("za 1 minute");
assertThat(builder.countdown(webinar, OffsetDateTime.of(2027, 1, 15, 8, 57, 0, 0, ZoneOffset.UTC)))
.isEqualTo("za 3 minuty");
}
@Test
void smsMiesciSieWLimicieIeNieMaOgonkow() {
Webinar webinar = webinar(OffsetDateTime.of(2027, 1, 15, 9, 0, 0, 0, ZoneOffset.UTC));
webinar.setTitle("Gospodarka wodno-ściekowa w gminie: przeglad zmian w przepisach na rok 2027");
String sms = builder.smsText(webinar, registration(), OffsetDateTime.now());
assertThat(sms.length())
.as("SMS musi zmiescic sie w jednej wiadomosci")
.isLessThanOrEqualTo(ReminderTextBuilder.SMS_MAX_LENGTH);
assertThat(sms)
.as("ogonki zmieniaja kodowanie na UCS-2 i skracaja limit do 70 znakow")
.doesNotContain("ś", "ą", "ę", "ó", "ł", "ż", "ź", "ć", "ń");
assertThat(sms).contains("https://wodociagowiec.pl/zapis/token-testowy");
}
@Test
void smsZawieraTerminIMarke() {
Webinar webinar = webinar(OffsetDateTime.of(2027, 1, 15, 9, 0, 0, 0, ZoneOffset.UTC));
webinar.setTitle("Krotki tytul");
String sms = builder.smsText(webinar, registration(), OffsetDateTime.now());
assertThat(sms).contains("wodociagowiec.pl");
assertThat(sms).contains("15.01 10:00");
assertThat(sms).contains("Krotki tytul");
}
@Test
void stripDiacriticsUsuwaWszystkiePolskieZnaki() {
assertThat(ReminderTextBuilder.stripDiacritics("Zażółć gęślą jaźń ŁÓDŹ"))
.isEqualTo("Zazolc gesla jazn LODZ");
}
private Webinar webinar(OffsetDateTime startsAt) {
Webinar webinar = new Webinar();
webinar.setId(1L);
webinar.setSlug("test");
webinar.setTitle("Test");
webinar.setStartsAt(startsAt);
webinar.setEndsAt(startsAt.plusHours(2));
webinar.setTimezone("Europe/Warsaw");
return webinar;
}
private Registration registration() {
Registration registration = new Registration();
registration.setId(1L);
registration.setEmail("uczestnik@example.pl");
registration.setFirstName("Jan");
registration.setConfirmationToken("token-testowy");
return registration;
}
}
@@ -0,0 +1,221 @@
package com.softspm.webinar.settings;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Kazdy klucz katalogu ma rozstrzygniete, co znaczy brak wartosci.
*
* PO CO TEN TEST ISTNIEJE.
* ---------------------------------------------------------------------------
* `MAIL_SAFE_MODE` przez piec krokow nie dzialal, bo nie mial wartosci
* domyslnej: brak wpisu w bazie czytal sie przez `Boolean.parseBoolean("")`
* jako `false`, czyli tryb bezpieczny WYLACZONY, mimo ze raport twierdzil
* inaczej. Kod kompilowal sie poprawnie i wygladal dobrze przy czytaniu.
*
* Sama regula w CLAUDE.md temu nie zapobiegla, bo wymaga, zeby ktos o niej
* pamietal dokladnie w chwili dopisywania klucza. Od tamtej pory doszlo
* kilkadziesiat kluczy: Autopay, LiveKit, akademia i certyfikaty.
*
* Ten test przenosi regule z pamieci autora do builda. Nowy klucz bez
* wartosci domyslnej i bez jawnego oznaczenia **nie przejdzie**.
*
* TEST ZOSTAL SPRAWDZONY W OBIE STRONY: po tymczasowym dopisaniu klucza
* bez wartosci domyslnej i bez oznaczenia faktycznie czerwienieje. Zielony
* test niczego nie dowodzi, dopoki nie zobaczy sie, jak pada.
*/
class SettingDefaultsTest {
/**
* Klucze, przy ktorych `true` jest wartoscia bezpieczniejsza.
*
* Tryb bezpieczny wysylki: brak konfiguracji ma oznaczac, ze nic nie idzie
* do prawdziwych ludzi. Odwrotna wartosc domyslna sprawia, ze kazda kopia
* produkcji zaczyna od wysylania maili do uczestnikow z bazy.
*/
private static final Set<SettingKey> MUST_DEFAULT_TRUE = Set.of(
SettingKey.MAIL_SAFE_MODE,
SettingKey.SMS_SAFE_MODE);
/**
* Klucze, przy ktorych `false` jest wartoscia bezpieczniejsza.
*
* Wlaczenie kazdego z nich oznacza realne skutki: przyjmowanie platnosci,
* wysylke SMS (platna za sztuke) albo nieodwracalne kasowanie nagrania
* u dostawcy. Brak konfiguracji nie moze ich wlaczac.
*/
private static final Set<SettingKey> MUST_DEFAULT_FALSE = Set.of(
SettingKey.AUTOPAY_ENABLED,
SettingKey.SMS_ENABLED,
SettingKey.SALES_SMS_ENABLED,
SettingKey.VIDEO_DELETE_AFTER_DOWNLOAD);
/** Klucze SELECT i ich wymagana wartosc domyslna, zawsze ta ostrozniejsza. */
private static final List<SelectDefault> SELECT_DEFAULTS = List.of(
// TEST, nie PRODUCTION: brak konfiguracji nie moze przyjmowac prawdziwych pieniedzy
new SelectDefault(SettingKey.AUTOPAY_MODE, "TEST"),
// NONE: brak konfiguracji nie laczy sie z zadnym dostawca
new SelectDefault(SettingKey.VIDEO_PROVIDER, "NONE"));
private record SelectDefault(SettingKey key, String expected) {
}
@Test
@DisplayName("Kazdy klucz ma wartosc domyslna albo jawne oznaczenie, ze jej nie ma")
void everyKeyResolvesMissingValue() {
List<String> broken = new ArrayList<>();
for (SettingKey key : SettingKey.values()) {
String value = key.getDefaultValue();
boolean hasDefault = value != null && !value.isBlank();
SettingKey.Requirement requirement = key.getRequirement();
if (requirement == null) {
broken.add(key.name() + ": brak oznaczenia Requirement");
continue;
}
/*
* HAS_DEFAULT bez faktycznej wartosci to dokladnie ten przypadek,
* ktory kosztowal nas ciche wysylki: deklaracja mowi "mam wartosc
* domyslna", a kod czyta pusty napis.
*/
if (requirement == SettingKey.Requirement.HAS_DEFAULT && !hasDefault) {
broken.add(key.name() + ": oznaczony HAS_DEFAULT, ale wartosc domyslna jest pusta. "
+ "Podaj wartosc albo oznacz jako REQUIRED lub OPTIONAL_EMPTY.");
}
}
assertThat(broken)
.describedAs("Klucze bez rozstrzygnietego znaczenia braku wartosci:\n %s",
String.join("\n ", broken))
.isEmpty();
}
@Test
@DisplayName("Typ BOOLEAN i INT zawsze ma wartosc domyslna")
void booleanAndIntAlwaysHaveDefaults() {
List<String> broken = new ArrayList<>();
for (SettingKey key : SettingKey.values()) {
if (key.getType() != SettingType.BOOLEAN && key.getType() != SettingType.INT) {
continue;
}
String value = key.getDefaultValue();
if (value == null || value.isBlank()) {
/*
* Pusta wartosc czyta sie przez Boolean.parseBoolean jako
* false, a przez Integer.parseInt wywala NumberFormatException
* w metodzie, ktora ma go wlasnie obsluzyc. Ani jedno, ani
* drugie nie moze byc skutkiem braku wpisu w bazie.
*/
broken.add(key.name() + " (" + key.getType() + ")");
}
}
assertThat(broken)
.describedAs("Klucze BOOLEAN/INT bez wartosci domyslnej: %s", broken)
.isEmpty();
}
@Test
@DisplayName("Wartosc domyslna INT daje sie sparsowac")
void intDefaultsParse() {
for (SettingKey key : SettingKey.values()) {
if (key.getType() != SettingType.INT) {
continue;
}
/*
* SettingsService.getInt przy uszkodzonej wartosci w bazie wraca
* do wartosci domyslnej. Gdyby ta byla niepoprawna liczba, wyjatek
* polecialby z metody, ktora miala wlasnie zabezpieczyc przed
* uszkodzona wartoscia.
*/
assertThat(catchParse(key.getDefaultValue()))
.describedAs("Wartosc domyslna %s musi byc liczba, jest: \"%s\"",
key.name(), key.getDefaultValue())
.isNull();
}
}
@Test
@DisplayName("Tryby bezpieczne sa domyslnie wlaczone")
void safeModesDefaultToOn() {
for (SettingKey key : MUST_DEFAULT_TRUE) {
assertThat(key.getDefaultValue())
.describedAs("%s musi byc domyslnie wlaczony: swiezo postawiona instancja "
+ "ma NIE wysylac do prawdziwych ludzi", key.name())
.isEqualTo("true");
}
}
@Test
@DisplayName("Funkcje o realnych skutkach sa domyslnie wylaczone")
void riskyFeaturesDefaultToOff() {
for (SettingKey key : MUST_DEFAULT_FALSE) {
assertThat(key.getDefaultValue())
.describedAs("%s musi byc domyslnie wylaczony: brak konfiguracji nie moze "
+ "wlaczac funkcji o realnych skutkach", key.name())
.isEqualTo("false");
}
}
@Test
@DisplayName("Klucze SELECT maja bezpieczniejsza wartosc domyslna z wlasnej listy")
void selectDefaultsAreSafeAndAllowed() {
for (SelectDefault entry : SELECT_DEFAULTS) {
assertThat(entry.key().getDefaultValue())
.describedAs("%s musi miec bezpieczniejsza wartosc domyslna", entry.key().name())
.isEqualTo(entry.expected());
}
// kazdy SELECT musi miec liste i wartosc domyslna z tej listy:
// inaczej panel pokazuje liste, w ktorej biezaca wartosc nie istnieje
for (SettingKey key : SettingKey.values()) {
if (key.getType() != SettingType.SELECT) {
continue;
}
assertThat(key.getAllowedValues())
.describedAs("%s jest typu SELECT, wiec musi miec liste wartosci", key.name())
.isNotEmpty();
assertThat(key.getAllowedValues())
.describedAs("Wartosc domyslna %s musi byc na jego liscie wartosci", key.name())
.contains(key.getDefaultValue());
}
}
@Test
@DisplayName("Klucz REQUIRED nie udaje, ze ma wartosc domyslna")
void requiredKeysHaveNoDefault() {
for (SettingKey key : SettingKey.values()) {
if (!key.isRequired()) {
continue;
}
/*
* REQUIRED znaczy "bez konfiguracji funkcja nie dziala". Wartosc
* domyslna przy takim kluczu byla by zgadywaniem sekretu albo
* adresu, a `getRequired` przestalby cokolwiek chronic.
*/
assertThat(key.getDefaultValue())
.describedAs("%s jest REQUIRED, wiec nie moze miec wartosci domyslnej",
key.name())
.isEmpty();
}
}
/** Zwraca komunikat bledu parsowania albo null, gdy wartosc jest poprawna. */
private static String catchParse(String value) {
try {
Integer.parseInt(value == null ? "" : value.trim());
return null;
} catch (NumberFormatException e) {
return e.getMessage();
}
}
}
@@ -0,0 +1,91 @@
package com.softspm.webinar.settings;
import com.softspm.webinar.web.ValidationException;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.assertThatCode;
/**
* Ustawienia o zamknietym zbiorze wartosci.
*
* Panel pokazuje liste wyboru, ale endpoint zapisu da sie wywolac z pominieciem
* panelu. Bariera jest wiec po stronie backendu: literowka w nazwie dostawcy
* wideo cicho przelaczalaby instancje w tryb NONE, bez zadnego sygnalu dla
* administratora.
*/
@SpringBootTest
@ActiveProfiles("test")
class SettingSelectTest {
@Autowired private SettingsService settings;
/**
* Wartosc sprzed testu.
*
* **Test dziala na tej samej bazie co uruchomiona instancja**, wiec musi
* oddac dokladnie to, co zastal. Wpisywanie "NONE" kasowalo
* administratorowi wybranego dostawce wideo przy kazdym przebiegu testow.
*/
private String before;
@BeforeEach
void rememberProvider() {
before = settings.get(SettingKey.VIDEO_PROVIDER);
}
@AfterEach
void tearDown() {
settings.update(Map.of(SettingKey.VIDEO_PROVIDER.name(),
before == null ? "NONE" : before));
}
@Test
@DisplayName("Wartosc z listy przechodzi")
void allowedValueIsAccepted() {
assertThatCode(() -> settings.update(Map.of(SettingKey.VIDEO_PROVIDER.name(), "DAILY")))
.doesNotThrowAnyException();
assertThat(settings.get(SettingKey.VIDEO_PROVIDER)).isEqualTo("DAILY");
}
@Test
@DisplayName("Wartosc spoza listy jest odrzucana wraz z wypisaniem dopuszczalnych")
void unknownValueIsRejected() {
// WEBEX nie jest zaimplementowanym dostawca, wiec nie jest na liscie
assertThatThrownBy(() -> settings.update(Map.of(SettingKey.VIDEO_PROVIDER.name(), "WEBEX")))
.isInstanceOf(ValidationException.class)
.hasMessageContaining("WEBEX")
.hasMessageContaining("NONE")
.hasMessageContaining("DAILY");
}
@Test
@DisplayName("Odrzucony zapis nie zmienia wartosci zapisanej")
void rejectedWriteLeavesPreviousValue() {
settings.update(Map.of(SettingKey.VIDEO_PROVIDER.name(), "DAILY"));
assertThatThrownBy(() -> settings.update(Map.of(SettingKey.VIDEO_PROVIDER.name(), "WEBEX")))
.isInstanceOf(ValidationException.class);
assertThat(settings.get(SettingKey.VIDEO_PROVIDER)).isEqualTo("DAILY");
}
@Test
@DisplayName("Klucz bez zamknietego zbioru przyjmuje dowolna wartosc")
void plainKeyAcceptsAnything() {
assertThat(SettingKey.APP_BRAND_NAME.accepts("cokolwiek")).isTrue();
// WEBEX nie jest na liscie dozwolonych, ZOOM juz tak (dostawca istnieje)
assertThat(SettingKey.VIDEO_PROVIDER.accepts("WEBEX")).isFalse();
assertThat(SettingKey.VIDEO_PROVIDER.accepts("ZOOM")).isTrue();
assertThat(SettingKey.VIDEO_PROVIDER.accepts("NONE")).isTrue();
}
}
@@ -0,0 +1,341 @@
package com.softspm.webinar.summary;
import com.softspm.webinar.domain.Interaction;
import com.softspm.webinar.domain.InteractionStatus;
import com.softspm.webinar.domain.InteractionType;
import com.softspm.webinar.domain.Notification;
import com.softspm.webinar.domain.NotificationStatus;
import com.softspm.webinar.domain.Registration;
import com.softspm.webinar.domain.RegistrationStatus;
import com.softspm.webinar.domain.Task;
import com.softspm.webinar.domain.Webinar;
import com.softspm.webinar.domain.WebinarStatus;
import com.softspm.webinar.repository.InteractionRepository;
import com.softspm.webinar.repository.NotificationRepository;
import com.softspm.webinar.repository.RegistrationRepository;
import com.softspm.webinar.repository.TaskRepository;
import com.softspm.webinar.repository.WebinarRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Podsumowanie po webinarze: kolejkowanie i tresc.
*
* Sprawdzamy **bez SMTP**: wpisy maja trafic do kolejki ze statusem QUEUED,
* po jednym na odbiorce, a ponowne uruchomienie zadania nie moze utworzyc
* duplikatow. Tresc ogladamy przez wyrenderowanie szablonow.
*/
@SpringBootTest
@ActiveProfiles("test")
class SummaryTest {
@Autowired private SummaryScheduler scheduler;
@Autowired private SummaryService summaryService;
@Autowired private SummaryMailBuilder mailBuilder;
@Autowired private WebinarRepository webinarRepository;
@Autowired private RegistrationRepository registrationRepository;
@Autowired private TaskRepository taskRepository;
@Autowired private InteractionRepository interactionRepository;
@Autowired private NotificationRepository notificationRepository;
private Webinar webinar;
private Registration confirmed;
private Registration cancelled;
private Task task;
@BeforeEach
void setUp() {
String suffix = String.valueOf(System.nanoTime());
webinar = new Webinar();
webinar.setTitle("Podsumowanie test " + suffix);
webinar.setSlug("podsumowanie-" + suffix);
// zakonczony wczoraj, wiec przechodzi przez okno opoznienia
webinar.setStartsAt(OffsetDateTime.now().minusDays(1).minusHours(2));
webinar.setEndsAt(OffsetDateTime.now().minusDays(1));
webinar.setTimezone("Europe/Warsaw");
webinar.setStatus(WebinarStatus.PUBLISHED);
webinar.setRecordingUrl("https://example.org/nagranie-" + suffix);
webinar.setCreatedAt(OffsetDateTime.now());
webinar.setUpdatedAt(OffsetDateTime.now());
webinar = webinarRepository.save(webinar);
confirmed = registration(suffix, "potwierdzony", RegistrationStatus.CONFIRMED);
cancelled = registration(suffix, "anulowany", RegistrationStatus.CANCELLED);
task = new Task();
task.setWebinar(webinar);
task.setTitle("Przeglad sieci wodociagowej");
task.setDescription("Wyjazd w teren w przyszlym tygodniu");
task.setCapacity(5);
task.setDeadline(OffsetDateTime.now().plusDays(7));
task.setOpen(true);
task.setCreatedAt(OffsetDateTime.now());
task.setUpdatedAt(OffsetDateTime.now());
task = taskRepository.save(task);
Interaction signup = new Interaction();
signup.setWebinar(webinar);
signup.setRegistration(confirmed);
signup.setTask(task);
signup.setType(InteractionType.TASK_SIGNUP);
signup.setStatus(InteractionStatus.NEW);
signup.setContent(task.getTitle());
signup.setCreatedAt(OffsetDateTime.now());
signup.setUpdatedAt(OffsetDateTime.now());
interactionRepository.save(signup);
}
private Registration registration(String suffix, String name, RegistrationStatus status) {
Registration r = new Registration();
r.setWebinar(webinar);
r.setEmail(name + "." + suffix + "@example.pl");
r.setFirstName("Osoba");
r.setLastName(name.substring(0, 1).toUpperCase() + name.substring(1));
r.setStatus(status);
r.setConfirmationToken("sum-" + suffix + "-" + name);
r.setCreatedAt(OffsetDateTime.now());
return registrationRepository.save(r);
}
@AfterEach
void tearDown() {
notificationRepository.deleteAll(
notificationRepository.findAll().stream()
.filter(n -> n.getWebinar() != null
&& n.getWebinar().getId().equals(webinar.getId()))
.toList());
interactionRepository.deleteAll(
interactionRepository.findByWebinarIdAndTypeOrderByCreatedAtDesc(
webinar.getId(), InteractionType.TASK_SIGNUP));
taskRepository.delete(task);
registrationRepository.deleteAll(List.of(confirmed, cancelled));
webinarRepository.delete(webinar);
}
@Test
@DisplayName("Zakonczony webinar bez znacznika trafia do kolejki")
void queuesSummary() {
assertThat(summaryService.webinarsReadyForSummary()).contains(webinar.getId());
int queued = scheduler.queueSummary(webinar.getId());
assertThat(queued).isPositive();
List<Notification> entries = notificationRepository.findAll().stream()
.filter(n -> n.getWebinar() != null && n.getWebinar().getId().equals(webinar.getId()))
.toList();
assertThat(entries).isNotEmpty();
assertThat(entries).allMatch(n -> n.getStatus() == NotificationStatus.QUEUED);
}
@Test
@DisplayName("Tylko uczestnik CONFIRMED dostaje podsumowanie")
void onlyConfirmedParticipants() {
scheduler.queueSummary(webinar.getId());
List<String> recipients = notificationRepository.findAll().stream()
.filter(n -> n.getWebinar() != null && n.getWebinar().getId().equals(webinar.getId()))
.filter(n -> SummaryScheduler.TEMPLATE_PARTICIPANT.equals(n.getTemplate()))
.map(Notification::getRecipient)
.toList();
assertThat(recipients).contains(confirmed.getEmail());
assertThat(recipients).doesNotContain(cancelled.getEmail());
}
@Test
@DisplayName("Po jednym wpisie na odbiorce, bez duplikatow")
void oneEntryPerRecipient() {
scheduler.queueSummary(webinar.getId());
List<String> recipients = notificationRepository.findAll().stream()
.filter(n -> n.getWebinar() != null && n.getWebinar().getId().equals(webinar.getId()))
.map(Notification::getRecipient)
.toList();
assertThat(recipients).doesNotHaveDuplicates();
}
@Test
@DisplayName("Ponowne uruchomienie zadania nie tworzy duplikatow")
void secondRunCreatesNothing() {
scheduler.queueSummary(webinar.getId());
long afterFirst = notificationRepository.countByWebinarIdAndTemplate(
webinar.getId(), SummaryScheduler.TEMPLATE_PARTICIPANT);
// po pierwszym przebiegu webinar ma juz znacznik, wiec nie wraca na liste
assertThat(summaryService.webinarsReadyForSummary()).doesNotContain(webinar.getId());
scheduler.planSummaries();
long afterSecond = notificationRepository.countByWebinarIdAndTemplate(
webinar.getId(), SummaryScheduler.TEMPLATE_PARTICIPANT);
assertThat(afterSecond).isEqualTo(afterFirst);
}
@Test
@DisplayName("Tresc uczestnika ma tytul, termin w strefie webinaru i liste zadan")
void participantContentIsComplete() {
SummaryData.ForParticipant summary = summaryService
.participantSummaries(webinar.getId()).stream()
.filter(s -> s.email().equals(confirmed.getEmail()))
.findFirst()
.orElseThrow();
SummaryMailBuilder.Rendered rendered = mailBuilder.renderParticipant(summary);
for (String content : List.of(rendered.html(), rendered.text())) {
assertThat(content).contains(webinar.getTitle());
assertThat(content).contains(task.getTitle());
assertThat(content).contains(webinar.getTimezone());
// godzina przeliczona na strefe webinaru, nie surowe UTC
assertThat(content).contains(summary.startsAtText());
assertThat(content).contains(webinar.getRecordingUrl());
assertNoRawVariables(content);
}
}
@Test
@DisplayName("Zestawienie prowadzacego ma liczniki, zadania i osoby")
void hostContentIsComplete() {
SummaryData.ForHost summary = summaryService.hostSummary(webinar.getId());
SummaryMailBuilder.Rendered rendered = mailBuilder.renderHost(summary);
for (String content : List.of(rendered.html(), rendered.text())) {
assertThat(content).contains(webinar.getTitle());
assertThat(content).contains(task.getTitle());
// dane kontaktowe osoby zgloszonej do zadania
assertThat(content).contains(confirmed.getEmail());
assertNoRawVariables(content);
}
}
@Test
@DisplayName("Brak zadan i zapytan daje czytelny komunikat, nie pustke")
void emptySectionsAreExplained() {
// webinar bez zadan: uzywamy drugiego, czystego
SummaryData.ForHost summary = summaryService.hostSummary(webinar.getId());
SummaryMailBuilder.Rendered rendered = mailBuilder.renderHost(summary);
assertThat(rendered.text()).contains("Brak zapytań ofertowych");
assertNoRawVariables(rendered.text());
}
@Test
@DisplayName("Tresc maila jest poprawna polszczyzna, z ogonkami")
void mailContentUsesProperPolish() {
SummaryData.ForParticipant participant = summaryService
.participantSummaries(webinar.getId()).stream()
.filter(s -> s.email().equals(confirmed.getEmail()))
.findFirst()
.orElseThrow();
SummaryMailBuilder.Rendered forParticipant = mailBuilder.renderParticipant(participant);
SummaryMailBuilder.Rendered forHost =
mailBuilder.renderHost(summaryService.hostSummary(webinar.getId()));
for (SummaryMailBuilder.Rendered rendered : List.of(forParticipant, forHost)) {
for (String content : List.of(rendered.html(), rendered.text())) {
assertProperPolish(content);
}
}
}
/**
* Pilnuje, ze w mailu nie ma slow napisanych bez znakow diakrytycznych.
*
* Zasada bez ogonkow dotyczy komentarzy i nazw technicznych, nie tresci,
* ktora czyta uczestnik. Kazde z tych slow ma w poprawnej polszczyznie
* ogonek, wiec jego wystapienie oznacza regresje w szablonie.
*/
private void assertProperPolish(String content) {
/*
* Dopasowanie po calym slowie, nie po podciagu. "zadan" jest czescia
* poprawnego "zadania", a "tresc" czescia "tresci", wiec zwykle
* doesNotContain dawaloby falszywy blad.
*/
List<String> withoutDiacritics = List.of(
"dziekujemy", "udzial", "ktorych", "zglosiles", "zadnego",
"Materialy", "Wiadomosc", "wyslana", "platforme", "Tytul",
"ogloszono", "zadnych", "zadan", "zapytan", "glosow",
"zgloszen", "tresc", "imie", "jestes", "wiecej");
for (String word : withoutDiacritics) {
Matcher matcher = Pattern
.compile("\b" + Pattern.quote(word) + "\b")
.matcher(content);
assertThat(matcher.find())
.as("tresc dla uzytkownika ma byc poprawna polszczyzna, "
+ "a zawiera slowo \"" + word + "\"")
.isFalse();
}
}
@Test
@DisplayName("Rownolegle przebiegi nie tworza duplikatow")
void concurrentRunsDoNotDuplicate() throws Exception {
int threads = 8;
ExecutorService pool = Executors.newFixedThreadPool(threads);
CountDownLatch start = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(threads);
for (int i = 0; i < threads; i++) {
pool.submit(() -> {
try {
// wszystkie watki ruszaja naraz, zeby trafic w to samo okno
start.await();
scheduler.queueSummary(webinar.getId());
} catch (Exception e) {
// kolizja indeksu jest tu spodziewana, nie jest bledem testu
} finally {
done.countDown();
}
});
}
start.countDown();
done.await(30, TimeUnit.SECONDS);
pool.shutdown();
List<String> recipients = notificationRepository.findAll().stream()
.filter(n -> n.getWebinar() != null && n.getWebinar().getId().equals(webinar.getId()))
.map(n -> n.getTemplate() + " -> " + n.getRecipient())
.toList();
assertThat(recipients)
.as("osiem rownoleglych przebiegow, a kazdy odbiorca ma dostac jeden wpis")
.doesNotHaveDuplicates();
}
/**
* Sprawdza, ze w wyniku nie ma nierozwinietych zmiennych szablonu.
*
* Literalne `${...}` albo `th:` w wyniku oznacza, ze zmienna nie zostala
* podstawiona i uczestnik dostalby maila z surowym kodem szablonu.
*/
private void assertNoRawVariables(String content) {
assertThat(content)
.as("w wyniku nie moze byc nierozwinietych zmiennych")
.doesNotContain("${")
.doesNotContain("th:text")
.doesNotContain("th:if")
.doesNotContain("th:each");
}
}
@@ -0,0 +1,316 @@
package com.softspm.webinar.video;
import com.softspm.webinar.domain.Webinar;
import com.softspm.webinar.settings.SettingKey;
import com.softspm.webinar.settings.SettingsService;
import io.jsonwebtoken.Jwts;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Uprawnienia w tokenach LiveKit i Jitsi.
*
* TO JEST TEST GWARANCJI, NIE FORMATOWANIA
* ------------------------------------------------------------------------
* Uprawnienia wynikaja z tokenu, a nie z interfejsu: uczestnik bez prawa
* publikacji nie wlaczy kamery nawet po podmianie kodu strony. Cala ta obietnica
* opiera sie na tym, ze token faktycznie niesie wlasciwe granty, wiec
* sprawdzamy jego **zawartosc**, a nie to, ze cokolwiek zostalo zwrocone.
*
* Nie potrzeba tu dzialajacego serwera: token jest podpisywany po naszej
* stronie, wiec da sie go rozlozyc i obejrzec.
*/
@SpringBootTest
@ActiveProfiles("test")
class ProviderTokenTest {
private static final String SECRET = "sekret-livekit-do-testow-co-najmniej-32-znaki";
@Autowired private LiveKitVideoProvider livekit;
@Autowired private JitsiVideoProvider jitsi;
@Autowired private ZoomVideoProvider zoom;
@Autowired private SettingsService settings;
@Autowired private com.softspm.webinar.repository.AppSettingRepository settingRepository;
/**
* Klucze, ktorych nie bylo w bazie przed testem.
*
* Instancja na LiveKit nie ma wpisow ZOOM_*: `settings.update` tworzy je,
* a `settings.get` przy przywracaniu nie umie ich skasowac, tylko ustawic
* pusta wartosc. Zostawaly wiec jako nowe wiersze i licznik ustawien rosl
* z kazdym przebiegiem. Zapamietujemy, ktore utworzyl test, i kasujemy je
* w tearDown - zgodnie z regula "test sprzata takze to, czego nie obejmuje
* kaskada".
*/
private final java.util.List<SettingKey> createdByTest = new java.util.ArrayList<>();
private static final String ZOOM_SDK_KEY = "zoom-sdk-key-do-testow";
private static final String ZOOM_SDK_SECRET = "zoom-sdk-secret-do-testow-min-32-znaki-abc";
private Webinar webinar;
@BeforeEach
void setUp() {
// zapamietujemy stan sprzed testu, zeby oddac baze taka, jaka byla
before = new java.util.HashMap<>();
for (SettingKey key : List.of(
SettingKey.LIVEKIT_URL, SettingKey.LIVEKIT_API_KEY, SettingKey.LIVEKIT_API_SECRET,
SettingKey.JITSI_DOMAIN, SettingKey.JITSI_APP_ID, SettingKey.JITSI_APP_SECRET,
SettingKey.JITSI_JWT_ENABLED,
SettingKey.ZOOM_SDK_KEY, SettingKey.ZOOM_SDK_SECRET)) {
// wiersz nieobecny w bazie: test go utworzy, wiec skasuje w tearDown
if (!settingRepository.existsById(key.name())) {
createdByTest.add(key);
}
String value = settings.get(key);
before.put(key.name(), value == null ? "" : value);
}
settings.update(Map.of(
SettingKey.LIVEKIT_URL.name(), "wss://livekit.test.local",
SettingKey.LIVEKIT_API_KEY.name(), "klucz-testowy",
SettingKey.LIVEKIT_API_SECRET.name(), SECRET,
SettingKey.JITSI_DOMAIN.name(), "meet.test.local",
SettingKey.JITSI_APP_ID.name(), "webinar-app",
SettingKey.JITSI_APP_SECRET.name(), SECRET,
SettingKey.ZOOM_SDK_KEY.name(), ZOOM_SDK_KEY,
SettingKey.ZOOM_SDK_SECRET.name(), ZOOM_SDK_SECRET));
webinar = new Webinar();
webinar.setId(4242L);
webinar.setSlug("test-uprawnien");
webinar.setRoomId("webinar-4242");
}
/**
* Stan ustawien sprzed testu.
*
* **Test dziala na tej samej bazie co uruchomiona instancja**, wiec musi
* przywrocic dokladnie to, co zastal, a nie wartosci domyslne. Wpisywanie
* pustych wartosci kasowalo administratorowi konfiguracje LiveKit przy
* kazdym przebiegu testow: instancja po `mvn test` przestawala miec
* dostawce wideo i trzeba bylo ustawiac go od nowa.
*/
private Map<String, String> before;
@AfterEach
void tearDown() {
settings.update(before);
// kasujemy wiersze, ktorych przed testem nie bylo, zeby nie zostawac
// z pustymi kluczami ZOOM_* na wspoldzielonej bazie
for (SettingKey key : createdByTest) {
settingRepository.deleteById(key.name());
}
if (!createdByTest.isEmpty()) {
settings.invalidate();
}
}
/** Rozklada token LiveKit na mape uprawnien z pola `video`. */
@SuppressWarnings("unchecked")
private Map<String, Object> liveKitGrants(RoomRole role) {
String jwt = livekit.createJoinTicket(webinar,
new VideoProvider.JoinRequest("Jan Testowy", "reg-1", role)).token();
var claims = Jwts.parser()
.verifyWith(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256"))
.build()
.parseSignedClaims(jwt)
.getPayload();
return (Map<String, Object>) claims.get("video");
}
@Test
@DisplayName("VIEWER odbiera, ale nie publikuje niczego")
void viewerCannotPublish() {
var video = liveKitGrants(RoomRole.VIEWER);
assertThat(video.get("canSubscribe")).isEqualTo(true);
assertThat(video.get("canPublish")).isEqualTo(false);
assertThat(video.get("room")).isEqualTo("webinar-4242");
}
@Test
@DisplayName("SPEAKER publikuje wylacznie mikrofon, kamera jest zabroniona")
void speakerPublishesAudioOnly() {
var video = liveKitGrants(RoomRole.SPEAKER);
assertThat(video.get("canPublish")).isEqualTo(true);
/*
* Sedno calej roli SPEAKER: lista zrodel zawiera mikrofon i **nie
* zawiera kamery**. Gdyby zawierala, uczestnik z glosem moglby wlaczyc
* obraz, a wideo uczestnika ma byc wylaczone zawsze.
*/
assertThat(sources(video))
.contains("microphone")
.doesNotContain("camera");
}
@Test
@DisplayName("PRESENTER publikuje obraz i dzwiek oraz moderuje pokoj")
void presenterHasFullRights() {
var video = liveKitGrants(RoomRole.PRESENTER);
assertThat(video.get("canPublish")).isEqualTo(true);
assertThat(video.get("roomAdmin")).isEqualTo(true);
assertThat(sources(video)).contains("camera", "microphone");
}
/** Lista dozwolonych zrodel publikacji, jako napisy. */
@SuppressWarnings("unchecked")
private java.util.List<String> sources(Map<String, Object> video) {
Object raw = video.get("canPublishSources");
return raw == null ? java.util.List.of() : (java.util.List<String>) raw;
}
@Test
@DisplayName("Token LiveKit niesie tozsamosc, po ktorej rozpoznajemy uczestnika")
void tokenCarriesIdentity() {
String jwt = livekit.createJoinTicket(webinar,
new VideoProvider.JoinRequest("Anna Nowak", "reg-77", RoomRole.VIEWER)).token();
var claims = Jwts.parser()
.verifyWith(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256"))
.build()
.parseSignedClaims(jwt)
.getPayload();
// po tej wartosci webhook obecnosci odnajduje zapis
assertThat(claims.getSubject()).isEqualTo("reg-77");
assertThat(claims.get("name")).isEqualTo("Anna Nowak");
}
@Test
@DisplayName("Jitsi bez JWT nie wydaje tokenu, a pokoj jest otwarty")
void jitsiWithoutJwtIssuesNoToken() {
settings.update(Map.of(SettingKey.JITSI_JWT_ENABLED.name(), "false"));
var ticket = jitsi.createJoinTicket(webinar,
new VideoProvider.JoinRequest("Jan Testowy", "reg-1", RoomRole.VIEWER));
/*
* Brak tokenu jest tu **poprawnym wynikiem**, a nie bledem: serwer
* niczego nie sprawdza, a rola sluzy wylacznie frontowi do ustawienia
* interfejsu. To jest dokladnie to ograniczenie, o ktorym panel
* ostrzega administratora.
*/
assertThat(ticket.token()).isNull();
assertThat(ticket.url()).startsWith("https://meet.test.local/");
assertThat(ticket.role()).isEqualTo(RoomRole.VIEWER);
}
@Test
@DisplayName("Jitsi z JWT daje moderatora tylko prelegentowi")
void jitsiJwtMarksOnlyPresenterAsModerator() {
settings.update(Map.of(SettingKey.JITSI_JWT_ENABLED.name(), "true"));
assertThat(jitsiModerator(RoomRole.PRESENTER)).isEqualTo("true");
assertThat(jitsiModerator(RoomRole.SPEAKER)).isEqualTo("false");
assertThat(jitsiModerator(RoomRole.VIEWER)).isEqualTo("false");
}
@SuppressWarnings("unchecked")
private String jitsiModerator(RoomRole role) {
String jwt = jitsi.createJoinTicket(webinar,
new VideoProvider.JoinRequest("Jan Testowy", "reg-1", role)).token();
var claims = Jwts.parser()
.verifyWith(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256"))
.build()
.parseSignedClaims(jwt)
.getPayload();
var context = (Map<String, Object>) claims.get("context");
var user = (Map<String, Object>) context.get("user");
return String.valueOf(user.get("moderator"));
}
@Test
@DisplayName("Token daje sie zweryfikowac wylacznie wlasciwym sekretem")
void tokenIsSignedWithConfiguredSecret() {
String jwt = livekit.createJoinTicket(webinar,
new VideoProvider.JoinRequest("Jan", "reg-1", RoomRole.VIEWER)).token();
// podpis jest trzecim segmentem: token bez niego nie jest tokenem
assertThat(Base64.getUrlDecoder().decode(jwt.split("\\.")[1])).isNotEmpty();
assertThat(jwt.split("\\.")).hasSize(3);
}
// --- Zoom ---
/**
* Rozklada podpis Zoom (Meeting SDK signature) na claimy.
*
* Signature to JWT HS256 podpisany SDK Secret, wiec da sie go zweryfikowac
* i obejrzec bez dzialajacego Zooma, dokladnie jak przy LiveKit i Jitsi.
*/
private io.jsonwebtoken.Claims zoomSignature(RoomRole role) {
var ticket = zoom.createJoinTicket(webinar,
new VideoProvider.JoinRequest("Jan Testowy", "reg-9", role));
return Jwts.parser()
.verifyWith(new SecretKeySpec(
ZOOM_SDK_SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256"))
.build()
.parseSignedClaims(ticket.token())
.getPayload();
}
@Test
@DisplayName("Podpis Zoom niesie numer spotkania, klucz SDK i wygasa")
void zoomSignatureCarriesMeetingAndKey() {
var claims = zoomSignature(RoomRole.VIEWER);
// mn to numer spotkania, po ktorym SDK wie, dokad dolaczyc
assertThat(claims.get("mn")).isEqualTo("webinar-4242");
assertThat(claims.get("sdkKey")).isEqualTo(ZOOM_SDK_KEY);
// exp jest wymagany przez Web SDK i musi byc w przyszlosci
assertThat(claims.getExpiration()).isAfter(new java.util.Date());
// tokenExp Web SDK wymaga rownego exp
assertThat(((Number) claims.get("tokenExp")).longValue())
.isEqualTo(claims.getExpiration().toInstant().getEpochSecond());
}
@Test
@DisplayName("Rola Zoom: prelegent to host (1), uczestnik i speaker to nie-host (0)")
void zoomRoleMapsToHostFlag() {
/*
* Zoom rozroznia wylacznie host (1) i uczestnik (0). To slabsza granica
* niz LiveKit: SPEAKER nie dostaje tu osobnego uprawnienia do audio,
* bo Zoom takiego pojecia w podpisie nie ma. Wyciszenie i wideo steruje
* ustawieniami spotkania. Test utrwala te ODWZOROWANIE, zeby zmiana
* mapowania byla swiadoma.
*/
assertThat(((Number) zoomSignature(RoomRole.PRESENTER).get("role")).intValue())
.isEqualTo(1);
assertThat(((Number) zoomSignature(RoomRole.SPEAKER).get("role")).intValue())
.isEqualTo(0);
assertThat(((Number) zoomSignature(RoomRole.VIEWER).get("role")).intValue())
.isEqualTo(0);
}
@Test
@DisplayName("Podpis Zoom jest osadzalny: embedded i clientKey obecne")
void zoomTicketIsEmbeddable() {
var ticket = zoom.createJoinTicket(webinar,
new VideoProvider.JoinRequest("Jan", "reg-1", RoomRole.VIEWER));
// front osadza pokoj (embedded), a clientKey niesie publiczny SDK Key
assertThat(ticket.embedded()).isTrue();
assertThat(ticket.clientKey()).isEqualTo(ZOOM_SDK_KEY);
}
}
@@ -0,0 +1,141 @@
package com.softspm.webinar.video;
import com.softspm.webinar.domain.RegistrationRoomRole;
import com.softspm.webinar.domain.Webinar;
import com.softspm.webinar.settings.SettingKey;
import com.softspm.webinar.settings.SettingsService;
import com.softspm.webinar.web.ValidationException;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* Uprawnienia w pokoju i zachowanie przy brakujacej konfiguracji.
*
* Te dwie rzeczy latwo zepsuc niezauwazenie: uprawnienia, bo wygladaja jak
* szczegol interfejsu, i komunikat o braku klucza, bo widac go dopiero na
* instancji bez skonfigurowanego dostawcy.
*/
class RoomRoleTest {
@Test
@DisplayName("Uczestnik nie publikuje ani obrazu, ani dzwieku")
void viewerPublishesNothing() {
assertThat(RoomRole.VIEWER.canPublishVideo()).isFalse();
assertThat(RoomRole.VIEWER.canPublishAudio()).isFalse();
assertThat(RoomRole.VIEWER.isModerator()).isFalse();
}
@Test
@DisplayName("Uczestnik z glosem publikuje wylacznie dzwiek")
void speakerPublishesAudioOnly() {
assertThat(RoomRole.SPEAKER.canPublishAudio()).isTrue();
// wideo uczestnika jest wylaczone zawsze, takze po podniesieniu roli
assertThat(RoomRole.SPEAKER.canPublishVideo()).isFalse();
assertThat(RoomRole.SPEAKER.isModerator()).isFalse();
}
@Test
@DisplayName("Prelegent publikuje obraz i dzwiek oraz moderuje")
void presenterPublishesEverything() {
assertThat(RoomRole.PRESENTER.canPublishVideo()).isTrue();
assertThat(RoomRole.PRESENTER.canPublishAudio()).isTrue();
assertThat(RoomRole.PRESENTER.isModerator()).isTrue();
}
@Test
@DisplayName("Uczestnik w trybie wideo dla wszystkich publikuje obraz i dzwiek, ale NIE moderuje")
void activeParticipantPublishesButDoesNotModerate() {
// tryb "wideo dla wszystkich" daje uczestnikowi kamere, ale nie
// uprawnienia moderatora: to wciaz uczestnik, nie prowadzacy
assertThat(RoomRole.ACTIVE_PARTICIPANT.canPublishVideo()).isTrue();
assertThat(RoomRole.ACTIVE_PARTICIPANT.canPublishAudio()).isTrue();
assertThat(RoomRole.ACTIVE_PARTICIPANT.isModerator()).isFalse();
}
@Test
@DisplayName("Uczestnik z zapisu nie moze dostac uprawnien moderatora")
void registrationRoleNeverModerates() {
for (RegistrationRoomRole role : RegistrationRoomRole.values()) {
assertThat(role.toRoomRole().isModerator())
.as("rola %s nie moze moderowac", role)
.isFalse();
assertThat(role.toRoomRole().canPublishVideo())
.as("rola %s nie moze publikowac obrazu", role)
.isFalse();
}
}
@Test
@DisplayName("Brak klucza API daje czytelny komunikat, nie wyjatek z biblioteki")
void missingApiKeyGivesReadableError() {
SettingsService settings = mock(SettingsService.class);
when(settings.get(SettingKey.VIDEO_API_KEY)).thenReturn("");
when(settings.get(SettingKey.VIDEO_API_URL)).thenReturn("https://api.daily.co/v1");
DailyVideoProvider provider = new DailyVideoProvider(settings);
Webinar webinar = new Webinar();
webinar.setRoomId("pokoj-testowy");
assertThatThrownBy(() -> provider.createJoinTicket(webinar,
new VideoProvider.JoinRequest("Jan Testowy", "reg-1", RoomRole.VIEWER)))
.isInstanceOf(ValidationException.class)
.hasMessageContaining("klucza API")
.hasMessageContaining("ustawieniach instancji");
}
@Test
@DisplayName("Brak zalozonego pokoju daje podpowiedz, co zrobic")
void missingRoomGivesHint() {
SettingsService settings = mock(SettingsService.class);
when(settings.get(any())).thenReturn("klucz-testowy");
DailyVideoProvider provider = new DailyVideoProvider(settings);
assertThatThrownBy(() -> provider.createJoinTicket(new Webinar(),
new VideoProvider.JoinRequest("Jan", "reg-1", RoomRole.VIEWER)))
.isInstanceOf(ValidationException.class)
.hasMessageContaining("nie założono jeszcze pokoju");
}
@Test
@DisplayName("Tryb NONE bez wklejonego linku tlumaczy, czego brakuje")
void noneWithoutLinkExplains() {
NoneVideoProvider provider = new NoneVideoProvider();
assertThat(provider.supportsRooms()).isFalse();
assertThat(provider.listRecordings(new Webinar())).isEmpty();
assertThatThrownBy(() -> provider.createJoinTicket(new Webinar(),
new VideoProvider.JoinRequest("Jan", "reg-1", RoomRole.VIEWER)))
.isInstanceOf(ValidationException.class)
.hasMessageContaining("nie podał jeszcze linku");
}
@Test
@DisplayName("Tryb NONE oddaje link organizatora bez tokenu")
void noneReturnsOrganizerLink() {
NoneVideoProvider provider = new NoneVideoProvider();
Webinar webinar = new Webinar();
webinar.setJoinUrl("https://meet.example.pl/pokoj-123");
VideoProvider.JoinTicket ticket = provider.createJoinTicket(webinar,
new VideoProvider.JoinRequest("Jan", "reg-1", RoomRole.VIEWER));
assertThat(ticket.url()).isEqualTo("https://meet.example.pl/pokoj-123");
assertThat(ticket.token()).isNull();
// front ma przekierowac, a nie osadzac pokoj
assertThat(ticket.embedded()).isFalse();
}
@Test
@DisplayName("Zamkniecie pokoju w trybie NONE nie wybucha")
void noneCloseIsSilent() {
new NoneVideoProvider().closeRoom(new Webinar());
}
}
@@ -0,0 +1,94 @@
package com.softspm.webinar.web;
import com.softspm.webinar.settings.SettingKey;
import com.softspm.webinar.settings.SettingType;
import com.softspm.webinar.settings.SettingsService;
import org.junit.jupiter.api.Test;
import java.lang.reflect.RecordComponent;
import java.util.Arrays;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* Straznik jawnej konfiguracji.
*
* Endpoint jest dostepny bez uwierzytelnienia, wiec test pilnuje, zeby nie
* wyciekly z niego wartosci, ktorych nie powinien widziec kazdy. Sam komentarz
* w kodzie tego nie zagwarantuje, bo nikt nie jest zobowiazany go przeczytac.
*/
class PublicConfigControllerTest {
/** Klucze, ktore wolno zwracac. Kazde rozszerzenie tej listy jest swiadoma decyzja. */
private static final List<SettingKey> DOZWOLONE = List.of(
SettingKey.APP_BRAND_NAME,
SettingKey.APP_PUBLIC_URL,
SettingKey.APP_TIMEZONE,
// logo i favicon: widoczne na kazdej stronie, wiec jawne z natury
SettingKey.APP_LOGO_URL,
SettingKey.APP_FAVICON_URL);
@Test
void zwracaDokladnieJawneWartosci() {
SettingsService settings = mock(SettingsService.class);
when(settings.get(SettingKey.APP_BRAND_NAME)).thenReturn("wodociagowiec.pl");
when(settings.get(SettingKey.APP_PUBLIC_URL)).thenReturn("https://wodociagowiec.pl");
when(settings.get(SettingKey.APP_TIMEZONE)).thenReturn("Europe/Warsaw");
when(settings.get(SettingKey.APP_LOGO_URL)).thenReturn("https://wodociagowiec.pl/logo.svg");
when(settings.get(SettingKey.APP_FAVICON_URL)).thenReturn("");
PublicConfigView view = new PublicConfigController(settings).config();
assertThat(view.brandName()).isEqualTo("wodociagowiec.pl");
assertThat(view.publicUrl()).isEqualTo("https://wodociagowiec.pl");
assertThat(view.timezone()).isEqualTo("Europe/Warsaw");
assertThat(view.logoUrl()).isEqualTo("https://wodociagowiec.pl/logo.svg");
assertThat(view.faviconUrl()).isEmpty();
}
@Test
void odpowiedzNieRoznieSieOdListyDozwolonychPol() {
RecordComponent[] components = PublicConfigView.class.getRecordComponents();
assertThat(components)
.as("kazde nowe pole w PublicConfigView wymaga swiadomej decyzji, "
+ "wiec dopisz je takze do listy DOZWOLONE w tym tescie")
.hasSize(DOZWOLONE.size());
assertThat(Arrays.stream(components).map(RecordComponent::getName))
.containsExactlyInAnyOrder("brandName", "publicUrl", "timezone", "logoUrl", "faviconUrl");
}
@Test
void zadenSekretNieJestOdpytywany() {
SettingsService settings = mock(SettingsService.class);
when(settings.get(any(SettingKey.class))).thenReturn("wartosc");
new PublicConfigController(settings).config();
// kontroler nie moze siegac po klucze typu PASSWORD
for (SettingKey key : SettingKey.values()) {
if (key.getType() == SettingType.PASSWORD) {
org.mockito.Mockito.verify(settings, org.mockito.Mockito.never()).get(key);
}
}
}
@Test
void odpytywaneSaWylacznieKluczeZListyDozwolonych() {
SettingsService settings = mock(SettingsService.class);
when(settings.get(any(SettingKey.class))).thenReturn("wartosc");
new PublicConfigController(settings).config();
for (SettingKey key : SettingKey.values()) {
if (!DOZWOLONE.contains(key)) {
org.mockito.Mockito.verify(settings, org.mockito.Mockito.never()).get(key);
}
}
}
}
@@ -0,0 +1,121 @@
package com.softspm.webinar.web;
import com.softspm.webinar.settings.SettingKey;
import com.softspm.webinar.settings.SettingsService;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* Straznik danych prawnych.
*
* Endpoint /public/legal jest otwarty dla calego internetu - tak ma byc, bo
* prawo wymaga, zeby dane administratora, regulamin i polityka byly widoczne
* dla kazdego, kto zapisuje sie na webinar. Test pilnuje, ze zwraca dokladnie
* te dane i NIC WIECEJ: zaden sekret nie moze sie tu pojawic, bo dane firmy
* i tresc dokumentow sa jawne, ale reszta ustawien nie.
*/
class PublicLegalControllerTest {
@Test
void zwracaDaneFirmyIGotoweDokumenty() {
SettingsService settings = mock(SettingsService.class);
when(settings.get(SettingKey.COMPANY_NAME)).thenReturn("Softspm sp. z o.o.");
when(settings.get(SettingKey.COMPANY_ADDRESS)).thenReturn("ul. Przykladowa 1, Warszawa");
when(settings.get(SettingKey.COMPANY_TAX_ID)).thenReturn("1234567890");
when(settings.get(SettingKey.COMPANY_REGISTRY)).thenReturn("KRS 0000000000");
when(settings.get(SettingKey.COMPANY_EMAIL)).thenReturn("rodo@example.com");
when(settings.get(SettingKey.COMPANY_PHONE)).thenReturn("+48 000 000 000");
when(settings.get(SettingKey.DPO_CONTACT)).thenReturn("");
when(settings.get(SettingKey.APP_BRAND_NAME)).thenReturn("Marka");
PublicLegalController controller =
new PublicLegalController(settings, new LegalDocuments(settings));
PublicLegalController.LegalView view = controller.legal();
assertThat(view.company().name()).isEqualTo("Softspm sp. z o.o.");
assertThat(view.company().taxId()).isEqualTo("1234567890");
// puste pola sa null, zeby front pomijal linie zamiast pokazywac etykiete
assertThat(view.company().dpoContact()).isNull();
// dokumenty sa gotowe i ZAWSZE obecne, z podstawionymi danymi firmy.
// Nazwa w HTML jest bez koncowej kropki (dokleja ja zdanie), wiec
// sprawdzamy trzon nazwy, nie dokladny zapis z kropka.
assertThat(view.termsHtml()).contains("Softspm sp. z o.o");
assertThat(view.termsHtml()).contains("Regulamin", "reklamacyjne");
assertThat(view.privacyHtml()).contains("Administrator danych", "RODO");
// dane escapowane trafiaja bezpiecznie (email jako mailto)
assertThat(view.privacyHtml()).contains("rodo@example.com");
assertThat(view.brandName()).isEqualTo("Marka");
}
@Test
void dokumentyDzialajaBezDanychFirmy() {
// pusta konfiguracja: dokumenty maja sie zlozyc bez wyjatku, z fallbackiem
// na nazwe marki i czytelna informacja o brakujacych danych
SettingsService settings = mock(SettingsService.class);
when(settings.get(org.mockito.ArgumentMatchers.any(SettingKey.class))).thenReturn("");
when(settings.get(SettingKey.APP_BRAND_NAME)).thenReturn("Moja Marka");
PublicLegalController.LegalView view =
new PublicLegalController(settings, new LegalDocuments(settings)).legal();
// brak nazwy firmy -> fallback na marke
assertThat(view.termsHtml()).contains("Moja Marka");
// brak e-maila -> czytelna informacja zamiast pustego mailto
assertThat(view.privacyHtml()).contains("nie został jeszcze uzupełniony");
}
@Test
void escapujeDaneFirmyWDokumencie() {
// dane firmy pochodza z pola tekstowego i trafiaja do HTML - musza byc
// escapowane, zeby nazwa z nawiasem katowym nie zepsula dokumentu
SettingsService settings = mock(SettingsService.class);
when(settings.get(org.mockito.ArgumentMatchers.any(SettingKey.class))).thenReturn("");
when(settings.get(SettingKey.COMPANY_NAME)).thenReturn("Firma <script>x</script>");
when(settings.get(SettingKey.APP_BRAND_NAME)).thenReturn("Marka");
String terms = new LegalDocuments(settings).terms();
assertThat(terms).doesNotContain("<script>");
assertThat(terms).contains("&lt;script&gt;");
}
@Test
void nazwaZKropkaNieDajePodwojnejKropki() {
// "sp. z o.o." konczy sie kropka; zdanie dokleja swoja - nie moze wyjsc ".."
SettingsService settings = mock(SettingsService.class);
when(settings.get(org.mockito.ArgumentMatchers.any(SettingKey.class))).thenReturn("");
when(settings.get(SettingKey.COMPANY_NAME)).thenReturn("Softspm sp. z o.o.");
when(settings.get(SettingKey.APP_BRAND_NAME)).thenReturn("Marka");
String terms = new LegalDocuments(settings).terms();
// brak PODWOJNEJ kropki (pojedyncza konczaca zdanie jest poprawna)
assertThat(terms).doesNotContain("o.o..");
assertThat(terms).doesNotContain("o.o. .");
assertThat(terms).doesNotContain("o.o.</strong>.");
// trzon nazwy jest obecny (kropke konczaca zdanie dokleja szablon)
assertThat(terms).contains("Softspm sp. z o.o");
}
@Test
void nieSiegaPoZadenSekret() {
// kazdy klucz typu PASSWORD to sekret, ktory nie moze opuscic serwera.
// Gdyby kontroler albo dokumenty tknely sekret, poleci wyjatek.
SettingsService settings = mock(SettingsService.class);
when(settings.get(org.mockito.ArgumentMatchers.any(SettingKey.class)))
.thenAnswer(inv -> {
SettingKey key = inv.getArgument(0);
if (key.isSecret()) {
throw new AssertionError(
"Endpoint /public/legal siegnal po sekret: " + key.name());
}
return "";
});
// samo wykonanie bez wyjatku dowodzi, ze zaden sekret nie zostal odczytany
new PublicLegalController(settings, new LegalDocuments(settings)).legal();
}
}
@@ -0,0 +1,119 @@
package com.softspm.webinar.webinar;
import com.softspm.webinar.domain.Webinar;
import com.softspm.webinar.domain.WebinarStatus;
import org.junit.jupiter.api.Test;
import java.time.OffsetDateTime;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Rozdzielenie intencji administratora od wyliczanej dostepnosci miejsc.
*
* Kolumna registration_open trzyma wylacznie intencje i nigdy nie jest zapisywana
* automatycznie. Wyczerpanie miejsc zamyka zapisy tylko w wyliczeniu, wiec po
* rezygnacji uczestnika zapisy wracaja do stanu, ktory ustawil administrator.
*/
class RegistrationAvailabilityTest {
@Test
void zapisyOtwarteGdyIntencjaOtwartaISaMiejsca() {
var availability = RegistrationAvailability.of(webinar(true, 10), 3, true);
assertThat(availability.open()).isTrue();
assertThat(availability.reason()).isNull();
assertThat(availability.seatsLeft()).isEqualTo(7);
}
@Test
void wyczerpanieMiejscZamykaZapisyNieRuszajacIntencji() {
Webinar webinar = webinar(true, 3);
var full = RegistrationAvailability.of(webinar, 3, true);
assertThat(full.open()).isFalse();
assertThat(full.reason()).isEqualTo(WebinarView.ClosedReason.NO_SEATS);
assertThat(full.seatsLeft()).isZero();
// intencja administratora pozostaje nietknieta
assertThat(webinar.isRegistrationOpen()).isTrue();
}
@Test
void rezygnacjaPrzywracaZapisyGdyAdministratorIchNieZamknal() {
Webinar webinar = webinar(true, 3);
assertThat(RegistrationAvailability.of(webinar, 3, true).open()).isFalse();
// jeden uczestnik rezygnuje, miejsce sie zwalnia
assertThat(RegistrationAvailability.of(webinar, 2, true).open()).isTrue();
}
@Test
void rezygnacjaNieOtwieraZapisowZamknietychRecznie() {
Webinar webinar = webinar(false, 3);
var afterCancellation = RegistrationAvailability.of(webinar, 2, true);
assertThat(afterCancellation.open()).isFalse();
assertThat(afterCancellation.reason()).isEqualTo(WebinarView.ClosedReason.CLOSED_BY_ADMIN);
assertThat(afterCancellation.seatsLeft()).isEqualTo(1);
}
@Test
void minietyTerminZapisowZamykaZapisy() {
Webinar webinar = webinar(true, null);
webinar.setRegistrationDeadline(OffsetDateTime.now().minusDays(1));
var availability = RegistrationAvailability.of(webinar, 0, true);
assertThat(availability.open()).isFalse();
assertThat(availability.reason()).isEqualTo(WebinarView.ClosedReason.DEADLINE_PASSED);
}
@Test
void webinarNieopublikowanyNiePrzyjmujeZapisow() {
Webinar webinar = webinar(true, null);
webinar.setStatus(WebinarStatus.DRAFT);
var availability = RegistrationAvailability.of(webinar, 0, true);
assertThat(availability.open()).isFalse();
assertThat(availability.reason()).isEqualTo(WebinarView.ClosedReason.NOT_PUBLISHED);
}
@Test
void odwolanyWebinarNiePrzyjmujeZapisowMimoOtwartejIntencji() {
Webinar webinar = webinar(true, null);
webinar.setStatus(WebinarStatus.CANCELLED);
var availability = RegistrationAvailability.of(webinar, 0, true);
assertThat(availability.open()).isFalse();
assertThat(availability.reason()).isEqualTo(WebinarView.ClosedReason.NOT_PUBLISHED);
// intencja zachowana, wiec po ponownej publikacji zapisy wroca
assertThat(webinar.isRegistrationOpen()).isTrue();
}
@Test
void brakLimituOznaczaBrakInformacjiOWolnychMiejscach() {
var availability = RegistrationAvailability.of(webinar(true, null), 1000, true);
assertThat(availability.open()).isTrue();
assertThat(availability.seatsLeft()).isNull();
assertThat(availability.seatsAvailable()).isTrue();
}
private Webinar webinar(boolean registrationOpen, Integer capacity) {
Webinar webinar = new Webinar();
webinar.setId(1L);
webinar.setSlug("test");
webinar.setTitle("Test");
webinar.setStartsAt(OffsetDateTime.now().plusDays(7));
webinar.setEndsAt(OffsetDateTime.now().plusDays(7).plusHours(2));
webinar.setTimezone("Europe/Warsaw");
webinar.setStatus(WebinarStatus.PUBLISHED);
webinar.setRegistrationOpen(registrationOpen);
webinar.setCapacity(capacity);
return webinar;
}
}
@@ -0,0 +1,216 @@
package com.softspm.webinar.webinar;
import com.softspm.webinar.domain.NotificationChannel;
import com.softspm.webinar.domain.OfferTopic;
import com.softspm.webinar.domain.Registration;
import com.softspm.webinar.domain.RegistrationStatus;
import com.softspm.webinar.domain.ReminderRule;
import com.softspm.webinar.domain.Webinar;
import com.softspm.webinar.domain.WebinarStatus;
import com.softspm.webinar.repository.OfferTopicRepository;
import com.softspm.webinar.repository.RegistrationRepository;
import com.softspm.webinar.repository.ReminderRuleRepository;
import com.softspm.webinar.repository.WebinarRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import java.time.Duration;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Kopiowanie webinaru.
*
* Kopia ma przeniesc **pomysl na wydarzenie**, a nie slad po tym, ze sie odbylo.
* Test pilnuje obu stron tej granicy: co ma sie znalezc w kopii i co pod zadnym
* pozorem nie moze. Przeoczenie w druga strone jest grozniejsze: uczestnicy
* zapisani na webinar, ktory sie nie odbyl, albo termin sprzed miesiaca
* w swiezo utworzonej kopii.
*
* Test dziala na zatwierdzonych danych i sprzata tylko po sobie, bo kolejnosc
* testow jest losowa.
*/
@SpringBootTest
@ActiveProfiles("test")
class WebinarCopyTest {
@Autowired private WebinarCopyService copyService;
@Autowired private WebinarRepository webinarRepository;
@Autowired private ReminderRuleRepository reminderRuleRepository;
@Autowired private OfferTopicRepository offerTopicRepository;
@Autowired private RegistrationRepository registrationRepository;
private Webinar source;
private final List<Long> created = new ArrayList<>();
@BeforeEach
void setUp() {
String suffix = String.valueOf(System.nanoTime());
source = new Webinar();
source.setTitle("Szkolenie cykliczne " + suffix);
source.setSlug("szkolenie-cykliczne-" + suffix);
source.setDescription("Krotki opis do zajawki");
source.setContentHtml("<p>Tresc strony zapisu</p>");
source.setInvitationContentHtml("<p>Tresc zaproszenia</p>");
source.setTimezone("Europe/Warsaw");
source.setCapacity(30);
source.setStatus(WebinarStatus.PUBLISHED);
source.setStartsAt(OffsetDateTime.now().minusDays(7));
source.setEndsAt(OffsetDateTime.now().minusDays(7).plusHours(2));
source.setJoinUrl("https://przyklad.pl/pokoj");
source.setRoomId("pokoj-123");
source.setSummarySentAt(OffsetDateTime.now().minusDays(6));
source.setCertificatesEnabled(true);
source.setCertificateHours(new java.math.BigDecimal("2.5"));
source.setCertificateTemplateHtml("<div>Wlasny szablon</div>");
source.setCreatedAt(OffsetDateTime.now());
source.setUpdatedAt(OffsetDateTime.now());
source = webinarRepository.save(source);
created.add(source.getId());
ReminderRule enabled = new ReminderRule();
enabled.setWebinar(source);
enabled.setOffsetMinutes(1440);
enabled.setChannel(NotificationChannel.EMAIL);
enabled.setTemplate("reminder");
enabled.setEnabled(true);
enabled.setCreatedAt(OffsetDateTime.now());
reminderRuleRepository.save(enabled);
// regula wylaczona swiadomie: ma zostac wylaczona takze w kopii
ReminderRule disabled = new ReminderRule();
disabled.setWebinar(source);
disabled.setOffsetMinutes(60);
disabled.setChannel(NotificationChannel.SMS);
disabled.setTemplate("reminder");
disabled.setEnabled(false);
disabled.setCreatedAt(OffsetDateTime.now());
reminderRuleRepository.save(disabled);
OfferTopic topic = new OfferTopic();
topic.setWebinar(source);
topic.setName("Wdrozenie u nas");
topic.setSortOrder(1);
topic.setActive(true);
topic.setCreatedAt(OffsetDateTime.now());
offerTopicRepository.save(topic);
Registration registration = new Registration();
registration.setWebinar(source);
registration.setEmail("uczestnik." + suffix + "@example.pl");
registration.setFirstName("Jan");
registration.setLastName("Kowalski");
registration.setStatus(RegistrationStatus.CONFIRMED);
registration.setConfirmationToken("kopia-tok-" + suffix);
registration.setCreatedAt(OffsetDateTime.now());
registrationRepository.save(registration);
}
@AfterEach
void tearDown() {
// kasowanie kaskadowe zabiera reguly, tematy i zapisy
created.forEach(id -> webinarRepository.findById(id).ifPresent(webinarRepository::delete));
}
@Test
@DisplayName("Kopia przenosi tresc, ustawienia certyfikatow, reguly i tematy")
void copiesContentAndSettings() {
var result = copyService.copy(source.getId(), null);
created.add(result.id());
Webinar copy = webinarRepository.findById(result.id()).orElseThrow();
assertThat(copy.getTitle()).isEqualTo(source.getTitle() + " (kopia)");
assertThat(copy.getDescription()).isEqualTo("Krotki opis do zajawki");
assertThat(copy.getContentHtml()).isEqualTo("<p>Tresc strony zapisu</p>");
assertThat(copy.getInvitationContentHtml()).isEqualTo("<p>Tresc zaproszenia</p>");
assertThat(copy.getTimezone()).isEqualTo("Europe/Warsaw");
assertThat(copy.getCapacity()).isEqualTo(30);
assertThat(copy.isCertificatesEnabled()).isTrue();
assertThat(copy.getCertificateHours()).isEqualByComparingTo("2.5");
assertThat(copy.getCertificateTemplateHtml()).isEqualTo("<div>Wlasny szablon</div>");
assertThat(result.reminderRules()).isEqualTo(2);
assertThat(result.offerTopics()).isEqualTo(1);
// flaga wlaczenia przechodzi bez zmian, w obie strony
var rules = reminderRuleRepository.findByWebinarIdOrderByOffsetMinutesDesc(copy.getId());
assertThat(rules).hasSize(2);
assertThat(rules).filteredOn(r -> r.getOffsetMinutes() == 1440)
.singleElement()
.satisfies(r -> assertThat(r.isEnabled()).isTrue());
assertThat(rules).filteredOn(r -> r.getOffsetMinutes() == 60)
.singleElement()
.satisfies(r -> assertThat(r.isEnabled()).isFalse());
assertThat(offerTopicRepository.findByWebinarIdOrderBySortOrderAscIdAsc(copy.getId()))
.singleElement()
.satisfies(t -> assertThat(t.getName()).isEqualTo("Wdrozenie u nas"));
}
@Test
@DisplayName("Kopia nie przenosi terminu, pokoju ani sladu odbycia sie webinaru")
void doesNotCopyEventSpecificData() {
var result = copyService.copy(source.getId(), null);
created.add(result.id());
Webinar copy = webinarRepository.findById(result.id()).orElseThrow();
/*
* Termin nie jest przepisany. Kolumny maja NOT NULL, wiec kopia dostaje
* termin zastepczy w przyszlosci: data z przeszlosci pozwolilaby
* opublikowac webinar juz odbyty, a pusta nie przeszlaby przez baze.
*/
assertThat(copy.getStartsAt()).isNotEqualTo(source.getStartsAt());
assertThat(copy.getStartsAt()).isAfter(OffsetDateTime.now());
/*
* Dlugosc jest cecha programu szkolenia, wiec przechodzi do kopii.
* Porownujemy w minutach, bo PostgreSQL trzyma czas z dokladnoscia do
* mikrosekundy, a Java do nanosekundy: po odczycie z bazy koncowka
* jest ucieta i porownanie co do nanosekundy zawsze by zawiodlo.
*/
assertThat(Duration.between(copy.getStartsAt(), copy.getEndsAt()).toMinutes())
.isEqualTo(Duration.between(source.getStartsAt(), source.getEndsAt()).toMinutes());
assertThat(copy.getJoinUrl()).isNull();
assertThat(copy.getRoomId()).isNull();
assertThat(copy.getSummarySentAt()).isNull();
assertThat(copy.getStatus()).isEqualTo(WebinarStatus.DRAFT);
assertThat(copy.getSlug()).isNotEqualTo(source.getSlug());
}
@Test
@DisplayName("Kopia nie przenosi zapisow uczestnikow")
void doesNotCopyRegistrations() {
var result = copyService.copy(source.getId(), null);
created.add(result.id());
assertThat(registrationRepository.findByWebinarIdOrderByCreatedAtDesc(source.getId()))
.hasSize(1);
assertThat(registrationRepository.findByWebinarIdOrderByCreatedAtDesc(result.id()))
.isEmpty();
}
@Test
@DisplayName("Wlasny tytul zastepuje domyslny sufiks")
void ownTitleReplacesDefault() {
var result = copyService.copy(source.getId(), "Edycja jesienna");
created.add(result.id());
assertThat(result.title()).isEqualTo("Edycja jesienna");
assertThat(webinarRepository.findById(result.id()).orElseThrow().getTitle())
.isEqualTo("Edycja jesienna");
}
}
@@ -0,0 +1,139 @@
package com.softspm.webinar.webinar;
import com.softspm.webinar.domain.Registration;
import com.softspm.webinar.domain.RegistrationStatus;
import com.softspm.webinar.domain.Webinar;
import com.softspm.webinar.domain.WebinarStatus;
import com.softspm.webinar.repository.RegistrationRepository;
import com.softspm.webinar.repository.WebinarRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.test.context.ActiveProfiles;
import java.time.OffsetDateTime;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Wyszukiwanie na listach panelu.
*
* NAJWAZNIEJSZY PRZYPADEK TO BRAK WYSZUKIWANIA
* ------------------------------------------------------------------------
* Przy parametrze rownym null PostgreSQL nie zna jego typu i przyjmuje `bytea`,
* po czym `lower()` konczy sie bledem "function lower(bytea) does not exist".
* Cala lista przestaje wtedy dzialac dokladnie wtedy, gdy nikt niczego nie
* szuka, czyli w najzwyklejszym uzyciu. Blad wszedl na instancje, bo zaden
* test nie wolal listy bez frazy: stad ten test.
*/
@SpringBootTest
@ActiveProfiles("test")
class WebinarSearchTest {
@Autowired private WebinarRepository webinarRepository;
@Autowired private RegistrationRepository registrationRepository;
private Webinar webinar;
private String suffix;
@BeforeEach
void setUp() {
suffix = String.valueOf(System.nanoTime());
webinar = new Webinar();
webinar.setTitle("Bezpieczenstwo pracy " + suffix);
webinar.setSlug("bezpieczenstwo-" + suffix);
webinar.setTimezone("Europe/Warsaw");
webinar.setStatus(WebinarStatus.PUBLISHED);
webinar.setStartsAt(OffsetDateTime.now().plusDays(3));
webinar.setEndsAt(OffsetDateTime.now().plusDays(3).plusHours(2));
webinar.setCreatedAt(OffsetDateTime.now());
webinar.setUpdatedAt(OffsetDateTime.now());
webinar = webinarRepository.save(webinar);
Registration registration = new Registration();
registration.setWebinar(webinar);
registration.setEmail("anna.nowak." + suffix + "@example.pl");
registration.setFirstName("Anna");
registration.setLastName("Nowak");
registration.setStatus(RegistrationStatus.CONFIRMED);
registration.setConfirmationToken("szukaj-" + suffix);
registration.setCreatedAt(OffsetDateTime.now());
registrationRepository.save(registration);
}
@AfterEach
void tearDown() {
webinarRepository.findById(webinar.getId()).ifPresent(webinarRepository::delete);
}
@Test
@DisplayName("Lista webinarow dziala takze bez frazy wyszukiwania")
void webinarListWorksWithoutSearch() {
var page = webinarRepository.search(null, null, null, null,
PageRequest.of(0, 25, Sort.by(Sort.Direction.DESC, "createdAt")));
assertThat(page.getContent()).extracting(Webinar::getId).contains(webinar.getId());
}
@Test
@DisplayName("Fraza znajduje webinar bez wzgledu na wielkosc liter")
void webinarSearchIsCaseInsensitive() {
var page = webinarRepository.search(null, null, null, "BEZPIECZENSTWO",
PageRequest.of(0, 25, Sort.by(Sort.Direction.DESC, "createdAt")));
assertThat(page.getContent()).extracting(Webinar::getId).contains(webinar.getId());
}
@Test
@DisplayName("Fraza niepasujaca do niczego daje pusta strone, a nie blad")
void webinarSearchWithoutMatchesIsEmpty() {
var page = webinarRepository.search(null, null, null, "nieistniejaca-fraza-" + suffix,
PageRequest.of(0, 25, Sort.by(Sort.Direction.DESC, "createdAt")));
assertThat(page.getContent()).isEmpty();
}
@Test
@DisplayName("Lista zapisanych dziala bez frazy i bez filtru statusu")
void registrationListWorksWithoutSearch() {
var page = registrationRepository.search(webinar.getId(), null, null,
PageRequest.of(0, 25, Sort.by(Sort.Direction.DESC, "createdAt")));
assertThat(page.getContent()).hasSize(1);
}
@Test
@DisplayName("Zapisanego znajduje nazwisko, imie i adres")
void registrationSearchCoversNameAndEmail() {
var byLastName = registrationRepository.search(webinar.getId(), null, "nowak",
PageRequest.of(0, 25, Sort.by(Sort.Direction.DESC, "createdAt")));
assertThat(byLastName.getContent()).hasSize(1);
var byFirstName = registrationRepository.search(webinar.getId(), null, "anna",
PageRequest.of(0, 25, Sort.by(Sort.Direction.DESC, "createdAt")));
assertThat(byFirstName.getContent()).hasSize(1);
var byEmail = registrationRepository.search(webinar.getId(), null, suffix,
PageRequest.of(0, 25, Sort.by(Sort.Direction.DESC, "createdAt")));
assertThat(byEmail.getContent()).hasSize(1);
var noMatch = registrationRepository.search(webinar.getId(), null, "kowalski",
PageRequest.of(0, 25, Sort.by(Sort.Direction.DESC, "createdAt")));
assertThat(noMatch.getContent()).isEmpty();
}
@Test
@DisplayName("Sortowanie po obecnosci nie wywala zapytania")
void sortingByAttendanceWorks() {
var page = registrationRepository.search(webinar.getId(), null, null,
PageRequest.of(0, 25, Sort.by(Sort.Direction.DESC, "attended")));
assertThat(page.getContent()).hasSize(1);
}
}
@@ -0,0 +1,34 @@
# Testy integracyjne wymagaja prawdziwego PostgreSQL, bo sprawdzaja
# blokade wiersza SELECT ... FOR UPDATE. H2 nie odwzorowuje jej wiernie.
# Baza z docker compose, adres nadpisywany zmienna TEST_DB_HOST.
spring:
datasource:
url: jdbc:postgresql://${TEST_DB_HOST:localhost}:${TEST_DB_PORT:5432}/${TEST_DB_NAME:webinar}
username: ${TEST_DB_USER:webinar}
password: ${TEST_DB_PASSWORD:webinar}
hikari:
# Pula musi obsluzyc rownoczesne zapisy w tescie wspolbieznosci, ale nie
# moze wyczerpac max_connections bazy (100). Uruchomiona aplikacja trzyma
# wlasna pule, wiec pula testowa rowna 50 zostawiala zbyt maly zapas
# i kolejne konteksty Springa nie mogly sie wystartowac. Objawialo sie to
# bledem "sorry, too many clients already" w losowej klasie testowej,
# zaleznie od kolejnosci, co wygladalo jak blad aplikacji.
# 20 wystarcza testom wspolbieznosci: sprawdzaja blokade wiersza, a nie
# przepustowosc puli.
maximum-pool-size: 20
jpa:
hibernate:
ddl-auto: validate
flyway:
enabled: true
# JwtProperties czyta JWT_SECRET ze srodowiska, wiec w testach podajemy go jako property
JWT_SECRET: sekret-wylacznie-do-testow-integracyjnych-co-najmniej-32-znaki
JWT_EXPIRATION_MINUTES: 120
# w testach nie tworzymy konta z losowym haslem, zeby log byl czytelny
ADMIN_EMAIL: admin@test.local
ADMIN_PASSWORD: haslo-testowe-administratora
@@ -0,0 +1,5 @@
# Losowa kolejnosc klas i metod testowych.
# Wymusza niezaleznosc testow od siebie: kazdy musi zalozyc wlasne dane
# i po sobie posprzatac, bo nie ma gwarancji, ze poprzedni cokolwiek zostawil.
junit.jupiter.testclass.order.default=org.junit.jupiter.api.ClassOrderer$Random
junit.jupiter.testmethod.order.default=org.junit.jupiter.api.MethodOrderer$Random