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:
+7
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -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
@@ -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>
|
||||
}
|
||||
|
||||
+5
@@ -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,
|
||||
|
||||
+4
-1
@@ -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>
|
||||
}
|
||||
|
||||
+16
@@ -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,
|
||||
|
||||
+31
-1
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user