Coverage Summary for Class: SyncDtoMappersKt (com.georgeci.moneysurfer.data.sync)

Class Class, % Method, % Branch, % Line, % Instruction, %
SyncDtoMappersKt 100% (1/1) 100% (18/18) 93.8% (15/16) 100% (220/220) 99.7% (953/956)


 package com.georgeci.moneysurfer.data.sync
 
 import com.georgeci.moneysurfer.data.db.entity.AccountEntity
 import com.georgeci.moneysurfer.data.db.entity.AccountExtraDetailsColumn
 import com.georgeci.moneysurfer.data.db.entity.BudgetEntity
 import com.georgeci.moneysurfer.data.db.entity.CategoryEntity
 import com.georgeci.moneysurfer.data.db.entity.GoalContributionEntity
 import com.georgeci.moneysurfer.data.db.entity.GoalEntity
 import com.georgeci.moneysurfer.data.db.entity.TransactionEntity
 import com.georgeci.moneysurfer.data.db.entity.WorkspaceEntity
 import com.georgeci.moneysurfer.data.db.entity.WorkspaceInviteEntity
 import com.georgeci.moneysurfer.data.db.entity.WorkspaceMemberEntity
 import com.georgeci.moneysurfer.data.remote.AccountDoc
 import com.georgeci.moneysurfer.data.remote.AccountExtraDetailDoc
 import com.georgeci.moneysurfer.data.remote.BudgetDoc
 import com.georgeci.moneysurfer.data.remote.CategoryDoc
 import com.georgeci.moneysurfer.data.remote.GoalContributionDoc
 import com.georgeci.moneysurfer.data.remote.GoalDoc
 import com.georgeci.moneysurfer.data.remote.TransactionDoc
 import com.georgeci.moneysurfer.data.remote.WorkspaceDoc
 import com.georgeci.moneysurfer.data.remote.WorkspaceInviteDoc
 import com.georgeci.moneysurfer.data.remote.WorkspaceMemberDoc
 import com.georgeci.moneysurfer.domain.model.AccountExtraDetail
 import com.georgeci.moneysurfer.domain.model.TransactionTags
 import kotlinx.datetime.TimeZone
 import kotlinx.datetime.toLocalDateTime
 import kotlin.time.Instant
 
 /**
  * Shared Room ↔ Firestore-DTO mappers. Used by both the v1
  * [com.georgeci.moneysurfer.data.repository.WorkspaceSyncRepositoryImpl] and the
  * Phase-1 outbox path (repo dual-writes serialize the DTO into the queue payload).
  *
  * `clientVersionCode` is set via the DTO defaults (currently `1`) — the static
  * Firestore rule `hasValidClientVersion()` requires `>= 1`. When real version
  * threading is wired (AppInfo.versionCode), bump the static default or move
  * to a per-call parameter.
  */
 
 fun WorkspaceEntity.toDoc(): WorkspaceDoc = WorkspaceDoc(
     name = name,
     description = description,
     baseCurrency = baseCurrency,
     ownerId = ownerId,
     archived = archived,
     createdAt = createdAt,
     updatedAt = updatedAt,
 )
 
 fun WorkspaceDoc.toEntity(id: String): WorkspaceEntity = WorkspaceEntity(
     id = id,
     name = name,
     description = description,
     baseCurrency = baseCurrency,
     ownerId = ownerId,
     createdAt = createdAt,
     archived = archived,
     updatedAt = updatedAt,
 )
 
 fun AccountEntity.toDoc(): AccountDoc = AccountDoc(
     name = name,
     type = type,
     currency = currency,
     balance = balance,
     archived = archived,
     updatedAt = updatedAt,
     archivedAt = archivedAt,
     // Same stored-column↔wire-list conversion as TransactionEntity.tags below: the wire keeps
     // structured entries, Room keeps one JSON column.
     extraDetails = AccountExtraDetailsColumn.decode(extraDetails)
         .map { AccountExtraDetailDoc(key = it.key, value = it.value) },
     sortOrder = sortOrder,
 )
 
 fun AccountDoc.toEntity(id: String, workspaceId: String): AccountEntity = AccountEntity(
     id = id,
     workspaceId = workspaceId,
     name = name,
     type = type,
     currency = currency,
     balance = balance,
     archived = archived,
     updatedAt = updatedAt,
     archivedAt = archivedAt,
     // `encode` normalizes, so a doc from a client we do not control cannot seed the column with
     // blank keys, duplicates or an unbounded list.
     extraDetails = AccountExtraDetailsColumn.encode(
         extraDetails.map { AccountExtraDetail(key = it.key, value = it.value) },
     ),
     sortOrder = sortOrder,
 )
 
 fun CategoryEntity.toDoc(): CategoryDoc = CategoryDoc(
     name = name,
     type = type,
     parentId = parentId,
     createdAt = createdAt,
     updatedAt = updatedAt,
     systemKind = systemKind,
     iconKey = iconKey,
     hue = hue,
 )
 
 fun CategoryDoc.toEntity(id: String, workspaceId: String): CategoryEntity = CategoryEntity(
     id = id,
     workspaceId = workspaceId,
     name = name,
     type = type,
     parentId = parentId,
     createdAt = createdAt,
     updatedAt = updatedAt,
     systemKind = systemKind,
     iconKey = iconKey,
     hue = hue,
 )
 
 /**
  * Room stores `categoryIds` as a CSV column while the wire DTO carries a real list, so these
  * mappers convert on the entity↔wire boundary. (`BudgetRepositoryImpl` does the same conversion
  * on the entity↔domain boundary — two boundaries, same CSV encoding.) Blank segments are dropped
  * so a legacy `""` or a trailing comma decodes to an empty list rather than a blank id.
  */
 fun BudgetEntity.toDoc(): BudgetDoc = BudgetDoc(
     name = name,
     categoryIds = categoryIds.split(',').filter { it.isNotBlank() },
     amount = amount,
     period = period,
     startDate = startDate,
     alertPercent = alertPercent,
     isActive = isActive,
     rollover = rollover,
     createdAt = createdAt,
     updatedAt = updatedAt,
 )
 
 fun BudgetDoc.toEntity(id: String, workspaceId: String): BudgetEntity = BudgetEntity(
     id = id,
     workspaceId = workspaceId,
     name = name,
     categoryIds = categoryIds.filter { it.isNotBlank() }.joinToString(","),
     amount = amount,
     period = period,
     startDate = startDate,
     alertPercent = alertPercent,
     isActive = isActive,
     rollover = rollover,
     createdAt = createdAt,
     updatedAt = updatedAt,
 )
 
 fun TransactionEntity.toDoc(): TransactionDoc = TransactionDoc(
     accountId = accountId,
     categoryId = categoryId,
     amount = amount,
     currencyCode = currencyCode,
     note = note,
     merchant = merchant,
     // Same CSV-column↔wire-list conversion as BudgetEntity.categoryIds above.
     tags = tags.split(',').filter { it.isNotBlank() },
     operationAt = operationAt,
     operationDate = operationDate,
     type = type,
     status = status,
     createdAt = createdAt,
     updatedAt = updatedAt,
     // The Room column and the wire field are the same tombstone (issue #346), so an upsert push
     // carries it either way: a restore writes `null` and lifts the remote tombstone, and a row
     // pushed while deleted stays deleted for peers.
     deletedAt = deletedAt,
     transferId = transferId,
     splitId = splitId,
     recurringRuleId = recurringRuleId,
 )
 
 fun TransactionDoc.toEntity(id: String, workspaceId: String): TransactionEntity {
     // Tolerate legacy/partial Firestore docs:
     //  - operationDate may be "" (older schema): derive ISO date from operationAt.
     //    Use UTC, not the device default, so the same Firestore doc resolves to the
     //    same operationDate on every client (avoids cross-device divergence).
     //  - createdAt may be 0L (older schema): fall back to operationAt or updatedAt
     //    so audit ordering doesn't collapse to epoch zero.
     val resolvedOperationDate = operationDate.ifBlank {
         Instant.fromEpochMilliseconds(operationAt)
             .toLocalDateTime(TimeZone.UTC)
             .date
             .toString()
     }
     val resolvedCreatedAt = if (createdAt == 0L) {
         if (operationAt != 0L) operationAt else updatedAt
     } else {
         createdAt
     }
     return TransactionEntity(
         id = id,
         workspaceId = workspaceId,
         accountId = accountId,
         amount = amount,
         currencyCode = currencyCode,
         categoryId = categoryId,
         note = note,
         merchant = merchant,
         // Normalized, not just joined: a remote tag carrying the CSV separator would come
         // back from Room as two tags, and this doc may come from a client we do not control.
         tags = TransactionTags.normalize(tags).joinToString(","),
         operationAt = operationAt,
         operationDate = resolvedOperationDate,
         type = type,
         status = status,
         createdAt = resolvedCreatedAt,
         updatedAt = updatedAt,
         deletedAt = deletedAt,
         transferId = transferId,
         splitId = splitId,
         recurringRuleId = recurringRuleId,
     )
 }
 
 fun GoalEntity.toDoc(): GoalDoc = GoalDoc(
     title = title,
     emoji = emoji,
     hue = hue,
     target = target,
     currencyCode = currencyCode,
     startDate = startDate,
     targetDate = targetDate,
     accountId = accountId,
     status = status,
     createdAt = createdAt,
     updatedAt = updatedAt,
 )
 
 fun GoalDoc.toEntity(id: String, workspaceId: String): GoalEntity = GoalEntity(
     id = id,
     workspaceId = workspaceId,
     title = title,
     emoji = emoji,
     hue = hue,
     target = target,
     currencyCode = currencyCode,
     startDate = startDate,
     targetDate = targetDate,
     // A blank accountId passes the rules' nullable-string guard but is no account,
     // and storing it would violate the Room FK. Absent means null.
     accountId = accountId?.takeIf { it.isNotBlank() },
     // Legacy/partial docs may omit `status`; the DTO default already lands on ACTIVE,
     // but an explicitly-empty string would store an unparseable enum name.
     status = status.ifBlank { "ACTIVE" },
     createdAt = createdAt,
     updatedAt = updatedAt,
 )
 
 fun GoalContributionEntity.toDoc(): GoalContributionDoc = GoalContributionDoc(
     goalId = goalId,
     amount = amount,
     occurredOn = occurredOn,
     note = note,
     transferId = transferId,
     createdAt = createdAt,
     updatedAt = updatedAt,
 )
 
 fun GoalContributionDoc.toEntity(id: String, workspaceId: String): GoalContributionEntity =
     GoalContributionEntity(
         id = id,
         workspaceId = workspaceId,
         goalId = goalId,
         amount = amount,
         occurredOn = occurredOn,
         note = note,
         transferId = transferId,
         createdAt = createdAt,
         updatedAt = updatedAt,
     )
 
 fun WorkspaceMemberEntity.toDoc(): WorkspaceMemberDoc = WorkspaceMemberDoc(
     userId = userId,
     role = role,
     status = status,
     displayName = displayName,
     email = email,
     addedByUserId = addedByUserId,
     createdAt = createdAt,
     updatedAt = updatedAt,
     leftAt = leftAt,
     removedAt = removedAt,
 )
 
 fun WorkspaceMemberDoc.toEntity(workspaceId: String): WorkspaceMemberEntity = WorkspaceMemberEntity(
     userId = userId,
     workspaceId = workspaceId,
     role = role,
     status = status.ifEmpty { "ACTIVE" },
     displayName = displayName,
     email = email,
     addedByUserId = addedByUserId,
     createdAt = createdAt,
     updatedAt = updatedAt,
     leftAt = leftAt,
     removedAt = removedAt,
 )
 
 fun WorkspaceInviteEntity.toDoc(): WorkspaceInviteDoc = WorkspaceInviteDoc(
     email = email,
     targetUserId = targetUserId,
     role = role,
     status = status,
     invitedByUserId = invitedByUserId,
     createdAt = createdAt,
     updatedAt = updatedAt,
     expiresAt = expiresAt,
     respondedAt = respondedAt,
 )
 
 fun WorkspaceInviteDoc.toEntity(id: String, workspaceId: String): WorkspaceInviteEntity = WorkspaceInviteEntity(
     id = id,
     workspaceId = workspaceId,
     email = email,
     targetUserId = targetUserId,
     role = role,
     status = status.ifEmpty { "PENDING" },
     invitedByUserId = invitedByUserId,
     createdAt = createdAt,
     updatedAt = updatedAt,
     expiresAt = expiresAt,
     respondedAt = respondedAt,
 )