merge: integrate pinned/featured posts feature (resolve conflicts)

This commit is contained in:
Paweł Orzech 2026-03-19 10:52:52 +01:00
commit 34feca3461
No known key found for this signature in database
13 changed files with 582 additions and 7 deletions

View file

@ -22,6 +22,7 @@ abstract class AppDatabase : RoomDatabase() {
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE local_posts ADD COLUMN imageAlt TEXT DEFAULT NULL")
db.execSQL("ALTER TABLE local_posts ADD COLUMN featured INTEGER NOT NULL DEFAULT 0")
}
}

View file

@ -49,4 +49,10 @@ interface LocalPostDao {
@Query("SELECT * FROM local_posts ORDER BY updatedAt DESC")
suspend fun getAllPostsList(): List<LocalPost>
@Query("UPDATE local_posts SET featured = :featured WHERE localId = :localId")
suspend fun updateFeatured(localId: Long, featured: Boolean)
@Query("UPDATE local_posts SET featured = :featured WHERE ghostId = :ghostId")
suspend fun updateFeaturedByGhostId(ghostId: String, featured: Boolean)
}

View file

@ -37,6 +37,7 @@ data class GhostPost(
val plaintext: String? = null,
val mobiledoc: String? = null,
val status: String? = null,
val featured: Boolean? = false,
val feature_image: String? = null,
val feature_image_alt: String? = null,
val created_at: String? = null,
@ -64,6 +65,7 @@ data class LocalPost(
val content: String = "",
val htmlContent: String? = null,
val status: PostStatus = PostStatus.DRAFT,
val featured: Boolean = false,
val imageUri: String? = null,
val uploadedImageUrl: String? = null,
val linkUrl: String? = null,
@ -108,6 +110,7 @@ data class FeedPost(
val linkDescription: String?,
val linkImageUrl: String?,
val status: String,
val featured: Boolean = false,
val publishedAt: String?,
val createdAt: String?,
val updatedAt: String?,

View file

@ -150,6 +150,32 @@ class PostRepository(private val context: Context) {
suspend fun getAllLocalPostsList(): List<LocalPost> = dao.getAllPostsList()
// --- Featured/Pinned operations ---
suspend fun updateLocalFeatured(localId: Long, featured: Boolean) =
dao.updateFeatured(localId, featured)
suspend fun updateLocalFeaturedByGhostId(ghostId: String, featured: Boolean) =
dao.updateFeaturedByGhostId(ghostId, featured)
suspend fun toggleFeatured(ghostId: String, featured: Boolean, updatedAt: String): Result<GhostPost> =
withContext(Dispatchers.IO) {
try {
val post = GhostPost(
featured = featured,
updated_at = updatedAt
)
val response = getApi().updatePost(ghostId, PostWrapper(listOf(post)))
if (response.isSuccessful) {
Result.success(response.body()!!.posts.first())
} else {
Result.failure(Exception("Toggle featured failed ${response.code()}: ${response.errorBody()?.string()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
// --- Connectivity check ---
fun isNetworkAvailable(): Boolean {

View file

@ -330,6 +330,33 @@ fun ComposerScreen(
)
}
// Feature toggle
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.PushPin,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = if (state.featured) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Feature this post",
style = MaterialTheme.typography.bodyMedium
)
}
Switch(
checked = state.featured,
onCheckedChange = { viewModel.toggleFeatured() }
)
}
if (state.error != null) {
Spacer(modifier = Modifier.height(12.dp))
Text(

View file

@ -47,6 +47,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
description = post.linkDescription,
imageUrl = post.linkImageUrl
) else null,
featured = post.featured,
isEditing = true
)
}
@ -139,6 +140,10 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
_uiState.update { it.copy(previewHtml = html) }
}
fun toggleFeatured() {
_uiState.update { it.copy(featured = !it.featured) }
}
fun publish() = submitPost(PostStatus.PUBLISHED, QueueStatus.QUEUED_PUBLISH)
fun saveDraft() = submitPost(PostStatus.DRAFT, QueueStatus.NONE)
@ -167,6 +172,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
title = title,
content = state.text,
status = status,
featured = if (status != PostStatus.DRAFT) state.featured else false,
imageUri = state.imageUri?.toString(),
imageAlt = altText,
linkUrl = state.linkPreview?.url,
@ -211,6 +217,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
title = title,
mobiledoc = mobiledoc,
status = status.name.lowercase(),
featured = if (status != PostStatus.DRAFT) state.featured else false,
feature_image = featureImage,
feature_image_alt = altText,
published_at = state.scheduledAt,
@ -236,6 +243,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
title = title,
content = state.text,
status = status,
featured = if (status != PostStatus.DRAFT) state.featured else false,
imageUri = state.imageUri?.toString(),
uploadedImageUrl = featureImage,
imageAlt = altText,
@ -274,6 +282,7 @@ data class ComposerUiState(
val linkPreview: LinkPreview? = null,
val isLoadingLink: Boolean = false,
val scheduledAt: String? = null,
val featured: Boolean = false,
val isSubmitting: Boolean = false,
val isSuccess: Boolean = false,
val isEditing: Boolean = false,

View file

@ -22,9 +22,11 @@ import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.TextFields
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.outlined.PushPin
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@ -55,7 +57,8 @@ fun DetailScreen(
onBack: () -> Unit,
onEdit: (FeedPost) -> Unit,
onDelete: (FeedPost) -> Unit,
onPreview: ((String) -> Unit)? = null
onPreview: ((String) -> Unit)? = null,
onTogglePin: (FeedPost) -> Unit = {}
) {
var showDeleteDialog by remember { mutableStateOf(false) }
var showOverflowMenu by remember { mutableStateOf(false) }
@ -78,6 +81,14 @@ fun DetailScreen(
}
},
actions = {
// Pin/Unpin button
IconButton(onClick = { onTogglePin(post) }) {
Icon(
imageVector = if (post.featured) Icons.Filled.PushPin else Icons.Outlined.PushPin,
contentDescription = if (post.featured) "Unpin" else "Pin",
tint = if (post.featured) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Preview button - show rendered HTML
IconButton(onClick = {
val html = if (!post.htmlContent.isNullOrBlank()) {
@ -365,6 +376,9 @@ private fun PostStatsSection(post: FeedPost) {
if (post.publishedAt != null) {
MetadataRow("Published", post.publishedAt)
}
if (post.featured) {
MetadataRow("Featured", "Pinned")
}
Spacer(modifier = Modifier.height(8.dp))

View file

@ -25,10 +25,13 @@ import androidx.compose.material.icons.filled.DarkMode
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.LightMode
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.WifiOff
import androidx.compose.material.icons.outlined.PushPin
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
@ -81,6 +84,10 @@ fun FeedScreen(
// Track which post is pending delete confirmation
var postPendingDelete by remember { mutableStateOf<FeedPost?>(null) }
// Split posts into pinned and regular
val pinnedPosts = state.posts.filter { it.featured }
val regularPosts = state.posts.filter { !it.featured }
// Pull-to-refresh
val pullRefreshState = rememberPullRefreshState(
refreshing = state.isRefreshing,
@ -214,7 +221,52 @@ fun FeedScreen(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp)
) {
items(state.posts, key = { it.ghostId ?: "local_${it.localId}" }) { post ->
// Pinned section header
if (pinnedPosts.isNotEmpty()) {
item(key = "pinned_header") {
PinnedSectionHeader()
}
items(pinnedPosts, key = { "pinned_${it.ghostId ?: "local_${it.localId}"}" }) { post ->
SwipeablePostCard(
post = post,
onClick = { onPostClick(post) },
onCancelQueue = { viewModel.cancelQueuedPost(post) },
onShare = {
val postUrl = ShareUtils.resolvePostUrl(post, baseUrl)
if (postUrl != null) {
val shareText = ShareUtils.formatShareContent(post, postUrl)
val sendIntent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, shareText)
}
context.startActivity(Intent.createChooser(sendIntent, "Share post"))
}
},
onCopyLink = {
val postUrl = ShareUtils.resolvePostUrl(post, baseUrl)
if (postUrl != null) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("Post URL", postUrl))
}
},
onEdit = { onEditPost(post) },
onDelete = { postPendingDelete = post },
onTogglePin = { viewModel.toggleFeatured(post) },
snackbarHostState = snackbarHostState
)
}
// Separator between pinned and regular posts
if (regularPosts.isNotEmpty()) {
item(key = "pinned_separator") {
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
color = MaterialTheme.colorScheme.outlineVariant
)
}
}
}
items(regularPosts, key = { it.ghostId ?: "local_${it.localId}" }) { post ->
SwipeablePostCard(
post = post,
onClick = { onPostClick(post) },
@ -239,6 +291,7 @@ fun FeedScreen(
},
onEdit = { onEditPost(post) },
onDelete = { postPendingDelete = post },
onTogglePin = { viewModel.toggleFeatured(post) },
snackbarHostState = snackbarHostState
)
}
@ -261,6 +314,22 @@ fun FeedScreen(
modifier = Modifier.align(Alignment.TopCenter)
)
// Show snackbar for pin/unpin confirmation
if (state.snackbarMessage != null) {
Snackbar(
modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp),
dismissAction = {
TextButton(onClick = viewModel::clearSnackbar) { Text("OK") }
}
) {
Text(state.snackbarMessage!!)
}
LaunchedEffect(state.snackbarMessage) {
kotlinx.coroutines.delay(3000)
viewModel.clearSnackbar()
}
}
// Show non-connection errors as snackbar (when posts are visible)
if (state.error != null && (!state.isConnectionError || state.posts.isNotEmpty())) {
Snackbar(
@ -303,6 +372,30 @@ fun FeedScreen(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PinnedSectionHeader() {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Filled.PushPin,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = "Pinned",
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.primary
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SwipeablePostCard(
@ -313,6 +406,7 @@ fun SwipeablePostCard(
onCopyLink: () -> Unit = {},
onEdit: () -> Unit,
onDelete: () -> Unit,
onTogglePin: () -> Unit = {},
snackbarHostState: SnackbarHostState? = null
) {
val dismissState = rememberSwipeToDismissBoxState(
@ -362,6 +456,7 @@ fun SwipeablePostCard(
onCopyLink = onCopyLink,
onEdit = onEdit,
onDelete = onDelete,
onTogglePin = onTogglePin,
snackbarHostState = snackbarHostState
)
}
@ -449,6 +544,7 @@ fun PostCardContent(
onCopyLink: () -> Unit = {},
onEdit: () -> Unit,
onDelete: () -> Unit,
onTogglePin: () -> Unit = {},
snackbarHostState: SnackbarHostState? = null
) {
var expanded by remember { mutableStateOf(false) }
@ -475,7 +571,10 @@ fun PostCardContent(
}
),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
containerColor = if (post.featured)
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.15f)
else
MaterialTheme.colorScheme.surface
)
) {
Box {
@ -486,7 +585,18 @@ fun PostCardContent(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
StatusBadge(post)
Row(verticalAlignment = Alignment.CenterVertically) {
StatusBadge(post)
if (post.featured) {
Spacer(modifier = Modifier.width(6.dp))
Icon(
imageVector = Icons.Filled.PushPin,
contentDescription = "Pinned",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary
)
}
}
Text(
text = formatRelativeTime(post.publishedAt ?: post.createdAt),
@ -664,6 +774,20 @@ fun PostCardContent(
},
leadingIcon = { Icon(Icons.Default.Edit, contentDescription = null) }
)
DropdownMenuItem(
text = { Text(if (post.featured) "Unpin post" else "Pin post") },
onClick = {
showContextMenu = false
onTogglePin()
},
leadingIcon = {
Icon(
imageVector = if (post.featured) Icons.Outlined.PushPin else Icons.Filled.PushPin,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
}
)
if (isPublished && hasShareableUrl) {
DropdownMenuItem(
text = { Text("Copy link") },

View file

@ -184,6 +184,54 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
}
}
fun toggleFeatured(post: FeedPost) {
viewModelScope.launch {
val newFeatured = !post.featured
val ghostId = post.ghostId
val updatedAt = post.updatedAt
if (ghostId != null && updatedAt != null) {
// Optimistically update local state
remotePosts = remotePosts.map {
if (it.ghostId == ghostId) it.copy(featured = newFeatured) else it
}
mergePosts()
repository.toggleFeatured(ghostId, newFeatured, updatedAt).fold(
onSuccess = { updatedGhostPost ->
// Update with server response
remotePosts = remotePosts.map {
if (it.ghostId == ghostId) it.copy(
featured = updatedGhostPost.featured ?: false,
updatedAt = updatedGhostPost.updated_at
) else it
}
mergePosts()
val message = if (newFeatured) "Post pinned" else "Post unpinned"
_uiState.update { it.copy(snackbarMessage = message) }
},
onFailure = { e ->
// Revert optimistic update
remotePosts = remotePosts.map {
if (it.ghostId == ghostId) it.copy(featured = !newFeatured) else it
}
mergePosts()
_uiState.update { it.copy(error = "Failed to ${if (newFeatured) "pin" else "unpin"} post: ${e.message}") }
}
)
} else if (post.isLocal && post.localId != null) {
// Local-only post: just update the local DB
repository.updateLocalFeatured(post.localId, newFeatured)
val message = if (newFeatured) "Post pinned" else "Post unpinned"
_uiState.update { it.copy(snackbarMessage = message) }
}
}
}
fun clearSnackbar() {
_uiState.update { it.copy(snackbarMessage = null) }
}
fun clearError() {
_uiState.update { it.copy(error = null) }
}
@ -191,7 +239,13 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
private fun mergePosts(queuedPosts: List<FeedPost>? = null) {
val queued = queuedPosts ?: _uiState.value.posts.filter { it.isLocal }
val allPosts = queued + remotePosts
_uiState.update { it.copy(posts = allPosts) }
// Sort: featured/pinned posts first, then chronological
val sorted = allPosts.sortedWith(
compareByDescending<FeedPost> { it.featured }
.thenByDescending { it.isLocal } // local queued posts after pinned but before regular
.thenByDescending { it.publishedAt ?: it.createdAt ?: "" }
)
_uiState.update { it.copy(posts = sorted) }
}
private fun GhostPost.toFeedPost(): FeedPost = FeedPost(
@ -208,6 +262,7 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
linkDescription = null,
linkImageUrl = null,
status = status ?: "draft",
featured = featured ?: false,
publishedAt = published_at,
createdAt = created_at,
updatedAt = updated_at,
@ -227,6 +282,7 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
linkDescription = linkDescription,
linkImageUrl = linkImageUrl,
status = status.name.lowercase(),
featured = featured,
publishedAt = null,
createdAt = null,
updatedAt = null,
@ -240,7 +296,8 @@ data class FeedUiState(
val isRefreshing: Boolean = false,
val isLoadingMore: Boolean = false,
val error: String? = null,
val isConnectionError: Boolean = false
val isConnectionError: Boolean = false,
val snackbarMessage: String? = null
)
fun formatRelativeTime(isoString: String?): String {

View file

@ -105,6 +105,11 @@ fun SwooshNavGraph(
onPreview = { html ->
previewHtml = html
navController.navigate(Routes.PREVIEW)
},
onTogglePin = { p ->
feedViewModel.toggleFeatured(p)
// Update selected post so UI reflects the change
selectedPost = p.copy(featured = !p.featured)
}
)
}

View file

@ -50,6 +50,7 @@ class PostUploadWorker(
QueueStatus.QUEUED_SCHEDULED -> "scheduled"
else -> "draft"
},
featured = post.featured,
feature_image = featureImage,
feature_image_alt = post.imageAlt,
published_at = post.scheduledAt,

View file

@ -0,0 +1,301 @@
package com.swoosh.microblog.data.model
import com.google.gson.Gson
import org.junit.Assert.*
import org.junit.Test
class FeaturedPostsTest {
private val gson = Gson()
// --- GhostPost featured field ---
@Test
fun `GhostPost default featured is false`() {
val post = GhostPost()
assertEquals(false, post.featured)
}
@Test
fun `GhostPost featured can be set to true`() {
val post = GhostPost(featured = true)
assertTrue(post.featured!!)
}
@Test
fun `GhostPost featured can be set to null`() {
val post = GhostPost(featured = null)
assertNull(post.featured)
}
@Test
fun `GhostPost featured serializes to JSON correctly`() {
val post = GhostPost(id = "abc", featured = true)
val json = gson.toJson(post)
assertTrue(json.contains("\"featured\":true"))
}
@Test
fun `GhostPost featured false serializes correctly`() {
val post = GhostPost(id = "abc", featured = false)
val json = gson.toJson(post)
assertTrue(json.contains("\"featured\":false"))
}
@Test
fun `GhostPost deserializes featured true from JSON`() {
val json = """{"id":"xyz","featured":true}"""
val post = gson.fromJson(json, GhostPost::class.java)
assertTrue(post.featured!!)
}
@Test
fun `GhostPost deserializes featured false from JSON`() {
val json = """{"id":"xyz","featured":false}"""
val post = gson.fromJson(json, GhostPost::class.java)
assertFalse(post.featured!!)
}
@Test
fun `GhostPost deserializes missing featured field`() {
val json = """{"id":"xyz"}"""
val post = gson.fromJson(json, GhostPost::class.java)
// Gson may set missing Boolean? to null or false depending on initialization
// The important thing is it doesn't crash and treats it as not featured
assertTrue(post.featured == null || post.featured == false)
}
@Test
fun `GhostPost copy preserves featured`() {
val post = GhostPost(featured = true)
val copy = post.copy(id = "new")
assertTrue(copy.featured!!)
}
// --- LocalPost featured field ---
@Test
fun `LocalPost default featured is false`() {
val post = LocalPost()
assertFalse(post.featured)
}
@Test
fun `LocalPost featured can be set to true`() {
val post = LocalPost(featured = true)
assertTrue(post.featured)
}
@Test
fun `LocalPost copy preserves featured`() {
val post = LocalPost(featured = true)
val copy = post.copy(title = "updated")
assertTrue(copy.featured)
}
@Test
fun `LocalPost copy can change featured`() {
val post = LocalPost(featured = true)
val copy = post.copy(featured = false)
assertFalse(copy.featured)
}
// --- FeedPost featured field ---
@Test
fun `FeedPost default featured is false`() {
val post = createFeedPost()
assertFalse(post.featured)
}
@Test
fun `FeedPost featured can be set to true`() {
val post = createFeedPost(featured = true)
assertTrue(post.featured)
}
@Test
fun `FeedPost copy preserves featured`() {
val post = createFeedPost(featured = true)
val copy = post.copy(title = "updated")
assertTrue(copy.featured)
}
@Test
fun `FeedPost copy can toggle featured`() {
val post = createFeedPost(featured = true)
val toggled = post.copy(featured = !post.featured)
assertFalse(toggled.featured)
}
// --- Feed ordering (pinned first) ---
@Test
fun `featured posts sort before non-featured posts`() {
val regular = createFeedPost(ghostId = "1", featured = false, publishedAt = "2025-01-02T00:00:00Z")
val pinned = createFeedPost(ghostId = "2", featured = true, publishedAt = "2025-01-01T00:00:00Z")
val sorted = listOf(regular, pinned).sortedWith(
compareByDescending<FeedPost> { it.featured }
.thenByDescending { it.publishedAt ?: it.createdAt ?: "" }
)
assertTrue(sorted[0].featured)
assertEquals("2", sorted[0].ghostId)
assertFalse(sorted[1].featured)
assertEquals("1", sorted[1].ghostId)
}
@Test
fun `multiple featured posts sorted chronologically among themselves`() {
val pinned1 = createFeedPost(ghostId = "1", featured = true, publishedAt = "2025-01-01T00:00:00Z")
val pinned2 = createFeedPost(ghostId = "2", featured = true, publishedAt = "2025-01-03T00:00:00Z")
val pinned3 = createFeedPost(ghostId = "3", featured = true, publishedAt = "2025-01-02T00:00:00Z")
val sorted = listOf(pinned1, pinned2, pinned3).sortedWith(
compareByDescending<FeedPost> { it.featured }
.thenByDescending { it.publishedAt ?: it.createdAt ?: "" }
)
assertEquals("2", sorted[0].ghostId)
assertEquals("3", sorted[1].ghostId)
assertEquals("1", sorted[2].ghostId)
}
@Test
fun `non-featured posts sorted chronologically among themselves`() {
val post1 = createFeedPost(ghostId = "1", featured = false, publishedAt = "2025-01-01T00:00:00Z")
val post2 = createFeedPost(ghostId = "2", featured = false, publishedAt = "2025-01-03T00:00:00Z")
val post3 = createFeedPost(ghostId = "3", featured = false, publishedAt = "2025-01-02T00:00:00Z")
val sorted = listOf(post1, post2, post3).sortedWith(
compareByDescending<FeedPost> { it.featured }
.thenByDescending { it.publishedAt ?: it.createdAt ?: "" }
)
assertEquals("2", sorted[0].ghostId)
assertEquals("3", sorted[1].ghostId)
assertEquals("1", sorted[2].ghostId)
}
@Test
fun `mixed featured and non-featured posts sort correctly`() {
val posts = listOf(
createFeedPost(ghostId = "1", featured = false, publishedAt = "2025-01-05T00:00:00Z"),
createFeedPost(ghostId = "2", featured = true, publishedAt = "2025-01-01T00:00:00Z"),
createFeedPost(ghostId = "3", featured = false, publishedAt = "2025-01-04T00:00:00Z"),
createFeedPost(ghostId = "4", featured = true, publishedAt = "2025-01-03T00:00:00Z")
)
val sorted = posts.sortedWith(
compareByDescending<FeedPost> { it.featured }
.thenByDescending { it.publishedAt ?: it.createdAt ?: "" }
)
// Featured first: 4 (Jan 3), 2 (Jan 1)
assertEquals("4", sorted[0].ghostId)
assertTrue(sorted[0].featured)
assertEquals("2", sorted[1].ghostId)
assertTrue(sorted[1].featured)
// Then regular: 1 (Jan 5), 3 (Jan 4)
assertEquals("1", sorted[2].ghostId)
assertFalse(sorted[2].featured)
assertEquals("3", sorted[3].ghostId)
assertFalse(sorted[3].featured)
}
@Test
fun `empty list sorts without error`() {
val sorted = emptyList<FeedPost>().sortedWith(
compareByDescending<FeedPost> { it.featured }
.thenByDescending { it.publishedAt ?: it.createdAt ?: "" }
)
assertTrue(sorted.isEmpty())
}
@Test
fun `single featured post list sorts correctly`() {
val posts = listOf(createFeedPost(ghostId = "1", featured = true))
val sorted = posts.sortedWith(
compareByDescending<FeedPost> { it.featured }
.thenByDescending { it.publishedAt ?: it.createdAt ?: "" }
)
assertEquals(1, sorted.size)
assertTrue(sorted[0].featured)
}
@Test
fun `partitioning posts into pinned and regular sections works`() {
val posts = listOf(
createFeedPost(ghostId = "1", featured = true),
createFeedPost(ghostId = "2", featured = false),
createFeedPost(ghostId = "3", featured = true),
createFeedPost(ghostId = "4", featured = false)
)
val pinned = posts.filter { it.featured }
val regular = posts.filter { !it.featured }
assertEquals(2, pinned.size)
assertEquals(2, regular.size)
assertTrue(pinned.all { it.featured })
assertTrue(regular.none { it.featured })
}
// --- Model mapping with featured ---
@Test
fun `PostWrapper with featured post serializes correctly`() {
val wrapper = PostWrapper(listOf(GhostPost(title = "Test", featured = true, updated_at = "2025-01-01T00:00:00Z")))
val json = gson.toJson(wrapper)
assertTrue(json.contains("\"featured\":true"))
assertTrue(json.contains("\"updated_at\":\"2025-01-01T00:00:00Z\""))
}
@Test
fun `PostsResponse with featured posts deserializes correctly`() {
val json = """{
"posts": [
{"id": "1", "title": "Pinned", "featured": true},
{"id": "2", "title": "Regular", "featured": false}
],
"meta": {"pagination": {"page": 1, "limit": 15, "pages": 1, "total": 2, "next": null, "prev": null}}
}"""
val response = gson.fromJson(json, PostsResponse::class.java)
assertEquals(2, response.posts.size)
assertTrue(response.posts[0].featured!!)
assertFalse(response.posts[1].featured!!)
}
@Test
fun `toggle featured API request body is correct`() {
val post = GhostPost(featured = true, updated_at = "2025-01-01T00:00:00Z")
val wrapper = PostWrapper(listOf(post))
val json = gson.toJson(wrapper)
assertTrue(json.contains("\"featured\":true"))
assertTrue(json.contains("\"updated_at\""))
}
// --- Helper ---
private fun createFeedPost(
ghostId: String? = null,
featured: Boolean = false,
publishedAt: String? = null
) = FeedPost(
ghostId = ghostId,
title = "Test",
textContent = "Content",
htmlContent = null,
imageUrl = null,
linkUrl = null,
linkTitle = null,
linkDescription = null,
linkImageUrl = null,
status = "published",
featured = featured,
publishedAt = publishedAt,
createdAt = null,
updatedAt = null
)
}

View file

@ -63,7 +63,7 @@ class GhostModelsTest {
}
@Test
fun `GhostPost all fields default to null except visibility`() {
fun `GhostPost all fields default to null except visibility and featured`() {
val post = GhostPost()
assertNull(post.id)
assertNull(post.title)
@ -73,6 +73,7 @@ class GhostModelsTest {
assertNull(post.plaintext)
assertNull(post.mobiledoc)
assertNull(post.status)
assertEquals(false, post.featured)
assertNull(post.feature_image)
assertNull(post.feature_image_alt)
assertNull(post.created_at)