diff --git a/app/src/main/java/com/swoosh/microblog/data/api/GhostApiService.kt b/app/src/main/java/com/swoosh/microblog/data/api/GhostApiService.kt index cd41155..75e0af2 100644 --- a/app/src/main/java/com/swoosh/microblog/data/api/GhostApiService.kt +++ b/app/src/main/java/com/swoosh/microblog/data/api/GhostApiService.kt @@ -1,5 +1,6 @@ package com.swoosh.microblog.data.api +import com.swoosh.microblog.data.model.MembersResponse import com.swoosh.microblog.data.model.PostWrapper import com.swoosh.microblog.data.model.PostsResponse import okhttp3.MultipartBody @@ -37,6 +38,21 @@ interface GhostApiService { @Path("id") id: String ): Response + @GET("ghost/api/admin/members/") + suspend fun getMembers( + @Query("limit") limit: Int = 15, + @Query("page") page: Int = 1, + @Query("order") order: String = "created_at desc", + @Query("filter") filter: String? = null, + @Query("include") include: String = "newsletters,labels" + ): Response + + @GET("ghost/api/admin/members/{id}/") + suspend fun getMember( + @Path("id") id: String, + @Query("include") include: String = "newsletters,labels" + ): Response + @GET("ghost/api/admin/users/me/") suspend fun getCurrentUser(): Response diff --git a/app/src/main/java/com/swoosh/microblog/data/model/MemberModels.kt b/app/src/main/java/com/swoosh/microblog/data/model/MemberModels.kt new file mode 100644 index 0000000..bab6ceb --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/data/model/MemberModels.kt @@ -0,0 +1,35 @@ +package com.swoosh.microblog.data.model + +data class MembersResponse( + val members: List, + val meta: Meta? +) + +data class GhostMember( + val id: String, + val email: String?, + val name: String?, + val status: String?, // "free" or "paid" + val avatar_image: String?, + val email_count: Int?, + val email_opened_count: Int?, + val email_open_rate: Double?, + val last_seen_at: String?, + val created_at: String?, + val updated_at: String?, + val labels: List?, + val newsletters: List?, + val subscriptions: List?, + val note: String?, + val geolocation: String? +) + +data class MemberLabel(val id: String?, val name: String, val slug: String?) +data class MemberNewsletter(val id: String, val name: String?, val slug: String?) +data class MemberSubscription( + val id: String?, val status: String?, val start_date: String?, + val current_period_end: String?, val cancel_at_period_end: Boolean?, + val price: SubscriptionPrice?, val tier: SubscriptionTier? +) +data class SubscriptionPrice(val amount: Int?, val currency: String?, val interval: String?) +data class SubscriptionTier(val id: String?, val name: String?) diff --git a/app/src/test/java/com/swoosh/microblog/data/model/MemberModelsTest.kt b/app/src/test/java/com/swoosh/microblog/data/model/MemberModelsTest.kt new file mode 100644 index 0000000..f624544 --- /dev/null +++ b/app/src/test/java/com/swoosh/microblog/data/model/MemberModelsTest.kt @@ -0,0 +1,280 @@ +package com.swoosh.microblog.data.model + +import com.google.gson.Gson +import org.junit.Assert.* +import org.junit.Test + +class MemberModelsTest { + + private val gson = Gson() + + @Test + fun `GhostMember deserializes from JSON correctly`() { + val json = """{ + "id": "member1", + "email": "test@example.com", + "name": "John Doe", + "status": "free", + "avatar_image": "https://example.com/avatar.jpg", + "email_count": 10, + "email_opened_count": 5, + "email_open_rate": 50.0, + "last_seen_at": "2026-03-15T10:00:00.000Z", + "created_at": "2026-01-01T00:00:00.000Z", + "updated_at": "2026-03-15T10:00:00.000Z", + "note": "VIP member", + "geolocation": "Warsaw, Poland" + }""" + val member = gson.fromJson(json, GhostMember::class.java) + assertEquals("member1", member.id) + assertEquals("test@example.com", member.email) + assertEquals("John Doe", member.name) + assertEquals("free", member.status) + assertEquals("https://example.com/avatar.jpg", member.avatar_image) + assertEquals(10, member.email_count) + assertEquals(5, member.email_opened_count) + assertEquals(50.0, member.email_open_rate!!, 0.001) + assertEquals("VIP member", member.note) + assertEquals("Warsaw, Poland", member.geolocation) + } + + @Test + fun `GhostMember deserializes with missing optional fields`() { + val json = """{"id": "member2"}""" + val member = gson.fromJson(json, GhostMember::class.java) + assertEquals("member2", member.id) + assertNull(member.email) + assertNull(member.name) + assertNull(member.status) + assertNull(member.avatar_image) + assertNull(member.email_count) + assertNull(member.email_opened_count) + assertNull(member.email_open_rate) + assertNull(member.last_seen_at) + assertNull(member.created_at) + assertNull(member.updated_at) + assertNull(member.labels) + assertNull(member.newsletters) + assertNull(member.subscriptions) + assertNull(member.note) + assertNull(member.geolocation) + } + + @Test + fun `GhostMember deserializes with labels`() { + val json = """{ + "id": "member3", + "labels": [ + {"id": "label1", "name": "VIP", "slug": "vip"}, + {"id": "label2", "name": "Beta", "slug": "beta"} + ] + }""" + val member = gson.fromJson(json, GhostMember::class.java) + assertEquals(2, member.labels?.size) + assertEquals("VIP", member.labels?.get(0)?.name) + assertEquals("vip", member.labels?.get(0)?.slug) + assertEquals("Beta", member.labels?.get(1)?.name) + } + + @Test + fun `GhostMember deserializes with newsletters`() { + val json = """{ + "id": "member4", + "newsletters": [ + {"id": "nl1", "name": "Weekly Digest", "slug": "weekly-digest"}, + {"id": "nl2", "name": "Product Updates", "slug": "product-updates"} + ] + }""" + val member = gson.fromJson(json, GhostMember::class.java) + assertEquals(2, member.newsletters?.size) + assertEquals("Weekly Digest", member.newsletters?.get(0)?.name) + assertEquals("nl2", member.newsletters?.get(1)?.id) + } + + @Test + fun `GhostMember deserializes with subscriptions`() { + val json = """{ + "id": "member5", + "status": "paid", + "subscriptions": [ + { + "id": "sub1", + "status": "active", + "start_date": "2026-01-01T00:00:00.000Z", + "current_period_end": "2026-04-01T00:00:00.000Z", + "cancel_at_period_end": false, + "price": {"amount": 500, "currency": "USD", "interval": "month"}, + "tier": {"id": "tier1", "name": "Gold"} + } + ] + }""" + val member = gson.fromJson(json, GhostMember::class.java) + assertEquals("paid", member.status) + assertEquals(1, member.subscriptions?.size) + val sub = member.subscriptions!![0] + assertEquals("sub1", sub.id) + assertEquals("active", sub.status) + assertEquals(false, sub.cancel_at_period_end) + assertEquals(500, sub.price?.amount) + assertEquals("USD", sub.price?.currency) + assertEquals("month", sub.price?.interval) + assertEquals("Gold", sub.tier?.name) + } + + @Test + fun `MembersResponse deserializes with members and pagination`() { + val json = """{ + "members": [ + {"id": "m1", "email": "a@example.com", "name": "Alice", "status": "free"}, + {"id": "m2", "email": "b@example.com", "name": "Bob", "status": "paid"} + ], + "meta": { + "pagination": { + "page": 1, "limit": 15, "pages": 3, "total": 42, "next": 2, "prev": null + } + } + }""" + val response = gson.fromJson(json, MembersResponse::class.java) + assertEquals(2, response.members.size) + assertEquals("m1", response.members[0].id) + assertEquals("Alice", response.members[0].name) + assertEquals("free", response.members[0].status) + assertEquals("Bob", response.members[1].name) + assertEquals("paid", response.members[1].status) + assertEquals(1, response.meta?.pagination?.page) + assertEquals(3, response.meta?.pagination?.pages) + assertEquals(42, response.meta?.pagination?.total) + assertEquals(2, response.meta?.pagination?.next) + assertNull(response.meta?.pagination?.prev) + } + + @Test + fun `MembersResponse deserializes with empty members list`() { + val json = """{ + "members": [], + "meta": { + "pagination": { + "page": 1, "limit": 15, "pages": 0, "total": 0, "next": null, "prev": null + } + } + }""" + val response = gson.fromJson(json, MembersResponse::class.java) + assertTrue(response.members.isEmpty()) + assertEquals(0, response.meta?.pagination?.total) + } + + @Test + fun `MemberLabel deserializes correctly`() { + val json = """{"id": "l1", "name": "Premium", "slug": "premium"}""" + val label = gson.fromJson(json, MemberLabel::class.java) + assertEquals("l1", label.id) + assertEquals("Premium", label.name) + assertEquals("premium", label.slug) + } + + @Test + fun `MemberLabel allows null id and slug`() { + val json = """{"name": "Test"}""" + val label = gson.fromJson(json, MemberLabel::class.java) + assertNull(label.id) + assertEquals("Test", label.name) + assertNull(label.slug) + } + + @Test + fun `MemberNewsletter deserializes correctly`() { + val json = """{"id": "n1", "name": "Daily News", "slug": "daily-news"}""" + val newsletter = gson.fromJson(json, MemberNewsletter::class.java) + assertEquals("n1", newsletter.id) + assertEquals("Daily News", newsletter.name) + assertEquals("daily-news", newsletter.slug) + } + + @Test + fun `SubscriptionPrice deserializes correctly`() { + val json = """{"amount": 1000, "currency": "EUR", "interval": "year"}""" + val price = gson.fromJson(json, SubscriptionPrice::class.java) + assertEquals(1000, price.amount) + assertEquals("EUR", price.currency) + assertEquals("year", price.interval) + } + + @Test + fun `SubscriptionTier deserializes correctly`() { + val json = """{"id": "t1", "name": "Premium"}""" + val tier = gson.fromJson(json, SubscriptionTier::class.java) + assertEquals("t1", tier.id) + assertEquals("Premium", tier.name) + } + + @Test + fun `GhostMember serializes to JSON correctly`() { + val member = GhostMember( + id = "m1", + email = "test@test.com", + name = "Test User", + status = "free", + avatar_image = null, + email_count = 5, + email_opened_count = 3, + email_open_rate = 60.0, + last_seen_at = null, + created_at = "2026-01-01T00:00:00.000Z", + updated_at = null, + labels = emptyList(), + newsletters = emptyList(), + subscriptions = null, + note = null, + geolocation = null + ) + val json = gson.toJson(member) + assertTrue(json.contains("\"id\":\"m1\"")) + assertTrue(json.contains("\"email\":\"test@test.com\"")) + assertTrue(json.contains("\"status\":\"free\"")) + } + + @Test + fun `MembersResponse reuses Meta and Pagination from GhostModels`() { + // Verify MembersResponse uses the same Meta/Pagination as PostsResponse + val json = """{ + "members": [{"id": "m1"}], + "meta": {"pagination": {"page": 2, "limit": 50, "pages": 5, "total": 250, "next": 3, "prev": 1}} + }""" + val response = gson.fromJson(json, MembersResponse::class.java) + val pagination = response.meta?.pagination!! + assertEquals(2, pagination.page) + assertEquals(50, pagination.limit) + assertEquals(5, pagination.pages) + assertEquals(250, pagination.total) + assertEquals(3, pagination.next) + assertEquals(1, pagination.prev) + } + + @Test + fun `GhostMember with zero email stats`() { + val json = """{ + "id": "m1", + "email_count": 0, + "email_opened_count": 0, + "email_open_rate": null + }""" + val member = gson.fromJson(json, GhostMember::class.java) + assertEquals(0, member.email_count) + assertEquals(0, member.email_opened_count) + assertNull(member.email_open_rate) + } + + @Test + fun `MemberSubscription with cancel_at_period_end true`() { + val json = """{ + "id": "sub1", + "status": "active", + "cancel_at_period_end": true, + "price": {"amount": 900, "currency": "USD", "interval": "month"}, + "tier": {"id": "t1", "name": "Basic"} + }""" + val sub = gson.fromJson(json, MemberSubscription::class.java) + assertEquals(true, sub.cancel_at_period_end) + assertEquals(900, sub.price?.amount) + } +}