mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 13:49:36 +01:00
feat(shared): Länderauswahl mit ISO 3166-1 Mapping und CountryPicker
Backend: Country-Record (Shared Kernel), InMemoryCountryRepository mit ~249 Ländern und DACH-Priorisierung, ListCountries-UseCase, GET /api/countries?q= Endpoint. Frontend: CountryPicker-Komponente mit Fuzzy-Suche, DACH-Favoriten bei leerem Query. SupplierCreate-, CustomerCreate- und AddDeliveryAddress- Screens verwenden jetzt den CountryPicker statt Freitext. Detail-Screens zeigen den Ländercode in der Adressanzeige. Closes #71
This commit is contained in:
parent
2811836039
commit
a77f0ec5df
20 changed files with 1136 additions and 63 deletions
|
|
@ -0,0 +1,78 @@
|
|||
package de.effigenix.application.shared;
|
||||
|
||||
import de.effigenix.shared.common.Country;
|
||||
import de.effigenix.shared.common.CountryRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ListCountriesTest {
|
||||
|
||||
@Mock
|
||||
private CountryRepository countryRepository;
|
||||
|
||||
@InjectMocks
|
||||
private ListCountries listCountries;
|
||||
|
||||
@Test
|
||||
void shouldDelegateToFindAllWhenQueryIsNull() {
|
||||
var expected = List.of(new Country("DE", "Deutschland"));
|
||||
when(countryRepository.findAll()).thenReturn(expected);
|
||||
|
||||
var result = listCountries.execute(null);
|
||||
|
||||
assertThat(result).isEqualTo(expected);
|
||||
verify(countryRepository).findAll();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDelegateToFindAllWhenQueryIsBlank() {
|
||||
var expected = List.of(new Country("DE", "Deutschland"));
|
||||
when(countryRepository.findAll()).thenReturn(expected);
|
||||
|
||||
var result = listCountries.execute(" ");
|
||||
|
||||
assertThat(result).isEqualTo(expected);
|
||||
verify(countryRepository).findAll();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDelegateToSearchWhenQueryIsProvided() {
|
||||
var expected = List.of(new Country("DE", "Deutschland"));
|
||||
when(countryRepository.search("deutsch")).thenReturn(expected);
|
||||
|
||||
var result = listCountries.execute("deutsch");
|
||||
|
||||
assertThat(result).isEqualTo(expected);
|
||||
verify(countryRepository).search("deutsch");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnEmptyListWhenNoMatch() {
|
||||
when(countryRepository.search("xyz")).thenReturn(List.of());
|
||||
|
||||
var result = listCountries.execute("xyz");
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDelegateToFindAllWhenQueryIsEmpty() {
|
||||
var expected = List.of(new Country("DE", "Deutschland"));
|
||||
when(countryRepository.findAll()).thenReturn(expected);
|
||||
|
||||
var result = listCountries.execute("");
|
||||
|
||||
assertThat(result).isEqualTo(expected);
|
||||
verify(countryRepository).findAll();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
package de.effigenix.infrastructure.shared;
|
||||
|
||||
import de.effigenix.shared.common.Country;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class InMemoryCountryRepositoryTest {
|
||||
|
||||
private InMemoryCountryRepository repository;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
repository = new InMemoryCountryRepository();
|
||||
}
|
||||
|
||||
@Test
|
||||
void findAllShouldReturnAllCountries() {
|
||||
var all = repository.findAll();
|
||||
assertThat(all).hasSizeGreaterThanOrEqualTo(200);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findAllShouldHaveDachFirst() {
|
||||
var all = repository.findAll();
|
||||
assertThat(all.get(0).code()).isEqualTo("DE");
|
||||
assertThat(all.get(1).code()).isEqualTo("AT");
|
||||
assertThat(all.get(2).code()).isEqualTo("CH");
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchByNameShouldFindGermany() {
|
||||
var results = repository.search("deutsch");
|
||||
assertThat(results).extracting(Country::code).contains("DE");
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchByCodeShouldFindGermany() {
|
||||
var results = repository.search("DE");
|
||||
assertThat(results).extracting(Country::code).contains("DE");
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchShouldPrioritizeDach() {
|
||||
var results = repository.search("sch");
|
||||
// Schweiz (CH) should appear before non-DACH results like Tadschikistan
|
||||
var codes = results.stream().map(Country::code).toList();
|
||||
if (codes.contains("CH") && codes.contains("TJ")) {
|
||||
assertThat(codes.indexOf("CH")).isLessThan(codes.indexOf("TJ"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchWithNoMatchShouldReturnEmptyList() {
|
||||
var results = repository.search("xyz123");
|
||||
assertThat(results).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchWithNullShouldReturnAll() {
|
||||
var results = repository.search(null);
|
||||
assertThat(results).hasSizeGreaterThanOrEqualTo(200);
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchWithEmptyStringShouldReturnAll() {
|
||||
var results = repository.search("");
|
||||
assertThat(results).hasSizeGreaterThanOrEqualTo(200);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByCodeShouldReturnCountry() {
|
||||
var result = repository.findByCode("DE");
|
||||
assertThat(result).isPresent();
|
||||
assertThat(result.get().name()).isEqualTo("Deutschland");
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByCodeShouldReturnEmptyForUnknown() {
|
||||
var result = repository.findByCode("XX");
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByCodeWithNullShouldReturnEmpty() {
|
||||
var result = repository.findByCode(null);
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchShouldBeCaseInsensitive() {
|
||||
var results = repository.search("DEUTSCH");
|
||||
assertThat(results).extracting(Country::code).contains("DE");
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchByLowercaseCodeShouldMatch() {
|
||||
var results = repository.search("de");
|
||||
assertThat(results).extracting(Country::code).contains("DE");
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchWithWhitespaceOnlyShouldReturnAll() {
|
||||
var results = repository.search(" ");
|
||||
assertThat(results).hasSizeGreaterThanOrEqualTo(200);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findAllShouldSortNonDachAlphabetically() {
|
||||
var all = repository.findAll();
|
||||
// Skip DACH (first 3), check alphabetical order of the rest
|
||||
var nonDach = all.subList(3, all.size());
|
||||
for (int i = 1; i < nonDach.size(); i++) {
|
||||
assertThat(nonDach.get(i).name().compareTo(nonDach.get(i - 1).name()))
|
||||
.as("'%s' should come after '%s'", nonDach.get(i).name(), nonDach.get(i - 1).name())
|
||||
.isGreaterThanOrEqualTo(0);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void findAllShouldReturnImmutableSnapshot() {
|
||||
var first = repository.findAll();
|
||||
var second = repository.findAll();
|
||||
assertThat(first).isEqualTo(second);
|
||||
assertThat(first).hasSize(second.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchShouldMatchPartialName() {
|
||||
var results = repository.search("Frank");
|
||||
assertThat(results).extracting(Country::code).contains("FR");
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchDachPrioritizationInResults() {
|
||||
// "land" matches Deutschland (DE) and other countries like Island, Irland, etc.
|
||||
var results = repository.search("land");
|
||||
var codes = results.stream().map(Country::code).toList();
|
||||
assertThat(codes).isNotEmpty();
|
||||
if (codes.contains("DE")) {
|
||||
// DE should be first since it's DACH
|
||||
assertThat(codes.get(0)).isEqualTo("DE");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
package de.effigenix.infrastructure.shared.web;
|
||||
|
||||
import de.effigenix.infrastructure.AbstractIntegrationTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.hamcrest.Matchers.*;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
class CountryControllerIntegrationTest extends AbstractIntegrationTest {
|
||||
|
||||
private String validToken() {
|
||||
return generateToken("test-user-id", "testuser", "");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnCountriesWithDachFirst() throws Exception {
|
||||
mockMvc.perform(get("/api/countries")
|
||||
.header("Authorization", "Bearer " + validToken()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$", hasSize(greaterThan(200))))
|
||||
.andExpect(jsonPath("$[0].code").value("DE"))
|
||||
.andExpect(jsonPath("$[1].code").value("AT"))
|
||||
.andExpect(jsonPath("$[2].code").value("CH"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldSearchByName() throws Exception {
|
||||
mockMvc.perform(get("/api/countries")
|
||||
.param("q", "deutsch")
|
||||
.header("Authorization", "Bearer " + validToken()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[?(@.code == 'DE')]").exists());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnEmptyListForNoMatch() throws Exception {
|
||||
mockMvc.perform(get("/api/countries")
|
||||
.param("q", "xyz123nevermatches")
|
||||
.header("Authorization", "Bearer " + validToken()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$", hasSize(0)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturn401WithoutToken() throws Exception {
|
||||
mockMvc.perform(get("/api/countries"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldSearchByCode() throws Exception {
|
||||
mockMvc.perform(get("/api/countries")
|
||||
.param("q", "DE")
|
||||
.header("Authorization", "Bearer " + validToken()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[?(@.code == 'DE')]").exists())
|
||||
.andExpect(jsonPath("$[?(@.name == 'Deutschland')]").exists());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnResponseWithCodeAndNameFields() throws Exception {
|
||||
mockMvc.perform(get("/api/countries")
|
||||
.param("q", "deutsch")
|
||||
.header("Authorization", "Bearer " + validToken()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].code").isString())
|
||||
.andExpect(jsonPath("$[0].name").isString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturn401WithExpiredToken() throws Exception {
|
||||
mockMvc.perform(get("/api/countries")
|
||||
.header("Authorization", "Bearer " + generateExpiredToken("test-user-id", "testuser")))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnAllCountriesWithWhitespaceQuery() throws Exception {
|
||||
mockMvc.perform(get("/api/countries")
|
||||
.param("q", " ")
|
||||
.header("Authorization", "Bearer " + validToken()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$", hasSize(greaterThan(200))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchResultsShouldPrioritizeDach() throws Exception {
|
||||
mockMvc.perform(get("/api/countries")
|
||||
.param("q", "land")
|
||||
.header("Authorization", "Bearer " + validToken()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].code").value("DE"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
package de.effigenix.shared.common;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class CountryTest {
|
||||
|
||||
@Test
|
||||
void shouldCreateValidCountry() {
|
||||
var country = new Country("DE", "Deutschland");
|
||||
assertThat(country.code()).isEqualTo("DE");
|
||||
assertThat(country.name()).isEqualTo("Deutschland");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectNullCode() {
|
||||
assertThatThrownBy(() -> new Country(null, "Test"))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("2 uppercase letters");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectLowercaseCode() {
|
||||
assertThatThrownBy(() -> new Country("de", "Test"))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("2 uppercase letters");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectThreeLetterCode() {
|
||||
assertThatThrownBy(() -> new Country("DEU", "Test"))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("2 uppercase letters");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectNullName() {
|
||||
assertThatThrownBy(() -> new Country("DE", null))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("blank");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectBlankName() {
|
||||
assertThatThrownBy(() -> new Country("DE", " "))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("blank");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectEmptyCode() {
|
||||
assertThatThrownBy(() -> new Country("", "Test"))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("2 uppercase letters");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectSingleLetterCode() {
|
||||
assertThatThrownBy(() -> new Country("A", "Test"))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("2 uppercase letters");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectCodeWithDigits() {
|
||||
assertThatThrownBy(() -> new Country("D1", "Test"))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("2 uppercase letters");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectMixedCaseCode() {
|
||||
assertThatThrownBy(() -> new Country("De", "Test"))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("2 uppercase letters");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectEmptyName() {
|
||||
assertThatThrownBy(() -> new Country("DE", ""))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("blank");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue