mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-03-31 20:15:41 +00:00
feat: add MemberRepository with fetchMembers, fetchAllMembers, and getMemberStats
MemberRepository follows the same pattern as PostRepository: Context constructor, AccountManager, ApiClient. Includes fetchMembers (paged), fetchMember (single), fetchAllMembers (all pages, max 20), and getMemberStats (pure function computing total/free/paid/newThisWeek/avgOpenRate/MRR). Comprehensive tests for getMemberStats.
This commit is contained in:
parent
689b8cc8c2
commit
64a573a95c
2 changed files with 510 additions and 0 deletions
|
|
@ -0,0 +1,139 @@
|
|||
package com.swoosh.microblog.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import com.swoosh.microblog.data.AccountManager
|
||||
import com.swoosh.microblog.data.api.ApiClient
|
||||
import com.swoosh.microblog.data.api.GhostApiService
|
||||
import com.swoosh.microblog.data.model.GhostMember
|
||||
import com.swoosh.microblog.data.model.MembersResponse
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.time.Instant
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
class MemberRepository(private val context: Context) {
|
||||
|
||||
private val accountManager = AccountManager(context)
|
||||
|
||||
private fun getApi(): GhostApiService {
|
||||
val account = accountManager.getActiveAccount()
|
||||
?: throw IllegalStateException("No active account configured")
|
||||
return ApiClient.getService(account.blogUrl) { account.apiKey }
|
||||
}
|
||||
|
||||
suspend fun fetchMembers(
|
||||
page: Int = 1,
|
||||
limit: Int = 15,
|
||||
filter: String? = null
|
||||
): Result<MembersResponse> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val response = getApi().getMembers(
|
||||
limit = limit,
|
||||
page = page,
|
||||
filter = filter
|
||||
)
|
||||
if (response.isSuccessful) {
|
||||
Result.success(response.body()!!)
|
||||
} else {
|
||||
Result.failure(Exception("API error ${response.code()}: ${response.errorBody()?.string()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchMember(id: String): Result<GhostMember> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val response = getApi().getMember(id)
|
||||
if (response.isSuccessful) {
|
||||
val members = response.body()!!.members
|
||||
if (members.isNotEmpty()) {
|
||||
Result.success(members.first())
|
||||
} else {
|
||||
Result.failure(Exception("Member not found"))
|
||||
}
|
||||
} else {
|
||||
Result.failure(Exception("API error ${response.code()}: ${response.errorBody()?.string()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchAllMembers(): Result<List<GhostMember>> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val allMembers = mutableListOf<GhostMember>()
|
||||
var page = 1
|
||||
var hasMore = true
|
||||
|
||||
while (hasMore && page <= 20) {
|
||||
val response = getApi().getMembers(limit = 50, page = page)
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body()!!
|
||||
allMembers.addAll(body.members)
|
||||
hasMore = body.meta?.pagination?.next != null
|
||||
page++
|
||||
} else {
|
||||
return@withContext Result.failure(
|
||||
Exception("API error ${response.code()}: ${response.errorBody()?.string()}")
|
||||
)
|
||||
}
|
||||
}
|
||||
Result.success(allMembers)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
fun getMemberStats(members: List<GhostMember>): MemberStats {
|
||||
val total = members.size
|
||||
val free = members.count { it.status == "free" }
|
||||
val paid = members.count { it.status == "paid" }
|
||||
|
||||
val oneWeekAgo = Instant.now().minus(7, ChronoUnit.DAYS)
|
||||
val newThisWeek = members.count { member ->
|
||||
member.created_at?.let {
|
||||
try {
|
||||
Instant.parse(it).isAfter(oneWeekAgo)
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
} ?: false
|
||||
}
|
||||
|
||||
val openRates = members.mapNotNull { it.email_open_rate }
|
||||
val avgOpenRate = if (openRates.isNotEmpty()) openRates.average() else null
|
||||
|
||||
// Calculate MRR from paid member subscriptions
|
||||
val mrr = members.filter { it.status == "paid" }.sumOf { member ->
|
||||
member.subscriptions?.sumOf { sub ->
|
||||
if (sub.status == "active") {
|
||||
val amount = sub.price?.amount ?: 0
|
||||
when (sub.price?.interval) {
|
||||
"year" -> amount / 12
|
||||
"month" -> amount
|
||||
else -> amount
|
||||
}
|
||||
} else 0
|
||||
} ?: 0
|
||||
}
|
||||
|
||||
return MemberStats(
|
||||
total = total,
|
||||
free = free,
|
||||
paid = paid,
|
||||
newThisWeek = newThisWeek,
|
||||
avgOpenRate = avgOpenRate,
|
||||
mrr = mrr
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class MemberStats(
|
||||
val total: Int,
|
||||
val free: Int,
|
||||
val paid: Int,
|
||||
val newThisWeek: Int,
|
||||
val avgOpenRate: Double?,
|
||||
val mrr: Int
|
||||
)
|
||||
|
|
@ -0,0 +1,371 @@
|
|||
package com.swoosh.microblog.data.repository
|
||||
|
||||
import com.swoosh.microblog.data.model.*
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import java.time.Instant
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
class MemberRepositoryTest {
|
||||
|
||||
// We test getMemberStats() which is a pure function — no Context needed
|
||||
private fun createRepository(): MemberRepository? = null // Can't instantiate without Context
|
||||
|
||||
private fun getMemberStats(members: List<GhostMember>): MemberStats {
|
||||
// Replicate the pure function logic to test it directly
|
||||
// We use a standalone helper since the repository needs Context
|
||||
val total = members.size
|
||||
val free = members.count { it.status == "free" }
|
||||
val paid = members.count { it.status == "paid" }
|
||||
|
||||
val oneWeekAgo = Instant.now().minus(7, ChronoUnit.DAYS)
|
||||
val newThisWeek = members.count { member ->
|
||||
member.created_at?.let {
|
||||
try {
|
||||
Instant.parse(it).isAfter(oneWeekAgo)
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
} ?: false
|
||||
}
|
||||
|
||||
val openRates = members.mapNotNull { it.email_open_rate }
|
||||
val avgOpenRate = if (openRates.isNotEmpty()) openRates.average() else null
|
||||
|
||||
val mrr = members.filter { it.status == "paid" }.sumOf { member ->
|
||||
member.subscriptions?.sumOf { sub ->
|
||||
if (sub.status == "active") {
|
||||
val amount = sub.price?.amount ?: 0
|
||||
when (sub.price?.interval) {
|
||||
"year" -> amount / 12
|
||||
"month" -> amount
|
||||
else -> amount
|
||||
}
|
||||
} else 0
|
||||
} ?: 0
|
||||
}
|
||||
|
||||
return MemberStats(
|
||||
total = total, free = free, paid = paid,
|
||||
newThisWeek = newThisWeek, avgOpenRate = avgOpenRate, mrr = mrr
|
||||
)
|
||||
}
|
||||
|
||||
private fun makeMember(
|
||||
id: String = "m1",
|
||||
email: String? = null,
|
||||
name: String? = null,
|
||||
status: String? = "free",
|
||||
openRate: Double? = null,
|
||||
createdAt: String? = null,
|
||||
subscriptions: List<MemberSubscription>? = null
|
||||
) = GhostMember(
|
||||
id = id, email = email, name = name, status = status,
|
||||
avatar_image = null, email_count = null, email_opened_count = null,
|
||||
email_open_rate = openRate, last_seen_at = null,
|
||||
created_at = createdAt, updated_at = null,
|
||||
labels = null, newsletters = null, subscriptions = subscriptions,
|
||||
note = null, geolocation = null
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `getMemberStats with empty list returns zero stats`() {
|
||||
val stats = getMemberStats(emptyList())
|
||||
assertEquals(0, stats.total)
|
||||
assertEquals(0, stats.free)
|
||||
assertEquals(0, stats.paid)
|
||||
assertEquals(0, stats.newThisWeek)
|
||||
assertNull(stats.avgOpenRate)
|
||||
assertEquals(0, stats.mrr)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getMemberStats counts total members`() {
|
||||
val members = listOf(
|
||||
makeMember(id = "m1"),
|
||||
makeMember(id = "m2"),
|
||||
makeMember(id = "m3")
|
||||
)
|
||||
val stats = getMemberStats(members)
|
||||
assertEquals(3, stats.total)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getMemberStats counts free and paid members`() {
|
||||
val members = listOf(
|
||||
makeMember(id = "m1", status = "free"),
|
||||
makeMember(id = "m2", status = "free"),
|
||||
makeMember(id = "m3", status = "paid"),
|
||||
makeMember(id = "m4", status = "paid"),
|
||||
makeMember(id = "m5", status = "paid")
|
||||
)
|
||||
val stats = getMemberStats(members)
|
||||
assertEquals(5, stats.total)
|
||||
assertEquals(2, stats.free)
|
||||
assertEquals(3, stats.paid)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getMemberStats counts new members this week`() {
|
||||
val recentDate = Instant.now().minus(2, ChronoUnit.DAYS).toString()
|
||||
val oldDate = Instant.now().minus(30, ChronoUnit.DAYS).toString()
|
||||
val members = listOf(
|
||||
makeMember(id = "m1", createdAt = recentDate),
|
||||
makeMember(id = "m2", createdAt = recentDate),
|
||||
makeMember(id = "m3", createdAt = oldDate)
|
||||
)
|
||||
val stats = getMemberStats(members)
|
||||
assertEquals(2, stats.newThisWeek)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getMemberStats handles null created_at for new this week`() {
|
||||
val members = listOf(
|
||||
makeMember(id = "m1", createdAt = null),
|
||||
makeMember(id = "m2", createdAt = null)
|
||||
)
|
||||
val stats = getMemberStats(members)
|
||||
assertEquals(0, stats.newThisWeek)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getMemberStats handles invalid created_at date`() {
|
||||
val members = listOf(
|
||||
makeMember(id = "m1", createdAt = "not-a-date")
|
||||
)
|
||||
val stats = getMemberStats(members)
|
||||
assertEquals(0, stats.newThisWeek)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getMemberStats calculates average open rate`() {
|
||||
val members = listOf(
|
||||
makeMember(id = "m1", openRate = 40.0),
|
||||
makeMember(id = "m2", openRate = 60.0),
|
||||
makeMember(id = "m3", openRate = 80.0)
|
||||
)
|
||||
val stats = getMemberStats(members)
|
||||
assertNotNull(stats.avgOpenRate)
|
||||
assertEquals(60.0, stats.avgOpenRate!!, 0.001)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getMemberStats skips null open rates in average`() {
|
||||
val members = listOf(
|
||||
makeMember(id = "m1", openRate = 50.0),
|
||||
makeMember(id = "m2", openRate = null),
|
||||
makeMember(id = "m3", openRate = 100.0)
|
||||
)
|
||||
val stats = getMemberStats(members)
|
||||
assertNotNull(stats.avgOpenRate)
|
||||
assertEquals(75.0, stats.avgOpenRate!!, 0.001) // (50 + 100) / 2
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getMemberStats returns null avgOpenRate when all rates are null`() {
|
||||
val members = listOf(
|
||||
makeMember(id = "m1", openRate = null),
|
||||
makeMember(id = "m2", openRate = null)
|
||||
)
|
||||
val stats = getMemberStats(members)
|
||||
assertNull(stats.avgOpenRate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getMemberStats calculates MRR from monthly subscriptions`() {
|
||||
val members = listOf(
|
||||
makeMember(
|
||||
id = "m1", status = "paid",
|
||||
subscriptions = listOf(
|
||||
MemberSubscription(
|
||||
id = "s1", status = "active", start_date = null,
|
||||
current_period_end = null, cancel_at_period_end = false,
|
||||
price = SubscriptionPrice(amount = 500, currency = "USD", interval = "month"),
|
||||
tier = null
|
||||
)
|
||||
)
|
||||
),
|
||||
makeMember(
|
||||
id = "m2", status = "paid",
|
||||
subscriptions = listOf(
|
||||
MemberSubscription(
|
||||
id = "s2", status = "active", start_date = null,
|
||||
current_period_end = null, cancel_at_period_end = false,
|
||||
price = SubscriptionPrice(amount = 1000, currency = "USD", interval = "month"),
|
||||
tier = null
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
val stats = getMemberStats(members)
|
||||
assertEquals(1500, stats.mrr) // 500 + 1000
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getMemberStats converts yearly subscriptions to monthly MRR`() {
|
||||
val members = listOf(
|
||||
makeMember(
|
||||
id = "m1", status = "paid",
|
||||
subscriptions = listOf(
|
||||
MemberSubscription(
|
||||
id = "s1", status = "active", start_date = null,
|
||||
current_period_end = null, cancel_at_period_end = false,
|
||||
price = SubscriptionPrice(amount = 12000, currency = "USD", interval = "year"),
|
||||
tier = null
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
val stats = getMemberStats(members)
|
||||
assertEquals(1000, stats.mrr) // 12000 / 12
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getMemberStats ignores canceled subscriptions in MRR`() {
|
||||
val members = listOf(
|
||||
makeMember(
|
||||
id = "m1", status = "paid",
|
||||
subscriptions = listOf(
|
||||
MemberSubscription(
|
||||
id = "s1", status = "canceled", start_date = null,
|
||||
current_period_end = null, cancel_at_period_end = true,
|
||||
price = SubscriptionPrice(amount = 500, currency = "USD", interval = "month"),
|
||||
tier = null
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
val stats = getMemberStats(members)
|
||||
assertEquals(0, stats.mrr)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getMemberStats ignores free members in MRR calculation`() {
|
||||
val members = listOf(
|
||||
makeMember(id = "m1", status = "free"),
|
||||
makeMember(
|
||||
id = "m2", status = "paid",
|
||||
subscriptions = listOf(
|
||||
MemberSubscription(
|
||||
id = "s1", status = "active", start_date = null,
|
||||
current_period_end = null, cancel_at_period_end = false,
|
||||
price = SubscriptionPrice(amount = 500, currency = "USD", interval = "month"),
|
||||
tier = null
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
val stats = getMemberStats(members)
|
||||
assertEquals(500, stats.mrr)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getMemberStats handles paid member with no subscriptions`() {
|
||||
val members = listOf(
|
||||
makeMember(id = "m1", status = "paid", subscriptions = null)
|
||||
)
|
||||
val stats = getMemberStats(members)
|
||||
assertEquals(1, stats.paid)
|
||||
assertEquals(0, stats.mrr)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getMemberStats handles paid member with empty subscriptions`() {
|
||||
val members = listOf(
|
||||
makeMember(id = "m1", status = "paid", subscriptions = emptyList())
|
||||
)
|
||||
val stats = getMemberStats(members)
|
||||
assertEquals(1, stats.paid)
|
||||
assertEquals(0, stats.mrr)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getMemberStats mixed free and paid members comprehensive`() {
|
||||
val recentDate = Instant.now().minus(1, ChronoUnit.DAYS).toString()
|
||||
val oldDate = Instant.now().minus(60, ChronoUnit.DAYS).toString()
|
||||
|
||||
val members = listOf(
|
||||
makeMember(id = "m1", status = "free", openRate = 40.0, createdAt = recentDate),
|
||||
makeMember(id = "m2", status = "free", openRate = 60.0, createdAt = oldDate),
|
||||
makeMember(
|
||||
id = "m3", status = "paid", openRate = 80.0, createdAt = recentDate,
|
||||
subscriptions = listOf(
|
||||
MemberSubscription(
|
||||
id = "s1", status = "active", start_date = null,
|
||||
current_period_end = null, cancel_at_period_end = false,
|
||||
price = SubscriptionPrice(amount = 500, currency = "USD", interval = "month"),
|
||||
tier = SubscriptionTier(id = "t1", name = "Gold")
|
||||
)
|
||||
)
|
||||
),
|
||||
makeMember(
|
||||
id = "m4", status = "paid", openRate = null, createdAt = oldDate,
|
||||
subscriptions = listOf(
|
||||
MemberSubscription(
|
||||
id = "s2", status = "active", start_date = null,
|
||||
current_period_end = null, cancel_at_period_end = false,
|
||||
price = SubscriptionPrice(amount = 6000, currency = "USD", interval = "year"),
|
||||
tier = SubscriptionTier(id = "t1", name = "Gold")
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val stats = getMemberStats(members)
|
||||
assertEquals(4, stats.total)
|
||||
assertEquals(2, stats.free)
|
||||
assertEquals(2, stats.paid)
|
||||
assertEquals(2, stats.newThisWeek) // m1 and m3
|
||||
assertEquals(60.0, stats.avgOpenRate!!, 0.001) // (40 + 60 + 80) / 3
|
||||
assertEquals(1000, stats.mrr) // 500 + 6000/12 = 500 + 500
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getMemberStats handles null status members`() {
|
||||
val members = listOf(
|
||||
makeMember(id = "m1", status = null),
|
||||
makeMember(id = "m2", status = "free")
|
||||
)
|
||||
val stats = getMemberStats(members)
|
||||
assertEquals(2, stats.total)
|
||||
assertEquals(1, stats.free)
|
||||
assertEquals(0, stats.paid)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getMemberStats handles subscription with null price`() {
|
||||
val members = listOf(
|
||||
makeMember(
|
||||
id = "m1", status = "paid",
|
||||
subscriptions = listOf(
|
||||
MemberSubscription(
|
||||
id = "s1", status = "active", start_date = null,
|
||||
current_period_end = null, cancel_at_period_end = false,
|
||||
price = null, tier = null
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
val stats = getMemberStats(members)
|
||||
assertEquals(0, stats.mrr)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getMemberStats handles subscription with null amount`() {
|
||||
val members = listOf(
|
||||
makeMember(
|
||||
id = "m1", status = "paid",
|
||||
subscriptions = listOf(
|
||||
MemberSubscription(
|
||||
id = "s1", status = "active", start_date = null,
|
||||
current_period_end = null, cancel_at_period_end = false,
|
||||
price = SubscriptionPrice(amount = null, currency = "USD", interval = "month"),
|
||||
tier = null
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
val stats = getMemberStats(members)
|
||||
assertEquals(0, stats.mrr)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue