From 689b8cc8c26464d2c14ae2ab60058fdac231958b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Orzech?= Date: Fri, 20 Mar 2026 00:26:32 +0100 Subject: [PATCH] feat: add Member model, API endpoints, and model parsing tests Add MemberModels.kt with GhostMember, MemberLabel, MemberNewsletter, MemberSubscription, SubscriptionPrice, and SubscriptionTier data classes. Add getMembers() and getMember() endpoints to GhostApiService. Add comprehensive JSON parsing tests for all member model types. --- .../microblog/data/api/GhostApiService.kt | 16 + .../microblog/data/model/MemberModels.kt | 35 +++ .../microblog/data/model/MemberModelsTest.kt | 280 ++++++++++++++++++ 3 files changed, 331 insertions(+) create mode 100644 app/src/main/java/com/swoosh/microblog/data/model/MemberModels.kt create mode 100644 app/src/test/java/com/swoosh/microblog/data/model/MemberModelsTest.kt 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) + } +}