From 4dd53edae0c2b90aa57e4174466a2f20110e3a89 Mon Sep 17 00:00:00 2001 From: Bennett Date: Thu, 5 Mar 2026 00:10:25 +0300 Subject: [PATCH 1/4] Add dynamic filters and fields metadata in API --- flextuma-api.http | 139 -------- .../core/controllers/BaseController.java | 59 +++- .../flextuma/core/dtos/AggregateDTO.java | 16 + .../flextuma/core/dtos/EntityFieldDTO.java | 17 + .../flextuma/core/events/EntityEvent.java | 22 ++ .../helpers/DynamicFetchSpecification.java | 113 +++++++ .../flextuma/core/helpers/FilterOperator.java | 9 +- .../core/helpers/GenericSpecification.java | 30 +- .../flextuma/core/services/BaseService.java | 252 ++++++++++++++- .../DynamicFetchSpecificationTest.java | 298 ++++++++++++++++++ 10 files changed, 796 insertions(+), 159 deletions(-) delete mode 100644 flextuma-api.http create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/dtos/AggregateDTO.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/dtos/EntityFieldDTO.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/events/EntityEvent.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/helpers/DynamicFetchSpecification.java create mode 100644 src/test/java/com/flexcodelabs/flextuma/core/helpers/DynamicFetchSpecificationTest.java diff --git a/flextuma-api.http b/flextuma-api.http deleted file mode 100644 index 0ba483e..0000000 --- a/flextuma-api.http +++ /dev/null @@ -1,139 +0,0 @@ -# Flextuma API Specification - -This file contains examples of common API requests for the Flextuma backend. - -### Authentication - -#### Log In -```http -POST /api/login -Content-Type: application/json - -{ - "username": "admin", - "password": "yourpassword" -} -``` - -#### Generate Personal Access Token (PAT) -```http -POST /api/tokens/generate -Content-Type: application/json -Authorization: Session ... - -{ - "name": "Frontend Integration Token", - "expiresAt": "2026-12-31T23:59:59" -} -``` - ---- - -### SMS Notifications - -#### Send Templated SMS (Immediate) -```http -POST /api/notifications -Content-Type: application/json -X-API-KEY: your_raw_pat_token - -{ - "phoneNumber": "255700000000", - "templateCode": "WELCOME_SMS", - "customerName": "John Doe", - "otpCode": "123456" -} -``` - -#### Send Templated SMS (Scheduled) -```http -POST /api/notifications -Content-Type: application/json -X-API-KEY: your_raw_pat_token - -{ - "phoneNumber": "255700000000", - "templateCode": "REMAINDER_SMS", - "scheduledAt": "2026-03-10T10:00:00", - "customerName": "John Doe" -} -``` - -#### Send Passthrough SMS (Raw Content) -```http -POST /api/notifications/raw -Content-Type: application/json -X-API-KEY: your_raw_pat_token - -{ - "phoneNumber": "255700000000", - "provider": "NEXT", - "content": "This is a direct message without using a template!" -} -``` - ---- - -### SMS Logs & Monitoring - -#### List Logs (Filtered by Failed) -```http -GET /api/smsLogs?filter=status:EQ:FAILED&page=0&size=10 -X-API-KEY: your_raw_pat_token -``` - -#### Retry Failed Log -```http -POST /api/smsLogs/{{log_id}}/retry -X-API-KEY: your_raw_pat_token - ---- - -### Webhook Triggers (Bulk Dispatch) - -#### Trigger Templated Bulk (Existing) -```http -POST /api/webhooks/{{connector_id}}/sms -Content-Type: application/json - -{ - "templateCode": "WELCOME_SMS", - "provider": "BEEM", - "filterQuery": { - "status": "active" - } -} -``` - -#### Trigger Passthrough Bulk (Raw Content) -```http -POST /api/webhooks/{{connector_id}}/sms -Content-Type: application/json - -{ - "content": "Urgent update: Our office will be closed tomorrow. Thank you!", - "provider": "NEXT", - "filterQuery": { - "type": "customer" - } -} -``` -``` - ---- - -### Templates - -#### Preview Template -```http -POST /api/smsTemplates/preview -Content-Type: application/json - -{ - "template": "Hello {{name}}, your balance is {{balance}}.", - "variables": { - "name": "Alice", - "balance": "5,000 TZS" - } -} -``` diff --git a/src/main/java/com/flexcodelabs/flextuma/core/controllers/BaseController.java b/src/main/java/com/flexcodelabs/flextuma/core/controllers/BaseController.java index 40d3ad5..ae965b7 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/controllers/BaseController.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/controllers/BaseController.java @@ -1,5 +1,7 @@ package com.flexcodelabs.flextuma.core.controllers; +import com.flexcodelabs.flextuma.core.dtos.AggregateDTO; +import com.flexcodelabs.flextuma.core.dtos.EntityFieldDTO; import com.flexcodelabs.flextuma.core.dtos.Pagination; import com.flexcodelabs.flextuma.core.entities.base.BaseEntity; import com.flexcodelabs.flextuma.core.services.BaseService; @@ -8,10 +10,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.UUID; +import java.util.*; public abstract class BaseController> { @@ -25,9 +24,10 @@ protected BaseController(S service) { public Map getAll( Pageable pageable, @RequestParam(required = false, name = "filter") List filters, - @RequestParam(required = false) String fields) { + @RequestParam(required = false) String fields, + @RequestParam(required = false, defaultValue = "AND") String rootJoin) { - Pagination result = service.findAllPaginated(pageable, filters, fields); + Pagination result = service.findAllPaginated(pageable, filters, fields, rootJoin); Map response = new LinkedHashMap<>(); response.put("page", result.getPage()); @@ -38,6 +38,11 @@ public Map getAll( return response; } + @GetMapping("/fields") + public ResponseEntity> getFields() { + return ResponseEntity.ok(service.getEntityFields()); + } + @GetMapping("/{id}") public ResponseEntity getById(@PathVariable UUID id) { return service.findById(id) @@ -45,6 +50,41 @@ public ResponseEntity getById(@PathVariable UUID id) { .orElse(ResponseEntity.notFound().build()); } + @GetMapping("/aggregate") + public ResponseEntity>> getAggregated( + @RequestParam(required = false) String aggregate, + @RequestParam(required = false) String groupBy, + @RequestParam(required = false, name = "filter") List filters, + @RequestParam(required = false, defaultValue = "AND") String rootJoin) { + + List aggregates = parseAggregates(aggregate); + List groupByFields = groupBy != null ? Arrays.asList(groupBy.split(",")) : null; + + return ResponseEntity.ok(service.getAggregatedData(aggregates, groupByFields, filters, rootJoin)); + } + + private List parseAggregates(String aggregateParam) { + if (aggregateParam == null || aggregateParam.isBlank()) { + return List.of(); + } + return Arrays.stream(aggregateParam.split(",")) + .map(agg -> { + java.util.regex.Matcher matcher = java.util.regex.Pattern + .compile("(\\w+)\\(([\\w\\.\\*]+)\\):(\\w+)") + .matcher(agg.trim()); + if (!matcher.matches()) { + throw new org.springframework.web.server.ResponseStatusException( + org.springframework.http.HttpStatus.BAD_REQUEST, "Invalid aggregation format: " + agg); + } + return AggregateDTO.builder() + .func(matcher.group(1)) + .column(matcher.group(2)) + .alias(matcher.group(3)) + .build(); + }) + .toList(); + } + @PostMapping public ResponseEntity create(@RequestBody T entity) { return ResponseEntity.ok(service.save(entity)); @@ -59,4 +99,11 @@ public ResponseEntity update(@PathVariable UUID id, @RequestBody T entity) { public ResponseEntity> delete(@PathVariable UUID id) { return ResponseEntity.ok(service.delete(id)); } + + @DeleteMapping("/bulky") + public ResponseEntity> deleteBulky( + @RequestParam(name = "filter") List filters, + @RequestParam(required = false, defaultValue = "AND") String rootJoin) { + return ResponseEntity.ok(service.deleteMany(filters, rootJoin)); + } } diff --git a/src/main/java/com/flexcodelabs/flextuma/core/dtos/AggregateDTO.java b/src/main/java/com/flexcodelabs/flextuma/core/dtos/AggregateDTO.java new file mode 100644 index 0000000..96513a2 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/dtos/AggregateDTO.java @@ -0,0 +1,16 @@ +package com.flexcodelabs.flextuma.core.dtos; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AggregateDTO { + private String func; + private String column; + private String alias; +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/dtos/EntityFieldDTO.java b/src/main/java/com/flexcodelabs/flextuma/core/dtos/EntityFieldDTO.java new file mode 100644 index 0000000..b642650 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/dtos/EntityFieldDTO.java @@ -0,0 +1,17 @@ +package com.flexcodelabs.flextuma.core.dtos; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class EntityFieldDTO { + private String name; + private String type; + private boolean mandatory; + private String description; +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/events/EntityEvent.java b/src/main/java/com/flexcodelabs/flextuma/core/events/EntityEvent.java new file mode 100644 index 0000000..cb0b180 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/events/EntityEvent.java @@ -0,0 +1,22 @@ +package com.flexcodelabs.flextuma.core.events; + +import com.flexcodelabs.flextuma.core.entities.base.BaseEntity; +import lombok.Getter; +import org.springframework.context.ApplicationEvent; + +@Getter +public class EntityEvent extends ApplicationEvent { + + private final transient T entity; + private final EntityEventType type; + + public EntityEvent(Object source, T entity, EntityEventType type) { + super(source); + this.entity = entity; + this.type = type; + } + + public enum EntityEventType { + CREATED, UPDATED, DELETED + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/helpers/DynamicFetchSpecification.java b/src/main/java/com/flexcodelabs/flextuma/core/helpers/DynamicFetchSpecification.java new file mode 100644 index 0000000..f32a6b7 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/helpers/DynamicFetchSpecification.java @@ -0,0 +1,113 @@ +package com.flexcodelabs.flextuma.core.helpers; + +import java.io.Serial; +import java.util.HashSet; +import java.util.Set; + +import org.springframework.data.jpa.domain.Specification; + +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Fetch; +import jakarta.persistence.criteria.FetchParent; +import jakarta.persistence.criteria.JoinType; +import jakarta.persistence.criteria.Predicate; +import jakarta.persistence.criteria.Root; +import jakarta.persistence.metamodel.Attribute; +import jakarta.persistence.metamodel.ManagedType; + +/** + * A Specification that dynamically adds fetch joins based on requested field + * paths. + * This helps prevent N+1 issues when specific relations are requested via the + * "fields" parameter. + */ +public class DynamicFetchSpecification implements Specification { + + @Serial + private static final long serialVersionUID = 1L; + + private final Set fieldPaths; + + public DynamicFetchSpecification(Set fieldPaths) { + this.fieldPaths = fieldPaths != null ? fieldPaths : new HashSet<>(); + } + + @Override + public Predicate toPredicate(Root root, CriteriaQuery query, CriteriaBuilder cb) { + // Fetch joins are not allowed in count queries + Class resultType = query.getResultType(); + if (resultType == Long.class || resultType == long.class || resultType == Integer.class + || resultType == int.class) { + return cb.conjunction(); + } + + // Apply fetch joins for requested paths + for (String path : fieldPaths) { + applyFetch(root, path); + } + + // Use distinct to avoid duplicates when fetching collections + query.distinct(true); + + return cb.conjunction(); + } + + private void applyFetch(Root root, String path) { + String[] parts = path.split("\\."); + FetchParent current = root; + ManagedType currentType = root.getModel(); + + for (String part : parts) { + Attribute attribute = getAttribute(currentType, part); + if (attribute != null && attribute.isAssociation()) { + current = SafeFetch.fetch(current, part); + currentType = getTargetType(attribute); + } else { + currentType = null; + } + + if (currentType == null) { + break; + } + } + } + + private Attribute getAttribute(ManagedType type, String attributeName) { + try { + return type.getAttribute(attributeName); + } catch (IllegalArgumentException e) { + return null; + } + } + + private ManagedType getTargetType(Attribute attribute) { + if (attribute instanceof jakarta.persistence.metamodel.PluralAttribute) { + jakarta.persistence.metamodel.PluralAttribute plural = (jakarta.persistence.metamodel.PluralAttribute) attribute; + if (plural.getElementType() instanceof ManagedType) { + return (ManagedType) plural.getElementType(); + } + } else if (attribute instanceof jakarta.persistence.metamodel.SingularAttribute) { + jakarta.persistence.metamodel.SingularAttribute singular = (jakarta.persistence.metamodel.SingularAttribute) attribute; + if (singular.getType() instanceof ManagedType) { + return (ManagedType) singular.getType(); + } + } + return null; + } + + /** + * Utility to prevent multiple fetches of the same attribute on the same parent. + */ + private static class SafeFetch { + @SuppressWarnings("unchecked") + static Fetch fetch(FetchParent parent, String attributeName) { + for (Fetch existingFetch : parent.getFetches()) { + if (existingFetch.getAttribute().getName().equals(attributeName)) { + return (Fetch) existingFetch; + } + } + return parent.fetch(attributeName, JoinType.LEFT); + } + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/helpers/FilterOperator.java b/src/main/java/com/flexcodelabs/flextuma/core/helpers/FilterOperator.java index 297ead8..c38d157 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/helpers/FilterOperator.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/helpers/FilterOperator.java @@ -10,7 +10,14 @@ public enum FilterOperator { ILIKE("ilike"), GT("gt"), LT("lt"), - IN("in"); + GTE("gte"), + LTE("lte"), + IN("in"), + BTN("btn"), + STARTS_WITH("startsWith"), + ENDS_WITH("endsWith"), + IS_TRUE("isTrue"), + IS_FALSE("isFalse"); private final String value; diff --git a/src/main/java/com/flexcodelabs/flextuma/core/helpers/GenericSpecification.java b/src/main/java/com/flexcodelabs/flextuma/core/helpers/GenericSpecification.java index 327fede..896454f 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/helpers/GenericSpecification.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/helpers/GenericSpecification.java @@ -23,24 +23,46 @@ public GenericSpecification(String filterStr) { @Override public Predicate toPredicate(Root root, CriteriaQuery query, CriteriaBuilder cb) { try { - Path path = root.get(field); + Path path = resolvePath(root, field); Class type = path.getJavaType(); return switch (operator) { case EQ -> cb.equal(path, castValue(type, value)); case NE -> cb.notEqual(path, castValue(type, value)); case LIKE -> cb.like(cb.lower(path.as(String.class)), "%" + value.toLowerCase() + "%"); + case ILIKE -> cb.like(cb.lower(path.as(String.class)), "%" + value.toLowerCase() + "%"); + case STARTS_WITH -> cb.like(cb.lower(path.as(String.class)), value.toLowerCase() + "%"); + case ENDS_WITH -> cb.like(cb.lower(path.as(String.class)), "%" + value.toLowerCase()); case IN -> path.in(Arrays.stream(value.split(",")).map(v -> castValue(type, v)).toList()); case GT -> cb.greaterThan(path.as(String.class), value); case LT -> cb.lessThan(path.as(String.class), value); - case ILIKE -> cb.like(cb.lower(path.as(String.class)), "%" + value.toLowerCase() + "%"); - + case GTE -> cb.greaterThanOrEqualTo(path.as(String.class), value); + case LTE -> cb.lessThanOrEqualTo(path.as(String.class), value); + case IS_TRUE -> cb.isTrue(path.as(Boolean.class)); + case IS_FALSE -> cb.isFalse(path.as(Boolean.class)); + case BTN -> { + String[] range = value.split(","); + if (range.length == 2) { + yield cb.between(path.as(String.class), range[0], range[1]); + } + yield cb.conjunction(); + } + default -> cb.conjunction(); }; - } catch (IllegalArgumentException e) { + } catch (Exception e) { return cb.conjunction(); } } + private Path resolvePath(Root root, String fieldPath) { + String[] parts = fieldPath.split("\\."); + Path path = root; + for (String part : parts) { + path = path.get(part); + } + return path; + } + private Object castValue(Class type, String value) { if (value == null || "null".equalsIgnoreCase(value)) return null; diff --git a/src/main/java/com/flexcodelabs/flextuma/core/services/BaseService.java b/src/main/java/com/flexcodelabs/flextuma/core/services/BaseService.java index b75ec45..69a9422 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/services/BaseService.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/services/BaseService.java @@ -5,12 +5,19 @@ import com.flexcodelabs.flextuma.core.dtos.Pagination; import com.flexcodelabs.flextuma.core.entities.base.BaseEntity; import com.flexcodelabs.flextuma.core.helpers.CurrentUserResolver; -import com.flexcodelabs.flextuma.core.helpers.GenericSpecification; -import com.flexcodelabs.flextuma.core.helpers.TenantAwareSpecification; +import com.flexcodelabs.flextuma.core.events.EntityEvent; +import com.flexcodelabs.flextuma.core.helpers.*; import com.flexcodelabs.flextuma.core.security.SecurityUtils; +import com.flexcodelabs.flextuma.core.dtos.AggregateDTO; +import com.flexcodelabs.flextuma.core.dtos.EntityFieldDTO; import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; +import jakarta.persistence.criteria.*; +import jakarta.persistence.metamodel.Attribute; +import jakarta.persistence.metamodel.EntityType; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.domain.Specification; @@ -19,6 +26,7 @@ import org.springframework.security.access.AccessDeniedException; import org.springframework.transaction.annotation.Transactional; +import java.lang.reflect.ParameterizedType; import java.util.*; public abstract class BaseService { @@ -26,6 +34,21 @@ public abstract class BaseService { @PersistenceContext protected EntityManager entityManager; + protected final Class entityClass; + + @SuppressWarnings("unchecked") + protected BaseService() { + this.entityClass = (Class) ((ParameterizedType) getClass() + .getGenericSuperclass()).getActualTypeArguments()[0]; + } + + private ApplicationEventPublisher eventPublisher; + + @Autowired + public void setEventPublisher(ApplicationEventPublisher eventPublisher) { + this.eventPublisher = eventPublisher; + } + private CurrentUserResolver currentUserResolver; @org.springframework.beans.factory.annotation.Autowired @@ -67,16 +90,72 @@ protected void checkPermission(String requiredPermission) { } } + protected Specification buildFetchSpec(String fields) { + return new DynamicFetchSpecification<>(FieldParser.parse(fields)); + } + + @Transactional(readOnly = true) + public List getEntityFields() { + checkPermission(getReadPermission()); + EntityType type = entityManager.getMetamodel().entity(entityClass); + + return type.getAttributes().stream() + .map(this::toFieldDTO) + .sorted(Comparator.comparing(EntityFieldDTO::getName)) + .toList(); + } + + private EntityFieldDTO toFieldDTO(Attribute attribute) { + boolean mandatory = false; + if (attribute instanceof jakarta.persistence.metamodel.SingularAttribute) { + mandatory = !((jakarta.persistence.metamodel.SingularAttribute) attribute).isOptional(); + } + + return EntityFieldDTO.builder() + .name(attribute.getName()) + .type(attribute.getJavaType().getSimpleName().toUpperCase()) + .mandatory(mandatory) + .description(attribute.getPersistentAttributeType().name()) + .build(); + } + + protected Specification buildFilterSpec(List filter, String rootJoin) { + if (filter == null || filter.isEmpty()) { + return null; + } + Specification filterSpec = null; + for (String filterStr : filter) { + Specification part = new GenericSpecification<>(filterStr); + if (filterSpec == null) { + filterSpec = part; + } else { + filterSpec = "OR".equalsIgnoreCase(rootJoin) ? filterSpec.or(part) : filterSpec.and(part); + } + } + return filterSpec; + } + @Transactional(readOnly = true) public Pagination findAllPaginated(Pageable pageable, List filter, String fields) { + return doFindAllPaginated(pageable, filter, fields, "AND"); + } + + @Transactional(readOnly = true) + public Pagination findAllPaginated(Pageable pageable, List filter, String fields, String rootJoin) { + return doFindAllPaginated(pageable, filter, fields, rootJoin); + } + + private Pagination doFindAllPaginated(Pageable pageable, List filter, String fields, String rootJoin) { checkPermission(getReadPermission()); Specification spec = buildTenantSpec(); + Specification filterSpec = buildFilterSpec(filter, rootJoin); + if (filterSpec != null) { + spec = spec.and(filterSpec); + } - if (filter != null && !filter.isEmpty()) { - for (String filterStr : filter) { - spec = spec.and(new GenericSpecification<>(filterStr)); - } + if (fields != null && !fields.isBlank()) { + spec = spec.and(buildFetchSpec(fields)); } Page resultPage = getRepositoryAsExecutor().findAll(spec, pageable); @@ -107,18 +186,55 @@ public List findAll() { return getRepositoryAsExecutor().findAll(spec); } + @Transactional(readOnly = true) + public List findAll(String fields) { + return doFindAll(fields, null, "AND"); + } + + @Transactional(readOnly = true) + public List findAll(String fields, List filter, String rootJoin) { + return doFindAll(fields, filter, rootJoin); + } + + private List doFindAll(String fields, List filter, String rootJoin) { + checkPermission(getReadPermission()); + Specification spec = buildTenantSpec(); + + Specification filterSpec = buildFilterSpec(filter, rootJoin); + if (filterSpec != null) { + spec = spec.and(filterSpec); + } + + if (fields != null && !fields.isBlank()) { + spec = spec.and(buildFetchSpec(fields)); + } + return getRepositoryAsExecutor().findAll(spec); + } + @Transactional(readOnly = true) public Optional findById(UUID id) { checkPermission(getReadPermission()); return getRepository().findById(id); } + @Transactional(readOnly = true) + public Optional findById(UUID id, String fields) { + checkPermission(getReadPermission()); + Specification spec = buildTenantSpec(); + spec = spec.and((root, query, cb) -> cb.equal(root.get("id"), id)); + if (fields != null && !fields.isBlank()) { + spec = spec.and(buildFetchSpec(fields)); + } + return getRepositoryAsExecutor().findOne(spec); + } + @Transactional public T save(T entity) { checkPermission(getAddPermission()); onPreSave(entity); T saved = getRepository().save(entity); onPostSave(saved); + eventPublisher.publishEvent(new EntityEvent<>(this, saved, EntityEvent.EntityEventType.CREATED)); return saved; } @@ -126,11 +242,14 @@ public T save(T entity) { public T update(UUID id, T entity) { checkPermission(getUpdatePermission()); T existing = getRepository().findById(id) - .orElseThrow(() -> new RuntimeException(getEntitySingular() + " not found")); + .orElseThrow(() -> new org.springframework.web.server.ResponseStatusException( + org.springframework.http.HttpStatus.NOT_FOUND, getEntitySingular() + " not found")); onPreUpdate(entity, existing); String[] excludedFields = getNullPropertyNames(entity); org.springframework.beans.BeanUtils.copyProperties(entity, existing, excludedFields); - return existing; + T saved = getRepository().save(existing); + eventPublisher.publishEvent(new EntityEvent<>(this, saved, EntityEvent.EntityEventType.UPDATED)); + return saved; } private String[] getNullPropertyNames(T source) { @@ -160,17 +279,132 @@ public Map delete(UUID id) { checkPermission(getDeletePermission()); T entity = getRepository().findById(id) - .orElseThrow(() -> new RuntimeException(getEntitySingular() + " not found")); + .orElseThrow(() -> new org.springframework.web.server.ResponseStatusException( + org.springframework.http.HttpStatus.NOT_FOUND, getEntitySingular() + " not found")); validateDelete(entity); getRepository().deleteById(id); onPostDelete(id); + eventPublisher.publishEvent(new EntityEvent<>(this, entity, EntityEvent.EntityEventType.DELETED)); return Map.of("message", getEntitySingular() + " deleted successfully"); } + @Transactional + public Map deleteMany(List filters) { + return doDeleteMany(filters, "AND"); + } + + @Transactional + public Map deleteMany(List filters, String rootJoin) { + return doDeleteMany(filters, rootJoin); + } + + private Map doDeleteMany(List filters, String rootJoin) { + checkPermission(getDeletePermission()); + Specification spec = buildTenantSpec(); + Specification filterSpec = buildFilterSpec(filters, rootJoin); + if (filterSpec != null) { + spec = spec.and(filterSpec); + } + + List entities = getRepositoryAsExecutor().findAll(spec); + if (entities.isEmpty()) { + throw new org.springframework.web.server.ResponseStatusException( + org.springframework.http.HttpStatus.NOT_FOUND, + "No " + getEntityPlural() + " found for deletion"); + } + + getRepository().deleteAll(entities); + return Map.of("message", entities.size() + " " + getEntityPlural() + " deleted successfully"); + } + + @Transactional(readOnly = true) + public List> getAggregatedData(List aggregates, List groupBy, + List filters, String rootJoin) { + checkPermission(getReadPermission()); + + CriteriaBuilder cb = entityManager.getCriteriaBuilder(); + CriteriaQuery query = cb.createQuery(Object[].class); + Root root = query.from(entityClass); + + List> selections = new ArrayList<>(); + + // Add GroupBy columns to selections + if (groupBy != null) { + for (String groupField : groupBy) { + selections.add(resolvePath(root, groupField).alias(groupField)); + } + } + + // Add Aggregates to selections + for (AggregateDTO agg : aggregates) { + selections.add(createAggregateExpression(cb, root, agg).alias(agg.getAlias())); + } + + query.select(cb.array(selections.toArray(new Selection[0]))); + + // Apply filters + Specification spec = buildTenantSpec(); + Specification filterSpec = buildFilterSpec(filters, rootJoin); + if (filterSpec != null) { + spec = spec.and(filterSpec); + } + + Predicate predicate = spec.toPredicate(root, query, cb); + if (predicate != null) { + query.where(predicate); + } + + // Apply GroupBy + if (groupBy != null && !groupBy.isEmpty()) { + query.groupBy(groupBy.stream() + .map(f -> (Expression) resolvePath(root, f)) + .toArray(Expression[]::new)); + } + + List results = entityManager.createQuery(query).getResultList(); + + return results.stream().map(row -> { + Map map = new LinkedHashMap<>(); + int i = 0; + if (groupBy != null) { + for (String groupField : groupBy) { + map.put(groupField, row[i++]); + } + } + for (AggregateDTO agg : aggregates) { + map.put(agg.getAlias(), row[i++]); + } + return map; + }).toList(); + } + + private Expression createAggregateExpression(CriteriaBuilder cb, Root root, AggregateDTO agg) { + String func = agg.getFunc().toUpperCase(); + Expression path = resolvePath(root, agg.getColumn()).as(Number.class); + + return switch (func) { + case "SUM" -> cb.sum(path); + case "AVG" -> cb.avg(path); + case "COUNT" -> cb.count(resolvePath(root, agg.getColumn())); + case "MIN" -> cb.min(path); + case "MAX" -> cb.max(path); + default -> throw new IllegalArgumentException("Unsupported aggregation function: " + func); + }; + } + + private Path resolvePath(Root root, String fieldPath) { + String[] parts = fieldPath.split("\\."); + Path path = root; + for (String part : parts) { + path = path.get(part); + } + return path; + } + protected void validateDelete(T entity) { } diff --git a/src/test/java/com/flexcodelabs/flextuma/core/helpers/DynamicFetchSpecificationTest.java b/src/test/java/com/flexcodelabs/flextuma/core/helpers/DynamicFetchSpecificationTest.java new file mode 100644 index 0000000..9526430 --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/core/helpers/DynamicFetchSpecificationTest.java @@ -0,0 +1,298 @@ +package com.flexcodelabs.flextuma.core.helpers; + +import jakarta.persistence.criteria.*; +import jakarta.persistence.metamodel.Attribute; +import jakarta.persistence.metamodel.EntityType; +import jakarta.persistence.metamodel.ManagedType; +import jakarta.persistence.metamodel.PluralAttribute; +import jakarta.persistence.metamodel.SingularAttribute; +import jakarta.persistence.metamodel.Type; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@SuppressWarnings({ "rawtypes", "unchecked" }) +class DynamicFetchSpecificationTest { + + @Mock + private Root root; + + @Mock + private CriteriaQuery query; + + @Mock + private CriteriaBuilder cb; + + @Mock + private EntityType rootType; + + @Mock + private SingularAttribute singularAttribute; + + @Mock + private PluralAttribute pluralAttribute; + + @Mock + private ManagedType targetType; + + @BeforeEach + void setUp() { + lenient().when(root.getModel()).thenReturn(rootType); + } + + @Test + void testConstructorWithNullFieldPaths() { + DynamicFetchSpecification spec = new DynamicFetchSpecification(null); + assertNotNull(spec); + } + + @Test + void testToPredicateForCountQuery() { + Set fieldPaths = Collections.singleton("relation"); + DynamicFetchSpecification spec = new DynamicFetchSpecification(fieldPaths); + + Class[] countTypes = { Long.class, long.class, Integer.class, int.class }; + for (Class type : countTypes) { + doReturn(type).when(query).getResultType(); + lenient().when(cb.conjunction()).thenReturn(mock(Predicate.class)); + assertNotNull(spec.toPredicate(root, query, cb)); + verify(root, never()).fetch(anyString(), any(JoinType.class)); + } + } + + @Test + void testToPredicateWithValidFetch() { + Set fieldPaths = Collections.singleton("relation"); + DynamicFetchSpecification spec = new DynamicFetchSpecification(fieldPaths); + + doReturn(Object.class).when(query).getResultType(); + when(rootType.getAttribute("relation")).thenReturn(singularAttribute); + when(singularAttribute.isAssociation()).thenReturn(true); + when(singularAttribute.getType()).thenReturn(targetType); + + when(root.getFetches()).thenReturn(Collections.emptySet()); + when(root.fetch("relation", JoinType.LEFT)).thenReturn(mock(Fetch.class)); + when(cb.conjunction()).thenReturn(mock(Predicate.class)); + + spec.toPredicate(root, query, cb); + + verify(query).distinct(true); + verify(root).fetch("relation", JoinType.LEFT); + } + + @Test + void testApplyFetchNonExistentAttribute() { + Set fieldPaths = Collections.singleton("nonExistent"); + DynamicFetchSpecification spec = new DynamicFetchSpecification(fieldPaths); + + doReturn(Object.class).when(query).getResultType(); + when(rootType.getAttribute("nonExistent")).thenThrow(new IllegalArgumentException()); + when(cb.conjunction()).thenReturn(mock(Predicate.class)); + + spec.toPredicate(root, query, cb); + + verify(root, never()).fetch(anyString(), any(JoinType.class)); + } + + @Test + void testApplyFetchNonAssociationAttribute() { + Set fieldPaths = Collections.singleton("simpleField"); + DynamicFetchSpecification spec = new DynamicFetchSpecification(fieldPaths); + + doReturn(Object.class).when(query).getResultType(); + when(rootType.getAttribute("simpleField")).thenReturn(singularAttribute); + when(singularAttribute.isAssociation()).thenReturn(false); + when(cb.conjunction()).thenReturn(mock(Predicate.class)); + + spec.toPredicate(root, query, cb); + + verify(root, never()).fetch(anyString(), any(JoinType.class)); + } + + @Test + void testApplyFetchPluralAttribute() { + Set fieldPaths = Collections.singleton("collection"); + DynamicFetchSpecification spec = new DynamicFetchSpecification(fieldPaths); + + doReturn(Object.class).when(query).getResultType(); + when(rootType.getAttribute("collection")).thenReturn(pluralAttribute); + when(pluralAttribute.isAssociation()).thenReturn(true); + when(pluralAttribute.getElementType()).thenReturn(targetType); + + when(root.getFetches()).thenReturn(Collections.emptySet()); + when(root.fetch("collection", JoinType.LEFT)).thenReturn(mock(Fetch.class)); + when(cb.conjunction()).thenReturn(mock(Predicate.class)); + + spec.toPredicate(root, query, cb); + + verify(root).fetch("collection", JoinType.LEFT); + } + + @Test + void testApplyFetchNestedPath() { + Set fieldPaths = Collections.singleton("relation.subRelation"); + DynamicFetchSpecification spec = new DynamicFetchSpecification(fieldPaths); + + doReturn(Object.class).when(query).getResultType(); + + // relation + when(rootType.getAttribute("relation")).thenReturn(singularAttribute); + when(singularAttribute.isAssociation()).thenReturn(true); + when(singularAttribute.getType()).thenReturn(targetType); + + Fetch firstFetch = mock(Fetch.class); + when(root.getFetches()).thenReturn(Collections.emptySet()); + when(root.fetch("relation", JoinType.LEFT)).thenReturn(firstFetch); + + // subRelation + ManagedType subTargetType = mock(ManagedType.class); + SingularAttribute subAttr = mock(SingularAttribute.class); + when(targetType.getAttribute("subRelation")).thenReturn(subAttr); + when(subAttr.isAssociation()).thenReturn(true); + when(subAttr.getType()).thenReturn(subTargetType); + + when(firstFetch.getFetches()).thenReturn(Collections.emptySet()); + when(firstFetch.fetch("subRelation", JoinType.LEFT)).thenReturn(mock(Fetch.class)); + + when(cb.conjunction()).thenReturn(mock(Predicate.class)); + + spec.toPredicate(root, query, cb); + + verify(root).fetch("relation", JoinType.LEFT); + verify(firstFetch).fetch("subRelation", JoinType.LEFT); + } + + @Test + void testSafeFetchReuseExisting() { + Set fieldPaths = Collections.singleton("relation"); + DynamicFetchSpecification spec = new DynamicFetchSpecification(fieldPaths); + + doReturn(Object.class).when(query).getResultType(); + when(rootType.getAttribute("relation")).thenReturn(singularAttribute); + when(singularAttribute.isAssociation()).thenReturn(true); + when(singularAttribute.getType()).thenReturn(targetType); + + Fetch existingFetch = mock(Fetch.class); + Attribute existingAttr = mock(Attribute.class); + lenient().when(existingAttr.getName()).thenReturn("relation"); + lenient().when(existingFetch.getAttribute()).thenReturn(existingAttr); + + when(root.getFetches()).thenReturn(Collections.singleton(existingFetch)); + when(cb.conjunction()).thenReturn(mock(Predicate.class)); + + spec.toPredicate(root, query, cb); + + verify(root, never()).fetch(anyString(), any(JoinType.class)); + } + + /** + * Test case to cover SafeFetch loop when multiple existing fetches exist. + */ + @Test + void testSafeFetchMultipleExisting() { + Set fieldPaths = Collections.singleton("relation"); + DynamicFetchSpecification spec = new DynamicFetchSpecification(fieldPaths); + + doReturn(Object.class).when(query).getResultType(); + when(rootType.getAttribute("relation")).thenReturn(singularAttribute); + when(singularAttribute.isAssociation()).thenReturn(true); + when(singularAttribute.getType()).thenReturn(targetType); + + // First existing fetch (mismatch) + Fetch mismatchFetch = mock(Fetch.class); + Attribute mismatchAttr = mock(Attribute.class); + when(mismatchAttr.getName()).thenReturn("other"); + when(mismatchFetch.getAttribute()).thenReturn(mismatchAttr); + + // Second existing fetch (match) + Fetch matchFetch = mock(Fetch.class); + Attribute matchAttr = mock(Attribute.class); + when(matchAttr.getName()).thenReturn("relation"); + when(matchFetch.getAttribute()).thenReturn(matchAttr); + + Set fetches = new LinkedHashSet<>(); + fetches.add(mismatchFetch); + fetches.add(matchFetch); + + when(root.getFetches()).thenReturn(fetches); + when(cb.conjunction()).thenReturn(mock(Predicate.class)); + + spec.toPredicate(root, query, cb); + + verify(root, never()).fetch(anyString(), any(JoinType.class)); + } + + @Test + void testGetTargetTypeReturnsNull() { + Set fieldPaths = Collections.singleton("relation"); + DynamicFetchSpecification spec = new DynamicFetchSpecification(fieldPaths); + + doReturn(Object.class).when(query).getResultType(); + when(rootType.getAttribute("relation")).thenReturn(singularAttribute); + when(singularAttribute.isAssociation()).thenReturn(true); + + // SingularAttribute returns null Type (not instance of ManagedType) + Type nonManagedType = mock(Type.class); + when(singularAttribute.getType()).thenReturn(nonManagedType); + + when(cb.conjunction()).thenReturn(mock(Predicate.class)); + + spec.toPredicate(root, query, cb); + + // Should break after first part if targetType is null + verify(root).fetch("relation", JoinType.LEFT); + } + + @Test + void testGetTargetTypePluralReturnsNull() { + Set fieldPaths = Collections.singleton("collection"); + DynamicFetchSpecification spec = new DynamicFetchSpecification(fieldPaths); + + doReturn(Object.class).when(query).getResultType(); + when(rootType.getAttribute("collection")).thenReturn(pluralAttribute); + when(pluralAttribute.isAssociation()).thenReturn(true); + + // PluralAttribute returns null element Type + Type nonManagedType = mock(Type.class); + when(pluralAttribute.getElementType()).thenReturn(nonManagedType); + + when(cb.conjunction()).thenReturn(mock(Predicate.class)); + + spec.toPredicate(root, query, cb); + + verify(root).fetch("collection", JoinType.LEFT); + } + + /** + * Test case to cover getTargetType with an attribute that is neither Singular + * nor Plural. + */ + @Test + void testGetTargetTypeNeitherSingularNorPlural() { + Set fieldPaths = Collections.singleton("other"); + DynamicFetchSpecification spec = new DynamicFetchSpecification(fieldPaths); + + doReturn(Object.class).when(query).getResultType(); + Attribute genericAttr = mock(Attribute.class); + when(rootType.getAttribute("other")).thenReturn(genericAttr); + when(genericAttr.isAssociation()).thenReturn(true); + + when(root.getFetches()).thenReturn(Collections.emptySet()); + when(root.fetch("other", JoinType.LEFT)).thenReturn(mock(Fetch.class)); + when(cb.conjunction()).thenReturn(mock(Predicate.class)); + + spec.toPredicate(root, query, cb); + + verify(root).fetch("other", JoinType.LEFT); + } +} From dd5469edab80e27b05f67574ed1c2587158ab22f Mon Sep 17 00:00:00 2001 From: Bennett Date: Fri, 6 Mar 2026 21:05:59 +0300 Subject: [PATCH 2/4] release: Add logs module --- docs/example-data.sql | 16 - docs/frontend-design-spec.md | 1045 +++++++++++++++++ .../core/config/RequestLoggingFilter.java | 14 +- .../core/entities/logging/SystemLog.java | 65 + .../flextuma/core/enums/LogLevel.java | 9 + .../flextuma/core/filters/TraceIdFilter.java | 66 ++ .../core/logging/DatabaseLogAppender.java | 199 ++++ .../core/logging/LogAppenderInitializer.java | 19 + .../core/logging/LogRetentionScheduler.java | 32 + .../repositories/SystemLogRepository.java | 21 + .../core/security/SecurityConfig.java | 3 + .../controllers/SystemLogController.java | 69 ++ .../logging/services/SystemLogService.java | 109 ++ src/main/resources/application.properties | 8 +- src/main/resources/logback-spring.xml | 10 +- .../core/controllers/BaseControllerTest.java | 2 +- .../core/entities/contact/ContactTest.java | 81 ++ .../core/entities/logging/SystemLogTest.java | 105 ++ .../core/filters/TraceIdFilterTest.java | 94 ++ .../core/helpers/CurrentUserResolverTest.java | 85 ++ .../helpers/GenericSpecificationTest.java | 265 ++++- .../core/helpers/PaginationHelperTest.java | 44 + .../interceptors/AuditorAwareImplTest.java | 9 +- .../core/logging/DatabaseLogAppenderTest.java | 463 ++++++++ .../logging/LogAppenderInitializerTest.java | 39 + .../logging/LogRetentionSchedulerTest.java | 40 + .../core/services/BaseServiceTest.java | 459 +++++--- .../core/webhooks/BeemDlrParserTest.java | 68 ++ .../OrganisationControllerTest.java | 25 + .../auth/services/RoleServiceTest.java | 11 + .../services/ConnectorConfigServiceTest.java | 72 ++ .../contact/services/ContactServiceTest.java | 46 + .../controllers/SystemLogControllerTest.java | 109 ++ .../services/SystemLogServiceTest.java | 132 +++ .../services/CampaignDispatchWorkerTest.java | 125 ++ .../services/NotificationServiceTest.java | 6 +- .../SmsConnectorControllerTest.java | 2 +- .../sms/services/SmsLogServiceTest.java | 2 +- 38 files changed, 3745 insertions(+), 224 deletions(-) delete mode 100644 docs/example-data.sql create mode 100644 docs/frontend-design-spec.md create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/entities/logging/SystemLog.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/enums/LogLevel.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/filters/TraceIdFilter.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/logging/DatabaseLogAppender.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/logging/LogAppenderInitializer.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/logging/LogRetentionScheduler.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/repositories/SystemLogRepository.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/logging/controllers/SystemLogController.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/logging/services/SystemLogService.java create mode 100644 src/test/java/com/flexcodelabs/flextuma/core/entities/contact/ContactTest.java create mode 100644 src/test/java/com/flexcodelabs/flextuma/core/entities/logging/SystemLogTest.java create mode 100644 src/test/java/com/flexcodelabs/flextuma/core/filters/TraceIdFilterTest.java create mode 100644 src/test/java/com/flexcodelabs/flextuma/core/helpers/CurrentUserResolverTest.java create mode 100644 src/test/java/com/flexcodelabs/flextuma/core/helpers/PaginationHelperTest.java create mode 100644 src/test/java/com/flexcodelabs/flextuma/core/logging/DatabaseLogAppenderTest.java create mode 100644 src/test/java/com/flexcodelabs/flextuma/core/logging/LogAppenderInitializerTest.java create mode 100644 src/test/java/com/flexcodelabs/flextuma/core/logging/LogRetentionSchedulerTest.java create mode 100644 src/test/java/com/flexcodelabs/flextuma/core/webhooks/BeemDlrParserTest.java create mode 100644 src/test/java/com/flexcodelabs/flextuma/modules/auth/controllers/OrganisationControllerTest.java create mode 100644 src/test/java/com/flexcodelabs/flextuma/modules/connector/services/ConnectorConfigServiceTest.java create mode 100644 src/test/java/com/flexcodelabs/flextuma/modules/contact/services/ContactServiceTest.java create mode 100644 src/test/java/com/flexcodelabs/flextuma/modules/logging/controllers/SystemLogControllerTest.java create mode 100644 src/test/java/com/flexcodelabs/flextuma/modules/logging/services/SystemLogServiceTest.java create mode 100644 src/test/java/com/flexcodelabs/flextuma/modules/notification/services/CampaignDispatchWorkerTest.java diff --git a/docs/example-data.sql b/docs/example-data.sql deleted file mode 100644 index 070b459..0000000 --- a/docs/example-data.sql +++ /dev/null @@ -1,16 +0,0 @@ --- Example Data for Flextuma - --- 1. Example Organisation -INSERT INTO organisation (id, name, active, code) -VALUES (gen_random_uuid(), 'Flex Code Labs', true, 'FLEX01'); - --- 2. Example SMS Connector (Beem) -INSERT INTO smsconnector (id, name, provider, apiKey, secretKey, active) -VALUES (gen_random_uuid(), 'Main Beem Account', 'BEEM', 'your_beem_api_key', 'your_beem_secret', true); - --- 3. Example SMS Template -INSERT INTO smstemplate (id, name, code, content, active, system) -VALUES (gen_random_uuid(), 'Welcome SMS', 'WELCOME_SMS', 'Hello {{name}}, welcome to Flextuma!', true, true); - --- 4. Admin User (if not exists) --- Note: Password hashing is usually handled at runtime, so use the /api/users endpoint ideally. diff --git a/docs/frontend-design-spec.md b/docs/frontend-design-spec.md new file mode 100644 index 0000000..494a3b6 --- /dev/null +++ b/docs/frontend-design-spec.md @@ -0,0 +1,1045 @@ +# Flextuma Frontend Design Specification + +> **Document purpose:** Provide a frontend designer/developer with everything needed to design and build a web UI that completes the Flextuma messaging gateway end-to-end. + +--- + +## 1. Product Summary + +Flextuma is a **multi-tenant messaging gateway** for organisations (SACCOs). It lets organisations: + +1. **Configure SMS providers** (Beem, NextSMS, etc.) +2. **Create message templates** with `{placeholder}` variables +3. **Manage contacts** organised by Tags and Lists +4. **Send SMS** — individually (templated or raw) or via scheduled campaigns +5. **Monitor delivery** — view logs, retry failures, track status lifecycle +6. **Manage finances** — wallet balance, top-ups, per-segment cost deductions +7. **Connect to external ERPs** to hydrate template data dynamically + +The backend is built, tested, and has a full REST API. The frontend must bring this to life as an **internal admin dashboard**. + +--- + +## 2. User Roles & Access Model + +### 2.1 Role Hierarchy + +| Role | Description | Sees | +|------|-------------|------| +| **Super Admin** | Platform operator, full access to all orgs | All data across all tenants | +| **Org Admin** | Manages their organisation's resources | Only data from their own organisation | +| **Org User** | Standard user within an org | Only their own records + org records | + +### 2.2 Permission System + +Every resource enforces 4 permissions: `READ_*`, `ADD_*`, `UPDATE_*`, `DELETE_*`. +The frontend should: +- **Conditionally render** action buttons (Create, Edit, Delete) based on the permissions returned in the `/api/me` response +- **Disable or hide** menu items the user has no `READ_*` permission for +- Users with `SUPER_ADMIN` or `ALL` authority bypass all checks + +### 2.3 Feature Flags (Org-Level) + +Some features can be gated per-organisation (e.g. `BULK_CAMPAIGN`, `CONNECTOR_PULL`). +If a gated action returns `403`, the UI should show a clear "This feature is not available for your plan" message — not a generic error. + +--- + +## 3. Authentication & Session + +### 3.1 Login Flow + +| Step | Detail | +|------|--------| +| **Endpoint** | `POST /api/login` with `{ "username": "...", "password": "..." }` | +| **Response** | The authenticated `User` object + a `SESSION` HttpOnly cookie | +| **CSRF** | Response includes `XSRF-TOKEN` cookie. All subsequent `POST`/`PUT`/`DELETE` requests must send `X-CSRF-TOKEN` header with this value | +| **Persistence** | Session is stored in Redis server-side; cookie-based `credentials: 'include'` on all fetches | +| **Concurrent sessions** | Maximum **1 session per user** — logging in elsewhere invalidates the old session | + +### 3.2 Session Check + +On app initialisation, call `GET /api/me`: +- **200** → user is authenticated, proceed and cache the user object +- **401** → redirect to login page + +### 3.3 Logout + +`POST /api/logout` — clears the session cookie. + +--- + +## 4. Core API Conventions + +Every resource follows the same pattern via `BaseController`: + +| Action | Method | Endpoint | Notes | +|--------|--------|----------|-------| +| **List (paginated)** | `GET` | `/api/{resource}?page=0&size=20` | Returns `{ page, total, pageSize, {resource}: [...] }` | +| **Filter** | `GET` | `/api/{resource}?filter=field:OP:value` | Operators: `EQ`, `NE`, `LIKE`, `ILIKE`, `IN`, `GT`, `LT` | +| **Multiple filters** | `GET` | `?filter=a:EQ:1&filter=b:GT:5&rootJoin=AND` | `rootJoin` = `AND` (default) or `OR` | +| **Select fields** | `GET` | `?fields=name,status` | Sparse field selection | +| **Get by ID** | `GET` | `/api/{resource}/{id}` | 200 or 404 | +| **Get fields schema** | `GET` | `/api/{resource}/fields` | Returns `EntityFieldDTO[]` — useful for dynamic form generation | +| **Aggregate** | `GET` | `/api/{resource}/aggregate?aggregate=COUNT(id):total&groupBy=status` | For dashboard charts/stats | +| **Create** | `POST` | `/api/{resource}` | Body = entity JSON | +| **Update** | `PUT` | `/api/{resource}/{id}` | Partial update (null-safe) | +| **Delete** | `DELETE` | `/api/{resource}/{id}` | Returns `{ "message": "..." }` | +| **Bulk delete** | `DELETE` | `/api/{resource}/bulky?filter=...` | Deletes matching records | + +### 4.1 Error Codes + +| Code | Meaning | Frontend Action | +|------|---------|-----------------| +| `400` | Validation error / missing fields | Show inline field errors | +| `401` | Not authenticated | Redirect to login | +| `403` | Missing permission or feature-gated | Show "Access Denied" or "Feature unavailable" | +| `404` | Resource not found | Show "Not found" state | +| `429` | Rate limited (Bucket4j) | Show "Too many requests, please wait" | + +--- + +## 5. Information Architecture & Pages + +### 5.1 Navigation Structure + +``` +├── Dashboard (home) +├── Messaging +│ ├── Send SMS +│ ├── Templates +│ ├── Campaigns +│ └── Message Logs +├── Contacts +│ ├── All Contacts +│ ├── Lists +│ └── Tags +├── Settings +│ ├── SMS Connectors +│ ├── ERP Connectors +│ ├── Wallet & Billing +│ └── API Tokens +├── Organisation (super admin only) +│ ├── Organisations +│ ├── Feature Flags +│ └── Users & Roles +└── Account + ├── Profile + └── Logout +``` + +--- + +## 6. Page-by-Page Design Specification + +### 6.1 Login Page + +**Purpose:** Authenticate the user. + +**Elements:** +- Username input +- Password input +- "Sign In" button +- Error message area (wrong credentials, locked account) + +**Behaviour:** +- `POST /api/login` → on success, redirect to Dashboard +- On 401 from `/api/me`, redirect here + +--- + +### 6.2 Dashboard + +**Purpose:** At-a-glance overview of messaging activity and wallet balance. + +**Data sources:** +| Widget | API Call | +|--------|----------| +| Total messages sent (today/week/month) | `GET /api/smsLogs/aggregate?aggregate=COUNT(id):total&filter=status:EQ:SENT` | +| Messages by status (doughnut chart) | `GET /api/smsLogs/aggregate?aggregate=COUNT(id):count&groupBy=status` | +| Wallet balance | `GET /api/wallets` (first item) | +| Recent transaction ledger | `GET /api/walletTransactions?size=5&sort=created,desc` | +| Active campaigns | `GET /api/campaigns?filter=status:IN:SCHEDULED,PROCESSING&size=5` | +| Failed messages needing retry | `GET /api/smsLogs?filter=status:EQ:FAILED&size=5` | + +**Widgets:** +1. **KPI Cards** — Messages Sent, Messages Failed, Wallet Balance, Active Campaigns +2. **Message Volume Chart** — Line or bar chart over last 7/30 days (use aggregate with date groupBy) +3. **Status Breakdown** — Doughnut: PENDING / PROCESSING / SENT / FAILED / DELIVERED +4. **Recent Failed Messages** — Quick table with Retry button +5. **Quick Send** — Shortcut CTA that links to Send SMS page + +--- + +### 6.3 Send SMS Page + +**Purpose:** Compose and send an individual SMS. + +**Two modes** (tab toggle): + +#### Mode A: Templated Send +| Field | Type | Source | +|-------|------|--------| +| Template | Dropdown | `GET /api/templates` | +| Phone Number | Text input | Manual entry or contact picker | +| Provider | Dropdown | `GET /api/connectors?filter=active:EQ:true` | +| Template placeholders | Dynamic text inputs | Extracted from template `content` — parse `{placeholder}` client-side | +| Schedule (optional) | DateTime picker | Adds `scheduledAt` to payload | + +**Live Preview Panel:** +As the user fills in placeholders, call `POST /api/templates/preview`: +```json +{ + "template": "Hello {name}, your balance is {balance}", + "variables": { "name": "John", "balance": "50,000" } +} +``` +Response: `{ "rendered", "segments", "encoding", "charactersRemaining" }` + +Display: +- Rendered message preview in a phone-shaped frame +- Segment count badge (e.g. "2 segments") +- Encoding indicator (GSM-7 / UCS-2) +- Characters remaining counter +- **Estimated cost** = segments × price_per_segment (show this with currency) + +**Submit:** `POST /api/notifications` with: +```json +{ + "templateCode": "...", + "phoneNumber": "255...", + "provider": "beem", + "{placeholder1}": "value1", + "scheduledAt": "2026-03-10T10:00:00" // optional +} +``` + +#### Mode B: Raw Send +| Field | Type | +|-------|------| +| Phone Number | Text input | +| Message Content | Textarea with live character count | +| Provider | Dropdown | +| Schedule (optional) | DateTime picker | + +**Submit:** `POST /api/notifications/raw` with: +```json +{ + "content": "Your OTP is 1234", + "phoneNumber": "255...", + "provider": "beem" +} +``` + +--- + +### 6.4 Templates Page + +**Purpose:** CRUD management of SMS templates. + +**API:** `/api/templates` + +**List View:** +| Column | Field | +|--------|-------| +| Name | `name` | +| Code | `code` | +| Category | `category` — show as coloured badge | +| Content Preview | `content` truncated to ~60 chars | +| System | `system` — badge if true (protected from delete) | +| Created | `created` | + +**Filters:** Category dropdown, search by name/code + +**Create/Edit Form:** +| Field | Type | Validation | +|-------|------|------------| +| Name | Text | Required | +| Code | Text | Required, unique per user | +| Category | Dropdown | `PROMOTIONAL`, `TRANSACTIONAL`, `OTP`, `ALERT`, `REMINDER`, `SYSTEM` | +| Content | Textarea | Required. Show live preview panel with segment count | +| Description | Textarea | Optional | + +**Special behaviour:** +- System templates (`system: true`) should have the Delete button disabled with tooltip "System template — cannot be deleted" +- The content textarea should highlight `{placeholders}` in a different colour + +--- + +### 6.5 Message Logs Page + +**Purpose:** Full history of all sent/pending/failed messages. + +**API:** `/api/smsLogs` + +**List View:** +| Column | Field | +|--------|-------| +| Recipient | `recipient` | +| Content | `content` (truncated) | +| Status | `status` — colour-coded badge | +| Template | `template.name` (if present) | +| Connector | `connector.provider` | +| Retries | `retries` | +| Scheduled | `scheduledAt` (if present) | +| Created | `created` | + +**Status Badge Colours:** + +| Status | Colour | Icon | +|--------|--------|------| +| `PENDING` | Yellow/Amber | ⏳ Clock | +| `PROCESSING` | Blue | ⚙️ Gear | +| `SENT` | Green | ✓ Check | +| `FAILED` | Red | ✗ Cross | +| `DELIVERED` | Dark Green | ✓✓ Double check | + +**Row Actions:** +- **View Detail** — Expand to show full content, provider response, error message +- **Retry** — `POST /api/smsLogs/{id}/retry` (only for `FAILED` status) + +**Filters:** +- Status dropdown (multi-select) +- Date range picker +- Recipient search +- Template filter + +--- + +### 6.6 Campaigns Page + +**Purpose:** Create and manage bulk SMS campaigns. + +**API:** `/api/campaigns` + +**List View:** +| Column | Field | +|--------|-------| +| Name | `name` | +| Status | `status` — badge | +| Scheduled At | `scheduledAt` | +| Template | `template.name` | +| Connector | `connector.provider` | +| Created | `created` | + +**Status Badge Colours:** + +| Status | Colour | +|--------|--------| +| `DRAFT` | Grey | +| `SCHEDULED` | Blue | +| `PROCESSING` | Amber | +| `COMPLETED` | Green | +| `CANCELLED` | Red | + +**Create Campaign Form:** +| Field | Type | Notes | +|-------|------|-------| +| Name | Text | Required | +| Template | Dropdown | From `/api/templates` | +| Content | Textarea | Auto-filled from template, editable | +| Recipients | Textarea or import | Comma-separated phone numbers | +| Connector | Dropdown | From `/api/connectors` | +| Scheduled At | DateTime picker | Required — must be future | + +**Campaign Detail View:** +- Header with campaign name, status badge +- Message preview (rendered content) +- Recipients count +- Scheduled date/time +- Action buttons: Cancel (if SCHEDULED), Delete (if DRAFT) + +--- + +### 6.7 Contacts Page + +**Purpose:** Manage contact database for targeted messaging. + +**API:** `/api/contacts` + +**List View:** +| Column | Field | +|--------|-------| +| First Name | `firstName` | +| Surname | `surname` | +| Phone Number | `phoneNumber` | +| Status | `status` — badge (`ACTIVE`, `INACTIVE`, `RETIRED`, `DELETED`) | +| Tags | `tags[].name` — show as pill badges | +| Lists | `lists[].name` — show as pill badges | +| Created | `created` | + +**Create/Edit Form:** +| Field | Type | Validation | +|-------|------|------------| +| First Name | Text | Required | +| Middle Name | Text | Optional | +| Surname | Text | Required | +| Phone Number | Text | Required | +| Status | Dropdown | `ACTIVE` (default), `INACTIVE`, `RETIRED`, `DELETED` | +| Tags | Multi-select | From `/api/tags` | +| Lists | Multi-select | From `/api/lists` | + +**Bulk actions:** Bulk delete via filter, bulk tag assignment + +--- + +### 6.8 Lists & Tags Pages + +**Purpose:** Organise contacts into groups. + +**API:** `/api/lists` and `/api/tags` + +Both follow the same pattern: + +**List View:** +| Column | Field | +|--------|-------| +| Name | `name` | +| Description | `description` | +| Contact Count | `contacts.length` or aggregate | +| Created | `created` | + +**Create/Edit Form:** +| Field | Type | +|-------|------| +| Name | Text (required, unique per user) | +| Description | Textarea (optional) | + +--- + +### 6.9 SMS Connectors Page + +**Purpose:** Configure SMS provider connections. + +**API:** `/api/connectors` + +**List View:** +| Column | Field | +|--------|-------| +| Provider | `provider` | +| URL | `url` (masked) | +| Sender ID | `senderId` | +| Default | `isDefault` — toggle or badge | +| API Key | `key` (masked, e.g. `****abcd`) | +| Active | `active` — toggle | + +**Create/Edit Form:** +| Field | Type | Notes | +|-------|------|-------| +| Provider | Text | e.g. "beem", "nextsms" | +| URL | Text | Provider API base URL | +| API Key | Password input | Write-only; shows masked on read | +| Secret | Password input | Write-only; shows masked on read | +| Sender ID | Text | Optional — display name on SMS | +| Extra Settings | JSON editor or key-value pairs | Optional | +| Default | Toggle | One connector should be default | + +> **Important:** `key` and `secret` are write-only. On GET, the API returns masked values (e.g. `****abcd`). The form should leave these fields blank on edit and only send them if the user explicitly types new values. + +--- + +### 6.10 ERP Connector Config Page + +**Purpose:** Configure external ERP/data source connections for data hydration. + +**API:** `/api/connectorConfigs` + +**List View:** +| Column | Field | +|--------|-------| +| URL | `url` (masked) | +| Endpoint | `endpoint` | +| Auth Type | `authType` — badge | +| Mappings | Count of field mappings | + +**Create/Edit Form:** +| Field | Type | Notes | +|-------|------|-------| +| Tenant ID | Text | Hidden/auto-set for org users | +| URL | Text | Base URL of external API | +| Endpoint | Text | Path with `{id}` placeholder | +| Search | Text | Optional search endpoint path | +| Auth Type | Dropdown | `NONE`, `BASIC`, `BEARER`, `API_KEY` | +| Token | Password input | Shown only when `authType = BEARER` | +| API Key | Password input | Shown only when `authType = API_KEY` | +| Username | Text | Shown only when `authType = BASIC` | +| Password | Password input | Shown only when `authType = BASIC` | +| Field Mappings | Dynamic key-value list | Source JSONPath → System Key | + +**Dynamic auth fields:** Use conditional visibility — show only the credential fields relevant to the selected `authType`. + +**Field Mappings Editor:** +A table with "Add Row" button: +| Source Path (JSONPath) | System Key | +|------------------------|------------| +| `$.data.fullName` | `member_name` | +| `$.data.account.balance` | `balance` | + +--- + +### 6.11 Wallet & Billing Page + +**Purpose:** View wallet balance and transaction history. + +**API:** `/api/wallets` and `/api/walletTransactions` + +**Wallet Summary Card:** +- Current balance with currency (e.g. "TZS 245,000.00") +- "Top Up" button (for Super Admin — opens top-up flow) + +**Transaction History Table:** +| Column | Field | +|--------|-------| +| Date | `created` | +| Type | `type` — `CREDIT` (green) / `DEBIT` (red) | +| Amount | `amount` — formatted with currency | +| Balance After | `balanceAfter` | +| Description | `description` | +| Reference | `reference` | + +**Filters:** Type dropdown (CREDIT/DEBIT), date range + +--- + +### 6.12 API Tokens Page (Personal Access Tokens) + +**Purpose:** Manage PATs for programmatic API access. + +**API:** `/api/tokens` + +**List View:** +| Column | Field | +|--------|-------| +| Name | `name` | +| Last Used | `lastUsedAt` | +| Expires | `expiresAt` | +| Active | `active` | +| Created | `created` | + +**Create Token Flow:** +1. User enters a token name and optional expiry date +2. `POST /api/tokens` → response includes `rawToken` **once only** +3. Show modal with the raw token and a "Copy to clipboard" button +4. **Critical UX:** Display a warning: "This token will only be shown once. Copy it now." + +--- + +### 6.13 Users & Roles Page (Super Admin / Org Admin) + +**Purpose:** Manage platform users and RBAC. + +#### Users Tab +**API:** `/api/users` + +**List View:** +| Column | Field | +|--------|-------| +| Name | `name` | +| Username | `username` | +| Email | `email` | +| Phone | `phoneNumber` | +| Organisation | `organisation.name` | +| Roles | `roles[].name` — pill badges | +| Verified | `verified` — badge | +| Last Login | `lastLogin` | +| Active | `active` — toggle | + +**Create/Edit Form:** +| Field | Type | Validation | +|-------|------|------------| +| Name | Text | Required | +| Username | Text | Required, unique | +| Email | Email | Optional, unique | +| Phone Number | Text | Required, unique | +| Password | Password | Required on create, optional on edit | +| Organisation | Dropdown | From `/api/organisations` | +| Roles | Multi-select | From `/api/roles` | + +#### Roles Tab +**API:** `/api/roles` + +**List View:** +| Column | Field | +|--------|-------| +| Name | `name` | +| System | `system` — badge | +| Privileges | Count | + +**Create/Edit Form:** +| Field | Type | +|-------|------| +| Name | Text (required, unique) | +| Privileges | Checkbox grid | From `/api/privileges` — group by module | + +System roles (`system: true`) should not be editable or deletable. + +--- + +### 6.14 Organisations Page (Super Admin only) + +**API:** `/api/organisations` + +**List View:** +| Column | Field | +|--------|-------| +| Name | `name` | +| Phone | `phoneNumber` | +| Email | `email` | +| Website | `website` | +| Active | `active` | + +**Create/Edit Form:** +| Field | Type | Validation | +|-------|------|------------| +| Name | Text | Required | +| Description | Textarea | Optional | +| Phone Number | Text | Required | +| Email | Email | Optional | +| Address | Text | Optional | +| Website | URL | Optional | + +--- + +### 6.15 Feature Flags Page (Super Admin only) + +**Purpose:** Enable/disable features per organisation. + +**API:** `/api/tenantFeatures` + +**View:** Table grouped by organisation, or filterable by org. + +| Column | Field | +|--------|-------| +| Organisation | `organisation.name` | +| Feature Key | `featureKey` | +| Enabled | `enabled` — toggle switch | + +**Create Form:** +| Field | Type | +|-------|------| +| Organisation | Dropdown (from `/api/organisations`) | +| Feature Key | Dropdown or text: `BULK_CAMPAIGN`, `WHATSAPP_SEND`, `EMAIL_SEND`, `CONNECTOR_PULL` | +| Enabled | Toggle (default: true) | + +**Inline toggle:** Clicking the toggle should `PUT /api/tenantFeatures/{id}` with `{ "enabled": !current }`. + +--- + +## 7. Data Model Reference + +### 7.1 Entities & API Endpoints + +| Entity | API Path | Notes | +|--------|----------|-------| +| User | `/api/users` | RBAC-protected | +| Organisation | `/api/organisations` | Multi-tenancy anchor | +| Role | `/api/roles` | Has many Privileges | +| Privilege | `/api/privileges` | Fine-grained permissions | +| PersonalAccessToken | `/api/tokens` | `rawToken` shown once | +| Contact | `/api/contacts` | ManyToMany with Tags & Lists | +| Tag | `/api/tags` | Metadata for contacts | +| ListEntity | `/api/lists` | Contact grouping | +| SmsTemplate | `/api/templates` | `{placeholder}` content | +| SmsConnector | `/api/connectors` | Provider config, masked credentials | +| SmsCampaign | `/api/campaigns` | Scheduled bulk sends | +| SmsLog | `/api/smsLogs` | Delivery history + retry | +| ConnectorConfig | `/api/connectorConfigs` | ERP integration config | +| TenantFeature | `/api/tenantFeatures` | Per-org feature flags | +| Wallet | `/api/wallets` | Org balance | +| WalletTransaction | `/api/walletTransactions` | Ledger entries | + +### 7.2 Enums + +These values should be used as dropdown options and badge labels: + +| Enum | Values | +|------|--------| +| `SmsLogStatus` | `PENDING`, `PROCESSING`, `SENT`, `FAILED`, `DELIVERED` | +| `SmsCampaignStatus` | `DRAFT`, `SCHEDULED`, `PROCESSING`, `COMPLETED`, `CANCELLED` | +| `CategoryEnum` | `PROMOTIONAL`, `TRANSACTIONAL`, `OTP`, `ALERT`, `REMINDER`, `SYSTEM` | +| `AuthType` | `NONE`, `BASIC`, `BEARER`, `API_KEY` | +| `StatusEnum` | `ACTIVE`, `INACTIVE`, `RETIRED`, `DELETED` | +| `TransactionType` | `CREDIT`, `DEBIT` | +| `UserType` | `SYSTEM` (+ potential expansion) | + +### 7.3 Common Base Fields + +Every entity includes these fields from `BaseEntity`: + +| Field | Type | Notes | +|-------|------|-------| +| `id` | UUID | Primary key | +| `created` | DateTime | Auto-set | +| `updated` | DateTime | Auto-set | +| `active` | Boolean | Soft-delete flag | +| `code` | String | Generated/custom code | + +Entities extending `Owner` additionally have: +| Field | Type | +|-------|------| +| `createdBy` | User (object) | +| `updatedBy` | User (object) | + +--- + +## 8. Custom Endpoints (Non-CRUD) + +Beyond the standard CRUD operations, these specialised endpoints exist: + +| Endpoint | Method | Purpose | Request Body | +|----------|--------|---------|--------------| +| `/api/login` | POST | Authenticate | `{ "username", "password" }` | +| `/api/logout` | POST | End session | — | +| `/api/me` | GET | Current user info | — | +| `/api/notifications` | POST | Send templated SMS | `{ "templateCode", "phoneNumber", "provider", ...placeholders }` | +| `/api/notifications/raw` | POST | Send raw SMS | `{ "content", "phoneNumber", "provider" }` | +| `/api/templates/preview` | POST | Live template preview | `{ "template": "...", "variables": {...} }` | +| `/api/smsLogs/{id}/retry` | POST | Retry a failed message | — | +| `/api/{resource}/fields` | GET | Get entity field schema | — | +| `/api/{resource}/aggregate` | GET | Aggregated data | Query params | + +--- + +## 9. Design System Guidance + +### 9.1 General Aesthetics + +- **Style:** Modern enterprise dashboard. Clean, data-dense but not cluttered +- **Colour palette:** Professional dark sidebar with a light main content area (or full dark mode) +- **Typography:** System font stack or Google Fonts (Inter, Outfit) +- **Density:** Admin tools benefit from medium density — avoid excessive whitespace + +### 9.2 Component Library + +These core components are needed throughout: + +| Component | Usage | +|-----------|-------| +| **Data Table** | Every list view — sortable, filterable, paginated | +| **Form** | Entity create/edit — dynamic fields from `/fields` endpoint | +| **Status Badge** | Coloured pills for enums (PENDING=amber, SENT=green, etc.) | +| **KPI Card** | Dashboard stat cards with icon + value + label | +| **Chart** | Line/bar for trends, doughnut for status breakdown | +| **Sidebar Navigation** | Persistent left nav with icon + label | +| **Modal / Sheet** | Confirmations, token display, quick actions | +| **Toast Notifications** | Success/error feedback on form submissions | +| **Empty States** | Illustration + CTA for when a list has no data | +| **Phone Preview** | SMS preview in a phone-shaped frame (for template editor) | +| **Multi-select** | For tags, lists, roles, privileges selection | +| **DateTime Picker** | For scheduling campaigns and messages | +| **JSON/Key-Value Editor** | For connector field mappings | +| **Toggle Switch** | For boolean fields (active, default, enabled) | + +### 9.3 Responsive Considerations + +- **Primary target:** Desktop browsers (this is an admin dashboard) +- **Minimum breakpoint:** 1024px +- **Nice to have:** Tablet support (sidebar collapses to icons) +- **Not required:** Mobile-first design + +--- + +## 10. Key User Flows + +### 10.1 First-Time Setup Flow (Org Admin) + +```mermaid +flowchart TD + A[Login] --> B[Dashboard - Empty State] + B --> C["Setup SMS Connector\n(Settings > SMS Connectors)"] + C --> D["Create Template\n(Messaging > Templates)"] + D --> E["Import/Add Contacts\n(Contacts > All Contacts)"] + E --> F["Send First SMS\n(Messaging > Send SMS)"] + F --> G["View Delivery Status\n(Messaging > Logs)"] +``` + +### 10.2 Send Templated SMS Flow + +```mermaid +sequenceDiagram + actor User + participant UI as Frontend + participant API as Flextuma API + + User->>UI: Select template, enter phone, fill placeholders + UI->>API: POST /api/templates/preview + API-->>UI: { rendered, segments, encoding, cost } + UI->>User: Show preview, segment count, cost + User->>UI: Click "Send" + UI->>API: POST /api/notifications + API-->>UI: SmsLog { status: PENDING } + UI->>User: Success toast: "Message queued" +``` + +### 10.3 Campaign Flow + +```mermaid +stateDiagram-v2 + [*] --> DRAFT: Create campaign + DRAFT --> SCHEDULED: Set schedule & save + DRAFT --> [*]: Delete + SCHEDULED --> PROCESSING: Scheduler picks up + SCHEDULED --> CANCELLED: Admin cancels + PROCESSING --> COMPLETED: All sent + CANCELLED --> [*] + COMPLETED --> [*] +``` + +--- + +## 11. Implementation Notes for Frontend Dev + +### 11.1 CSRF Handling + +```javascript +// On every mutating request, read the XSRF-TOKEN cookie and send it as a header +fetch('/api/...', { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': getCookie('XSRF-TOKEN') + }, + body: JSON.stringify(payload) +}); +``` + +### 11.2 Pagination Pattern + +The API uses Spring Data `Pageable` conventions: +``` +GET /api/smsLogs?page=0&size=20&sort=created,desc +``` + +Response: +```json +{ + "page": 0, + "total": 142, + "pageSize": 20, + "smsLog": [ ... ] +} +``` + +Note: The array key name is the entity's `propertyName` (singular, camelCase). + +### 11.3 Filter Pattern + +``` +GET /api/contacts?filter=status:EQ:ACTIVE&filter=firstName:LIKE:John&rootJoin=AND +``` + +Build filter strings client-side from the UI's filter controls. + +### 11.4 Aggregate Pattern + +``` +GET /api/smsLogs/aggregate?aggregate=COUNT(id):total,SUM(retries):totalRetries&groupBy=status&filter=created:GT:2026-03-01 +``` + +Supported functions: `COUNT`, `SUM`, `AVG`, `MIN`, `MAX`. + +### 11.5 Masked Fields + +Connector credentials (`key`, `secret`, `token`, `apiKey`, `password`) are masked on read (e.g. `****abcd`). When editing: +- Show the masked value as placeholder text +- Only send the field if the user explicitly enters a new value +- If left blank on edit, do **not** include the field in the PUT body + +### 11.6 Dynamic Form Generation (Optional) + +The `/api/{resource}/fields` endpoint returns: +```json +[ + { "name": "phoneNumber", "type": "String", "mandatory": true, "attributeType": "BASIC" }, + { "name": "organisation", "type": "Organisation", "mandatory": false, "attributeType": "MANY_TO_ONE" } +] +``` + +This can be used to dynamically generate forms, or at minimum to validate required fields client-side. + +--- + +## 12. Recommended Tech Stack + +| Concern | Recommendation | +|---------|----------------| +| Framework | **Next.js** (App Router) or **Vite + React** | +| UI Library | Shadcn/ui, Ant Design, or custom components | +| State | React Query / TanStack Query (ideal for this API pattern) | +| Charts | Recharts or Chart.js | +| Forms | React Hook Form + Zod validation | +| Date/Time | date-fns or dayjs | +| Icons | Lucide React or Phosphor | +| Tables | TanStack Table | + +> These are recommendations — the team should use whatever they're most productive with. + +--- + +## 13. API Payload Reference + +When creating or updating entities via `POST` / `PUT` requests, submit a JSON body with the shape described below. Note that base fields like `id`, `created`, `updated`, and `active` are managed by the backend. Do not send them in `POST` payloads. On `PUT` requests, they are ignored. + +### 13.1 User +**Endpoint:** `POST /api/users` +**Payload:** +```json +{ + "name": "Jane Doe", + "username": "janedoe", + "email": "jane@example.com", + "phoneNumber": "255700000000", + "password": "securepassword123", + "organisation": { "id": "uuid-of-org" }, + "roles": [ { "id": "uuid-of-role-1" } ] +} +``` +* **username / phoneNumber:** Required and unique. +* **password:** Required on create (`POST`). Exclude or send null on update (`PUT`) unless changing. + +### 13.2 Organisation +**Endpoint:** `POST /api/organisations` +**Payload:** +```json +{ + "name": "Acme SACCO", + "phoneNumber": "255700111222", + "email": "info@acmesacco.com", + "description": "Main branch cooperative", + "address": "123 Main St, Dar es Salaam", + "website": "https://acmesacco.com" +} +``` +* **name & phoneNumber:** Required. + +### 13.3 Role +**Endpoint:** `POST /api/roles` +**Payload:** +```json +{ + "name": "Campaign Manager", + "privileges": [ + { "id": "uuid-of-privilege" } + ] +} +``` + +### 13.4 PersonalAccessToken +**Endpoint:** `POST /api/tokens` +**Payload:** +```json +{ + "name": "ERP Integration Token", + "expiresAt": "2026-12-31T23:59:59" +} +``` +* **Note:** The `rawToken` string is only returned once in the response. + +### 13.5 Contact +**Endpoint:** `POST /api/contacts` +**Payload:** +```json +{ + "firstName": "John", + "surname": "Smith", + "middleName": "A", + "phoneNumber": "255700999888", + "status": "ACTIVE", + "tags": [ { "id": "uuid-of-tag" } ], + "lists": [ { "id": "uuid-of-list" } ] +} +``` +* **status:** `ACTIVE` (default), `INACTIVE`, `RETIRED`, `DELETED`. + +### 13.6 Tag & ListEntity +**Endpoints:** `POST /api/tags` and `POST /api/lists` +**Payload:** +```json +{ + "name": "VIP Members", + "description": "High value members" +} +``` + +### 13.7 SmsTemplate +**Endpoint:** `POST /api/templates` +**Payload:** +```json +{ + "name": "Account Reminder", + "code": "ACC_REMINDER", + "category": "REMINDER", + "content": "Hello {name}, your balance is {balance}." +} +``` +* **category:** `PROMOTIONAL`, `TRANSACTIONAL`, `OTP`, `ALERT`, `REMINDER`, `SYSTEM`. + +### 13.8 SmsConnector +**Endpoint:** `POST /api/connectors` +**Payload:** +```json +{ + "provider": "BEEM", + "url": "https://apisms.beem.africa/v1/send", + "senderId": "ACME", + "key": "api-key-here", + "secret": "api-secret-here", + "isDefault": true, + "extraSettings": "{\"timeout\": 5000}" +} +``` +* **key, secret:** Write-only fields. Appears masked (`****abcd`) in reads. Do not send on `PUT` unless changing. + +### 13.9 ConnectorConfig +**Endpoint:** `POST /api/connectorConfigs` +**Payload:** +```json +{ + "url": "https://api.acmesacco.com", + "endpoint": "/v1/members/{id}", + "search": "/v1/members/search", + "authType": "BEARER", + "token": "secret-token", + "mappings": [ + { "sourcePath": "$.data.firstName", "systemKey": "member_name" }, + { "sourcePath": "$.data.accountBalance", "systemKey": "balance" } + ] +} +``` +* **authType:** `NONE`, `BASIC`, `BEARER`, `API_KEY`. (Include `username`/`password` for `BASIC`, `apiKey` for `API_KEY`). + +### 13.10 SmsCampaign +**Endpoint:** `POST /api/campaigns` +**Payload:** +```json +{ + "name": "June Promo", + "template": { "id": "uuid-of-template" }, + "connector": { "id": "uuid-of-connector" }, + "recipients": "255700111000,255700222000", + "scheduledAt": "2026-06-01T09:00:00" +} +``` + +### 13.11 TenantFeature +**Endpoint:** `POST /api/tenantFeatures` +**Payload:** +```json +{ + "organisation": { "id": "uuid-of-org" }, + "featureKey": "BULK_CAMPAIGN", + "enabled": true +} +``` + +### 13.12 Wallet Top-Up +**Endpoint:** `POST /api/wallets/topup/{walletId}` +**Payload:** +```json +{ + "amount": 50000.00, + "reference": "BANK-TXN-12345", + "description": "Manual top-up" +} +``` diff --git a/src/main/java/com/flexcodelabs/flextuma/core/config/RequestLoggingFilter.java b/src/main/java/com/flexcodelabs/flextuma/core/config/RequestLoggingFilter.java index 72de1ff..2306435 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/config/RequestLoggingFilter.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/config/RequestLoggingFilter.java @@ -36,15 +36,25 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse int status = response.getStatus(); boolean isError = status >= 400; + String logColor = isError ? "\u001B[31m" : "\u001B[32m"; String reset = "\u001B[0m"; - String greenLog = logColor + (isError ? "ERROR" : "LOG") + reset; + String statusLog = logColor + (isError ? "ERROR" : "LOG") + reset; String userInfo = "\u001B[33m[" + username + "]\u001B[0m"; String coloredMethod = logColor + request.getMethod() + reset; String coloredUri = logColor + fullUri + reset; - log.info("{} {} {} {} {}ms", greenLog, userInfo, coloredMethod, coloredUri, duration); + org.slf4j.MDC.put("username", username); + try { + if (isError) { + log.error("{} {} {} {} {}ms", statusLog, userInfo, coloredMethod, coloredUri, duration); + } else { + log.info("{} {} {} {} {}ms", statusLog, userInfo, coloredMethod, coloredUri, duration); + } + } finally { + org.slf4j.MDC.remove("username"); + } } } diff --git a/src/main/java/com/flexcodelabs/flextuma/core/entities/logging/SystemLog.java b/src/main/java/com/flexcodelabs/flextuma/core/entities/logging/SystemLog.java new file mode 100644 index 0000000..c0f850f --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/entities/logging/SystemLog.java @@ -0,0 +1,65 @@ +package com.flexcodelabs.flextuma.core.entities.logging; + +import com.flexcodelabs.flextuma.core.enums.LogLevel; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.time.LocalDateTime; +import java.util.Map; +import java.util.UUID; + +@Entity +@Table(name = "system_log", indexes = { + @Index(name = "idx_syslog_timestamp", columnList = "timestamp"), + @Index(name = "idx_syslog_level", columnList = "level"), + @Index(name = "idx_syslog_source", columnList = "source"), + @Index(name = "idx_syslog_trace_id", columnList = "trace_id") +}) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class SystemLog { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(nullable = false, updatable = false) + private LocalDateTime timestamp; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, updatable = false) + private LogLevel level; + + @Column(nullable = false, updatable = false) + private String source; + + @Column(columnDefinition = "TEXT", nullable = false, updatable = false) + private String message; + + @Column(name = "trace_id", updatable = false) + private String traceId; + + @Column(columnDefinition = "TEXT", updatable = false) + private String stackTrace; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(columnDefinition = "jsonb", updatable = false) + private Map metadata; + + @Column(updatable = false) + private String username; + + @PrePersist + void prePersist() { + if (this.timestamp == null) { + this.timestamp = LocalDateTime.now(); + } + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/enums/LogLevel.java b/src/main/java/com/flexcodelabs/flextuma/core/enums/LogLevel.java new file mode 100644 index 0000000..f8528e6 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/enums/LogLevel.java @@ -0,0 +1,9 @@ +package com.flexcodelabs.flextuma.core.enums; + +public enum LogLevel { + DEBUG, + INFO, + WARNING, + ERROR, + CRITICAL +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/filters/TraceIdFilter.java b/src/main/java/com/flexcodelabs/flextuma/core/filters/TraceIdFilter.java new file mode 100644 index 0000000..b200482 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/filters/TraceIdFilter.java @@ -0,0 +1,66 @@ +package com.flexcodelabs.flextuma.core.filters; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.MDC; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.security.SecureRandom; + +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +public class TraceIdFilter extends OncePerRequestFilter { + + static final String TRACE_ID_KEY = "traceId"; + static final String USERNAME_KEY = "username"; + static final String TRACE_HEADER = "X-Trace-Id"; + + private static final String CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + private static final int TRACE_SUFFIX_LENGTH = 6; + private static final SecureRandom RANDOM = new SecureRandom(); + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + String traceId = generateTraceId(); + MDC.put(TRACE_ID_KEY, traceId); + response.setHeader(TRACE_HEADER, traceId); + + try { + filterChain.doFilter(request, response); + + String username = resolveUsername(); + if (username != null) { + MDC.put(USERNAME_KEY, username); + } + } finally { + MDC.remove(TRACE_ID_KEY); + MDC.remove(USERNAME_KEY); + } + } + + String generateTraceId() { + StringBuilder sb = new StringBuilder("tr_"); + for (int i = 0; i < TRACE_SUFFIX_LENGTH; i++) { + sb.append(CHARS.charAt(RANDOM.nextInt(CHARS.length()))); + } + return sb.toString(); + } + + private String resolveUsername() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth != null && auth.isAuthenticated() && !"anonymousUser".equals(auth.getPrincipal())) { + return auth.getName(); + } + return null; + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/logging/DatabaseLogAppender.java b/src/main/java/com/flexcodelabs/flextuma/core/logging/DatabaseLogAppender.java new file mode 100644 index 0000000..cf3b9b2 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/logging/DatabaseLogAppender.java @@ -0,0 +1,199 @@ +package com.flexcodelabs.flextuma.core.logging; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.IThrowableProxy; +import ch.qos.logback.classic.spi.ThrowableProxyUtil; +import ch.qos.logback.core.AppenderBase; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.flexcodelabs.flextuma.core.entities.logging.SystemLog; +import com.flexcodelabs.flextuma.core.enums.LogLevel; + +import javax.sql.DataSource; +import java.io.PrintStream; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +public class DatabaseLogAppender extends AppenderBase { + + private static final int DEFAULT_BATCH_SIZE = 50; + private static final long DEFAULT_FLUSH_INTERVAL_MS = 2000; + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final String INSERT_SQL = "INSERT INTO system_log (id, timestamp, level, source, message, trace_id, stack_trace, metadata, username) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?::jsonb, ?)"; + + private static final AtomicReference dataSource = new AtomicReference<>(); + private static final CopyOnWriteArrayList> listeners = new CopyOnWriteArrayList<>(); + + private final ConcurrentLinkedQueue buffer = new ConcurrentLinkedQueue<>(); + private ScheduledExecutorService scheduler; + + private int batchSize = DEFAULT_BATCH_SIZE; + private long flushIntervalMs = DEFAULT_FLUSH_INTERVAL_MS; + + public static void setDataSource(DataSource ds) { + dataSource.set(ds); + } + + public static void addListener(Consumer listener) { + listeners.add(listener); + } + + public static void removeListener(Consumer listener) { + listeners.remove(listener); + } + + // visible for testing + public static void clearListeners() { + listeners.clear(); + } + + public void setBatchSize(int batchSize) { + this.batchSize = batchSize; + } + + public void setFlushIntervalMs(long flushIntervalMs) { + this.flushIntervalMs = flushIntervalMs; + } + + @Override + public void start() { + super.start(); + scheduler = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "db-log-flusher"); + t.setDaemon(true); + return t; + }); + scheduler.scheduleAtFixedRate(this::flush, flushIntervalMs, flushIntervalMs, TimeUnit.MILLISECONDS); + } + + @Override + public void stop() { + flush(); + if (scheduler != null) { + scheduler.shutdown(); + } + super.stop(); + } + + @Override + protected void append(ILoggingEvent event) { + if (event.getLoggerName().startsWith("com.flexcodelabs.flextuma.core.logging")) { + return; + } + + SystemLog log = mapEvent(event); + buffer.add(log); + + for (Consumer listener : listeners) { + try { + listener.accept(log); + } catch (Exception e) { + listeners.remove(listener); + } + } + + if (buffer.size() >= batchSize) { + flush(); + } + } + + private SystemLog mapEvent(ILoggingEvent event) { + SystemLog log = new SystemLog(); + log.setId(UUID.randomUUID()); + log.setTimestamp(LocalDateTime.now()); + log.setLevel(mapLevel(event.getLevel())); + log.setSource(extractSource(event.getLoggerName())); + + String msg = event.getFormattedMessage(); + if (msg != null) { + msg = msg.replaceAll("\u001B\\[[;\\d]*m", ""); + } + log.setMessage(msg); + log.setTraceId(event.getMDCPropertyMap().get("traceId")); + log.setUsername(event.getMDCPropertyMap().get("username")); + + IThrowableProxy throwable = event.getThrowableProxy(); + if (throwable != null) { + log.setStackTrace(ThrowableProxyUtil.asString(throwable)); + } + + return log; + } + + private LogLevel mapLevel(Level level) { + return switch (level.toInt()) { + case Level.DEBUG_INT -> LogLevel.DEBUG; + case Level.INFO_INT -> LogLevel.INFO; + case Level.WARN_INT -> LogLevel.WARNING; + case Level.ERROR_INT -> LogLevel.ERROR; + default -> LogLevel.INFO; + }; + } + + private String extractSource(String loggerName) { + if (loggerName == null) { + return "Unknown"; + } + int lastDot = loggerName.lastIndexOf('.'); + return lastDot >= 0 ? loggerName.substring(lastDot + 1) : loggerName; + } + + @SuppressWarnings("java:S106") // System.err is intentional here — using a logger would cause infinite + // recursion + private void flush() { + DataSource ds = dataSource.get(); + if (buffer.isEmpty() || ds == null) { + return; + } + + try (Connection conn = ds.getConnection(); + PreparedStatement ps = conn.prepareStatement(INSERT_SQL)) { + + conn.setAutoCommit(false); + int count = 0; + SystemLog log; + + while ((log = buffer.poll()) != null && count < batchSize * 2) { + ps.setObject(1, log.getId()); + ps.setTimestamp(2, Timestamp.valueOf(log.getTimestamp())); + ps.setString(3, log.getLevel().name()); + ps.setString(4, log.getSource()); + ps.setString(5, log.getMessage()); + ps.setString(6, log.getTraceId()); + ps.setString(7, log.getStackTrace()); + ps.setString(8, log.getMetadata() != null ? toJson(log.getMetadata()) : null); + ps.setString(9, log.getUsername()); + ps.addBatch(); + count++; + } + + if (count > 0) { + ps.executeBatch(); + conn.commit(); + } + } catch (Exception e) { + PrintStream stderr = System.err; + stderr.println("[DatabaseLogAppender] Failed to flush logs: " + e.getMessage()); + } + } + + private static String toJson(Map map) { + try { + return OBJECT_MAPPER.writeValueAsString(map); + } catch (Exception e) { + return "{}"; + } + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/logging/LogAppenderInitializer.java b/src/main/java/com/flexcodelabs/flextuma/core/logging/LogAppenderInitializer.java new file mode 100644 index 0000000..25c564a --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/logging/LogAppenderInitializer.java @@ -0,0 +1,19 @@ +package com.flexcodelabs.flextuma.core.logging; + +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import javax.sql.DataSource; + +@Component +@RequiredArgsConstructor +public class LogAppenderInitializer { + + private final DataSource dataSource; + + @PostConstruct + public void init() { + DatabaseLogAppender.setDataSource(dataSource); + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/logging/LogRetentionScheduler.java b/src/main/java/com/flexcodelabs/flextuma/core/logging/LogRetentionScheduler.java new file mode 100644 index 0000000..f7b483a --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/logging/LogRetentionScheduler.java @@ -0,0 +1,32 @@ +package com.flexcodelabs.flextuma.core.logging; + +import com.flexcodelabs.flextuma.core.repositories.SystemLogRepository; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; + +@Component +@RequiredArgsConstructor +public class LogRetentionScheduler { + + private static final Logger log = LoggerFactory.getLogger(LogRetentionScheduler.class); + + private final SystemLogRepository systemLogRepository; + + @Value("${flextuma.logging.retention-days:30}") + private int retentionDays; + + @Scheduled(cron = "0 0 2 * * *") + public void purgeOldLogs() { + LocalDateTime cutoff = LocalDateTime.now().minusDays(retentionDays); + int deleted = systemLogRepository.deleteByTimestampBefore(cutoff); + if (deleted > 0) { + log.info("Purged {} system log entries older than {} days", deleted, retentionDays); + } + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/repositories/SystemLogRepository.java b/src/main/java/com/flexcodelabs/flextuma/core/repositories/SystemLogRepository.java new file mode 100644 index 0000000..e9e2e0c --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/repositories/SystemLogRepository.java @@ -0,0 +1,21 @@ +package com.flexcodelabs.flextuma.core.repositories; + +import com.flexcodelabs.flextuma.core.entities.logging.SystemLog; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Repository +public interface SystemLogRepository extends JpaRepository, JpaSpecificationExecutor { + + @Modifying + @Transactional + @Query("DELETE FROM SystemLog s WHERE s.timestamp < :cutoff") + int deleteByTimestampBefore(LocalDateTime cutoff); +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/security/SecurityConfig.java b/src/main/java/com/flexcodelabs/flextuma/core/security/SecurityConfig.java index 01e9818..1fb500e 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/security/SecurityConfig.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/security/SecurityConfig.java @@ -14,8 +14,11 @@ import org.springframework.session.web.http.CookieSerializer; import org.springframework.session.web.http.DefaultCookieSerializer; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; + @Configuration @EnableWebSecurity +@EnableMethodSecurity @EnableRedisHttpSession public class SecurityConfig { diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/logging/controllers/SystemLogController.java b/src/main/java/com/flexcodelabs/flextuma/modules/logging/controllers/SystemLogController.java new file mode 100644 index 0000000..3a2860b --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/logging/controllers/SystemLogController.java @@ -0,0 +1,69 @@ +package com.flexcodelabs.flextuma.modules.logging.controllers; + +import com.flexcodelabs.flextuma.core.entities.logging.SystemLog; +import com.flexcodelabs.flextuma.core.enums.LogLevel; +import com.flexcodelabs.flextuma.modules.logging.services.SystemLogService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.Map; + +@RestController +@RequestMapping("/api/systemLogs") +@RequiredArgsConstructor +@PreAuthorize("hasAnyAuthority('SUPER_ADMIN', 'ALL')") +public class SystemLogController { + + private final SystemLogService systemLogService; + + @GetMapping + public Map getAll( + Pageable pageable, + @RequestParam(required = false) LogLevel level, + @RequestParam(required = false) String source, + @RequestParam(required = false) String traceId, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime from, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime to) { + + Page page = systemLogService.findAll(pageable, level, source, traceId, from, to); + + Map response = new LinkedHashMap<>(); + response.put("page", page.getNumber()); + response.put("total", page.getTotalElements()); + response.put("pageSize", page.getSize()); + response.put("systemLog", page.getContent()); + + return response; + } + + @GetMapping(value = "/tail", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter tail(@RequestParam(required = false) LogLevel level) { + return systemLogService.streamLogs(level); + } + + @GetMapping("/health") + public ResponseEntity> health() { + return ResponseEntity.ok(systemLogService.getSystemHealth()); + } + + @DeleteMapping("/purge") + public ResponseEntity> purge( + @RequestParam(defaultValue = "30") int days) { + int deleted = systemLogService.purgeOlderThan(days); + + Map response = new LinkedHashMap<>(); + response.put("message", deleted + " log entries purged"); + response.put("olderThanDays", days); + + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/logging/services/SystemLogService.java b/src/main/java/com/flexcodelabs/flextuma/modules/logging/services/SystemLogService.java new file mode 100644 index 0000000..da6fc35 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/logging/services/SystemLogService.java @@ -0,0 +1,109 @@ +package com.flexcodelabs.flextuma.modules.logging.services; + +import com.flexcodelabs.flextuma.core.entities.logging.SystemLog; +import com.flexcodelabs.flextuma.core.enums.LogLevel; +import com.flexcodelabs.flextuma.core.logging.DatabaseLogAppender; +import com.flexcodelabs.flextuma.core.repositories.SystemLogRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.lang.management.ManagementFactory; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.function.Consumer; + +@Service +@RequiredArgsConstructor +public class SystemLogService { + + private final SystemLogRepository repository; + + @Value("${flextuma.logging.retention-days:30}") + private int retentionDays; + + public Page findAll(Pageable pageable, LogLevel level, String source, String traceId, + LocalDateTime from, LocalDateTime to) { + Specification spec = (root, query, cb) -> cb.conjunction(); + + if (level != null) { + spec = spec.and((root, query, cb) -> cb.equal(root.get("level"), level)); + } + if (source != null && !source.isBlank()) { + spec = spec.and((root, query, cb) -> cb.like(cb.lower(root.get("source")), + "%" + source.toLowerCase() + "%")); + } + if (traceId != null && !traceId.isBlank()) { + spec = spec.and((root, query, cb) -> cb.equal(root.get("traceId"), traceId)); + } + if (from != null) { + spec = spec.and((root, query, cb) -> cb.greaterThanOrEqualTo(root.get("timestamp"), from)); + } + if (to != null) { + spec = spec.and((root, query, cb) -> cb.lessThanOrEqualTo(root.get("timestamp"), to)); + } + + return repository.findAll(spec, pageable); + } + + public SseEmitter streamLogs(LogLevel minLevel) { + SseEmitter emitter = new SseEmitter(0L); + + Consumer listener = log -> { + if (minLevel != null && log.getLevel().ordinal() < minLevel.ordinal()) { + return; + } + try { + emitter.send(SseEmitter.event() + .name("log") + .data(log)); + } catch (Exception e) { + emitter.completeWithError(e); + } + }; + + DatabaseLogAppender.addListener(listener); + + emitter.onCompletion(() -> DatabaseLogAppender.removeListener(listener)); + emitter.onTimeout(() -> DatabaseLogAppender.removeListener(listener)); + emitter.onError(e -> DatabaseLogAppender.removeListener(listener)); + + return emitter; + } + + @Transactional + public int purgeOlderThan(int days) { + LocalDateTime cutoff = LocalDateTime.now().minusDays(days); + return repository.deleteByTimestampBefore(cutoff); + } + + public Map getSystemHealth() { + Runtime runtime = Runtime.getRuntime(); + long uptimeMs = ManagementFactory.getRuntimeMXBean().getUptime(); + Duration uptime = Duration.ofMillis(uptimeMs); + + Map health = new LinkedHashMap<>(); + health.put("status", "ONLINE"); + health.put("uptime", String.format("%dd %dh %dm %ds", + uptime.toDays(), uptime.toHoursPart(), uptime.toMinutesPart(), uptime.toSecondsPart())); + health.put("uptimeMs", uptimeMs); + health.put("memory", Map.of( + "totalMb", runtime.totalMemory() / (1024 * 1024), + "freeMb", runtime.freeMemory() / (1024 * 1024), + "usedMb", (runtime.totalMemory() - runtime.freeMemory()) / (1024 * 1024), + "maxMb", runtime.maxMemory() / (1024 * 1024))); + health.put("activeThreads", Thread.activeCount()); + health.put("availableProcessors", runtime.availableProcessors()); + health.put("version", getClass().getPackage().getImplementationVersion()); + health.put("retentionDays", retentionDays); + + return health; + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index af776f2..f154155 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -26,4 +26,10 @@ logging.level.org.hibernate.engine.jdbc.spi.SqlExceptionHelper=${SQL_EXCEPTION_H spring.web.error.include-message=${ERROR_INCLUDE_MESSAGE:always} # SMS Pricing -flextuma.sms.price-per-segment=${SMS_PRICE_PER_SEGMENT:20.0} \ No newline at end of file +flextuma.sms.price-per-segment=${SMS_PRICE_PER_SEGMENT:20.0} + +# System Logging +flextuma.logging.min-level=${LOG_MIN_LEVEL:WARN} +flextuma.logging.retention-days=${LOG_RETENTION_DAYS:30} +flextuma.logging.batch-size=${LOG_BATCH_SIZE:50} +flextuma.logging.flush-interval-ms=${LOG_FLUSH_INTERVAL:2000} \ No newline at end of file diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index 16bccde..fddd1d4 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -5,17 +5,23 @@ - %d{yyyy-MM-dd HH:mm:ss.SSS} %clr(%5p) %clr(---){faint} %clr([%X{user:-SYSTEM}]){magenta} %clr([%logger{0}]){cyan} : %m%n + %d{yyyy-MM-dd HH:mm:ss.SSS} %clr(%5p) %clr(---){faint} %clr([%X{traceId:-no-trace}]){yellow} %clr([%X{user:-SYSTEM}]){magenta} %clr([%logger{0}]){cyan} : %m%n utf8 + + + INFO + + + - + \ No newline at end of file diff --git a/src/test/java/com/flexcodelabs/flextuma/core/controllers/BaseControllerTest.java b/src/test/java/com/flexcodelabs/flextuma/core/controllers/BaseControllerTest.java index f3c900d..8c585be 100644 --- a/src/test/java/com/flexcodelabs/flextuma/core/controllers/BaseControllerTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/core/controllers/BaseControllerTest.java @@ -62,7 +62,7 @@ public void getAll_shouldReturnPagination() throws Exception { // Use any() for Pageable since standalone setup might not resolve it perfectly // or we don't care about exact instance - when(getService().findAllPaginated(any(Pageable.class), any(), any())).thenReturn(pagination); + when(getService().findAllPaginated(any(Pageable.class), any(), any(), any())).thenReturn(pagination); // Note: BaseController.getAll calls service.getPropertyName(). // We need to ensure service mock returns something if queried, or Pagination // object structure is enough. diff --git a/src/test/java/com/flexcodelabs/flextuma/core/entities/contact/ContactTest.java b/src/test/java/com/flexcodelabs/flextuma/core/entities/contact/ContactTest.java new file mode 100644 index 0000000..57747c1 --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/core/entities/contact/ContactTest.java @@ -0,0 +1,81 @@ +package com.flexcodelabs.flextuma.core.entities.contact; + +import com.flexcodelabs.flextuma.core.entities.metadata.ListEntity; +import com.flexcodelabs.flextuma.core.entities.metadata.Tag; +import com.flexcodelabs.flextuma.core.enums.StatusEnum; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class ContactTest { + + @Test + void testNoArgsConstructor() { + Contact contact = new Contact(); + assertNotNull(contact.getLists()); + assertNotNull(contact.getTags()); + assertEquals(StatusEnum.ACTIVE, contact.getStatus()); + } + + @Test + void testGettersAndSetters() { + Contact contact = new Contact(); + contact.setFirstName("John"); + contact.setSurname("Doe"); + contact.setPhoneNumber("123456789"); + contact.setStatus(StatusEnum.INACTIVE); + + assertEquals("John", contact.getFirstName()); + assertEquals("Doe", contact.getSurname()); + assertEquals("123456789", contact.getPhoneNumber()); + assertEquals(StatusEnum.INACTIVE, contact.getStatus()); + } + + @Test + void addToList_shouldUpdateBothSides() { + Contact contact = new Contact(); + ListEntity list = mock(ListEntity.class); + when(list.getContacts()).thenReturn(new ArrayList<>()); + + contact.addToList(list); + + assertTrue(contact.getLists().contains(list)); + assertTrue(list.getContacts().contains(contact)); + } + + @Test + void addToTag_shouldUpdateBothSides() { + Contact contact = new Contact(); + Tag tag = mock(Tag.class); + when(tag.getContacts()).thenReturn(new ArrayList<>()); + + contact.addToTag(tag); + + assertTrue(contact.getTags().contains(tag)); + assertTrue(tag.getContacts().contains(contact)); + } + + @Test + void onCreate_shouldSetDefaultStatus() { + Contact contact = new Contact(); + contact.setStatus(null); + + contact.onCreate(); + + assertEquals(StatusEnum.ACTIVE, contact.getStatus()); + } + + @Test + void onCreate_withExistingStatus_shouldNotOverride() { + Contact contact = new Contact(); + contact.setStatus(StatusEnum.INACTIVE); + + contact.onCreate(); + + assertEquals(StatusEnum.INACTIVE, contact.getStatus()); + } +} diff --git a/src/test/java/com/flexcodelabs/flextuma/core/entities/logging/SystemLogTest.java b/src/test/java/com/flexcodelabs/flextuma/core/entities/logging/SystemLogTest.java new file mode 100644 index 0000000..e541a68 --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/core/entities/logging/SystemLogTest.java @@ -0,0 +1,105 @@ +package com.flexcodelabs.flextuma.core.entities.logging; + +import com.flexcodelabs.flextuma.core.enums.LogLevel; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +class SystemLogTest { + + @Test + void testNoArgsConstructor() { + SystemLog log = new SystemLog(); + assertNull(log.getId()); + assertNull(log.getTimestamp()); + assertNull(log.getLevel()); + assertNull(log.getSource()); + assertNull(log.getMessage()); + assertNull(log.getTraceId()); + assertNull(log.getStackTrace()); + assertNull(log.getMetadata()); + assertNull(log.getUsername()); + } + + @Test + void testAllArgsConstructor() { + UUID id = UUID.randomUUID(); + LocalDateTime now = LocalDateTime.now(); + LogLevel level = LogLevel.ERROR; + String source = "Source"; + String message = "Message"; + String traceId = "trace-123"; + String stackTrace = "stack trace"; + Map metadata = Map.of("key", "value"); + String username = "user"; + + SystemLog log = new SystemLog(id, now, level, source, message, traceId, stackTrace, metadata, username); + + assertEquals(id, log.getId()); + assertEquals(now, log.getTimestamp()); + assertEquals(level, log.getLevel()); + assertEquals(source, log.getSource()); + assertEquals(message, log.getMessage()); + assertEquals(traceId, log.getTraceId()); + assertEquals(stackTrace, log.getStackTrace()); + assertEquals(metadata, log.getMetadata()); + assertEquals(username, log.getUsername()); + } + + @Test + void testGettersAndSetters() { + SystemLog log = new SystemLog(); + UUID id = UUID.randomUUID(); + LocalDateTime now = LocalDateTime.now(); + LogLevel level = LogLevel.INFO; + String source = "App"; + String message = "Hello"; + String traceId = "abc"; + String stackTrace = "error at..."; + Map metadata = Map.of("foo", "bar"); + String username = "admin"; + + log.setId(id); + log.setTimestamp(now); + log.setLevel(level); + log.setSource(source); + log.setMessage(message); + log.setTraceId(traceId); + log.setStackTrace(stackTrace); + log.setMetadata(metadata); + log.setUsername(username); + + assertEquals(id, log.getId()); + assertEquals(now, log.getTimestamp()); + assertEquals(level, log.getLevel()); + assertEquals(source, log.getSource()); + assertEquals(message, log.getMessage()); + assertEquals(traceId, log.getTraceId()); + assertEquals(stackTrace, log.getStackTrace()); + assertEquals(metadata, log.getMetadata()); + assertEquals(username, log.getUsername()); + } + + @Test + void testPrePersist() { + SystemLog log = new SystemLog(); + assertNull(log.getTimestamp()); + + log.prePersist(); + assertNotNull(log.getTimestamp()); + } + + @Test + void testPrePersistWithExistingTimestamp() { + SystemLog log = new SystemLog(); + LocalDateTime existing = LocalDateTime.of(2023, 1, 1, 10, 0); + log.setTimestamp(existing); + + log.prePersist(); + assertEquals(existing, log.getTimestamp()); + } +} diff --git a/src/test/java/com/flexcodelabs/flextuma/core/filters/TraceIdFilterTest.java b/src/test/java/com/flexcodelabs/flextuma/core/filters/TraceIdFilterTest.java new file mode 100644 index 0000000..bda4b41 --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/core/filters/TraceIdFilterTest.java @@ -0,0 +1,94 @@ +package com.flexcodelabs.flextuma.core.filters; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +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 org.slf4j.MDC; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class TraceIdFilterTest { + + @Mock + private HttpServletRequest request; + + @Mock + private HttpServletResponse response; + + @Mock + private FilterChain filterChain; + + @Mock + private Authentication authentication; + + @Mock + private SecurityContext securityContext; + + @InjectMocks + private TraceIdFilter traceIdFilter; + + @BeforeEach + void setUp() { + MDC.clear(); + SecurityContextHolder.setContext(securityContext); + } + + @AfterEach + void tearDown() { + MDC.clear(); + SecurityContextHolder.clearContext(); + } + + @Test + void doFilterInternal_shouldGenerateTraceIdAndSetHeader() throws ServletException, IOException { + traceIdFilter.doFilterInternal(request, response, filterChain); + + verify(response).setHeader(eq(TraceIdFilter.TRACE_HEADER), anyString()); + verify(filterChain).doFilter(request, response); + + // Context should be cleared after filter + assertNull(MDC.get(TraceIdFilter.TRACE_ID_KEY)); + assertNull(MDC.get(TraceIdFilter.USERNAME_KEY)); + } + + @Test + void doFilterInternal_shouldPopulateUsernameWhenAuthenticated() throws ServletException, IOException { + when(securityContext.getAuthentication()).thenReturn(authentication); + when(authentication.isAuthenticated()).thenReturn(true); + when(authentication.getName()).thenReturn("testuser"); + when(authentication.getPrincipal()).thenReturn("testuser"); + + // We need to capture MDC state during execution + doAnswer(invocation -> { + assertNotNull(MDC.get(TraceIdFilter.TRACE_ID_KEY)); + return null; + }).when(filterChain).doFilter(request, response); + + traceIdFilter.doFilterInternal(request, response, filterChain); + + } + + @Test + void generateTraceId_shouldReturnCorrectFormat() { + String traceId = traceIdFilter.generateTraceId(); + assertTrue(traceId.startsWith("tr_")); + assertEquals(9, traceId.length()); // "tr_" (3) + 6 random chars + } +} diff --git a/src/test/java/com/flexcodelabs/flextuma/core/helpers/CurrentUserResolverTest.java b/src/test/java/com/flexcodelabs/flextuma/core/helpers/CurrentUserResolverTest.java new file mode 100644 index 0000000..519d0b9 --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/core/helpers/CurrentUserResolverTest.java @@ -0,0 +1,85 @@ +package com.flexcodelabs.flextuma.core.helpers; + +import com.flexcodelabs.flextuma.core.entities.auth.User; +import com.flexcodelabs.flextuma.core.repositories.UserRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +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 org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class CurrentUserResolverTest { + + @Mock + private UserRepository userRepository; + + @Mock + private SecurityContext securityContext; + + @Mock + private Authentication authentication; + + @InjectMocks + private CurrentUserResolver currentUserResolver; + + @BeforeEach + void setUp() { + SecurityContextHolder.setContext(securityContext); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + void getCurrentUser_withNoAuthentication_shouldReturnEmpty() { + when(securityContext.getAuthentication()).thenReturn(null); + Optional result = currentUserResolver.getCurrentUser(); + assertTrue(result.isEmpty()); + } + + @Test + void getCurrentUser_notAuthenticated_shouldReturnEmpty() { + when(securityContext.getAuthentication()).thenReturn(authentication); + when(authentication.isAuthenticated()).thenReturn(false); + Optional result = currentUserResolver.getCurrentUser(); + assertTrue(result.isEmpty()); + } + + @Test + void getCurrentUser_principalNotString_shouldReturnEmpty() { + when(securityContext.getAuthentication()).thenReturn(authentication); + when(authentication.isAuthenticated()).thenReturn(true); + when(authentication.getPrincipal()).thenReturn(new Object()); + Optional result = currentUserResolver.getCurrentUser(); + assertTrue(result.isEmpty()); + } + + @Test + void getCurrentUser_successful_shouldReturnUser() { + String username = "testuser"; + User user = new User(); + user.setUsername(username); + + when(securityContext.getAuthentication()).thenReturn(authentication); + when(authentication.isAuthenticated()).thenReturn(true); + when(authentication.getPrincipal()).thenReturn(username); + when(userRepository.findByUsername(username)).thenReturn(Optional.of(user)); + + Optional result = currentUserResolver.getCurrentUser(); + assertTrue(result.isPresent()); + assertEquals(username, result.get().getUsername()); + } +} diff --git a/src/test/java/com/flexcodelabs/flextuma/core/helpers/GenericSpecificationTest.java b/src/test/java/com/flexcodelabs/flextuma/core/helpers/GenericSpecificationTest.java index 4203cf2..6b110ea 100644 --- a/src/test/java/com/flexcodelabs/flextuma/core/helpers/GenericSpecificationTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/core/helpers/GenericSpecificationTest.java @@ -5,13 +5,19 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import java.util.UUID; +import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; @@ -59,93 +65,65 @@ private void mockPathForType(Class type) { lenient().when(path.getJavaType()).thenReturn((Class) type); } - @Test - void testEqOperatorString() { - mockPathForType(String.class); - when(cb.equal(path, "value")).thenReturn(predicate); - - GenericSpecification spec = new GenericSpecification<>("name:EQ:value"); - Predicate p = spec.toPredicate(root, query, cb); + // --- Parameterized EQ tests for different types --- - assertNotNull(p); - verify(cb).equal(path, "value"); + static Stream eqOperatorProvider() { + UUID testUuid = UUID.fromString("00000000-0000-0000-0000-000000000001"); + return Stream.of( + Arguments.of("name:EQ:value", String.class, "value"), + Arguments.of("id:EQ:" + testUuid, UUID.class, testUuid), + Arguments.of("active:EQ:true", Boolean.class, true), + Arguments.of("status:EQ:ONE", TestEnum.class, TestEnum.ONE)); } - @Test - void testEqOperatorUUID() { - mockPathForType(UUID.class); - UUID id = UUID.randomUUID(); - when(cb.equal(path, id)).thenReturn(predicate); + @ParameterizedTest + @MethodSource("eqOperatorProvider") + void testEqOperator(String filterString, Class type, Object expectedValue) { + mockPathForType(type); + when(cb.equal(path, expectedValue)).thenReturn(predicate); - GenericSpecification spec = new GenericSpecification<>("id:EQ:" + id); + GenericSpecification spec = new GenericSpecification<>(filterString); Predicate p = spec.toPredicate(root, query, cb); assertNotNull(p); - verify(cb).equal(path, id); + verify(cb).equal(path, expectedValue); } - @Test - void testEqOperatorBoolean() { - mockPathForType(Boolean.class); - when(cb.equal(path, true)).thenReturn(predicate); - - GenericSpecification spec = new GenericSpecification<>("active:EQ:true"); - Predicate p = spec.toPredicate(root, query, cb); + // --- Parameterized LIKE-family tests --- - assertNotNull(p); - verify(cb).equal(path, true); + static Stream likeOperatorProvider() { + return Stream.of( + Arguments.of("name:LIKE:value", "%value%"), + Arguments.of("name:ILIKE:VaLuE", "%value%"), + Arguments.of("name:startsWith:value", "value%"), + Arguments.of("name:endsWith:value", "%value")); } - @Test - void testEqOperatorEnum() { - mockPathForType(TestEnum.class); - when(cb.equal(path, TestEnum.ONE)).thenReturn(predicate); - - GenericSpecification spec = new GenericSpecification<>("status:EQ:ONE"); - Predicate p = spec.toPredicate(root, query, cb); - - assertNotNull(p); - verify(cb).equal(path, TestEnum.ONE); - } - - @Test - void testNeOperator() { - mockPathForType(String.class); - when(cb.notEqual(path, "value")).thenReturn(predicate); - - GenericSpecification spec = new GenericSpecification<>("name:NE:value"); - Predicate p = spec.toPredicate(root, query, cb); - - assertNotNull(p); - verify(cb).notEqual(path, "value"); - } - - @Test - void testLikeOperator() { + @ParameterizedTest + @MethodSource("likeOperatorProvider") + void testLikeOperators(String filterString, String expectedPattern) { mockPathForType(String.class); when(path.as(String.class)).thenReturn(stringPath); when(cb.lower(any())).thenReturn(stringPath); when(cb.like(any(), anyString())).thenReturn(predicate); - GenericSpecification spec = new GenericSpecification<>("name:LIKE:value"); + GenericSpecification spec = new GenericSpecification<>(filterString); Predicate p = spec.toPredicate(root, query, cb); assertNotNull(p); - verify(cb).like(any(), eq("%value%")); + verify(cb).like(any(), eq(expectedPattern)); } @Test - void testILikeOperator() { + void testNeOperator() { mockPathForType(String.class); - when(path.as(String.class)).thenReturn(stringPath); - when(cb.lower(any())).thenReturn(stringPath); - when(cb.like(any(), anyString())).thenReturn(predicate); + when(cb.notEqual(path, "value")).thenReturn(predicate); - GenericSpecification spec = new GenericSpecification<>("name:ILIKE:VaLuE"); + GenericSpecification spec = new GenericSpecification<>("name:NE:value"); Predicate p = spec.toPredicate(root, query, cb); assertNotNull(p); - verify(cb).like(any(), eq("%value%")); + verify(cb).notEqual(path, "value"); } @Test @@ -223,4 +201,173 @@ void testMissingValue() { assertNotNull(p); verify(cb).equal(path, ""); } + + @Test + void testGteOperator() { + mockPathForType(Integer.class); + when(path.as(String.class)).thenReturn(stringPath); + when(cb.greaterThanOrEqualTo(any(), eq("10"))).thenReturn(predicate); + + GenericSpecification spec = new GenericSpecification<>("age:GTE:10"); + Predicate p = spec.toPredicate(root, query, cb); + + assertNotNull(p); + verify(cb).greaterThanOrEqualTo(any(), eq("10")); + } + + @Test + void testLteOperator() { + mockPathForType(Integer.class); + when(path.as(String.class)).thenReturn(stringPath); + when(cb.lessThanOrEqualTo(any(), eq("10"))).thenReturn(predicate); + + GenericSpecification spec = new GenericSpecification<>("age:LTE:10"); + Predicate p = spec.toPredicate(root, query, cb); + + assertNotNull(p); + verify(cb).lessThanOrEqualTo(any(), eq("10")); + } + + @SuppressWarnings("unchecked") + @Test + void testIsTrueOperator() { + mockPathForType(Boolean.class); + Path booleanPath = mock(Path.class); + when(path.as(Boolean.class)).thenReturn(booleanPath); + when(cb.isTrue(booleanPath)).thenReturn(predicate); + + GenericSpecification spec = new GenericSpecification<>("active:isTrue:ignored"); + Predicate p = spec.toPredicate(root, query, cb); + + assertNotNull(p); + verify(cb).isTrue(booleanPath); + } + + @SuppressWarnings("unchecked") + @Test + void testIsFalseOperator() { + mockPathForType(Boolean.class); + Path booleanPath = mock(Path.class); + when(path.as(Boolean.class)).thenReturn(booleanPath); + when(cb.isFalse(booleanPath)).thenReturn(predicate); + + GenericSpecification spec = new GenericSpecification<>("active:isFalse:ignored"); + Predicate p = spec.toPredicate(root, query, cb); + + assertNotNull(p); + verify(cb).isFalse(booleanPath); + } + + @Test + void testBtnOperator() { + mockPathForType(Integer.class); + when(path.as(String.class)).thenReturn(stringPath); + when(cb.between(stringPath, "10", "20")).thenReturn(predicate); + + GenericSpecification spec = new GenericSpecification<>("age:BTN:10,20"); + Predicate p = spec.toPredicate(root, query, cb); + + assertNotNull(p); + verify(cb).between(stringPath, "10", "20"); + } + + @Test + void testBtnOperatorInvalidRange() { + mockPathForType(Integer.class); + when(cb.conjunction()).thenReturn(predicate); + + GenericSpecification spec = new GenericSpecification<>("age:BTN:10"); + Predicate p = spec.toPredicate(root, query, cb); + + assertNotNull(p); + verify(cb).conjunction(); + } + + @Test + void testCastValueInteger() { + mockPathForType(Integer.class); + when(cb.equal(path, 10)).thenReturn(predicate); + + GenericSpecification spec = new GenericSpecification<>("age:EQ:10"); + Predicate p = spec.toPredicate(root, query, cb); + + assertNotNull(p); + verify(cb).equal(path, 10); + } + + @Test + void testToPredicateExceptionReturnsConjunction() { + when(root.get(anyString())).thenThrow(new RuntimeException("Test exception")); + when(cb.conjunction()).thenReturn(predicate); + + GenericSpecification spec = new GenericSpecification<>("field:EQ:value"); + Predicate p = spec.toPredicate(root, query, cb); + + assertNotNull(p); + verify(cb).conjunction(); + } + + @Test + void testCastValueBooleanPrimitive() { + mockPathForType(boolean.class); + when(cb.equal(path, true)).thenReturn(predicate); + + GenericSpecification spec = new GenericSpecification<>("active:eq:true"); + Predicate p = spec.toPredicate(root, query, cb); + + assertNotNull(p); + verify(cb).equal(path, true); + } + + @Test + void testCastValueIntPrimitive() { + mockPathForType(int.class); + when(cb.equal(path, 10)).thenReturn(predicate); + + GenericSpecification spec = new GenericSpecification<>("age:eq:10"); + Predicate p = spec.toPredicate(root, query, cb); + + assertNotNull(p); + verify(cb).equal(path, 10); + } + + @Test + void testCastValueNullString() { + mockPathForType(String.class); + when(cb.equal(path, (Object) null)).thenReturn(predicate); + + GenericSpecification spec = new GenericSpecification<>("name:eq:null"); + Predicate p = spec.toPredicate(root, query, cb); + + assertNotNull(p); + verify(cb).equal(path, (Object) null); + } + + @Test + void testCastValueEmptyValue() { + mockPathForType(String.class); + when(cb.equal(path, "")).thenReturn(predicate); + + GenericSpecification spec = new GenericSpecification<>("name:eq:"); + Predicate p = spec.toPredicate(root, query, cb); + + assertNotNull(p); + verify(cb).equal(path, ""); + } + + @Test + @SuppressWarnings({ "unchecked", "rawtypes" }) + void testNestedPathResolution() { + Path nestedPath = mock(Path.class); + when(root.get("parent")).thenReturn((Path) nestedPath); + when(nestedPath.get("child")).thenReturn((Path) path); + lenient().when(path.getJavaType()).thenReturn((Class) String.class); + when(cb.equal(path, "value")).thenReturn(predicate); + + GenericSpecification spec = new GenericSpecification<>("parent.child:EQ:value"); + Predicate p = spec.toPredicate(root, query, cb); + + assertNotNull(p); + verify(cb).equal(path, "value"); + } } diff --git a/src/test/java/com/flexcodelabs/flextuma/core/helpers/PaginationHelperTest.java b/src/test/java/com/flexcodelabs/flextuma/core/helpers/PaginationHelperTest.java new file mode 100644 index 0000000..6f0051c --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/core/helpers/PaginationHelperTest.java @@ -0,0 +1,44 @@ +package com.flexcodelabs.flextuma.core.helpers; + +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; + +import static org.junit.jupiter.api.Assertions.*; + +class PaginationHelperTest { + + @Test + void getPageable_withNulls_shouldReturnDefaultPageable() { + Pageable pageable = PaginationHelper.getPageable(null, null); + assertEquals(0, pageable.getPageNumber()); + assertEquals(15, pageable.getPageSize()); + assertEquals(Sort.by("created").descending(), pageable.getSort()); + } + + @Test + void getPageable_withValues_shouldReturnRequestedPageable() { + Pageable pageable = PaginationHelper.getPageable(5, 20); + assertEquals(5, pageable.getPageNumber()); + assertEquals(20, pageable.getPageSize()); + } + + @Test + void getPageable_withInvalidValues_shouldReturnCorrectPageable() { + Pageable pageable = PaginationHelper.getPageable(-1, 0); + assertEquals(0, pageable.getPageNumber()); + assertEquals(15, pageable.getPageSize()); + } + + @Test + void testPrivateConstructor() + throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException { + Constructor constructor = PaginationHelper.class.getDeclaredConstructor(); + constructor.setAccessible(true); + PaginationHelper instance = constructor.newInstance(); + assertNotNull(instance); + } +} diff --git a/src/test/java/com/flexcodelabs/flextuma/core/interceptors/AuditorAwareImplTest.java b/src/test/java/com/flexcodelabs/flextuma/core/interceptors/AuditorAwareImplTest.java index 76abc3b..70eff0d 100644 --- a/src/test/java/com/flexcodelabs/flextuma/core/interceptors/AuditorAwareImplTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/core/interceptors/AuditorAwareImplTest.java @@ -12,9 +12,9 @@ import org.junit.jupiter.api.BeforeEach; 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 org.springframework.beans.factory.ObjectProvider; import org.springframework.security.authentication.AnonymousAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContext; @@ -30,6 +30,9 @@ @ExtendWith(MockitoExtension.class) class AuditorAwareImplTest { + @Mock + private ObjectProvider userRepositoryProvider; + @Mock private UserRepository userRepository; @@ -39,12 +42,12 @@ class AuditorAwareImplTest { @Mock private SecurityContext securityContext; - @InjectMocks private AuditorAwareImpl auditorAware; @BeforeEach void setUp() { SecurityContextHolder.setContext(securityContext); + auditorAware = new AuditorAwareImpl(userRepositoryProvider, entityManager); } @Test @@ -88,6 +91,7 @@ void getCurrentAuditor_shouldReturnUser_whenAuthenticated() { User user = new User(); when(entityManager.getFlushMode()).thenReturn(FlushModeType.AUTO); + when(userRepositoryProvider.getObject()).thenReturn(userRepository); when(userRepository.findByIdentifier("testuser")).thenReturn(Optional.of(user)); Optional result = auditorAware.getCurrentAuditor(); @@ -108,6 +112,7 @@ void getCurrentAuditor_shouldPropagateException_andExecuteFinally_whenExceptionO when(securityContext.getAuthentication()).thenReturn(auth); when(entityManager.getFlushMode()).thenReturn(FlushModeType.AUTO); + when(userRepositoryProvider.getObject()).thenReturn(userRepository); when(userRepository.findByIdentifier("testuser")).thenThrow(new RuntimeException("DB Error")); assertThrows(RuntimeException.class, () -> auditorAware.getCurrentAuditor()); diff --git a/src/test/java/com/flexcodelabs/flextuma/core/logging/DatabaseLogAppenderTest.java b/src/test/java/com/flexcodelabs/flextuma/core/logging/DatabaseLogAppenderTest.java new file mode 100644 index 0000000..de66d14 --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/core/logging/DatabaseLogAppenderTest.java @@ -0,0 +1,463 @@ +package com.flexcodelabs.flextuma.core.logging; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.IThrowableProxy; +import com.flexcodelabs.flextuma.core.entities.logging.SystemLog; +import com.flexcodelabs.flextuma.core.enums.LogLevel; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +class DatabaseLogAppenderTest { + + private DatabaseLogAppender appender; + + @BeforeEach + void setUp() { + DatabaseLogAppender.setDataSource(null); + appender = new DatabaseLogAppender(); + appender.setBatchSize(50); + appender.setFlushIntervalMs(2000); + } + + @AfterEach + void tearDown() { + appender.stop(); + DatabaseLogAppender.setDataSource(null); + DatabaseLogAppender.clearListeners(); + } + + @Test + void addListener_shouldNotifyOnNewEvent() { + AtomicReference received = new AtomicReference<>(); + Consumer listener = received::set; + + DatabaseLogAppender.addListener(listener); + try { + SystemLog log = new SystemLog(); + log.setLevel(LogLevel.ERROR); + log.setMessage("Test error"); + log.setSource("TestSource"); + listener.accept(log); + + assertNotNull(received.get()); + assertEquals("Test error", received.get().getMessage()); + assertEquals(LogLevel.ERROR, received.get().getLevel()); + } finally { + DatabaseLogAppender.removeListener(listener); + } + } + + @Test + void removeListener_shouldStopNotifications() { + AtomicReference received = new AtomicReference<>(); + Consumer listener = received::set; + + DatabaseLogAppender.addListener(listener); + DatabaseLogAppender.removeListener(listener); + + assertNull(received.get()); + } + + @Test + void setDataSource_shouldAcceptNull() { + assertDoesNotThrow(() -> DatabaseLogAppender.setDataSource(null)); + } + + // ─── start / stop lifecycle ───────────────────────────────── + + @Test + void start_shouldInitializeScheduler() { + appender.start(); + assertTrue(appender.isStarted()); + } + + @Test + void stop_shouldShutdownCleanly() { + appender.start(); + assertTrue(appender.isStarted()); + + appender.stop(); + assertFalse(appender.isStarted()); + } + + @Test + void stop_whenNotStarted_shouldNotThrow() { + // scheduler is null when stop() is called without start() + assertDoesNotThrow(() -> appender.stop()); + } + + // ─── append ───────────────────────────────────────────────── + + @Test + void append_shouldSkipOwnLoggingPackage() { + ILoggingEvent event = mockEvent( + "com.flexcodelabs.flextuma.core.logging.SomeClass", + Level.INFO, "Should be skipped", null); + + appender.start(); + appender.doAppend(event); + + // Nothing should be buffered since it's filtered out + // Verify by flushing — if buffered, this would try to use a null DataSource + assertDoesNotThrow(() -> appender.stop()); + } + + @Test + void append_shouldBufferEventAndNotifyListeners() { + List received = new ArrayList<>(); + Consumer listener = received::add; + DatabaseLogAppender.addListener(listener); + + try { + ILoggingEvent event = mockEvent( + "com.example.MyService", Level.WARN, + "a warning", null); + + appender.start(); + appender.doAppend(event); + + assertEquals(1, received.size()); + assertEquals("a warning", received.get(0).getMessage()); + assertEquals(LogLevel.WARNING, received.get(0).getLevel()); + assertEquals("MyService", received.get(0).getSource()); + } finally { + DatabaseLogAppender.removeListener(listener); + } + } + + @Test + void append_shouldRemoveListenerThatThrows() { + Consumer badListener = log -> { + throw new RuntimeException("boom"); + }; + DatabaseLogAppender.addListener(badListener); + + try { + ILoggingEvent event = mockEvent( + "com.example.Foo", Level.INFO, "msg", null); + appender.start(); + // Should not throw, and the bad listener should be removed + assertDoesNotThrow(() -> appender.doAppend(event)); + } finally { + DatabaseLogAppender.removeListener(badListener); + } + } + + @Test + void append_shouldTriggerFlushWhenBatchSizeReached() throws Exception { + DataSource ds = mock(DataSource.class); + Connection conn = mock(Connection.class); + PreparedStatement ps = mock(PreparedStatement.class); + + when(ds.getConnection()).thenReturn(conn); + when(conn.prepareStatement(anyString())).thenReturn(ps); + when(ps.executeBatch()).thenReturn(new int[] { 1 }); + + DatabaseLogAppender.setDataSource(ds); + + appender.setBatchSize(2); + appender.start(); + + // Append 2 events to reach batchSize and trigger auto-flush + appender.doAppend(mockEvent("com.example.A", Level.INFO, "msg1", null)); + appender.doAppend(mockEvent("com.example.B", Level.DEBUG, "msg2", null)); + + verify(conn).setAutoCommit(false); + verify(ps, atLeastOnce()).addBatch(); + verify(ps).executeBatch(); + verify(conn).commit(); + } + + // ─── mapEvent / mapLevel ──────────────────────────────────── + + @Test + void append_shouldMapEventWithStackTrace() { + List received = new ArrayList<>(); + Consumer listener = received::add; + DatabaseLogAppender.addListener(listener); + + try { + IThrowableProxy throwableProxy = mock(IThrowableProxy.class); + // ThrowableProxyUtil.asString needs class/message at minimum + when(throwableProxy.getClassName()).thenReturn("java.lang.RuntimeException"); + when(throwableProxy.getMessage()).thenReturn("test error"); + when(throwableProxy.getStackTraceElementProxyArray()) + .thenReturn(new ch.qos.logback.classic.spi.StackTraceElementProxy[0]); + + ILoggingEvent event = mockEvent( + "com.example.Service", Level.ERROR, + "something failed", throwableProxy); + + appender.start(); + appender.doAppend(event); + + assertEquals(1, received.size()); + SystemLog log = received.get(0); + assertNotNull(log.getStackTrace()); + assertEquals(LogLevel.ERROR, log.getLevel()); + assertNotNull(log.getId()); + assertNotNull(log.getTimestamp()); + } finally { + DatabaseLogAppender.removeListener(listener); + // clean up by clearing all listeners + } + } + + @Test + void append_shouldMapMdcFields() { + List received = new ArrayList<>(); + Consumer listener = received::add; + DatabaseLogAppender.addListener(listener); + + try { + ILoggingEvent event = mockEvent( + "com.example.Ctrl", Level.INFO, "hello", null); + when(event.getMDCPropertyMap()).thenReturn( + Map.of("traceId", "abc-123", "username", "admin")); + + appender.start(); + appender.doAppend(event); + + SystemLog log = received.get(0); + assertEquals("abc-123", log.getTraceId()); + assertEquals("admin", log.getUsername()); + } finally { + DatabaseLogAppender.removeListener(listener); + } + } + + static Stream levelMappings() { + return Stream.of( + Arguments.of(Level.DEBUG, LogLevel.DEBUG), + Arguments.of(Level.INFO, LogLevel.INFO), + Arguments.of(Level.WARN, LogLevel.WARNING), + Arguments.of(Level.ERROR, LogLevel.ERROR), + Arguments.of(Level.TRACE, LogLevel.INFO) // default case + ); + } + + @ParameterizedTest(name = "Logback {0} -> LogLevel.{1}") + @MethodSource("levelMappings") + void append_shouldMapLogbackLevelCorrectly(Level logbackLevel, LogLevel expected) { + List received = new ArrayList<>(); + Consumer listener = received::add; + DatabaseLogAppender.addListener(listener); + + try { + ILoggingEvent event = mockEvent( + "com.example.X", logbackLevel, "test", null); + appender.start(); + appender.doAppend(event); + + assertEquals(expected, received.get(0).getLevel()); + } finally { + DatabaseLogAppender.removeListener(listener); + } + } + + // ─── extractSource ────────────────────────────────────────── + + @Test + void append_shouldExtractSourceFromLoggerName() { + List received = new ArrayList<>(); + Consumer listener = received::add; + DatabaseLogAppender.addListener(listener); + + try { + appender.start(); + appender.doAppend(mockEvent( + "com.example.deep.pkg.MyClass", Level.INFO, "m", null)); + assertEquals("MyClass", received.get(0).getSource()); + } finally { + DatabaseLogAppender.removeListener(listener); + } + } + + @Test + void append_shouldReturnLoggerNameWhenNoDot() { + List received = new ArrayList<>(); + Consumer listener = received::add; + DatabaseLogAppender.addListener(listener); + + try { + appender.start(); + appender.doAppend(mockEvent("SimpleLogger", Level.INFO, "m", null)); + assertEquals("SimpleLogger", received.get(0).getSource()); + } finally { + DatabaseLogAppender.removeListener(listener); + } + } + + @Test + void append_shouldReturnUnknownForNullLoggerName() { + List received = new ArrayList<>(); + Consumer listener = received::add; + DatabaseLogAppender.addListener(listener); + + try { + ILoggingEvent event = mock(ILoggingEvent.class); + // getLoggerName returns null — but we first check startsWith, so we need + // to make it NOT start with the logging package. Null would NPE on startsWith, + // so let's use an empty string instead and test the null path via extractSource + // indirectly. + when(event.getLoggerName()).thenReturn(""); + when(event.getLevel()).thenReturn(Level.INFO); + when(event.getFormattedMessage()).thenReturn("msg"); + when(event.getMDCPropertyMap()).thenReturn(Map.of()); + when(event.getThrowableProxy()).thenReturn(null); + + appender.start(); + appender.doAppend(event); + + // "" has no dot, so extractSource returns "" itself (lastDot = -1) + assertEquals("", received.get(0).getSource()); + } finally { + DatabaseLogAppender.removeListener(listener); + } + } + + // ─── flush ────────────────────────────────────────────────── + + @Test + void flush_shouldSkipWhenBufferEmpty() { + DataSource ds = mock(DataSource.class); + DatabaseLogAppender.setDataSource(ds); + + appender.start(); + // Stop triggers flush, but buffer is empty — should not get a connection + appender.stop(); + + verifyNoInteractions(ds); + } + + @Test + void flush_shouldSkipWhenDataSourceNull() { + // dataSource is null by default in setUp + appender.start(); + appender.doAppend(mockEvent("com.example.X", Level.INFO, "msg", null)); + + // Stop triggers flush, but no datasource — should not throw + assertDoesNotThrow(() -> appender.stop()); + } + + @Test + void flush_shouldWriteBufferedLogs() throws Exception { + DataSource ds = mock(DataSource.class); + Connection conn = mock(Connection.class); + PreparedStatement ps = mock(PreparedStatement.class); + + when(ds.getConnection()).thenReturn(conn); + when(conn.prepareStatement(anyString())).thenReturn(ps); + when(ps.executeBatch()).thenReturn(new int[] { 1 }); + + DatabaseLogAppender.setDataSource(ds); + + appender.start(); + appender.doAppend(mockEvent("com.example.A", Level.INFO, "hello", null)); + + // stop() calls flush() + appender.stop(); + + verify(conn).setAutoCommit(false); + verify(ps).addBatch(); + verify(ps).executeBatch(); + verify(conn).commit(); + } + + @Test + void flush_shouldHandleMetadataInLog() throws Exception { + DataSource ds = mock(DataSource.class); + Connection conn = mock(Connection.class); + PreparedStatement ps = mock(PreparedStatement.class); + + when(ds.getConnection()).thenReturn(conn); + when(conn.prepareStatement(anyString())).thenReturn(ps); + when(ps.executeBatch()).thenReturn(new int[] { 1 }); + + DatabaseLogAppender.setDataSource(ds); + + // Add a log with metadata via listener to set metadata + Consumer listener = log -> { + log.setMetadata(Map.of("key", "value")); + }; + DatabaseLogAppender.addListener(listener); + + try { + appender.start(); + appender.doAppend(mockEvent("com.example.B", Level.INFO, "with meta", null)); + appender.stop(); + + // Metadata should have been serialized to JSON via toJson + verify(ps).setString(eq(8), contains("key")); + } finally { + DatabaseLogAppender.removeListener(listener); + } + } + + @Test + void flush_shouldHandleNullMetadata() throws Exception { + DataSource ds = mock(DataSource.class); + Connection conn = mock(Connection.class); + PreparedStatement ps = mock(PreparedStatement.class); + + when(ds.getConnection()).thenReturn(conn); + when(conn.prepareStatement(anyString())).thenReturn(ps); + when(ps.executeBatch()).thenReturn(new int[] { 1 }); + + DatabaseLogAppender.setDataSource(ds); + + appender.start(); + appender.doAppend(mockEvent("com.example.C", Level.INFO, "no meta", null)); + appender.stop(); + + // metadata is null → ps.setString(8, null) + verify(ps).setString(8, null); + } + + @Test + void flush_shouldHandleSqlException() throws Exception { + DataSource ds = mock(DataSource.class); + when(ds.getConnection()).thenThrow(new SQLException("connection refused")); + + DatabaseLogAppender.setDataSource(ds); + + appender.start(); + appender.doAppend(mockEvent("com.example.D", Level.ERROR, "msg", null)); + + // flush should catch the exception and print to stderr, not throw + assertDoesNotThrow(() -> appender.stop()); + } + + // ─── helpers ──────────────────────────────────────────────── + + private ILoggingEvent mockEvent(String loggerName, Level level, + String message, IThrowableProxy throwable) { + ILoggingEvent event = mock(ILoggingEvent.class); + when(event.getLoggerName()).thenReturn(loggerName); + when(event.getLevel()).thenReturn(level); + when(event.getFormattedMessage()).thenReturn(message); + when(event.getMDCPropertyMap()).thenReturn(Map.of()); + when(event.getThrowableProxy()).thenReturn(throwable); + return event; + } +} diff --git a/src/test/java/com/flexcodelabs/flextuma/core/logging/LogAppenderInitializerTest.java b/src/test/java/com/flexcodelabs/flextuma/core/logging/LogAppenderInitializerTest.java new file mode 100644 index 0000000..7fbf054 --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/core/logging/LogAppenderInitializerTest.java @@ -0,0 +1,39 @@ +package com.flexcodelabs.flextuma.core.logging; + +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 javax.sql.DataSource; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +@ExtendWith(MockitoExtension.class) +class LogAppenderInitializerTest { + + @Mock + private DataSource dataSource; + + @InjectMocks + private LogAppenderInitializer initializer; + + @org.junit.jupiter.api.BeforeEach + void setUp() { + DatabaseLogAppender.setDataSource(null); + } + + @Test + void init_shouldSetDataSourceOnAppender() { + initializer.init(); + // Since setDataSource is static and we can't easily mock static methods + // without Mockito inline/PowerMock, we verify by checking a side effect + // or just ensuring it doesn't throw. + // However, we can use a "spy" or check if DatabaseLogAppender has a getter (it + // doesn't appear to). + // For now, we'll stick to assertDoesNotThrow but we've improved it by + // ensuring the context is clean. + assertDoesNotThrow(() -> initializer.init()); + } +} diff --git a/src/test/java/com/flexcodelabs/flextuma/core/logging/LogRetentionSchedulerTest.java b/src/test/java/com/flexcodelabs/flextuma/core/logging/LogRetentionSchedulerTest.java new file mode 100644 index 0000000..911e428 --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/core/logging/LogRetentionSchedulerTest.java @@ -0,0 +1,40 @@ +package com.flexcodelabs.flextuma.core.logging; + +import com.flexcodelabs.flextuma.core.repositories.SystemLogRepository; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class LogRetentionSchedulerTest { + + @Mock + private SystemLogRepository systemLogRepository; + + @InjectMocks + private LogRetentionScheduler scheduler; + + @ParameterizedTest(name = "retentionDays={0}, deletedCount={1}") + @CsvSource({ + "30, 10", + "30, 0", + "7, 5" + }) + void purgeOldLogs_shouldDeleteByConfiguredRetention(int retentionDays, int deletedCount) { + ReflectionTestUtils.setField(scheduler, "retentionDays", retentionDays); + when(systemLogRepository.deleteByTimestampBefore(any(LocalDateTime.class))).thenReturn(deletedCount); + + scheduler.purgeOldLogs(); + + verify(systemLogRepository).deleteByTimestampBefore(any(LocalDateTime.class)); + } +} diff --git a/src/test/java/com/flexcodelabs/flextuma/core/services/BaseServiceTest.java b/src/test/java/com/flexcodelabs/flextuma/core/services/BaseServiceTest.java index b83017c..226a983 100644 --- a/src/test/java/com/flexcodelabs/flextuma/core/services/BaseServiceTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/core/services/BaseServiceTest.java @@ -1,15 +1,18 @@ package com.flexcodelabs.flextuma.core.services; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - +import com.flexcodelabs.flextuma.core.dtos.AggregateDTO; +import com.flexcodelabs.flextuma.core.dtos.EntityFieldDTO; +import com.flexcodelabs.flextuma.core.dtos.Pagination; +import com.flexcodelabs.flextuma.core.entities.base.BaseEntity; import com.flexcodelabs.flextuma.core.helpers.CurrentUserResolver; -import java.util.List; -import java.util.Optional; -import java.util.Set; -import java.util.UUID; - +import com.flexcodelabs.flextuma.core.security.SecurityUtils; +import jakarta.persistence.EntityManager; +import jakarta.persistence.TypedQuery; +import jakarta.persistence.criteria.*; +import jakarta.persistence.metamodel.Attribute; +import jakarta.persistence.metamodel.EntityType; +import jakarta.persistence.metamodel.Metamodel; +import jakarta.persistence.metamodel.SingularAttribute; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -18,233 +21,417 @@ import org.mockito.MockedStatic; import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.security.access.AccessDeniedException; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContext; -import org.springframework.security.core.context.SecurityContextHolder; -import com.flexcodelabs.flextuma.core.dtos.Pagination; -import com.flexcodelabs.flextuma.core.entities.base.BaseEntity; +import java.util.*; -import jakarta.persistence.EntityManager; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) -@org.mockito.junit.jupiter.MockitoSettings(strictness = org.mockito.quality.Strictness.LENIENT) +@MockitoSettings(strictness = Strictness.LENIENT) class BaseServiceTest { - private TestService service; + @Mock + private EntityManager entityManager; @Mock - private JpaRepository repository; + private ApplicationEventPublisher eventPublisher; @Mock - private JpaSpecificationExecutor specificationExecutor; + private CurrentUserResolver currentUserResolver; @Mock - private EntityManager entityManager; + private JpaRepository repository; @Mock - private CurrentUserResolver currentUserResolver; + private JpaSpecificationExecutor executor; @Mock - private SecurityContext securityContext; + private Metamodel metamodel; @Mock - private Authentication authentication; + private EntityType entityType; - private MockedStatic securityContextHolderMock; + private MockedStatic securityUtilsMock; + + private TestService service; + + static class TestEntity extends BaseEntity { + private String name; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + } + + class TestService extends BaseService { + @Override + protected JpaRepository getRepository() { + return repository; + } + + @Override + protected String getReadPermission() { + return "READ"; + } + + @Override + protected String getAddPermission() { + return "ADD"; + } + + @Override + protected String getUpdatePermission() { + return "UPDATE"; + } + + @Override + protected String getDeletePermission() { + return "DELETE"; + } + + @Override + public String getEntityPlural() { + return "tests"; + } + + @Override + public String getPropertyName() { + return "test"; + } + + @Override + protected String getEntitySingular() { + return "test"; + } + + @Override + protected JpaSpecificationExecutor getRepositoryAsExecutor() { + return executor; + } + } @BeforeEach void setUp() { - service = new TestService(repository, specificationExecutor); + service = new TestService(); service.entityManager = entityManager; + service.setEventPublisher(eventPublisher); service.setCurrentUserResolver(currentUserResolver); - when(currentUserResolver.getCurrentUser()).thenReturn(Optional.empty()); - - securityContextHolderMock = Mockito.mockStatic(SecurityContextHolder.class); - securityContextHolderMock.when(SecurityContextHolder::getContext).thenReturn(securityContext); - when(securityContext.getAuthentication()).thenReturn(authentication); + securityUtilsMock = Mockito.mockStatic(SecurityUtils.class); + securityUtilsMock.when(SecurityUtils::getCurrentUserAuthorities) + .thenReturn(Set.of("READ", "ADD", "UPDATE", "DELETE")); } @AfterEach void tearDown() { - securityContextHolderMock.close(); + securityUtilsMock.close(); + } + + private void mockPermissions(Set permissions) { + securityUtilsMock.when(SecurityUtils::getCurrentUserAuthorities).thenReturn(permissions); } @Test - void findAllPaginated_shouldReturnPagination_whenAuthorized() { - mockPermissions(Set.of("READ_TEST")); - Pageable pageable = Pageable.ofSize(10).withPage(0); - TestEntity entity = new TestEntity(); - Page page = new PageImpl<>(List.of(entity)); + void checkPermission_shouldThrowException_whenNoPermission() { + securityUtilsMock.when(SecurityUtils::getCurrentUserAuthorities).thenReturn(Set.of("OTHER")); + assertThrows(AccessDeniedException.class, () -> service.findAll()); + } - when(specificationExecutor.findAll(any(), eq(pageable))).thenReturn(page); + @Test + @SuppressWarnings("unchecked") + void getEntityFields_shouldReturnFieldDTOs() { + when(entityManager.getMetamodel()).thenReturn(metamodel); + when(metamodel.entity(TestEntity.class)).thenReturn(entityType); - Pagination result = service.findAllPaginated(pageable, null, null); + SingularAttribute attr = mock(SingularAttribute.class); + when(attr.getName()).thenReturn("name"); + when(attr.getJavaType()).thenReturn(String.class); + when(attr.getPersistentAttributeType()).thenReturn(Attribute.PersistentAttributeType.BASIC); + when(attr.isOptional()).thenReturn(true); - assertNotNull(result); - assertEquals(1, result.getTotal()); - assertEquals(1, result.getData().size()); + when(entityType.getAttributes()).thenReturn(Set.of(attr)); + + List fields = service.getEntityFields(); + + assertNotNull(fields); + assertEquals(1, fields.size()); + assertEquals("name", fields.get(0).getName()); + assertFalse(fields.get(0).isMandatory()); } @Test - void findAllPaginated_shouldThrowAccessDenied_whenUnauthorized() { - mockPermissions(Set.of("OTHER_PERMISSION")); - Pageable pageable = Pageable.ofSize(10); + @SuppressWarnings("unchecked") + void findAll_shouldReturnAllEntities() { + TestEntity entity = new TestEntity(); + when(currentUserResolver.getCurrentUser()).thenReturn(Optional.empty()); + when(executor.findAll(any(Specification.class))).thenReturn(List.of(entity)); - assertThrows(AccessDeniedException.class, () -> service.findAllPaginated(pageable, null, null)); + List result = service.findAll(); + + assertNotNull(result); + assertEquals(1, result.size()); + verify(executor).findAll(any(Specification.class)); } @Test - void findById_shouldReturnEntity_whenAuthorized() { - mockPermissions(Set.of("READ_TEST")); - UUID id = UUID.randomUUID(); + void findAllPaginated_shouldReturnPagination() { TestEntity entity = new TestEntity(); - when(repository.findById(id)).thenReturn(Optional.of(entity)); + Pageable pageable = PageRequest.of(0, 10); + Page page = new PageImpl<>(List.of(entity), pageable, 1); - Optional result = service.findById(id); + when(currentUserResolver.getCurrentUser()).thenReturn(Optional.empty()); + when(executor.findAll(Mockito.>any(), eq(pageable))).thenReturn(page); - assertTrue(result.isPresent()); - assertEquals(entity, result.get()); + Pagination result = service.findAllPaginated(pageable, List.of("name:eq:value"), "name"); + + assertNotNull(result); + assertEquals(1, result.getTotal()); + assertEquals(1, result.getData().size()); } @Test - void save_shouldSaveEntity_whenAuthorized() { - mockPermissions(Set.of("ADD_TEST")); + void save_shouldSaveEntityAndPublishEvent() { TestEntity entity = new TestEntity(); when(repository.save(entity)).thenReturn(entity); - TestEntity result = service.save(entity); + TestEntity saved = service.save(entity); - assertNotNull(result); + assertNotNull(saved); verify(repository).save(entity); + verify(eventPublisher).publishEvent(any()); } @Test - void update_shouldUpdateEntity_whenAuthorized() { - mockPermissions(Set.of("UPDATE_TEST")); + void update_shouldUpdateExistingEntity() { UUID id = UUID.randomUUID(); TestEntity existing = new TestEntity(); - existing.setId(id); - TestEntity update = new TestEntity(); - update.setId(id); + TestEntity updatePayload = new TestEntity(); + updatePayload.setName("new name"); when(repository.findById(id)).thenReturn(Optional.of(existing)); + when(repository.save(existing)).thenReturn(existing); - TestEntity result = service.update(id, update); + TestEntity result = service.update(id, updatePayload); assertNotNull(result); + assertEquals("new name", existing.getName()); + verify(repository).save(existing); } @Test - void delete_shouldDeleteEntity_whenAuthorized() { - mockPermissions(Set.of("DELETE_TEST")); + void findById_shouldReturnEmpty_whenNotFound() { UUID id = UUID.randomUUID(); - TestEntity entity = new TestEntity(); - when(repository.findById(id)).thenReturn(Optional.of(entity)); + when(repository.findById(id)).thenReturn(Optional.empty()); - service.delete(id); + Optional result = service.findById(id); - verify(repository).deleteById(id); + assertTrue(result.isEmpty()); } - private void mockPermissions(Set permissions) { - when(authentication.isAuthenticated()).thenReturn(true); + @SuppressWarnings("unchecked") + @Test + void findById_shouldReturnWithFields_whenAuthorized() { + mockPermissions(Set.of("READ")); + UUID id = UUID.randomUUID(); + TestEntity entity = new TestEntity(); + when(executor.findOne(any(Specification.class))).thenReturn(Optional.of(entity)); - List authorities = permissions.stream() - .map(p -> (org.springframework.security.core.GrantedAuthority) () -> p) - .toList(); + Optional result = service.findById(id, "name"); - doReturn(authorities).when(authentication).getAuthorities(); + assertTrue(result.isPresent()); + verify(executor).findOne(any(Specification.class)); } - // Concrete implementation for testing - static class TestEntity extends BaseEntity { - } + @SuppressWarnings("unchecked") + @Test + void findAll_withFields_shouldReturnEntities() { + TestEntity entity = new TestEntity(); + when(currentUserResolver.getCurrentUser()).thenReturn(Optional.empty()); + when(executor.findAll(any(Specification.class))).thenReturn(List.of(entity)); - static class TestService extends BaseService { - private final JpaRepository repository; - private final JpaSpecificationExecutor executor; + List result = service.findAll("name", List.of("name:eq:value"), "AND"); - public TestService(JpaRepository repository, JpaSpecificationExecutor executor) { - this.repository = repository; - this.executor = executor; - } + assertNotNull(result); + assertEquals(1, result.size()); + } - @Override - protected JpaRepository getRepository() { - return repository; + @Test + void getAggregatedData_shouldSupportAllFunctions() { + CriteriaBuilder cb = mock(CriteriaBuilder.class); + @SuppressWarnings("unchecked") + CriteriaQuery cq = mock(CriteriaQuery.class); + @SuppressWarnings("unchecked") + Root root = mock(Root.class); + @SuppressWarnings("unchecked") + TypedQuery typedQuery = mock(TypedQuery.class); + + when(entityManager.getCriteriaBuilder()).thenReturn(cb); + when(cb.createQuery(Object[].class)).thenReturn(cq); + when(cq.from(TestEntity.class)).thenReturn(root); + + @SuppressWarnings("unchecked") + Path path = mock(Path.class); + when(root.get("name")).thenReturn(path); + @SuppressWarnings("unchecked") + Expression numExpr = mock(Expression.class); + lenient().when(path.as(Number.class)).thenReturn(numExpr); + lenient().when(numExpr.alias(anyString())).thenReturn(numExpr); + + @SuppressWarnings("unchecked") + Expression avgExpr = mock(Expression.class); + when(cb.avg(any())).thenReturn(avgExpr); + lenient().when(avgExpr.alias(anyString())).thenReturn(avgExpr); + + @SuppressWarnings("unchecked") + Expression countExpr = mock(Expression.class); + when(cb.count(any())).thenReturn(countExpr); + lenient().when(countExpr.alias(anyString())).thenReturn(countExpr); + + @SuppressWarnings("unchecked") + Expression minExpr = mock(Expression.class); + when(cb.min(any())).thenReturn(minExpr); + lenient().when(minExpr.alias(anyString())).thenReturn(minExpr); + + @SuppressWarnings("unchecked") + Expression maxExpr = mock(Expression.class); + when(cb.max(any())).thenReturn(maxExpr); + lenient().when(maxExpr.alias(anyString())).thenReturn(maxExpr); + + when(cb.sum(any())).thenReturn(numExpr); + + when(cq.select(any())).thenReturn(cq); + when(entityManager.createQuery(cq)).thenReturn(typedQuery); + when(typedQuery.getResultList()).thenReturn(new ArrayList<>()); + + List aggs = new ArrayList<>(); + for (String func : List.of("SUM", "AVG", "COUNT", "MIN", "MAX")) { + AggregateDTO agg = new AggregateDTO(); + agg.setColumn("name"); + agg.setFunc(func); + agg.setAlias(func.toLowerCase()); + aggs.add(agg); } - @Override - protected String getReadPermission() { - return "READ_TEST"; - } + service.getAggregatedData(aggs, null, null, "AND"); - @Override - protected String getAddPermission() { - return "ADD_TEST"; - } + verify(cb, atLeastOnce()).sum(any()); + verify(cb, atLeastOnce()).avg(any()); + verify(cb, atLeastOnce()).count(any()); + verify(cb, atLeastOnce()).min(any()); + verify(cb, atLeastOnce()).max(any()); + } - @Override - protected String getUpdatePermission() { - return "UPDATE_TEST"; - } + @Test + void save_shouldThrowAccessDenied_whenUnauthorized() { + mockPermissions(Set.of("NONE")); + TestEntity entity = new TestEntity(); + assertThrows(AccessDeniedException.class, () -> service.save(entity)); + } - @Override - protected String getDeletePermission() { - return "DELETE_TEST"; - } + @Test + void update_shouldThrowAccessDenied_whenUnauthorized() { + mockPermissions(Set.of("NONE")); + UUID id = UUID.randomUUID(); + TestEntity entity = new TestEntity(); + assertThrows(AccessDeniedException.class, () -> service.update(id, entity)); + } - @Override - public String getEntityPlural() { - return "testEntities"; - } + @Test + void delete_shouldThrowAccessDenied_whenUnauthorized() { + mockPermissions(Set.of("NONE")); + UUID id = UUID.randomUUID(); + assertThrows(AccessDeniedException.class, () -> service.delete(id)); + } - @Override - public String getPropertyName() { - return "testEntities"; - } + @Test + void deleteMany_shouldThrowAccessDenied_whenUnauthorized() { + mockPermissions(Set.of("NONE")); + List emptyList = List.of(); + assertThrows(AccessDeniedException.class, () -> service.deleteMany(emptyList)); + } - @Override - protected String getEntitySingular() { - return "testEntity"; - } + @Test + void delete_shouldDeleteById() { + UUID id = UUID.randomUUID(); + TestEntity entity = new TestEntity(); + when(repository.findById(id)).thenReturn(Optional.of(entity)); - @Override - protected JpaSpecificationExecutor getRepositoryAsExecutor() { - return executor; - } + Map response = service.delete(id); + + assertEquals("test deleted successfully", response.get("message")); + verify(repository).deleteById(id); } + @SuppressWarnings("unchecked") @Test - void onPreUpdate_shouldReturnNewEntity_whenExceptionOccurs() { - com.fasterxml.jackson.databind.ObjectMapper mockMapper = mock( - com.fasterxml.jackson.databind.ObjectMapper.class); - org.springframework.test.util.ReflectionTestUtils.setField(service, "objectMapper", mockMapper); + void deleteMany_shouldDeleteMultiple() { + TestEntity entity = new TestEntity(); + when(currentUserResolver.getCurrentUser()).thenReturn(Optional.empty()); + when(executor.findAll(any(Specification.class))).thenReturn(List.of(entity)); - TestEntity oldEntity = new TestEntity(); - TestEntity newEntity = new TestEntity(); + Map response = service.deleteMany(List.of("name:eq:value")); - try { - lenient().when(mockMapper.updateValue(any(), any())).thenThrow(new RuntimeException("Update error")); - } catch (Exception e) { - fail("Mock setup failed"); - } + assertTrue(response.get("message").contains("1 tests deleted successfully")); + verify(repository).deleteAll(anyList()); + } - // onPreUpdate is protected, but we are in the same package (by package name - // matching), so we can access it. - // We call onPreUpdate directly to test the return value logic in the catch - // block. + @Test + @SuppressWarnings("unchecked") + void getAggregatedData_shouldReturnComplexData() { + CriteriaBuilder cb = mock(CriteriaBuilder.class); + CriteriaQuery cq = mock(CriteriaQuery.class); + Root root = mock(Root.class); + TypedQuery typedQuery = mock(TypedQuery.class); + + when(entityManager.getCriteriaBuilder()).thenReturn(cb); + when(cb.createQuery(Object[].class)).thenReturn(cq); + when(cq.from(TestEntity.class)).thenReturn(root); + + Path path = mock(Path.class); + when(root.get("name")).thenReturn(path); + when(path.alias("name")).thenReturn(path); + + Expression numPath = mock(Expression.class); + lenient().when(path.as(Number.class)).thenReturn(numPath); + Expression sumExpr = mock(Expression.class); + when(cb.sum(any(Expression.class))).thenReturn(sumExpr); + when(sumExpr.alias("total")).thenReturn(sumExpr); + + when(cq.select(any())).thenReturn(cq); + when(currentUserResolver.getCurrentUser()).thenReturn(Optional.empty()); + when(entityManager.createQuery(cq)).thenReturn(typedQuery); + + List results = new ArrayList<>(); + results.add(new Object[] { "test", 100L }); + when(typedQuery.getResultList()).thenReturn(results); - TestEntity result = service.onPreUpdate(newEntity, oldEntity); + AggregateDTO agg = new AggregateDTO(); + agg.setColumn("name"); + agg.setFunc("SUM"); + agg.setAlias("total"); - assertEquals(newEntity, result); + List> result = service.getAggregatedData(List.of(agg), List.of("name"), null, "AND"); + + assertNotNull(result); + assertEquals(1, result.size()); + assertEquals("test", result.get(0).get("name")); + assertEquals(100L, result.get(0).get("total")); } } \ No newline at end of file diff --git a/src/test/java/com/flexcodelabs/flextuma/core/webhooks/BeemDlrParserTest.java b/src/test/java/com/flexcodelabs/flextuma/core/webhooks/BeemDlrParserTest.java new file mode 100644 index 0000000..a556fd4 --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/core/webhooks/BeemDlrParserTest.java @@ -0,0 +1,68 @@ +package com.flexcodelabs.flextuma.core.webhooks; + +import com.flexcodelabs.flextuma.core.enums.SmsLogStatus; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class BeemDlrParserTest { + + private final BeemDlrParser parser = new BeemDlrParser(); + + @Test + void getProvider_shouldReturnBeem() { + assertEquals("BEEM", parser.getProvider()); + } + + @Test + void parse_withDeliveredStatus_shouldReturnSent() { + Map payload = Map.of( + "messageID", "12345", + "status", "Delivered"); + + DlrResult result = parser.parse(payload); + + assertEquals("12345", result.messageId()); + assertEquals(SmsLogStatus.SENT, result.status()); + assertEquals("delivered", result.rawStatus()); + } + + @Test + void parse_withFailedStatus_shouldReturnFailed() { + Map payload = Map.of( + "messageID", "67890", + "status", "REJECTED"); + + DlrResult result = parser.parse(payload); + + assertEquals("67890", result.messageId()); + assertEquals(SmsLogStatus.FAILED, result.status()); + assertEquals("rejected", result.rawStatus()); + } + + @Test + void parse_withUnknownStatus_shouldReturnNullStatus() { + Map payload = Map.of( + "messageID", "555", + "status", "pEnDiNg"); + + DlrResult result = parser.parse(payload); + + assertEquals("555", result.messageId()); + assertNull(result.status()); + assertEquals("pending", result.rawStatus()); + } + + @Test + void parse_withMissingFields_shouldHandleGracefully() { + Map payload = Map.of(); + + DlrResult result = parser.parse(payload); + + assertEquals("", result.messageId()); + assertNull(result.status()); + assertEquals("", result.rawStatus()); + } +} diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/auth/controllers/OrganisationControllerTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/auth/controllers/OrganisationControllerTest.java new file mode 100644 index 0000000..6af485b --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/modules/auth/controllers/OrganisationControllerTest.java @@ -0,0 +1,25 @@ +package com.flexcodelabs.flextuma.modules.auth.controllers; + +import com.flexcodelabs.flextuma.modules.auth.services.OrganisationService; +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 static org.junit.jupiter.api.Assertions.assertNotNull; + +@ExtendWith(MockitoExtension.class) +class OrganisationControllerTest { + + @Mock + private OrganisationService organisationService; + + @InjectMocks + private OrganisationController organisationController; + + @Test + void testConstructor() { + assertNotNull(organisationController); + } +} diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/auth/services/RoleServiceTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/auth/services/RoleServiceTest.java index 9a205e0..01f0094 100644 --- a/src/test/java/com/flexcodelabs/flextuma/modules/auth/services/RoleServiceTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/modules/auth/services/RoleServiceTest.java @@ -17,11 +17,14 @@ import org.mockito.MockedStatic; import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.util.ReflectionTestUtils; import com.flexcodelabs.flextuma.core.entities.auth.Role; +import com.flexcodelabs.flextuma.core.helpers.CurrentUserResolver; import com.flexcodelabs.flextuma.core.repositories.RoleRepository; @ExtendWith(MockitoExtension.class) @@ -36,6 +39,12 @@ class RoleServiceTest { @Mock private Authentication authentication; + @Mock + private ApplicationEventPublisher eventPublisher; + + @Mock + private CurrentUserResolver currentUserResolver; + private MockedStatic securityContextHolderMock; private RoleService service; @@ -43,6 +52,8 @@ class RoleServiceTest { @BeforeEach void setUp() { service = new RoleService(repository); + ReflectionTestUtils.setField(service, "eventPublisher", eventPublisher); + ReflectionTestUtils.setField(service, "currentUserResolver", currentUserResolver); securityContextHolderMock = Mockito.mockStatic(SecurityContextHolder.class); securityContextHolderMock.when(SecurityContextHolder::getContext).thenReturn(securityContext); diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/connector/services/ConnectorConfigServiceTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/connector/services/ConnectorConfigServiceTest.java new file mode 100644 index 0000000..229a0d7 --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/modules/connector/services/ConnectorConfigServiceTest.java @@ -0,0 +1,72 @@ +package com.flexcodelabs.flextuma.modules.connector.services; + +import com.flexcodelabs.flextuma.core.entities.connector.ConnectorConfig; +import com.flexcodelabs.flextuma.core.repositories.ConnectorConfigRepository; +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 static org.junit.jupiter.api.Assertions.*; + +@ExtendWith(MockitoExtension.class) +class ConnectorConfigServiceTest { + + @Mock + private ConnectorConfigRepository repository; + + @InjectMocks + private ConnectorConfigService service; + + @Test + void getRepository_shouldReturnRepository() { + assertEquals(repository, service.getRepository()); + } + + @Test + void getRepositoryAsExecutor_shouldReturnRepository() { + assertEquals(repository, service.getRepositoryAsExecutor()); + } + + @Test + void getPermissions_shouldReturnCorrectValues() { + assertEquals(ConnectorConfig.READ, service.getReadPermission()); + assertEquals(ConnectorConfig.ADD, service.getAddPermission()); + assertEquals(ConnectorConfig.UPDATE, service.getUpdatePermission()); + assertEquals(ConnectorConfig.DELETE, service.getDeletePermission()); + } + + @Test + void getEntityNames_shouldReturnCorrectValues() { + assertEquals(ConnectorConfig.NAME_PLURAL, service.getEntityPlural()); + assertEquals(ConnectorConfig.NAME_SINGULAR, service.getEntitySingular()); + assertEquals(ConnectorConfig.PLURAL, service.getPropertyName()); + } + + @Test + void validateDelete_whenActive_shouldThrowException() { + ConnectorConfig config = new ConnectorConfig(); + config.setActive(true); + + IllegalStateException exception = assertThrows(IllegalStateException.class, + () -> service.validateDelete(config)); + assertEquals("Cannot delete an active config", exception.getMessage()); + } + + @Test + void validateDelete_whenNotActive_shouldNotThrow() { + ConnectorConfig config = new ConnectorConfig(); + config.setActive(false); + + assertDoesNotThrow(() -> service.validateDelete(config)); + } + + @Test + void validateDelete_whenActiveIsNull_shouldNotThrow() { + ConnectorConfig config = new ConnectorConfig(); + config.setActive(null); + + assertDoesNotThrow(() -> service.validateDelete(config)); + } +} diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/contact/services/ContactServiceTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/contact/services/ContactServiceTest.java new file mode 100644 index 0000000..f17064a --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/modules/contact/services/ContactServiceTest.java @@ -0,0 +1,46 @@ +package com.flexcodelabs.flextuma.modules.contact.services; + +import com.flexcodelabs.flextuma.core.entities.contact.Contact; +import com.flexcodelabs.flextuma.core.repositories.ContactRepository; +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 static org.junit.jupiter.api.Assertions.assertEquals; + +@ExtendWith(MockitoExtension.class) +class ContactServiceTest { + + @Mock + private ContactRepository repository; + + @InjectMocks + private ContactService service; + + @Test + void getRepository_shouldReturnRepository() { + assertEquals(repository, service.getRepository()); + } + + @Test + void getRepositoryAsExecutor_shouldReturnRepository() { + assertEquals(repository, service.getRepositoryAsExecutor()); + } + + @Test + void getPermissions_shouldReturnCorrectValues() { + assertEquals(Contact.READ, service.getReadPermission()); + assertEquals(Contact.ADD, service.getAddPermission()); + assertEquals(Contact.UPDATE, service.getUpdatePermission()); + assertEquals(Contact.DELETE, service.getDeletePermission()); + } + + @Test + void getEntityNames_shouldReturnCorrectValues() { + assertEquals(Contact.NAME_PLURAL, service.getEntityPlural()); + assertEquals(Contact.NAME_SINGULAR, service.getEntitySingular()); + assertEquals(Contact.PLURAL, service.getPropertyName()); + } +} diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/logging/controllers/SystemLogControllerTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/logging/controllers/SystemLogControllerTest.java new file mode 100644 index 0000000..8fec2d7 --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/modules/logging/controllers/SystemLogControllerTest.java @@ -0,0 +1,109 @@ +package com.flexcodelabs.flextuma.modules.logging.controllers; + +import com.flexcodelabs.flextuma.core.entities.logging.SystemLog; +import com.flexcodelabs.flextuma.core.enums.LogLevel; +import com.flexcodelabs.flextuma.modules.logging.services.SystemLogService; +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 org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.http.ResponseEntity; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class SystemLogControllerTest { + + @Mock + private SystemLogService systemLogService; + + @InjectMocks + private SystemLogController controller; + + @Test + void getAll_shouldReturnPaginatedResponse() { + Pageable pageable = PageRequest.of(0, 20); + SystemLog log = new SystemLog(); + log.setLevel(LogLevel.ERROR); + log.setMessage("Test"); + Page page = new PageImpl<>(List.of(log), pageable, 1); + + when(systemLogService.findAll(pageable, null, null, null, null, null)).thenReturn(page); + + Map response = controller.getAll(pageable, null, null, null, null, null); + + assertEquals(0, response.get("page")); + assertEquals(1L, response.get("total")); + assertEquals(20, response.get("pageSize")); + assertNotNull(response.get("systemLog")); + } + + @Test + void getAll_shouldPassFiltersToService() { + Pageable pageable = PageRequest.of(0, 10); + LocalDateTime from = LocalDateTime.now().minusDays(1); + LocalDateTime to = LocalDateTime.now(); + Page page = new PageImpl<>(List.of()); + + when(systemLogService.findAll(pageable, LogLevel.ERROR, "Notification", "tr_abc", from, to)) + .thenReturn(page); + + Map response = controller.getAll(pageable, LogLevel.ERROR, "Notification", "tr_abc", from, to); + + assertNotNull(response); + verify(systemLogService).findAll(pageable, LogLevel.ERROR, "Notification", "tr_abc", from, to); + } + + @Test + void tail_shouldReturnSseEmitter() { + SseEmitter emitter = new SseEmitter(); + when(systemLogService.streamLogs(LogLevel.ERROR)).thenReturn(emitter); + + SseEmitter result = controller.tail(LogLevel.ERROR); + + assertSame(emitter, result); + } + + @Test + void health_shouldReturnOkWithHealthData() { + Map healthData = Map.of("status", "ONLINE", "uptimeMs", 1000L); + when(systemLogService.getSystemHealth()).thenReturn(healthData); + + ResponseEntity> result = controller.health(); + + assertEquals(200, result.getStatusCode().value()); + assertEquals("ONLINE", result.getBody().get("status")); + } + + @Test + void purge_shouldReturnDeletedCount() { + when(systemLogService.purgeOlderThan(30)).thenReturn(42); + + ResponseEntity> result = controller.purge(30); + + assertEquals(200, result.getStatusCode().value()); + assertEquals("42 log entries purged", result.getBody().get("message")); + assertEquals(30, result.getBody().get("olderThanDays")); + } + + @Test + void purge_shouldUseDefaultDays() { + when(systemLogService.purgeOlderThan(30)).thenReturn(0); + + ResponseEntity> result = controller.purge(30); + + assertEquals(200, result.getStatusCode().value()); + assertEquals("0 log entries purged", result.getBody().get("message")); + } +} diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/logging/services/SystemLogServiceTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/logging/services/SystemLogServiceTest.java new file mode 100644 index 0000000..67d7b69 --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/modules/logging/services/SystemLogServiceTest.java @@ -0,0 +1,132 @@ +package com.flexcodelabs.flextuma.modules.logging.services; + +import com.flexcodelabs.flextuma.core.entities.logging.SystemLog; +import com.flexcodelabs.flextuma.core.enums.LogLevel; +import com.flexcodelabs.flextuma.core.repositories.SystemLogRepository; +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 org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class SystemLogServiceTest { + + @Mock + private SystemLogRepository repository; + + @InjectMocks + private SystemLogService service; + + @SuppressWarnings("unchecked") + @Test + void findAll_shouldReturnPaginatedResults() { + Pageable pageable = PageRequest.of(0, 20); + SystemLog log = new SystemLog(); + log.setLevel(LogLevel.ERROR); + log.setSource("TestService"); + log.setMessage("Test error"); + Page expected = new PageImpl<>(List.of(log)); + + when(repository.findAll(any(Specification.class), eq(pageable))).thenReturn(expected); + + Page result = service.findAll(pageable, null, null, null, null, null); + + assertNotNull(result); + assertEquals(1, result.getTotalElements()); + } + + @SuppressWarnings("unchecked") + @Test + void findAll_shouldApplyLevelFilter() { + Pageable pageable = PageRequest.of(0, 20); + Page expected = new PageImpl<>(List.of()); + + when(repository.findAll(any(Specification.class), eq(pageable))).thenReturn(expected); + + Page result = service.findAll(pageable, LogLevel.ERROR, null, null, null, null); + + assertNotNull(result); + } + + @SuppressWarnings("unchecked") + @Test + void findAll_shouldApplyAllFilters() { + Pageable pageable = PageRequest.of(0, 20); + Page expected = new PageImpl<>(List.of()); + LocalDateTime from = LocalDateTime.now().minusDays(7); + LocalDateTime to = LocalDateTime.now(); + + when(repository.findAll(any(Specification.class), eq(pageable))).thenReturn(expected); + + Page result = service.findAll(pageable, LogLevel.WARNING, "Notification", "tr_abc123", from, to); + + assertNotNull(result); + } + + @Test + void purgeOlderThan_shouldDeleteOldLogs() { + when(repository.deleteByTimestampBefore(any(LocalDateTime.class))).thenReturn(42); + + int deleted = service.purgeOlderThan(30); + + assertEquals(42, deleted); + verify(repository).deleteByTimestampBefore(any(LocalDateTime.class)); + } + + @Test + void streamLogs_shouldReturnSseEmitter() { + SseEmitter emitter = service.streamLogs(null); + + assertNotNull(emitter); + } + + @Test + void streamLogs_shouldReturnSseEmitterWithLevelFilter() { + SseEmitter emitter = service.streamLogs(LogLevel.ERROR); + + assertNotNull(emitter); + } + + @Test + void getSystemHealth_shouldReturnHealthInfo() { + Map health = service.getSystemHealth(); + + assertNotNull(health); + assertEquals("ONLINE", health.get("status")); + assertNotNull(health.get("uptime")); + assertNotNull(health.get("uptimeMs")); + assertNotNull(health.get("memory")); + assertNotNull(health.get("activeThreads")); + assertNotNull(health.get("availableProcessors")); + assertNotNull(health.get("retentionDays")); + } + + @SuppressWarnings("unchecked") + @Test + void getSystemHealth_shouldContainMemoryInfo() { + Map health = service.getSystemHealth(); + + Map memory = (Map) health.get("memory"); + assertNotNull(memory); + assertNotNull(memory.get("totalMb")); + assertNotNull(memory.get("freeMb")); + assertNotNull(memory.get("usedMb")); + assertNotNull(memory.get("maxMb")); + } +} diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/CampaignDispatchWorkerTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/CampaignDispatchWorkerTest.java new file mode 100644 index 0000000..34b6e01 --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/CampaignDispatchWorkerTest.java @@ -0,0 +1,125 @@ +package com.flexcodelabs.flextuma.modules.notification.services; + +import com.flexcodelabs.flextuma.core.entities.auth.User; +import com.flexcodelabs.flextuma.core.entities.sms.SmsCampaign; +import com.flexcodelabs.flextuma.core.entities.sms.SmsLog; +import com.flexcodelabs.flextuma.core.enums.SmsCampaignStatus; +import com.flexcodelabs.flextuma.core.helpers.SmsSegmentCalculator; +import com.flexcodelabs.flextuma.core.helpers.SmsSegmentResult; +import com.flexcodelabs.flextuma.core.repositories.SmsCampaignRepository; +import com.flexcodelabs.flextuma.core.repositories.SmsLogRepository; +import com.flexcodelabs.flextuma.modules.finance.services.WalletService; +import org.junit.jupiter.api.BeforeEach; +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 org.springframework.data.domain.Pageable; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class CampaignDispatchWorkerTest { + + @Mock + private SmsCampaignRepository campaignRepository; + + @Mock + private SmsLogRepository logRepository; + + @Mock + private WalletService walletService; + + @Mock + private SmsSegmentCalculator segmentCalculator; + + @InjectMocks + private CampaignDispatchWorker worker; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(worker, "pricePerSegment", new BigDecimal("1.5")); + } + + @Test + void processCampaigns_whenNoCampaignsDue_shouldDoNothing() { + when(campaignRepository.findDueCampaigns(eq(SmsCampaignStatus.SCHEDULED), any(LocalDateTime.class), + any(Pageable.class))) + .thenReturn(Collections.emptyList()); + + worker.processCampaigns(); + + verify(campaignRepository, never()).save(any()); + } + + @Test + void processCampaigns_withDueCampaigns_shouldProcessThem() { + SmsCampaign campaign = new SmsCampaign(); + campaign.setName("Test Campaign"); + campaign.setRecipients("255700112233, 255700445566"); + campaign.setContent("Hello world"); + User adminUser = new User(); + adminUser.setUsername("admin"); + campaign.setCreatedBy(adminUser); + + when(campaignRepository.findDueCampaigns(eq(SmsCampaignStatus.SCHEDULED), any(LocalDateTime.class), + any(Pageable.class))) + .thenReturn(List.of(campaign)); + + when(segmentCalculator.calculate(anyString())).thenReturn(new SmsSegmentResult(1, true, 11, 149)); + + worker.processCampaigns(); + + verify(campaignRepository, atLeastOnce()).save(campaign); + verify(walletService, times(2)).debit(eq(adminUser), any(BigDecimal.class), anyString(), any()); + verify(logRepository, times(2)).save(any(SmsLog.class)); + assert (campaign.getStatus() == SmsCampaignStatus.COMPLETED); + } + + @Test + void processCampaigns_withEmptyRecipients_shouldCompleteImmediately() { + SmsCampaign campaign = new SmsCampaign(); + campaign.setRecipients(""); + + when(campaignRepository.findDueCampaigns(eq(SmsCampaignStatus.SCHEDULED), any(LocalDateTime.class), + any(Pageable.class))) + .thenReturn(List.of(campaign)); + + worker.processCampaigns(); + + verify(campaignRepository, atLeastOnce()).save(campaign); + assert (campaign.getStatus() == SmsCampaignStatus.COMPLETED); + verify(logRepository, never()).save(any()); + } + + @Test + void processCampaigns_whenDebitFails_shouldContinueProcessing() { + SmsCampaign campaign = new SmsCampaign(); + campaign.setRecipients("255700112233"); + User user1 = new User(); + user1.setUsername("user1"); + campaign.setCreatedBy(user1); + + when(campaignRepository.findDueCampaigns(eq(SmsCampaignStatus.SCHEDULED), any(LocalDateTime.class), + any(Pageable.class))) + .thenReturn(List.of(campaign)); + when(segmentCalculator.calculate(any())).thenReturn(new SmsSegmentResult(1, true, 5, 155)); + + doThrow(new RuntimeException("InSufficient Funds")).when(walletService).debit(any(), any(), any(), any()); + + worker.processCampaigns(); + + verify(walletService).debit(any(), any(), any(), any()); + verify(logRepository, never()).save(any()); + assert (campaign.getStatus() == SmsCampaignStatus.COMPLETED); + } +} diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationServiceTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationServiceTest.java index 76b1b36..276d707 100644 --- a/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationServiceTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationServiceTest.java @@ -106,9 +106,9 @@ void queueTemplatedSms_shouldThrowWhenUserNotFound() { @ParameterizedTest @CsvSource({ - "provider, SMS provider is missing", - "templateCode, Template is missing", - "phoneNumber, Phone number is missing" + "provider, provider is missing", + "templateCode, templateCode is missing", + "phoneNumber, phoneNumber is missing" }) void queueTemplatedSms_shouldThrowWhenRequiredPlaceholderMissing(String missingKey, String expectedMessage) { when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(testUser)); diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/sms/controllers/SmsConnectorControllerTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/sms/controllers/SmsConnectorControllerTest.java index 6863dfd..32ef7a2 100644 --- a/src/test/java/com/flexcodelabs/flextuma/modules/sms/controllers/SmsConnectorControllerTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/modules/sms/controllers/SmsConnectorControllerTest.java @@ -36,6 +36,6 @@ protected SmsConnector createEntity() { @Override protected String getBaseUrl() { - return "/api/smsConnectors"; + return "/api/" + SmsConnector.PLURAL; } } diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/sms/services/SmsLogServiceTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/sms/services/SmsLogServiceTest.java index 5816026..0754ae4 100644 --- a/src/test/java/com/flexcodelabs/flextuma/modules/sms/services/SmsLogServiceTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/modules/sms/services/SmsLogServiceTest.java @@ -72,7 +72,7 @@ void retryFailedMessage_LogNotFound() { try (MockedStatic utils = mockStatic( com.flexcodelabs.flextuma.core.security.SecurityUtils.class)) { utils.when(com.flexcodelabs.flextuma.core.security.SecurityUtils::getCurrentUserAuthorities) - .thenReturn(Set.of("UPDATE_SMS_LOGS")); + .thenReturn(Set.of(SmsLog.UPDATE)); when(smsLogRepository.findById(logId)).thenReturn(Optional.empty()); From 470f9877c5d76f47cd05d6871e3b56adb9d3a51d Mon Sep 17 00:00:00 2001 From: Bennett Date: Fri, 6 Mar 2026 21:11:05 +0300 Subject: [PATCH 3/4] release: Add logs module --- .github/workflows/release.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3c0005f..bf8ef47 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -127,4 +127,9 @@ jobs: echo "Pushing images..." docker push $IMAGE_NAME:$VERSION - docker push $IMAGE_NAME:latest \ No newline at end of file + docker push $IMAGE_NAME:latest + + - name: 🔀 + uses: BaharaJr/merge-pr@0.0.1 + with: + GITHUB_TOKEN: ${{ secrets.TOKEN }} \ No newline at end of file From 100daee160d927078d5154a3acca9eea37ac4c74 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 6 Mar 2026 18:11:55 +0000 Subject: [PATCH 4/4] Release v0.0.3 [skip ci] --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 3c2f1e3..9ccd7b2 100644 --- a/build.gradle +++ b/build.gradle @@ -8,7 +8,7 @@ plugins { } group = 'com.flexcodelabs' -version = '0.0.2' +version = '0.0.3' description = 'Flextuma App' java {