2 Commits

Author SHA1 Message Date
b-mob-notes 8a8f1cfffb feat(notes): P1 слой данных — Notes API v1 клиент + репозиторий + ETag-кэш + модель
- NotesApiClient: публичный /api/v1 (list exclude=content + If-None-Match, get, create,
  saveContent с If-Match→412 NoteConflictException, setFavorite/Category/rename, delete)
- NotesRepository: сессия→клиент→кэш, offline-first список (304/сеть → кэш)
- NotesCache: дисковый ETag-кэш списка (образец CalendarEventsCache)
- Note модель + разбор JSON; NoteConflictException (явный выбор, не молчаливая перезапись)
- NotesApiClientTest: 7 юнит-тестов через MockWebServer (пишущее — юнитами, не forbion; mail/073)

Верифицировано изолированно (worktree на чистом HEAD): compileDebugKotlin + 7/7 tests + detekt OK.
Закрывает часть P1 notes.diff.md (слой данных). mail/096/093, план mail/071.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 07:31:00 +00:00
b-mob-deck 6eeba1bba2 feat(deck): назначить/снять себя на карточке (P1 diff — исполнители были read-only)
DeckRepository.assignUser/unassignUser (POST /cards/{id}/assign, PUT /cards/{id}/unassign,
type 0), parseAssigneeUids для определения «назначен ли текущий пользователь»; VM.toggleAssignSelf;
кнопка «Назначить мне / Снять назначение с меня» + список исполнителей в DeckCardSheet.
Компилируется (Kotlin ok); detekt модуля 103=103 к HEAD (без новых замечаний; baseline-рефреш — лид, mail/095).
2026-07-13 07:28:46 +00:00
11 changed files with 543 additions and 0 deletions
@@ -55,10 +55,12 @@ fun DeckCardSheet(
stacks: List<DeckStack>, stacks: List<DeckStack>,
canEdit: Boolean, canEdit: Boolean,
busy: Boolean, busy: Boolean,
assignedToMe: Boolean,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onToggleDone: () -> Unit, onToggleDone: () -> Unit,
onSave: (title: String, description: String, duedate: String?) -> Unit, onSave: (title: String, description: String, duedate: String?) -> Unit,
onToggleLabel: (DeckLabel) -> Unit, onToggleLabel: (DeckLabel) -> Unit,
onToggleAssignSelf: () -> Unit,
onMove: (targetStackId: Int) -> Unit, onMove: (targetStackId: Int) -> Unit,
onArchive: () -> Unit, onArchive: () -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
@@ -146,6 +148,24 @@ fun DeckCardSheet(
} }
} }
// Исполнители
Text("Исполнители", style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextSecondary)
if (card.assignees.isNotEmpty()) {
Text(
card.assignees.joinToString(", "),
style = MaterialTheme.typography.bodyMedium,
color = F7Colors.TextPrimary,
)
}
if (canEdit) {
F7SecondaryButton(
text = if (assignedToMe) "Снять назначение с меня" else "Назначить мне",
onClick = onToggleAssignSelf,
enabled = !busy,
modifier = Modifier.fillMaxWidth(),
)
}
// Переместить в колонку // Переместить в колонку
if (canEdit && stacks.size > 1) { if (canEdit && stacks.size > 1) {
Text("Колонка", style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextSecondary) Text("Колонка", style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextSecondary)
@@ -99,6 +99,7 @@ class DeckRepository {
stackId = stackId, stackId = stackId,
labels = parseLabels(card.optJSONArray("labels")), labels = parseLabels(card.optJSONArray("labels")),
assignees = parseAssignees(card.optJSONArray("assignedUsers")), assignees = parseAssignees(card.optJSONArray("assignedUsers")),
assigneeUids = parseAssigneeUids(card.optJSONArray("assignedUsers")),
) )
private fun parseLabels(array: JSONArray?): List<DeckLabel> { private fun parseLabels(array: JSONArray?): List<DeckLabel> {
@@ -126,6 +127,21 @@ class DeckRepository {
return out return out
} }
/** uid'ы назначенных пользователей (type 0 — user) — для «назначен ли текущий пользователь». */
private fun parseAssigneeUids(array: JSONArray?): List<String> {
if (array == null) return emptyList()
val out = mutableListOf<String>()
for (i in 0 until array.length()) {
val a = array.optJSONObject(i)
if (a != null && a.optInt("type", 0) == 0) {
val uid = a.optJSONObject("participant")?.optString("uid")?.takeIf { it.isNotBlank() }
?: a.optString("participant").takeIf { it.isNotBlank() }
if (!uid.isNullOrBlank()) out += uid
}
}
return out
}
// --- Запись --- // --- Запись ---
fun createCard(session: AuthSession, stackId: Int, title: String): DeckCard { fun createCard(session: AuthSession, stackId: Int, title: String): DeckCard {
@@ -179,6 +195,18 @@ class DeckRepository {
sendJson(session, "DELETE", "${apiBase(session)}/cards/$cardId/label/$labelId", null) sendJson(session, "DELETE", "${apiBase(session)}/cards/$cardId/label/$labelId", null)
} }
/** Назначить пользователя (type 0) на карточку. */
fun assignUser(session: AuthSession, cardId: Int, uid: String) {
val body = JSONObject().put("userId", uid).put("type", 0)
sendJson(session, "POST", "${apiBase(session)}/cards/$cardId/assign", body)
}
/** Снять назначение пользователя (type 0) с карточки. */
fun unassignUser(session: AuthSession, cardId: Int, uid: String) {
val body = JSONObject().put("userId", uid).put("type", 0)
sendJson(session, "PUT", "${apiBase(session)}/cards/$cardId/unassign", body)
}
fun createStack(session: AuthSession, boardId: Int, title: String): DeckStack { fun createStack(session: AuthSession, boardId: Int, title: String): DeckStack {
val body = JSONObject().put("title", title).put("boardId", boardId).put("order", 999) val body = JSONObject().put("title", title).put("boardId", boardId).put("order", 999)
val json = sendJson(session, "POST", "${apiBase(session)}/stacks", body) as? JSONObject val json = sendJson(session, "POST", "${apiBase(session)}/stacks", body) as? JSONObject
@@ -258,6 +286,7 @@ data class DeckCard(
val stackId: Int = 0, val stackId: Int = 0,
val labels: List<DeckLabel> = emptyList(), val labels: List<DeckLabel> = emptyList(),
val assignees: List<String> = emptyList(), val assignees: List<String> = emptyList(),
val assigneeUids: List<String> = emptyList(),
) )
data class DeckBoardDetail( data class DeckBoardDetail(
@@ -135,10 +135,12 @@ fun DeckScreen(
stacks = state.boardDetail?.stacks.orEmpty(), stacks = state.boardDetail?.stacks.orEmpty(),
canEdit = state.boardDetail?.canEdit ?: false, canEdit = state.boardDetail?.canEdit ?: false,
busy = state.busy, busy = state.busy,
assignedToMe = card.assigneeUids.contains(session.username),
onDismiss = vm::closeCard, onDismiss = vm::closeCard,
onToggleDone = { vm.toggleDone(session, card) }, onToggleDone = { vm.toggleDone(session, card) },
onSave = { title, desc, due -> vm.updateCard(session, card, title, desc, due) }, onSave = { title, desc, due -> vm.updateCard(session, card, title, desc, due) },
onToggleLabel = { vm.toggleLabel(session, card, it) }, onToggleLabel = { vm.toggleLabel(session, card, it) },
onToggleAssignSelf = { vm.toggleAssignSelf(session, card) },
onMove = { vm.moveCard(session, card, it) }, onMove = { vm.moveCard(session, card, it) },
onArchive = { vm.archiveCard(session, card) }, onArchive = { vm.archiveCard(session, card) },
onDelete = { vm.deleteCard(session, card) }, onDelete = { vm.deleteCard(session, card) },
@@ -111,6 +111,14 @@ class DeckViewModel(
} }
} }
fun toggleAssignSelf(session: AuthSession, card: DeckCard) = mutate(session) {
if (card.assigneeUids.contains(session.username)) {
repository.unassignUser(session, card.id, session.username)
} else {
repository.assignUser(session, card.id, session.username)
}
}
fun createStack(session: AuthSession, title: String) { fun createStack(session: AuthSession, title: String) {
val boardId = _state.value.selectedBoardId ?: return val boardId = _state.value.selectedBoardId ?: return
mutate(session) { repository.createStack(session, boardId, title.trim()) } mutate(session) { repository.createStack(session, boardId, title.trim()) }
+2
View File
@@ -37,4 +37,6 @@ dependencies {
implementation libs.coil.compose implementation libs.coil.compose
implementation libs.coil.svg implementation libs.coil.svg
testImplementation libs.junit testImplementation libs.junit
testImplementation libs.okhttp
testImplementation libs.okhttp.mockwebserver
} }
@@ -0,0 +1,45 @@
package ru.forbion.f7cloud.feature.notes
import org.json.JSONArray
import org.json.JSONObject
/**
* Заметка (Notes API v1). Поля контракта: id, etag, readonly, content, title, category,
* favorite, modified. Источник модели — `nextcloud/notes` v5.0.0 (карта app-map/notes.md).
*/
data class Note(
val id: Long,
val etag: String = "",
val readonly: Boolean = false,
val title: String = "",
val category: String = "",
val content: String = "",
val favorite: Boolean = false,
val modified: Long = 0L,
) {
companion object {
/** Разбор одной заметки из JSON ответа API v1. */
fun fromJson(obj: JSONObject): Note = Note(
id = obj.optLong("id", 0L),
etag = obj.optString("etag"),
readonly = obj.optBoolean("readonly", false),
title = obj.optString("title"),
category = obj.optString("category"),
content = obj.optString("content"),
favorite = obj.optBoolean("favorite", false),
modified = obj.optLong("modified", 0L),
)
/** Разбор списка заметок (GET /notes, в т.ч. с exclude=content). */
fun listFromJson(array: JSONArray): List<Note> {
val out = ArrayList<Note>(array.length())
for (i in 0 until array.length()) {
val obj = array.optJSONObject(i)
if (obj != null && obj.optLong("id", 0L) > 0L) {
out += fromJson(obj)
}
}
return out
}
}
}
@@ -0,0 +1,14 @@
package ru.forbion.f7cloud.feature.notes
/**
* Конфликт версий заметки: сервер вернул 412 Precondition Failed на PUT с `If-Match`
* (заметка изменена в другой сессии). Разрешается ЯВНЫМ выбором пользователя (версия сервера
* или текущая), НЕ молчаливой перезаписью — согласовано с лидом (mail/071 п.2, диалог
* ConflictSolution в карте app-map/notes.md).
*
* @param serverNote версия с сервера (если её удалось перечитать) — для показа в диалоге.
*/
class NoteConflictException(
val noteId: Long,
val serverNote: Note? = null,
) : RuntimeException("Note $noteId changed on server (412)")
@@ -0,0 +1,142 @@
package ru.forbion.f7cloud.feature.notes
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
/**
* Клиент публичного Notes API v1 (`/index.php/apps/notes/api/v1`). Стабильный контракт для
* сторонних клиентов (его же использует официальный Nextcloud Notes Android): ETag на
* коллекцию и заметку, `If-None-Match` для дешёвого рефреша, `exclude=content` для лёгкого
* списка, `If-Match` для конфликтов при записи (mail/071, карта app-map/notes.md).
*
* `client` инъектируется (в проде — [ru.forbion.f7cloud.core.network.NetworkFactory]
* .newAuthedClient), что делает клиент юнит-тестируемым через MockWebServer — пишущие
* сценарии проверяются юнитами, НЕ на боевом forbion (mail/073).
*/
class NotesApiClient(
private val client: OkHttpClient,
serverUrl: String,
) {
private val apiBase = serverUrl.trimEnd('/') + "/index.php/apps/notes/api/v1"
/** Результат листинга: заметки + ETag коллекции (для последующего If-None-Match). */
data class ListResult(val notes: List<Note>, val etag: String, val notModified: Boolean = false)
/**
* GET /notes — список. [excludeContent] → `exclude=content` (лёгкий список).
* [pruneBefore] → инкрементальный синк (только изменённые после метки). [ifNoneMatch] —
* ETag коллекции; при 304 возвращается [ListResult.notModified] = true с пустым списком.
*/
fun list(
excludeContent: Boolean = true,
pruneBefore: Long? = null,
ifNoneMatch: String? = null,
): ListResult {
val params = buildList {
if (excludeContent) add("exclude=content")
if (pruneBefore != null) add("pruneBefore=$pruneBefore")
}
val url = apiBase + "/notes" + if (params.isEmpty()) "" else "?" + params.joinToString("&")
val req = Request.Builder()
.url(url)
.header("Accept", "application/json")
.apply { if (!ifNoneMatch.isNullOrBlank()) header("If-None-Match", ifNoneMatch) }
.build()
client.newCall(req).execute().use { resp ->
if (resp.code == HTTP_NOT_MODIFIED) {
return ListResult(emptyList(), ifNoneMatch.orEmpty(), notModified = true)
}
if (!resp.isSuccessful) error("Notes list HTTP ${resp.code}")
val body = resp.body?.string().orEmpty()
val notes = Note.listFromJson(JSONArray(if (body.isBlank()) "[]" else body))
return ListResult(notes, resp.header("ETag").orEmpty())
}
}
/** GET /notes/{id} — одна заметка (с содержимым). */
fun get(id: Long): Note {
val req = Request.Builder()
.url("$apiBase/notes/$id")
.header("Accept", "application/json")
.build()
client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) error("Notes get HTTP ${resp.code}")
return Note.fromJson(JSONObject(resp.body?.string().orEmpty()))
}
}
/** POST /notes — создать заметку. */
fun create(content: String, category: String = ""): Note {
val payload = JSONObject().put("content", content)
if (category.isNotEmpty()) payload.put("category", category)
val req = Request.Builder()
.url("$apiBase/notes")
.header("Accept", "application/json")
.post(payload.toString().toRequestBody(JSON))
.build()
client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) error("Notes create HTTP ${resp.code}")
return Note.fromJson(JSONObject(resp.body?.string().orEmpty()))
}
}
/**
* PUT /notes/{id} с телом `content` и заголовком `If-Match: "<etag>"`. На 412 — конфликт
* версий: перечитываем серверную версию и бросаем [NoteConflictException] (НЕ молчаливо
* перезаписываем — mail/071 п.2).
*/
fun saveContent(note: Note): Note {
val payload = JSONObject().put("content", note.content)
val builder = Request.Builder()
.url("$apiBase/notes/${note.id}")
.header("Accept", "application/json")
.put(payload.toString().toRequestBody(JSON))
if (note.etag.isNotBlank()) builder.header("If-Match", "\"${note.etag}\"")
client.newCall(builder.build()).execute().use { resp ->
if (resp.code == HTTP_PRECONDITION_FAILED) {
val server = runCatching { get(note.id) }.getOrNull()
throw NoteConflictException(note.id, server)
}
if (!resp.isSuccessful) error("Notes save HTTP ${resp.code}")
return Note.fromJson(JSONObject(resp.body?.string().orEmpty()))
}
}
/** PUT /notes/{id} — обновить метаданные (favorite/category/title) без If-Match. */
private fun patchMeta(id: Long, field: String, value: Any): Note {
val payload = JSONObject().put(field, value)
val req = Request.Builder()
.url("$apiBase/notes/$id")
.header("Accept", "application/json")
.put(payload.toString().toRequestBody(JSON))
.build()
client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) error("Notes patch HTTP ${resp.code}")
return Note.fromJson(JSONObject(resp.body?.string().orEmpty()))
}
}
fun setFavorite(id: Long, favorite: Boolean): Note = patchMeta(id, "favorite", favorite)
fun setCategory(id: Long, category: String): Note = patchMeta(id, "category", category)
fun rename(id: Long, title: String): Note = patchMeta(id, "title", title)
/** DELETE /notes/{id}. */
fun delete(id: Long) {
val req = Request.Builder().url("$apiBase/notes/$id").delete().build()
client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) error("Notes delete HTTP ${resp.code}")
}
}
private companion object {
val JSON = "application/json; charset=utf-8".toMediaType()
const val HTTP_NOT_MODIFIED = 304
const val HTTP_PRECONDITION_FAILED = 412
}
}
@@ -0,0 +1,49 @@
package ru.forbion.f7cloud.feature.notes
import android.content.Context
import java.io.File
import java.security.MessageDigest
/**
* Дисковый кэш списка заметок, ключуемый ETag коллекции (образец — CalendarEventsCache).
* Формат файла: первая строка — ETag на момент загрузки, дальше сырой JSON массива заметок
* (переиспользуем боевой парсер [Note.listFromJson], без хрупкого промежуточного маппинга).
*
* Даёт: (1) offline-отдачу списка при отсутствии сети; (2) быстрый рефреш через
* `If-None-Match` — при 304 сеть не качает тело, читаем диск.
*/
class NotesCache(private val dir: File) {
constructor(context: Context) : this(File(context.cacheDir, "notes_list"))
data class Entry(val etag: String, val json: String)
/** Кэш списка для аккаунта, если есть; иначе null. */
fun get(accountKey: String): Entry? {
val f = entryFile(accountKey)
if (!f.isFile) return null
return runCatching {
val text = f.readText()
val nl = text.indexOf('\n')
if (nl <= 0) null else Entry(text.substring(0, nl), text.substring(nl + 1))
}.getOrNull()
}
fun put(accountKey: String, etag: String, json: String) {
if (etag.isBlank()) return
runCatching {
dir.mkdirs()
entryFile(accountKey).writeText("$etag\n$json")
}
}
fun clear(accountKey: String) {
runCatching { entryFile(accountKey).delete() }
}
private fun entryFile(accountKey: String): File = File(dir, md5(accountKey) + ".json")
private fun md5(s: String): String =
MessageDigest.getInstance("MD5").digest(s.toByteArray())
.joinToString("") { "%02x".format(it) }
}
@@ -0,0 +1,98 @@
package ru.forbion.f7cloud.feature.notes
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.network.NetworkFactory
/**
* Репозиторий Заметок: связывает сессию ([AuthSession]) → [NotesApiClient] → [NotesCache].
* Сеть — на IO. Списки отдаются из ETag-кэша при 304 или отсутствии сети (offline-first
* read). Пишущие операции идут напрямую в API (проверяются юнит-тестами, не на forbion —
* mail/073).
*/
class NotesRepository(
private val cache: NotesCache? = null,
private val apiFactory: (AuthSession) -> NotesApiClient = ::defaultApi,
) {
private fun accountKey(s: AuthSession) = "${s.serverUrl}|${s.davUserId ?: s.username}"
/**
* Список заметок (лёгкий, exclude=content). Пытается сеть с `If-None-Match` по кэш-ETag;
* при 304 или сетевой ошибке возвращает кэш (если он есть). Свежий ответ кладётся в кэш.
*/
suspend fun listNotes(session: AuthSession): List<Note> = withContext(Dispatchers.IO) {
val api = apiFactory(session)
val key = accountKey(session)
val cached = cache?.get(key)
try {
val res = api.list(excludeContent = true, ifNoneMatch = cached?.etag)
if (res.notModified) {
cached?.let { return@withContext Note.listFromJson(JSONArray(it.json)) }
return@withContext emptyList()
}
cache?.put(key, res.etag, notesToJson(res.notes))
res.notes
} catch (e: Exception) {
// offline / сеть недоступна → отдаём кэш, если он есть
cached?.let { return@withContext Note.listFromJson(JSONArray(it.json)) }
throw e
}
}
suspend fun getNote(session: AuthSession, id: Long): Note = withContext(Dispatchers.IO) {
apiFactory(session).get(id)
}
suspend fun createNote(session: AuthSession, content: String, category: String = ""): Note =
withContext(Dispatchers.IO) { apiFactory(session).create(content, category) }
/** Сохранение содержимого с If-Match; [NoteConflictException] на 412 (обрабатывает UI). */
suspend fun saveNote(session: AuthSession, note: Note): Note = withContext(Dispatchers.IO) {
apiFactory(session).saveContent(note)
}
suspend fun setFavorite(session: AuthSession, id: Long, favorite: Boolean): Note =
withContext(Dispatchers.IO) { apiFactory(session).setFavorite(id, favorite) }
suspend fun setCategory(session: AuthSession, id: Long, category: String): Note =
withContext(Dispatchers.IO) { apiFactory(session).setCategory(id, category) }
suspend fun renameNote(session: AuthSession, id: Long, title: String): Note =
withContext(Dispatchers.IO) { apiFactory(session).rename(id, title) }
suspend fun deleteNote(session: AuthSession, id: Long) = withContext(Dispatchers.IO) {
apiFactory(session).delete(id)
}
private fun notesToJson(notes: List<Note>): String {
val arr = JSONArray()
notes.forEach { n ->
arr.put(
JSONObject()
.put("id", n.id)
.put("etag", n.etag)
.put("readonly", n.readonly)
.put("title", n.title)
.put("category", n.category)
.put("content", n.content)
.put("favorite", n.favorite)
.put("modified", n.modified),
)
}
return arr.toString()
}
private companion object {
fun defaultApi(session: AuthSession): NotesApiClient {
val client = NetworkFactory.newAuthedClient(
username = session.username,
appPassword = session.appPassword,
trustAllCerts = session.trustAllCerts,
)
return NotesApiClient(client, session.serverUrl)
}
}
}
@@ -0,0 +1,134 @@
package ru.forbion.f7cloud.feature.notes
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
/**
* Юнит-тесты Notes API v1 через MockWebServer — пишущие сценарии (create/save/If-Match/412)
* проверяются здесь, НЕ на боевом forbion (mail/073). Покрывают контракт из карты
* app-map/notes.md: exclude=content, ETag/If-None-Match, If-Match→412 конфликт.
*/
class NotesApiClientTest {
private lateinit var server: MockWebServer
private lateinit var api: NotesApiClient
@Before
fun setUp() {
server = MockWebServer()
server.start()
api = NotesApiClient(OkHttpClient(), server.url("/").toString().trimEnd('/'))
}
@After
fun tearDown() {
server.shutdown()
}
@Test
fun `list parses notes and sends exclude=content plus ETag`() {
val body = """[{"id":7,"etag":"e7","title":"Заметка",""" +
""""category":"Работа","favorite":true,"modified":100}]"""
server.enqueue(MockResponse().setHeader("ETag", "coll-1").setBody(body))
val res = api.list(excludeContent = true, ifNoneMatch = "prev-etag")
assertFalse(res.notModified)
assertEquals("coll-1", res.etag)
assertEquals(1, res.notes.size)
assertEquals(7L, res.notes[0].id)
assertEquals("Заметка", res.notes[0].title)
assertTrue(res.notes[0].favorite)
val recorded = server.takeRequest()
assertEquals("/index.php/apps/notes/api/v1/notes?exclude=content", recorded.path)
assertEquals("prev-etag", recorded.getHeader("If-None-Match"))
}
@Test
fun `list returns notModified on 304`() {
server.enqueue(MockResponse().setResponseCode(304))
val res = api.list(ifNoneMatch = "coll-1")
assertTrue(res.notModified)
assertTrue(res.notes.isEmpty())
assertEquals("coll-1", res.etag)
}
@Test
fun `saveContent sends If-Match with quoted etag`() {
server.enqueue(MockResponse().setBody("""{"id":7,"etag":"e8","content":"new"}"""))
val saved = api.saveContent(Note(id = 7, etag = "e7", content = "new"))
assertEquals("e8", saved.etag)
val recorded = server.takeRequest()
assertEquals("PUT", recorded.method)
assertEquals("/index.php/apps/notes/api/v1/notes/7", recorded.path)
assertEquals("\"e7\"", recorded.getHeader("If-Match"))
assertTrue(recorded.body.readUtf8().contains("\"content\""))
}
@Test
fun `saveContent on 412 rereads server version and throws conflict`() {
server.enqueue(MockResponse().setResponseCode(412)) // PUT → конфликт
server.enqueue(MockResponse().setBody("""{"id":7,"etag":"server","content":"srv"}""")) // GET перечит
val ex = assertThrows(NoteConflictException::class.java) {
api.saveContent(Note(id = 7, etag = "stale", content = "mine"))
}
assertEquals(7L, ex.noteId)
assertEquals("server", ex.serverNote?.etag)
assertEquals("srv", ex.serverNote?.content)
}
@Test
fun `create posts content and category`() {
server.enqueue(MockResponse().setBody("""{"id":9,"etag":"e9","content":"c","category":"Личное"}"""))
val note = api.create(content = "c", category = "Личное")
assertEquals(9L, note.id)
val recorded = server.takeRequest()
assertEquals("POST", recorded.method)
assertEquals("/index.php/apps/notes/api/v1/notes", recorded.path)
val body = recorded.body.readUtf8()
assertTrue(body.contains("\"content\""))
assertTrue(body.contains("Личное"))
}
@Test
fun `setFavorite puts favorite field without If-Match`() {
server.enqueue(MockResponse().setBody("""{"id":7,"favorite":true}"""))
val note = api.setFavorite(7, true)
assertTrue(note.favorite)
val recorded = server.takeRequest()
assertEquals("PUT", recorded.method)
assertNull(recorded.getHeader("If-Match"))
assertTrue(recorded.body.readUtf8().contains("\"favorite\""))
}
@Test
fun `delete issues DELETE on note id`() {
server.enqueue(MockResponse().setResponseCode(200))
api.delete(7)
val recorded = server.takeRequest()
assertEquals("DELETE", recorded.method)
assertEquals("/index.php/apps/notes/api/v1/notes/7", recorded.path)
}
}