Initial commit
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
+135
@@ -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-");
|
||||
}
|
||||
}
|
||||
+323
@@ -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 & 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+226
@@ -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("<script>");
|
||||
}
|
||||
|
||||
@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
|
||||
Reference in New Issue
Block a user