feat(sync): implement Delta-Sync API and update clients to support offline-first workflow

Added `/ping/sync` endpoint with timestamp-based Delta-Sync functionality to efficiently support offline-first clients. Extended `PingApi` and frontend clients (`PingApiClient`, `PingApiKoinClient`) with `syncPings`. Updated repository, service, and controller logic for sync handling, including new JPA query `findByCreatedAtAfter`. Adjusted test doubles and completed unit tests for backend and frontend alignment. Documented sync approach and API usage.
This commit is contained in:
2026-01-17 12:22:16 +01:00
parent 59568a42d8
commit 351fe7a672
15 changed files with 190 additions and 62 deletions
@@ -8,6 +8,7 @@ import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
import java.time.Instant
/**
* Application Service.
@@ -43,4 +44,10 @@ class PingService(
override fun getPing(id: Uuid): Ping? {
return repository.findById(id)
}
@Transactional(readOnly = true)
override fun getPingsSince(timestamp: Long): List<Ping> {
val instant = Instant.ofEpochMilli(timestamp)
return repository.findByTimestampAfter(instant)
}
}
@@ -13,4 +13,5 @@ interface PingUseCase {
fun executePing(message: String): Ping
fun getPingHistory(): List<Ping>
fun getPing(id: Uuid): Ping?
fun getPingsSince(timestamp: Long): List<Ping>
}
@@ -2,6 +2,7 @@ package at.mocode.ping.domain
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
import java.time.Instant
/**
* Secondary Port (Outbound Port).
@@ -12,4 +13,5 @@ interface PingRepository {
fun save(ping: Ping): Ping
fun findAll(): List<Ping>
fun findById(id: Uuid): Ping?
fun findByTimestampAfter(timestamp: Instant): List<Ping>
}
@@ -8,6 +8,7 @@ import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
import kotlin.uuid.toJavaUuid
import kotlin.uuid.toKotlinUuid
import java.time.Instant
@OptIn(ExperimentalUuidApi::class)
@Repository
@@ -30,6 +31,10 @@ class PingRepositoryAdapter(
return jpaRepository.findById(id.toJavaUuid()).map { it.toDomain() }.orElse(null)
}
override fun findByTimestampAfter(timestamp: Instant): List<Ping> {
return jpaRepository.findByCreatedAtAfter(timestamp).map { it.toDomain() }
}
private fun Ping.toEntity() = PingJpaEntity(
id = this.id.toJavaUuid(),
message = this.message,
@@ -2,5 +2,8 @@ package at.mocode.ping.infrastructure.persistence
import org.springframework.data.jpa.repository.JpaRepository
import java.util.UUID
import java.time.Instant
interface SpringDataPingRepository : JpaRepository<PingJpaEntity, UUID>
interface SpringDataPingRepository : JpaRepository<PingJpaEntity, UUID> {
fun findByCreatedAtAfter(createdAt: Instant): List<PingJpaEntity>
}
@@ -3,6 +3,7 @@ package at.mocode.ping.infrastructure.web
import at.mocode.ping.api.EnhancedPingResponse
import at.mocode.ping.api.HealthResponse
import at.mocode.ping.api.PingApi
import at.mocode.ping.api.PingEvent
import at.mocode.ping.api.PingResponse
import at.mocode.ping.application.PingUseCase
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker
@@ -12,6 +13,7 @@ import org.springframework.web.bind.annotation.*
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import kotlin.random.Random
import kotlin.uuid.ExperimentalUuidApi
/**
* Driving Adapter (REST Controller).
@@ -20,6 +22,7 @@ import kotlin.random.Random
@RestController
// Spring requires using `originPatterns` (not wildcard `origins`) when credentials are enabled.
@CrossOrigin(allowedHeaders = ["*"], allowCredentials = "true", originPatterns = ["*"])
@OptIn(ExperimentalUuidApi::class)
class PingController(
private val pingUseCase: PingUseCase
) : PingApi {
@@ -75,6 +78,19 @@ class PingController(
return createResponse(domainPing, "secure-pong")
}
@GetMapping("/ping/sync")
override suspend fun syncPings(
@RequestParam(required = false, defaultValue = "0") lastSyncTimestamp: Long
): List<PingEvent> {
return pingUseCase.getPingsSince(lastSyncTimestamp).map {
PingEvent(
id = it.id.toString(),
message = it.message,
lastModified = it.timestamp.toEpochMilli()
)
}
}
// Helper
private fun createResponse(domainPing: at.mocode.ping.domain.Ping, status: String) = PingResponse(
status = status,
@@ -41,7 +41,7 @@ import java.time.Instant
@ContextConfiguration(classes = [TestPingServiceApplication::class])
@ActiveProfiles("test")
@Import(PingControllerTest.PingControllerTestConfig::class)
@AutoConfigureMockMvc
@AutoConfigureMockMvc(addFilters = false) // Disable security filters for unit tests
class PingControllerTest {
@Autowired
@@ -140,4 +140,34 @@ class PingControllerTest {
assertThat(json["status"].asText()).isEqualTo("up")
assertThat(json["service"].asText()).isEqualTo("ping-service")
}
@Test
fun `should return sync pings`() {
// Given
val timestamp = 1696154400000L // 2023-10-01T10:00:00Z
every { pingUseCase.getPingsSince(timestamp) } returns listOf(
Ping(
message = "Sync Ping",
timestamp = Instant.ofEpochMilli(timestamp + 1000)
)
)
// When & Then
val mvcResult: MvcResult = mockMvc.perform(get("/ping/sync").param("lastSyncTimestamp", timestamp.toString()))
.andExpect(request().asyncStarted())
.andReturn()
val result = mockMvc.perform(asyncDispatch(mvcResult))
.andExpect(status().isOk)
.andReturn()
val body = result.response.contentAsString
val json = objectMapper.readTree(body)
assertThat(json.isArray).isTrue
assertThat(json.size()).isEqualTo(1)
assertThat(json[0]["message"].asText()).isEqualTo("Sync Ping")
assertThat(json[0]["lastModified"].asLong()).isEqualTo(timestamp + 1000)
verify { pingUseCase.getPingsSince(timestamp) }
}
}