Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
71 commits
Select commit Hold shift + click to select a range
a4f4aab
initial design - needs prototype
oguzkocer Nov 3, 2025
22be586
Add initial Entity pattern and service layer prototype
oguzkocer Nov 3, 2025
2f5046b
Implement Entity data() method with closure-based reading
oguzkocer Nov 4, 2025
3075193
Rename data() to load_data() to communicate operation cost
oguzkocer Nov 4, 2025
4f7aa29
Add unit test infrastructure and first test for PostService
oguzkocer Nov 4, 2025
14a75d6
Add entity pattern test and refactor test helpers
oguzkocer Nov 4, 2025
e17c41b
Fix UniFFI error field naming from message to err_message
oguzkocer Nov 6, 2025
7e6d49f
Add Entity update relevance checking for platform observers
oguzkocer Nov 6, 2025
dfd6397
Add temporary mock post methods for testing
oguzkocer Nov 6, 2025
18573bc
Implement Kotlin ObservableEntity pattern
oguzkocer Nov 6, 2025
cf5865e
Add integration tests for observable pattern
oguzkocer Nov 6, 2025
8a0d5b9
Refactor WpSelfHostedService to use Arc<PostService>
oguzkocer Nov 6, 2025
7d103fe
Rename get_entity() to get_entity_with_edit_context()
oguzkocer Nov 6, 2025
af2893c
Add EntityId and FullEntity prototype to wp_mobile_cache
oguzkocer Nov 6, 2025
b0fd6c8
Update PostRepository methods to return FullEntity wrapper
oguzkocer Nov 6, 2025
847eaba
Update SiteRepository methods to return FullEntity wrapper
oguzkocer Nov 6, 2025
481cf60
Change EntityId to store DbSite and use RowId type
oguzkocer Nov 6, 2025
83e4fc9
Use &'static str for EntityId table_name field
oguzkocer Nov 6, 2025
6d94c9d
Move Entity<T> to wp_mobile_cache
oguzkocer Nov 6, 2025
2ce9161
Update Entity::load_data() to return FullEntity<T>
oguzkocer Nov 6, 2025
7ac7433
Generate FullEntity wrapper type for UniFFI compatibility
oguzkocer Nov 6, 2025
89618dc
Simplify wp_mobile_entity! macro to auto-generate FullEntity name
oguzkocer Nov 6, 2025
d5c0b61
Consolidate entity types into single entity.rs file
oguzkocer Nov 6, 2025
83f5eb3
Add async load_data_async() method to Entity wrappers
oguzkocer Nov 6, 2025
51303b2
Make ObservableEntity generic over data type
oguzkocer Nov 6, 2025
a810d39
Return EntityId from repository upsert methods
oguzkocer Nov 7, 2025
b8da091
Update wp_mobile to use EntityId from repositories
oguzkocer Nov 7, 2025
baa9bda
Update Kotlin bindings to use EntityId
oguzkocer Nov 7, 2025
3778426
Extract mock post methods into MockPostService
oguzkocer Nov 7, 2025
bbffe68
Add bulk insert and count methods for stress testing
oguzkocer Nov 7, 2025
1a4b14a
Add background thread stress testing for observables
oguzkocer Nov 7, 2025
904c1fa
Fix SQLite concurrency deadlock with scoped connection access
oguzkocer Nov 7, 2025
6f8bb7b
Refactor MockPostService to be independent of PostService
oguzkocer Nov 8, 2025
12b2c5f
Add EntityKey for HashMap usage across language boundaries
oguzkocer Nov 8, 2025
580d1ad
Add stress test ViewModel and DI setup for observability testing
oguzkocer Nov 8, 2025
e23afa9
Add stress test UI with navigation and real-time metrics display
oguzkocer Nov 8, 2025
5c66f2e
Fix threading in stress test ViewModel for proper performance
oguzkocer Nov 8, 2025
adebf8d
Add comprehensive stress test with batch updates and multiplatform su…
oguzkocer Nov 8, 2025
2e4983a
Add AllPostsWithEditContextCollection for table-level observation
oguzkocer Nov 8, 2025
74af60e
Add ObservableCollection for Kotlin and update StressTestViewModel
oguzkocer Nov 8, 2025
6c345f9
Add DbTable enum as single source of truth for table names
oguzkocer Nov 11, 2025
a438e92
Refactor collections to use closure-based pattern with multi-table su…
oguzkocer Nov 11, 2025
01a802e
Add runComposeDesktopApp Make target for launching desktop app
oguzkocer Nov 11, 2025
41f8fa0
Add performance metrics tracking to StressTestViewModel
oguzkocer Nov 11, 2025
b9bd911
Fix Android threading issues and add performance metrics
oguzkocer Nov 11, 2025
ddd7167
Move NaiveCollection from wp_mobile_cache to wp_mobile
oguzkocer Nov 13, 2025
f313c87
Add FetchError and FetchResult types for networking
oguzkocer Nov 13, 2025
abef30d
Add AnyPostFilter with status field
oguzkocer Nov 13, 2025
0988bcb
Add select_by_filter method to PostRepository
oguzkocer Nov 13, 2025
fec18af
Refactor select_all to use select_by_filter
oguzkocer Nov 13, 2025
f2c200d
Add fetch_posts_page networking primitive to PostService
oguzkocer Nov 13, 2025
a6e6577
Add PostCollection with direct PostService dependency
oguzkocer Nov 13, 2025
f6a1b72
Add wp_mobile_post_collection! macro for UniFFI wrappers
oguzkocer Nov 13, 2025
b055452
Export create_post_collection_with_edit_context via UniFFI and add Ko…
oguzkocer Nov 13, 2025
87f1f2d
Update stress test to use PostCollection with AnyPostFilter
oguzkocer Nov 13, 2025
f21e2a2
Add PostCollection demo screen with filtering and pagination
oguzkocer Nov 14, 2025
93551d0
Remove working design documents
oguzkocer Nov 16, 2025
c243968
Convert EntityId to uniffi::Record and remove EntityKey
oguzkocer Nov 16, 2025
43e5606
Add non_exhaustive to DbTable enum
oguzkocer Nov 16, 2025
c97316e
Optimize term loading in single-post repository methods
oguzkocer Nov 16, 2025
cb77b68
Polish wp_mobile_cache for PR review
oguzkocer Nov 16, 2025
18f5be8
Refactor WpSelfHostedService to accept explicit site URLs
oguzkocer Nov 16, 2025
cce0220
Improve error handling in SiteService::get_current_site_info
oguzkocer Nov 16, 2025
4870ee3
Reorganize wp_mobile module structure for better organization
oguzkocer Nov 16, 2025
626a7fa
Remove dummy wp_mobile_crate_works test function
oguzkocer Nov 16, 2025
fe14fb6
Rename NaiveCollection to StatelessCollection
oguzkocer Nov 16, 2025
d5bc0fc
Simplify load_data API to async-only for UniFFI clients
oguzkocer Nov 16, 2025
34f8c79
Add AutoCloseable lifecycle management to observables
oguzkocer Nov 16, 2025
f6f3603
Remove naive_collection_performance_metrics.md
oguzkocer Nov 16, 2025
f7764d5
Fix doc test in wp_api
oguzkocer Nov 16, 2025
ddd354f
Fix doc tests in wp_mobile_cache by marking complex examples as ignore
oguzkocer Nov 16, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ linkify = "0.10.0"
parse_link_header = "0.4"
paste = "1.0"
proc-macro-crate = "3.4.0"
rand = "0.8"
proc-macro2 = "1.0"
quote = "1.0"
regex = "1.12"
Expand All @@ -57,6 +58,7 @@ rocket_dyn_templates = "0.2.0"
roxmltree = "0.21"
rstest = "0.26"
rstest_reuse = "0.7.0"
rusqlite = "0.37.0"
rustls = "0.23.35"
scraper = "0.24"
semver = "1.0"
Expand Down
4 changes: 4 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,10 @@ test-kotlin-integration:
@# Help: Run Kotlin integration tests in test server.
docker exec -i wordpress /bin/bash < ./scripts/run-kotlin-integration-tests.sh

runComposeDesktopApp:
@# Help: Run the Compose Multiplatform desktop application.
cd native/kotlin && ./gradlew :example:composeApp:run

restore-test-server:
@# Help: Restore the test server from backup.
curl "http://localhost:4000/restore?db=true&plugins=true"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,16 @@ package rs.wordpress.api.kotlin

import okhttp3.OkHttpClient
import okhttp3.Request
import uniffi.wp_api.ParsedUrl
import uniffi.wp_api.UserId
import uniffi.wp_api.WpApiClientDelegate
import uniffi.wp_api.WpApiMiddlewarePipeline
import uniffi.wp_api.WpAuthenticationProvider
import uniffi.wp_api.WpErrorCode
import uniffi.wp_api.WpOrgSiteApiUrlResolver
import uniffi.wp_mobile.MockPostService
import uniffi.wp_mobile.WpSelfHostedService
import rs.wordpress.cache.kotlin.WordPressApiCache

const val FIRST_USER_ID: UserId = 1
const val SECOND_USER_ID: UserId = 2
Expand All @@ -24,6 +31,52 @@ fun defaultApiClient(): WpApiClient {
return WpApiClient(testCredentials.apiRootUrl, authProvider)
}

data class TestServiceContext(
val service: WpSelfHostedService,
val mockPostService: MockPostService
)

fun createTestServiceContext(): TestServiceContext {
// Create and migrate cache
val wordPressApiCache = WordPressApiCache()
wordPressApiCache.performMigrations()

val testCredentials = TestCredentials.INSTANCE

// Create auth provider
val authProvider = WpAuthenticationProvider.staticWithUsernameAndPassword(
username = testCredentials.adminUsername,
password = testCredentials.adminPassword
)

val apiRootUrl = testCredentials.apiRootUrl.toString()
// Extract site URL by removing /wp-json suffix
val siteUrl = apiRootUrl.removeSuffix("/wp-json")

// Create self-hosted service
val service = WpSelfHostedService(
siteUrl = siteUrl,
apiRoot = apiRootUrl,
apiUrlResolver = WpOrgSiteApiUrlResolver(apiRootUrl = ParsedUrl.parse(apiRootUrl)),
delegate = WpApiClientDelegate(
authProvider,
requestExecutor = WpRequestExecutor(),
middlewarePipeline = WpApiMiddlewarePipeline(emptyList()),
appNotifier = EmptyAppNotifier()
),
cache = wordPressApiCache.cache
)

// Create mock post service with shared cache
val mockPostService = MockPostService(
wordPressApiCache.cache,
siteUrl,
apiRootUrl
)

return TestServiceContext(service, mockPostService)
}

fun <T> WpRequestResult<T>.assertSuccess() {
assert(this is WpRequestResult.Success)
}
Expand All @@ -44,4 +97,4 @@ fun restoreTestServer() {
OkHttpClient().newCall(
Request.Builder().url("http://localhost:4000/restore?db=true&plugins=true").build()
).execute()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.parallel.Execution
import org.junit.jupiter.api.parallel.ExecutionMode
import rs.wordpress.api.kotlin.createTestServiceContext
import rs.wordpress.cache.kotlin.getObservableEntityWithEditContext
import java.util.concurrent.atomic.AtomicInteger
import kotlin.test.assertEquals

/**
* Tests for the Kotlin-specific ObservableEntity pattern.
*
* ObservableEntity is a platform-native wrapper that:
* - Wraps Entity<T> from Rust
* - Manages observers in Kotlin memory
* - Automatically receives database change notifications
* - Filters updates using Entity's is_relevant_update() method
*/
@Execution(ExecutionMode.CONCURRENT)
class ObservableEntityTest {

@Test
fun `observable entity notifies observers when database updates`() = runTest {
val context = createTestServiceContext()
val postService = context.service.posts()
val mockPostService = context.mockPostService

// Setup: Insert a test post
val postId = 42L
val entityId = mockPostService.insertMockPost(postId, "Original Title")

// Create observable entity and add observer
val observableEntity = postService.getObservableEntityWithEditContext(entityId)
val callCount = AtomicInteger(0)

observableEntity.addObserver {
callCount.incrementAndGet()
}

// Observer should not have been called yet
assertEquals(0, callCount.get())

// Update the post - should trigger observer
mockPostService.updateMockPost(postId, "Updated Title")

// Verify observer was called exactly once
assertEquals(1, callCount.get())

// Verify data was actually updated
val fullEntity = observableEntity.loadData()!!
assertEquals("Updated Title", fullEntity.data.title.rendered)
}

@Test
fun `observable entity supports multiple observers`() = runTest {
val context = createTestServiceContext()
val postService = context.service.posts()
val mockPostService = context.mockPostService

val postId = 100L
val entityId = mockPostService.insertMockPost(postId, "Multi Observer Test")

val observableEntity = postService.getObservableEntityWithEditContext(entityId)

val observer1Calls = AtomicInteger(0)
val observer2Calls = AtomicInteger(0)

observableEntity.addObserver { observer1Calls.incrementAndGet() }
observableEntity.addObserver { observer2Calls.incrementAndGet() }

// Update should trigger both observers
mockPostService.updateMockPost(postId, "Updated")

assertEquals(1, observer1Calls.get())
assertEquals(1, observer2Calls.get())
}

@Test
fun `observable entity only fires for relevant updates`() = runTest {
val context = createTestServiceContext()
val postService = context.service.posts()
val mockPostService = context.mockPostService

// Create two different posts
val post1Id = 200L
val post2Id = 201L
val entity1Id = mockPostService.insertMockPost(post1Id, "Post 1")
mockPostService.insertMockPost(post2Id, "Post 2")

// Create observable entity for post1
val observablePost1 = postService.getObservableEntityWithEditContext(entity1Id)
val post1Calls = AtomicInteger(0)
observablePost1.addObserver { post1Calls.incrementAndGet() }

// Update post2 - should NOT trigger post1's observer
mockPostService.updateMockPost(post2Id, "Post 2 Updated")
assertEquals(0, post1Calls.get(), "Observer should not fire for unrelated post")

// Update post1 - SHOULD trigger observer
mockPostService.updateMockPost(post1Id, "Post 1 Updated")
assertEquals(1, post1Calls.get(), "Observer should fire for relevant post")
}

@Test
fun `observers can be removed`() = runTest {
val context = createTestServiceContext()
val postService = context.service.posts()
val mockPostService = context.mockPostService

val postId = 300L
val entityId = mockPostService.insertMockPost(postId, "Remove Test")

val observableEntity = postService.getObservableEntityWithEditContext(entityId)
val callCount = AtomicInteger(0)

val observer = { callCount.incrementAndGet(); Unit }
observableEntity.addObserver(observer)

// First update - should fire
mockPostService.updateMockPost(postId, "Update 1")
assertEquals(1, callCount.get())

// Remove observer
observableEntity.removeObserver(observer)

// Second update - should NOT fire
mockPostService.updateMockPost(postId, "Update 2")
assertEquals(1, callCount.get(), "Observer should not fire after removal")
}

@Test
fun `bulk insert posts and verify count`() = runTest {
val context = createTestServiceContext()
val postService = context.service.posts()
val mockPostService = context.mockPostService

// Verify initial count is 0
val initialCount = postService.countEditContext()
assertEquals(0, initialCount)

// Generate and insert 50 posts
val postCount = 50u
val entityIds = mockPostService.generateAndInsertPosts(postCount)

// Verify the correct number of entity IDs were returned
assertEquals(postCount.toInt(), entityIds.size)

// Verify the count matches the number of posts inserted
val finalCount = postService.countEditContext()
assertEquals(postCount.toLong(), finalCount)
}

@Test
fun `stress test with random updates triggers observers`() = runTest {
val context = createTestServiceContext()
val postService = context.service.posts()
val mockPostService = context.mockPostService

// Generate a small set of posts for stress testing
val postCount = 5u
val entityIds = mockPostService.generateAndInsertPosts(postCount)

// Create observables for all posts and count notifications
val observedCount = AtomicInteger(0)
val observables = entityIds.map { entityId ->
postService.getObservableEntityWithEditContext(entityId).apply {
addObserver { observedCount.incrementAndGet() }
}
}

// Start random updates with 50ms delay
val handle = mockPostService.startRandomUpdates(entityIds, 0.05)

// Let it run for ~500ms (should get roughly 10 updates)
Thread.sleep(500)

// Stop the updates
handle.stop()

// Get the final counts
val updateCount = handle.updateCount()
val totalObserved = observedCount.get()

// Verify we got a reasonable number of updates
// Update count should be > 0 and roughly around 10 (500ms / 50ms)
assert(updateCount > 0u) { "Should have performed some updates" }
assert(updateCount >= 8u) { "Should have performed at least 8 updates in 500ms with 50ms delay" }

// Observed count should be close to update count
// It might be slightly less due to timing, but should be reasonably close
assert(totalObserved > 0) { "Should have observed some updates" }
assert(totalObserved.toULong() >= updateCount - 2u) {
"Observed count ($totalObserved) should be close to update count ($updateCount)"
}

println("Stress test completed: $updateCount updates, $totalObserved observed events")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,12 @@ import uniffi.wp_api.SparseUserFieldWithEditContext
import uniffi.wp_api.UserListParams
import uniffi.wp_api.WpApiParamUsersHasPublishedPosts
import uniffi.wp_api.WpErrorCode
import uniffi.wp_mobile.wpMobileCrateWorks
import kotlin.test.assertEquals
import kotlin.test.assertNull

class UsersEndpointTest {
private val client = defaultApiClient()

@Test
fun testThatWpMobileCrateWorks() = runTest {
assertEquals("foo is bar", wpMobileCrateWorks("bar"))
}

@Test
fun testUserListRequest() = runTest {
val userList = client.request { requestBuilder ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import org.junit.jupiter.api.Test
import org.junit.jupiter.api.parallel.Execution
import org.junit.jupiter.api.parallel.ExecutionMode
import rs.wordpress.cache.kotlin.WordPressApiCache
import rs.wordpress.cache.kotlin.WordPressApiCacheDelegate
import kotlin.test.assertEquals

@Execution(ExecutionMode.CONCURRENT)
Expand All @@ -13,20 +12,4 @@ class WordPressApiCacheTest {
fun testThatMigrationsWork() = runTest {
assertEquals(6, WordPressApiCache().performMigrations())
}

@Test
fun testBackgroundUpdateNotificationsWork() = runTest {
var updateCount = 0
val delegate = WordPressApiCacheDelegate(
callback = { updateHook ->
updateCount += 1
}
)

val cache = WordPressApiCache(delegate = delegate)
cache.startListeningForUpdates()

val migrationCount = cache.performMigrations()
assertEquals(updateCount, migrationCount)
}
}
Loading