Ergänzt Auftragserfassung und -bearbeitung, Unternehmerbelege, Stornos, Lagerkorrekturen, Stammdaten, Abrechnung, CRM, Formulare, Berichte und Preisverwaltung auf Basis der aktuellen PHP-Anwendung. Enthält Integrations- und Oberflächentests sowie dokumentierte Abgleiche und Prüfstände.
29 lines
9.5 KiB
Java
29 lines
9.5 KiB
Java
package de.stadtbote.votian;
|
|
import de.stadtbote.votian.database.DatabaseService;
|
|
import org.junit.jupiter.api.*;
|
|
import java.util.*;import java.util.function.Supplier;import java.time.*;import java.math.*;
|
|
import static org.assertj.core.api.Assertions.*;
|
|
import static org.mockito.Mockito.*;
|
|
import static org.mockito.ArgumentMatchers.*;
|
|
import static de.stadtbote.votian.OrderEntryService.fields;
|
|
class CrmServiceTest {
|
|
final DatabaseService db=mock(DatabaseService.class);final OrderEntryCatalog catalog=mock(OrderEntryCatalog.class);final CrmScope scope=new CrmScope(db);final ContactService contacts=new ContactService(db,scope);final SalesAnalytics sales=new SalesAnalytics(db,scope,catalog,"phoenix_log");final LegacyUser user=AppointmentServiceTest.user(0,11),master=AppointmentServiceTest.user(0,10,11);
|
|
final List<Map<String,Object>> defs=List.of(fields("id",1L,"keyId",101,"label","Name","type","varchar","history",1),fields("id",2L,"keyId",104,"label","Geburtstag","type","birthdate","history",0),fields("id",3L,"keyId",300,"label","Kunde","type","co2cs","history",0));
|
|
List<Map<String,Object>> values=List.of(fields("mtfck_id",1L,"mtfv_id",44L,"mtfv_value","Alt"));final List<String> queries=new ArrayList<>(),writes=new ArrayList<>();final List<List<Object>> arguments=new ArrayList<>();List<Map<String,Object>> targetRows=List.of();boolean huge=false,brokenSchema=false;
|
|
@BeforeEach void setup(){when(db.readTransaction(any())).thenAnswer(c->((Supplier<?>)c.getArgument(0)).get());when(db.writeTransaction(any())).thenAnswer(c->((Supplier<?>)c.getArgument(0)).get());when(db.contactWriteTransaction(any())).thenAnswer(c->((Supplier<?>)c.getArgument(0)).get());when(db.queryForObject(startsWith("SELECT COALESCE(MAX"),eq(Long.class))).thenReturn(99L);when(db.queryForList(anyString(),any(Object[].class))).thenAnswer(c->{String sql=c.getArgument(0);queries.add(sql);arguments.add(Arrays.asList(c.getArguments()).subList(1,c.getArguments().length));if(sql.startsWith("SELECT DISTINCT h.hq_id"))return sql.contains(" AND h.hq_id=?")?List.of(fields("id",7L,"label","B")):List.of(fields("id",7L,"label","B"),fields("id",8L,"label","HH"));if(sql.startsWith("SELECT ck.mtfck_id"))return brokenSchema?List.of():defs;if(sql.startsWith("SELECT mtfck_id,mtfv_id"))return values;if(sql.startsWith("SELECT cs_id"))return List.of(fields("cs_id",9L));if(sql.startsWith("SELECT DISTINCT v.mtfv_id"))return huge?Collections.nCopies(501,fields("id",44L,"headquartersId",7L)):List.of();if(sql.startsWith("SELECT jb.hq_id"))return List.of(fields("id",7L,"jobs",2L,"amount",new BigDecimal("100.00")));if(sql.startsWith("SELECT l.usr_id"))return List.of(fields("userId",11L,"person","Test","headquarters","B","customerId",9L,"description","SRC=jb|OLD=1|NEW=0"),fields("userId",11L,"person","Test","headquarters","B","customerId",9L,"description","SRC=cs|OLD=|NEW=0"));if(sql.startsWith("SELECT cs.cs_id AS id"))return List.of(fields("id",9L,"amount",new BigDecimal("20.00")));if(sql.startsWith("SELECT par_id"))return targetRows;return List.of();});when(db.update(anyString(),any(Object[].class))).thenAnswer(c->{writes.add(c.getArgument(0));return 1;});when(db.lockRows(startsWith("SELECT hq_id"),any(Object[].class))).thenReturn(List.of(fields("hq_id",7L)));when(db.lockRows(startsWith("SELECT par_id"),any(Object[].class))).thenAnswer(c->targetRows);when(catalog.parameter(anyLong(),anyString())).thenReturn("");}
|
|
ContactService.Save save(String rev,Map<Long,String> v){return new ContactService.Save(7,rev,AdminSupport.revision(defs),v);}
|
|
@Test void scopeCannotExpandToOtherTenantOrMissingSalesRight(){for(var u:List.of(AppointmentServiceTest.user(0),AppointmentServiceTest.user(11)))assertThatThrownBy(()->contacts.schema(u,7)).isInstanceOf(org.springframework.security.access.AccessDeniedException.class);verifyNoInteractions(db);assertThatThrownBy(()->contacts.schema(user,8)).isInstanceOf(org.springframework.security.access.AccessDeniedException.class);assertThatThrownBy(()->contacts.schema(master,99)).isInstanceOf(org.springframework.security.access.AccessDeniedException.class);}
|
|
@Test void creationUsesLockedGlobalIdAndLegacyHistory(){assertThat(contacts.save(user,null,save(null,Map.of(1L,"Neu")))).containsEntry("id",99L);verify(db).contactWriteTransaction(any());assertThat(writes).filteredOn(s->s.startsWith("INSERT INTO metafieldvalue ")).hasSize(3);verify(db).update(startsWith("INSERT INTO metafieldvaluehistory"),eq(1L),eq(99L),eq("Neu"),any(LocalDateTime.class));}
|
|
@Test void staleValuesAndDefinitionChangesCannotDeleteExistingContact(){assertThatThrownBy(()->contacts.save(user,44L,save("stale",Map.of(1L,"Neu")))).hasMessageContaining("409");assertThatThrownBy(()->contacts.save(user,44L,new ContactService.Save(7,"x","stale",Map.of(1L,"Neu")))).hasMessageContaining("409");assertThat(writes).isEmpty();}
|
|
@Test void contactsRejectInvalidDatesUnknownFieldsAndOversizeUtf8(){for(var vals:List.of(Map.of(2L,"31.02.2026"),Map.of(999L,"unbekannt"),Map.of(1L,"€".repeat(22000)),Map.of(1L," ")))assertThatThrownBy(()->contacts.save(user,null,save(null,vals))).hasMessageContaining("400");assertThat(writes).isEmpty();}
|
|
@Test void updatesAndDeletesAreScopedToDefinitionIdsNotObjectIdAlone(){contacts.save(user,44L,save(AdminSupport.revision(values),Map.of(1L,"Neu")));contacts.remove(user,7,44,AdminSupport.revision(values));assertThat(writes).filteredOn(s->s.startsWith("DELETE")).hasSize(2).allMatch(s->s.contains("mtfv_id=? AND mtfck_id IN (?,?,?)"));}
|
|
@Test void historyRequiresAnAccessibleCurrentContact(){values=List.of();assertThatThrownBy(()->contacts.history(user,7,44)).hasMessageContaining("404");assertThat(queries).noneMatch(s->s.contains("FROM metafieldvaluehistory"));}
|
|
@Test void contactSearchBindsWildcardsAndCustomerScope(){contacts.list(user,new ContactService.Query(List.of(7L),"%_x'",false,"A",9L));assertThat(queries).anyMatch(s->s.contains("sv.mtfv_value LIKE ?")&&s.contains("cv.mtfv_value=?"));assertThat(arguments.stream().flatMap(Collection::stream)).contains("%\\%\\_x'%","A%","9");huge=true;assertThatThrownBy(()->contacts.list(user,new ContactService.Query(List.of(7L),"",false,"",null))).hasMessageContaining("500 Kontakte");}
|
|
@Test void statusCounterIncludesOnlyConversionsToCustomers(){var r=sales.switches(user,new SalesAnalytics.Query(LocalDate.of(2026,1,1),LocalDate.of(2026,10,13),List.of(7L)));assertThat((List<?>)r.get("conversions")).hasSize(1);assertThat(r.get("summary").toString()).contains("jb1=1");assertThat(queries).anyMatch(s->s.contains("l.hq_id IN (?) AND c.hq_id IN (?)"));assertThat(queries).anyMatch(s->s.contains("jb_status=2")&&s.contains("jb_storno IN (0,1,3)")&&s.contains("jb.hq_id=cs.hq_id"));}
|
|
@Test void statusParserAndPriorYearMatchLegacyLeapHandling(){assertThat(SalesAnalytics.description("SRC=jb|OLD=2|NEW=0")).containsEntry("NEW","0");assertThat(SalesAnalytics.previousYear(LocalDate.of(2024,2,29))).isEqualTo(LocalDate.of(2023,2,28));assertThat(SalesAnalytics.previousYear(LocalDate.of(2025,2,28))).isEqualTo(LocalDate.of(2024,2,29));}
|
|
@Test void motivationHandlesMissingTargetsAndEmptyBranchWithoutDividingByZero(){var r=sales.motivation(master,YearMonth.now().toString(),List.of(7L,8L));var rows=(List<Map<String,Object>>)r.get("items");assertThat(rows.getFirst()).containsEntry("actual",new BigDecimal("100.00")).containsEntry("percent",null);assertThat(rows.get(1)).containsEntry("actual",BigDecimal.ZERO).containsEntry("jobs",0L);assertThat(SalesAnalytics.percent(new BigDecimal("75"),new BigDecimal("100"))).isEqualByComparingTo("75");}
|
|
@Test void exclusionsAreBoundAndRelatedCustomerSettingIsHonored(){when(catalog.parameter(0,"MOTIVATIONCOUNTER_EXCLUDED_CS")).thenReturn("TEST-1, TEST-2'");sales.motivation(user,YearMonth.now().toString(),List.of(7L));assertThat(queries).anyMatch(s->s.contains("cs.cs_eid NOT IN (?,?)")&&!s.contains("TEST-2"));assertThat(arguments.stream().flatMap(Collection::stream)).contains("TEST-1","TEST-2'");when(catalog.enabled(0,"GLOBAL_USE_RELATED_CUSTOMER")).thenReturn(true);sales.switches(user,new SalesAnalytics.Query(LocalDate.now(),LocalDate.now(),List.of(7L)));assertThat(queries).anyMatch(s->s.contains("csc.csc_id=jb.csc_id_related"));}
|
|
@Test void targetsRequireConfiguredPasswordAndLockOutRepeatedFailures(){assertThatThrownBy(()->sales.targets(user,new SalesAnalytics.TargetAccess(7,"x"))).hasMessageContaining("kein Motivation");when(catalog.parameter(7,"MOTIVATIONCOUNTER_PASSWORD")).thenReturn("test-only");for(int i=0;i<5;i++)assertThatThrownBy(()->sales.targets(user,new SalesAnalytics.TargetAccess(7,"wrong"))).isInstanceOf(org.springframework.security.access.AccessDeniedException.class);assertThatThrownBy(()->sales.targets(user,new SalesAnalytics.TargetAccess(7,"test-only"))).hasMessageContaining("fünf Minuten");assertThat(writes).isEmpty();}
|
|
@Test void targetEditingPreservesHistoryAndChecksRevision(){when(catalog.parameter(7,"MOTIVATIONCOUNTER_PASSWORD")).thenReturn("test-only");String m=YearMonth.now().toString();targetRows=List.of(fields("id",5L,"name","MOTIVATIONCOUNTER_"+m.replace('-','_'),"amount","100.00"));var t=new SalesAnalytics.Target(7,m,new BigDecimal("150.00"),"test-only",AdminSupport.revision(targetRows.getFirst()));assertThat(sales.targets(user,new SalesAnalytics.TargetAccess(7,"test-only")).getFirst()).containsEntry("month",m);sales.target(user,t);assertThat(writes).contains("UPDATE parameter SET par_value=? WHERE par_id=?");assertThatThrownBy(()->sales.target(user,new SalesAnalytics.Target(7,m,t.amount(),t.password(),"stale"))).hasMessageContaining("409");assertThatThrownBy(()->sales.target(user,new SalesAnalytics.Target(7,YearMonth.now().minusMonths(1).toString(),t.amount(),t.password(),t.revision()))).hasMessageContaining("vergangener");}
|
|
}
|