Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a8f1cfffb | |||
| 6eeba1bba2 |
@@ -55,10 +55,12 @@ fun DeckCardSheet(
|
||||
stacks: List<DeckStack>,
|
||||
canEdit: Boolean,
|
||||
busy: Boolean,
|
||||
assignedToMe: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onToggleDone: () -> Unit,
|
||||
onSave: (title: String, description: String, duedate: String?) -> Unit,
|
||||
onToggleLabel: (DeckLabel) -> Unit,
|
||||
onToggleAssignSelf: () -> Unit,
|
||||
onMove: (targetStackId: Int) -> Unit,
|
||||
onArchive: () -> 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) {
|
||||
Text("Колонка", style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextSecondary)
|
||||
|
||||
@@ -99,6 +99,7 @@ class DeckRepository {
|
||||
stackId = stackId,
|
||||
labels = parseLabels(card.optJSONArray("labels")),
|
||||
assignees = parseAssignees(card.optJSONArray("assignedUsers")),
|
||||
assigneeUids = parseAssigneeUids(card.optJSONArray("assignedUsers")),
|
||||
)
|
||||
|
||||
private fun parseLabels(array: JSONArray?): List<DeckLabel> {
|
||||
@@ -126,6 +127,21 @@ class DeckRepository {
|
||||
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 {
|
||||
@@ -179,6 +195,18 @@ class DeckRepository {
|
||||
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 {
|
||||
val body = JSONObject().put("title", title).put("boardId", boardId).put("order", 999)
|
||||
val json = sendJson(session, "POST", "${apiBase(session)}/stacks", body) as? JSONObject
|
||||
@@ -258,6 +286,7 @@ data class DeckCard(
|
||||
val stackId: Int = 0,
|
||||
val labels: List<DeckLabel> = emptyList(),
|
||||
val assignees: List<String> = emptyList(),
|
||||
val assigneeUids: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
data class DeckBoardDetail(
|
||||
|
||||
@@ -135,10 +135,12 @@ fun DeckScreen(
|
||||
stacks = state.boardDetail?.stacks.orEmpty(),
|
||||
canEdit = state.boardDetail?.canEdit ?: false,
|
||||
busy = state.busy,
|
||||
assignedToMe = card.assigneeUids.contains(session.username),
|
||||
onDismiss = vm::closeCard,
|
||||
onToggleDone = { vm.toggleDone(session, card) },
|
||||
onSave = { title, desc, due -> vm.updateCard(session, card, title, desc, due) },
|
||||
onToggleLabel = { vm.toggleLabel(session, card, it) },
|
||||
onToggleAssignSelf = { vm.toggleAssignSelf(session, card) },
|
||||
onMove = { vm.moveCard(session, card, it) },
|
||||
onArchive = { vm.archiveCard(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) {
|
||||
val boardId = _state.value.selectedBoardId ?: return
|
||||
mutate(session) { repository.createStack(session, boardId, title.trim()) }
|
||||
|
||||
@@ -37,4 +37,6 @@ dependencies {
|
||||
implementation libs.coil.compose
|
||||
implementation libs.coil.svg
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user