1
0
Fork 0
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:
Sebastian Frick 2026-02-24 09:28:56 +01:00
parent 2811836039
commit a77f0ec5df
20 changed files with 1136 additions and 63 deletions

View file

@ -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();
}
}

View file

@ -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");
}
}
}

View file

@ -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"));
}
}

View file

@ -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");
}
}