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.
This commit is contained in:
Paweł Orzech 2026-03-20 00:26:32 +01:00
parent 0891013df6
commit 689b8cc8c2
3 changed files with 331 additions and 0 deletions

View file

@ -1,5 +1,6 @@
package com.swoosh.microblog.data.api 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.PostWrapper
import com.swoosh.microblog.data.model.PostsResponse import com.swoosh.microblog.data.model.PostsResponse
import okhttp3.MultipartBody import okhttp3.MultipartBody
@ -37,6 +38,21 @@ interface GhostApiService {
@Path("id") id: String @Path("id") id: String
): Response<Unit> ): Response<Unit>
@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<MembersResponse>
@GET("ghost/api/admin/members/{id}/")
suspend fun getMember(
@Path("id") id: String,
@Query("include") include: String = "newsletters,labels"
): Response<MembersResponse>
@GET("ghost/api/admin/users/me/") @GET("ghost/api/admin/users/me/")
suspend fun getCurrentUser(): Response<UsersResponse> suspend fun getCurrentUser(): Response<UsersResponse>

View file

@ -0,0 +1,35 @@
package com.swoosh.microblog.data.model
data class MembersResponse(
val members: List<GhostMember>,
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<MemberLabel>?,
val newsletters: List<MemberNewsletter>?,
val subscriptions: List<MemberSubscription>?,
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?)

View file

@ -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)
}
}