feat: add newsletter model, API endpoint, and per-account preferences

- Create NewsletterModels.kt with GhostNewsletter and NewslettersResponse
- Add getNewsletters() endpoint to GhostApiService
- Add optional newsletter/emailSegment query params to createPost/updatePost
- Create NewsletterPreferences for per-account newsletter toggle
- Add fetchNewsletters() and fetchSubscriberCount() to PostRepository
- Pass newsletter params through PostRepository to API service
- Add Robolectric tests for NewsletterPreferences
This commit is contained in:
Paweł Orzech 2026-03-20 00:43:53 +01:00
parent 807c6d559e
commit ed11577be1
5 changed files with 217 additions and 6 deletions

View file

@ -0,0 +1,33 @@
package com.swoosh.microblog.data
import android.content.Context
import android.content.SharedPreferences
class NewsletterPreferences private constructor(
private val prefs: SharedPreferences,
private val accountIdProvider: () -> String
) {
constructor(context: Context) : this(
prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE),
accountIdProvider = { AccountManager(context).getActiveAccount()?.id ?: "" }
)
/** Constructor for testing with plain SharedPreferences and a fixed account ID. */
constructor(prefs: SharedPreferences, accountId: String) : this(
prefs = prefs,
accountIdProvider = { accountId }
)
private fun activeAccountId(): String = accountIdProvider()
fun isNewsletterEnabled(): Boolean =
prefs.getBoolean("newsletter_enabled_${activeAccountId()}", false)
fun setNewsletterEnabled(enabled: Boolean) =
prefs.edit().putBoolean("newsletter_enabled_${activeAccountId()}", enabled).apply()
companion object {
const val PREFS_NAME = "newsletter_prefs"
}
}

View file

@ -2,6 +2,7 @@ package com.swoosh.microblog.data.api
import com.swoosh.microblog.data.model.GhostSite
import com.swoosh.microblog.data.model.MembersResponse
import com.swoosh.microblog.data.model.NewslettersResponse
import com.swoosh.microblog.data.model.PageWrapper
import com.swoosh.microblog.data.model.PagesResponse
import com.swoosh.microblog.data.model.PostWrapper
@ -28,14 +29,18 @@ interface GhostApiService {
@POST("ghost/api/admin/posts/")
@Headers("Content-Type: application/json")
suspend fun createPost(
@Body body: PostWrapper
@Body body: PostWrapper,
@Query("newsletter") newsletter: String? = null,
@Query("email_segment") emailSegment: String? = null
): Response<PostsResponse>
@PUT("ghost/api/admin/posts/{id}/")
@Headers("Content-Type: application/json")
suspend fun updatePost(
@Path("id") id: String,
@Body body: PostWrapper
@Body body: PostWrapper,
@Query("newsletter") newsletter: String? = null,
@Query("email_segment") emailSegment: String? = null
): Response<PostsResponse>
@DELETE("ghost/api/admin/posts/{id}/")
@ -61,6 +66,12 @@ interface GhostApiService {
@Query("include") include: String = "newsletters,labels"
): Response<MembersResponse>
@GET("ghost/api/admin/newsletters/")
suspend fun getNewsletters(
@Query("filter") filter: String = "status:active",
@Query("limit") limit: String = "all"
): Response<NewslettersResponse>
@GET("ghost/api/admin/users/me/")
suspend fun getCurrentUser(): Response<UsersResponse>

View file

@ -0,0 +1,21 @@
package com.swoosh.microblog.data.model
data class NewslettersResponse(
val newsletters: List<GhostNewsletter>
)
data class GhostNewsletter(
val id: String,
val uuid: String?,
val name: String,
val slug: String,
val description: String?,
val status: String?,
val visibility: String?,
val subscribe_on_signup: Boolean?,
val sort_order: Int?,
val sender_name: String?,
val sender_email: String?,
val created_at: String?,
val updated_at: String?
)

View file

@ -8,6 +8,7 @@ import com.swoosh.microblog.data.api.GhostApiService
import com.swoosh.microblog.data.db.AppDatabase
import com.swoosh.microblog.data.db.LocalPostDao
import com.swoosh.microblog.data.model.*
import com.swoosh.microblog.data.model.GhostNewsletter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
@ -68,10 +69,18 @@ class PostRepository(private val context: Context) {
}
}
suspend fun createPost(post: GhostPost): Result<GhostPost> =
suspend fun createPost(
post: GhostPost,
newsletter: String? = null,
emailSegment: String? = null
): Result<GhostPost> =
withContext(Dispatchers.IO) {
try {
val response = getApi().createPost(PostWrapper(listOf(post)))
val response = getApi().createPost(
PostWrapper(listOf(post)),
newsletter = newsletter,
emailSegment = emailSegment
)
if (response.isSuccessful) {
Result.success(response.body()!!.posts.first())
} else {
@ -82,10 +91,20 @@ class PostRepository(private val context: Context) {
}
}
suspend fun updatePost(id: String, post: GhostPost): Result<GhostPost> =
suspend fun updatePost(
id: String,
post: GhostPost,
newsletter: String? = null,
emailSegment: String? = null
): Result<GhostPost> =
withContext(Dispatchers.IO) {
try {
val response = getApi().updatePost(id, PostWrapper(listOf(post)))
val response = getApi().updatePost(
id,
PostWrapper(listOf(post)),
newsletter = newsletter,
emailSegment = emailSegment
)
if (response.isSuccessful) {
Result.success(response.body()!!.posts.first())
} else {
@ -96,6 +115,35 @@ class PostRepository(private val context: Context) {
}
}
suspend fun fetchNewsletters(): Result<List<GhostNewsletter>> =
withContext(Dispatchers.IO) {
try {
val response = getApi().getNewsletters()
if (response.isSuccessful) {
Result.success(response.body()!!.newsletters)
} else {
Result.failure(Exception("Newsletters fetch failed ${response.code()}: ${response.errorBody()?.string()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun fetchSubscriberCount(): Result<Int> =
withContext(Dispatchers.IO) {
try {
val response = getApi().getMembers(limit = 1)
if (response.isSuccessful) {
val total = response.body()!!.meta?.pagination?.total ?: 0
Result.success(total)
} else {
Result.failure(Exception("Member count fetch failed ${response.code()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun deletePost(id: String): Result<Unit> =
withContext(Dispatchers.IO) {
try {

View file

@ -0,0 +1,98 @@
package com.swoosh.microblog.data
import android.content.Context
import android.content.SharedPreferences
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], application = android.app.Application::class)
class NewsletterPreferencesTest {
private lateinit var prefs: SharedPreferences
private lateinit var newsletterPreferences: NewsletterPreferences
private val testAccountId = "test-account-123"
@Before
fun setup() {
val context = RuntimeEnvironment.getApplication()
prefs = context.getSharedPreferences(NewsletterPreferences.PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit().clear().commit()
newsletterPreferences = NewsletterPreferences(prefs, testAccountId)
}
// --- Default values ---
@Test
fun `default newsletter enabled is false`() {
assertFalse(newsletterPreferences.isNewsletterEnabled())
}
// --- Setting and getting ---
@Test
fun `setting newsletter enabled to true persists`() {
newsletterPreferences.setNewsletterEnabled(true)
assertTrue(newsletterPreferences.isNewsletterEnabled())
}
@Test
fun `setting newsletter enabled to false persists`() {
newsletterPreferences.setNewsletterEnabled(true)
newsletterPreferences.setNewsletterEnabled(false)
assertFalse(newsletterPreferences.isNewsletterEnabled())
}
@Test
fun `newsletter enabled persists across instances`() {
newsletterPreferences.setNewsletterEnabled(true)
val newInstance = NewsletterPreferences(prefs, testAccountId)
assertTrue(newInstance.isNewsletterEnabled())
}
@Test
fun `toggling on then off round-trips correctly`() {
newsletterPreferences.setNewsletterEnabled(true)
assertTrue(newsletterPreferences.isNewsletterEnabled())
newsletterPreferences.setNewsletterEnabled(false)
assertFalse(newsletterPreferences.isNewsletterEnabled())
}
// --- Per-account isolation ---
@Test
fun `different accounts have independent newsletter settings`() {
val prefs1 = NewsletterPreferences(prefs, "account-1")
val prefs2 = NewsletterPreferences(prefs, "account-2")
prefs1.setNewsletterEnabled(true)
prefs2.setNewsletterEnabled(false)
assertTrue(prefs1.isNewsletterEnabled())
assertFalse(prefs2.isNewsletterEnabled())
}
@Test
fun `enabling for one account does not affect another`() {
val prefs1 = NewsletterPreferences(prefs, "account-a")
val prefs2 = NewsletterPreferences(prefs, "account-b")
prefs1.setNewsletterEnabled(true)
assertTrue(prefs1.isNewsletterEnabled())
assertFalse(prefs2.isNewsletterEnabled())
}
@Test
fun `empty account id still works`() {
val emptyPrefs = NewsletterPreferences(prefs, "")
emptyPrefs.setNewsletterEnabled(true)
assertTrue(emptyPrefs.isNewsletterEnabled())
}
}