feat(taiga): Ticket-Stand im Knoten-Fenster — gelesen, nie geschrieben (D91-Nachtrag 6, SPEC §9)
Wo eine Ref steht und ein `&taiga.<slug>` gilt, zeigt das Knoten-Fenster
Betreff, Status und Zuständigen des Tickets; Taigas Statusname steht neben
der Statusbox der Notation (`In progress → [~]`).
- Proxy: zwei benannte Lese-Endpunkte (`GET /taiga/userstories/{ref}` und
`…/tasks/{ref}`, je `?slug=`) — das Präfix der Ref trägt den Typ, Taiga
hat getrennte `by_ref`-Endpunkte. Erst `/projects/by_slug`, dann `by_ref`
(eine Ref ist nur je Projekt eindeutig); der Slug wird kodiert angehängt.
- Die Abbildung Status → Statusbox liegt im Editor (`mapTaigaStatus`,
headless): Statuscodes sind Notation, das Backend parst sie nicht (D14).
Unbekannte Namen bleiben unabgebildet — Raten hieße, dem Knoten eine
Aussage zu geben, die niemand gemacht hat.
- Geholt wird erst nach 400 ms Verweilen und je Ticket einmal je Sitzung
(↻ holt neu); ohne Anmeldung gar nicht — der Knopf meldet erst an.
- Nichts wird geschrieben: kein Text, keine Statusbox (das bleibt
`#trk.write`).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
e58f71bf90
commit
842e89795a
+11
-1
@@ -18,7 +18,8 @@ schneiden, dass die Berechtigungsprüfung dazukommen kann, ohne die Signatur
|
||||
zu brechen.
|
||||
|
||||
**Taiga-Proxy (D91):** schmale, benannte Endpunkte unter `/api/v1/taiga/*`
|
||||
(auth, projects, userstories, tasks) in `de.werkbaum.integration.taiga`
|
||||
(auth, projects, userstories, tasks — je POST zum Anlegen und GET
|
||||
`…/{ref}?slug=` zum **Lesen**, D91-Nachtrag 6) in `de.werkbaum.integration.taiga`
|
||||
(`TaigaClient` + `TaigaProperties`), Controller in `api`. Die Basis-URL der
|
||||
Taiga-**API** ist Server-Konfiguration (`werkbaum.taiga.api-url` bzw.
|
||||
`WERKBAUM_TAIGA_API_URL`), **nie** Request-Parameter — die SSRF-Falle
|
||||
@@ -71,3 +72,12 @@ docs/SPEC.md §10 testen — niemals eine zweite, abweichende Grammatik pflegen.
|
||||
URL, Status. Status-Mapping Taiga-Workflow → Notation konfigurierbar
|
||||
(Default: „New"→`[ ]`, „In progress"→`[~]`, „Ready for test"→`[/]`,
|
||||
„Done"→`[x]`, „Archived"→`[^]`).
|
||||
- **Abgebildet wird im Frontend, nicht hier** (D91-Nachtrag 6): Der Proxy
|
||||
reicht Taigas Status-**Namen** durch (`status_extra_info.name`), die
|
||||
Statuscodes sind Notations-Vokabular und das Backend parst die Notation
|
||||
nicht (D14). Die Tabelle steht headless in `frontend/src/taiga.js`
|
||||
(`mapTaigaStatus`); konfigurierbar ist sie noch nicht.
|
||||
- **Eine Ref ist nur je Projekt eindeutig:** Die Lese-Endpunkte nehmen
|
||||
deshalb den `slug` (aus `&taiga.<slug>`, SPEC §1) und fragen erst
|
||||
`/projects/by_slug`, dann `by_ref` — der Slug kommt vom Client und wird
|
||||
**kodiert** angehängt, sonst hängte ein `&` darin einen weiteren Filter an.
|
||||
|
||||
@@ -7,6 +7,7 @@ import de.werkbaum.generated.model.TaigaSession
|
||||
import de.werkbaum.generated.model.TaigaStoryCreateRequest
|
||||
import de.werkbaum.generated.model.TaigaTaskCreateRequest
|
||||
import de.werkbaum.generated.model.TaigaTicket
|
||||
import de.werkbaum.generated.model.TaigaTicketDetail
|
||||
import de.werkbaum.integration.taiga.TaigaClient
|
||||
import de.werkbaum.integration.taiga.TaigaTicketData
|
||||
import org.springframework.http.HttpStatus
|
||||
@@ -72,6 +73,31 @@ class TaigaController(private val client: TaigaClient) : TaigaApi {
|
||||
return created(ticket)
|
||||
}
|
||||
|
||||
/* Lesen (D91-Nachtrag 6): zwei Endpunkte statt eines mit Typ-Parameter —
|
||||
das Präfix der Ref trägt den Typ, und Taiga hat getrennte
|
||||
`by_ref`-Endpunkte. Die Abbildung des Status auf die Notation macht der
|
||||
Editor: Das Backend parst die Notation nicht (D14). */
|
||||
override fun taigaStoryByRef(xTaigaToken: String, ref: Long, slug: String) =
|
||||
ticket(xTaigaToken, slug, ref, task = false)
|
||||
|
||||
override fun taigaTaskByRef(xTaigaToken: String, ref: Long, slug: String) =
|
||||
ticket(xTaigaToken, slug, ref, task = true)
|
||||
|
||||
private fun ticket(token: String, slug: String, ref: Long, task: Boolean):
|
||||
ResponseEntity<TaigaTicketDetail> {
|
||||
val d = client.ticket(token, slug, ref, task)
|
||||
return ResponseEntity.ok(
|
||||
TaigaTicketDetail(
|
||||
id = d.id,
|
||||
ref = d.ref,
|
||||
subject = d.subject,
|
||||
status = d.status,
|
||||
statusClosed = d.statusClosed,
|
||||
assignee = d.assignee,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun created(ticket: TaigaTicketData): ResponseEntity<TaigaTicket> =
|
||||
ResponseEntity.status(HttpStatus.CREATED).body(
|
||||
TaigaTicket(id = ticket.id, ref = ticket.ref, subject = ticket.subject)
|
||||
|
||||
@@ -7,7 +7,9 @@ import org.springframework.stereotype.Service
|
||||
import org.springframework.web.client.ResourceAccessException
|
||||
import org.springframework.web.client.RestClient
|
||||
import org.springframework.web.client.RestClientResponseException
|
||||
import java.net.URLEncoder
|
||||
import java.net.http.HttpClient
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.time.Duration
|
||||
|
||||
/** Keine Taiga-Instanz konfiguriert — der Proxy hat kein Ziel (503). */
|
||||
@@ -36,6 +38,21 @@ data class TaigaProjectData(val id: Long, val name: String, val slug: String)
|
||||
|
||||
data class TaigaTicketData(val id: Long, val ref: Long, val subject: String)
|
||||
|
||||
/**
|
||||
* Der gelesene Stand eines Tickets (D91-Nachtrag 6). Status und Zuständiger
|
||||
* kommen aus Taigas `*_extra_info`-Blöcken und sind **nullbar**: Liefert die
|
||||
* Instanz sie nicht mit, fehlt die Zeile im Knoten-Fenster — geraten wird
|
||||
* nicht.
|
||||
*/
|
||||
data class TaigaTicketDetailData(
|
||||
val id: Long,
|
||||
val ref: Long,
|
||||
val subject: String,
|
||||
val status: String?,
|
||||
val statusClosed: Boolean?,
|
||||
val assignee: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Schmaler, benannter Client zur konfigurierten Taiga-Instanz (D91) — kein
|
||||
* Durchreich-Proxy: genau die vier Aufrufe, die die Ticket-Anlage braucht.
|
||||
@@ -91,6 +108,53 @@ class TaigaClient(private val properties: TaigaProperties) {
|
||||
// Taigas Feldname; unsere API sagt `userStory` (camelCase wie überall).
|
||||
create(token, "/tasks", mapOf("project" to project, "subject" to subject, "user_story" to userStory))
|
||||
|
||||
/**
|
||||
* Ein Ticket über seine **Ref** lesen (D91-Nachtrag 6). Zwei Schritte,
|
||||
* weil eine Ref nur je Projekt eindeutig ist: erst der Projekt-Slug zur
|
||||
* Id (`/projects/by_slug`), dann `by_ref` am passenden Endpunkt. Beide
|
||||
* sind dokumentierte Taiga-Endpunkte — der eine gesparte Umlauf über
|
||||
* `project__slug` wäre eine Wette auf eine Filter-Eigenheit gewesen.
|
||||
*
|
||||
* `task` unterscheidet die beiden Typen; welcher gemeint ist, weiß der
|
||||
* Aufrufer aus dem Präfix der Ref (`US-`/`T-`, SPEC §11) — hier steht
|
||||
* nur, wohin gefragt wird.
|
||||
*/
|
||||
fun ticket(token: String, slug: String, ref: Long, task: Boolean): TaigaTicketDetailData {
|
||||
val project = projectId(token, slug)
|
||||
val pfad = if (task) "/tasks/by_ref" else "/userstories/by_ref"
|
||||
val map = exchange {
|
||||
rest.get().uri(url("$pfad?project=$project&ref=$ref"))
|
||||
.header("Authorization", "Bearer $token")
|
||||
.retrieve().body(MAP)
|
||||
} ?: throw TaigaUnavailableException("Leere Antwort von Taiga ($pfad)")
|
||||
return TaigaTicketDetailData(
|
||||
id = num(map, "id"),
|
||||
ref = num(map, "ref"),
|
||||
subject = str(map, "subject"),
|
||||
status = extra(map, "status_extra_info")?.get("name") as? String,
|
||||
statusClosed = extra(map, "status_extra_info")?.get("is_closed") as? Boolean,
|
||||
assignee = extra(map, "assigned_to_extra_info")?.get("full_name_display") as? String,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Projekt-Slug -> Id; Taigas `by_ref` filtert über die Id. Der Slug kommt
|
||||
* vom Client und wird deshalb **kodiert** in die Anfrage gesetzt — sonst
|
||||
* hängte ein `&` daran einen weiteren Filter an.
|
||||
*/
|
||||
fun projectId(token: String, slug: String): Long {
|
||||
val map = exchange {
|
||||
rest.get().uri(url("/projects/by_slug?slug=" + enc(slug)))
|
||||
.header("Authorization", "Bearer $token")
|
||||
.retrieve().body(MAP)
|
||||
} ?: throw TaigaUnavailableException("Leere Antwort von Taiga (/projects/by_slug)")
|
||||
return num(map, "id")
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun extra(m: Map<String, Any?>, key: String): Map<String, Any?>? =
|
||||
m[key] as? Map<String, Any?>
|
||||
|
||||
private fun create(token: String, path: String, body: Map<String, Any>): TaigaTicketData {
|
||||
val map = exchange {
|
||||
rest.post().uri(url(path))
|
||||
@@ -102,6 +166,8 @@ class TaigaClient(private val properties: TaigaProperties) {
|
||||
return TaigaTicketData(id = num(map, "id"), ref = num(map, "ref"), subject = str(map, "subject"))
|
||||
}
|
||||
|
||||
private fun enc(value: String): String = URLEncoder.encode(value, StandardCharsets.UTF_8)
|
||||
|
||||
private fun url(path: String): String {
|
||||
if (!properties.configured) throw TaigaNotConfiguredException()
|
||||
return properties.apiUrl.trimEnd('/') + path
|
||||
|
||||
@@ -524,6 +524,88 @@ paths:
|
||||
"503":
|
||||
$ref: "#/components/responses/TaigaNotConfigured"
|
||||
|
||||
/taiga/userstories/{ref}:
|
||||
get:
|
||||
tags: [Taiga]
|
||||
operationId: taigaStoryByRef
|
||||
summary: User Story ueber ihre Ref lesen (Proxy)
|
||||
description: >
|
||||
Loest `#US-<ref>` auf (D91-Nachtrag 6): Betreff, Status und
|
||||
Zustaendiger einer Story. Refs sind nur **je Projekt** eindeutig -
|
||||
deshalb der `slug`, den der Editor aus dem Schlagwort
|
||||
`&taiga.<slug>` des Teilbaums nimmt (SPEC par. 1). Der Proxy fragt
|
||||
dafuer zuerst `GET <api-url>/projects/by_slug`, dann
|
||||
`GET <api-url>/userstories/by_ref?project=<id>&ref=<ref>`.
|
||||
Nur gelesen: Werkbaum schreibt hier nichts nach Taiga und nichts in
|
||||
den Notationstext.
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/TaigaToken"
|
||||
- $ref: "#/components/parameters/TaigaRef"
|
||||
- $ref: "#/components/parameters/TaigaSlug"
|
||||
responses:
|
||||
"200":
|
||||
description: Die Story
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/TaigaTicketDetail"
|
||||
"401":
|
||||
description: Token fehlt oder ist abgelaufen
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ProblemDetail"
|
||||
"404":
|
||||
description: >
|
||||
Projekt oder Ref gibt es nicht - Taigas Status wird
|
||||
durchgereicht (der Editor zeigt den Fehlertext im Knoten-Fenster).
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ProblemDetail"
|
||||
"502":
|
||||
$ref: "#/components/responses/TaigaUnavailable"
|
||||
"503":
|
||||
$ref: "#/components/responses/TaigaNotConfigured"
|
||||
|
||||
/taiga/tasks/{ref}:
|
||||
get:
|
||||
tags: [Taiga]
|
||||
operationId: taigaTaskByRef
|
||||
summary: Task ueber ihre Ref lesen (Proxy)
|
||||
description: >
|
||||
Wie `GET /taiga/userstories/{ref}`, nur ueber
|
||||
`GET <api-url>/tasks/by_ref` - loest `#T-<ref>` auf. Zwei Endpunkte
|
||||
statt eines mit Typ-Parameter, weil das Praefix der Ref den Typ
|
||||
traegt und Taiga getrennte `by_ref`-Endpunkte hat (SPEC par. 11).
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/TaigaToken"
|
||||
- $ref: "#/components/parameters/TaigaRef"
|
||||
- $ref: "#/components/parameters/TaigaSlug"
|
||||
responses:
|
||||
"200":
|
||||
description: Die Task
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/TaigaTicketDetail"
|
||||
"401":
|
||||
description: Token fehlt oder ist abgelaufen
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ProblemDetail"
|
||||
"404":
|
||||
description: Projekt oder Ref gibt es nicht
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ProblemDetail"
|
||||
"502":
|
||||
$ref: "#/components/responses/TaigaUnavailable"
|
||||
"503":
|
||||
$ref: "#/components/responses/TaigaNotConfigured"
|
||||
|
||||
/info:
|
||||
get:
|
||||
tags: [Documents]
|
||||
@@ -559,6 +641,31 @@ components:
|
||||
type: string
|
||||
maxLength: 512
|
||||
|
||||
TaigaRef:
|
||||
name: ref
|
||||
in: path
|
||||
required: true
|
||||
description: >
|
||||
Die nackte Taiga-Nummer, ohne das Werkbaum-Praefix: aus `#US-123`
|
||||
bzw. `#T-1234` wird `123` bzw. `1234`. Den Typ traegt der Pfad.
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
minimum: 1
|
||||
|
||||
TaigaSlug:
|
||||
name: slug
|
||||
in: query
|
||||
required: true
|
||||
description: >
|
||||
Projekt-Slug aus dem Schlagwort `&taiga.<slug>` (SPEC par. 1). Refs
|
||||
laufen je Projekt fortlaufend - ohne das Projekt ist eine Ref nicht
|
||||
eindeutig.
|
||||
schema:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 255
|
||||
|
||||
responses:
|
||||
TaigaNotConfigured:
|
||||
description: >
|
||||
@@ -975,6 +1082,38 @@ components:
|
||||
subject:
|
||||
type: string
|
||||
|
||||
TaigaTicketDetail:
|
||||
type: object
|
||||
description: >
|
||||
Der gelesene Stand eines Tickets (D91-Nachtrag 6) - schmal wie alles
|
||||
hier: genau die Felder, die das Knoten-Fenster zeigt.
|
||||
required: [id, ref, subject]
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
format: int64
|
||||
ref:
|
||||
type: integer
|
||||
format: int64
|
||||
subject:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
description: >
|
||||
Name des Taiga-Workflow-Status (`status_extra_info.name`), z. B.
|
||||
"In progress". Die Abbildung auf die Statusbox der Notation macht
|
||||
der Editor (SPEC par. 4/9) - das Backend parst die Notation nicht
|
||||
(D14). Fehlt, wenn Taiga den Namen nicht mitliefert.
|
||||
statusClosed:
|
||||
type: boolean
|
||||
description: Taigas eigene Aussage, ob der Status als erledigt gilt.
|
||||
assignee:
|
||||
type: string
|
||||
description: >
|
||||
Anzeigename des Zustaendigen
|
||||
(`assigned_to_extra_info.full_name_display`); fehlt, wenn niemand
|
||||
zugewiesen ist.
|
||||
|
||||
ProblemDetail:
|
||||
type: object
|
||||
description: Fehlerformat nach RFC 9457 (Problem Details)
|
||||
|
||||
@@ -115,6 +115,34 @@ class TaigaApiTest {
|
||||
result.responseBody!! shouldContain "\"ref\":124"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `eine Story-Ref wird ueber Slug und by_ref aufgeloest`() {
|
||||
val result = client.get()
|
||||
.uri("/api/v1/taiga/userstories/123?slug=mi-kunde")
|
||||
.header("X-Taiga-Token", "tok-abc123")
|
||||
.exchange()
|
||||
.returnResult(String::class.java)
|
||||
result.status.value() shouldBe 200
|
||||
result.responseBody!! shouldContain "\"subject\":\"Login bauen\""
|
||||
// Der Status kommt als NAME an; abgebildet wird er im Editor (D14).
|
||||
result.responseBody!! shouldContain "\"status\":\"In progress\""
|
||||
result.responseBody!! shouldContain "\"assignee\":\"Anna Beispiel\""
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `eine Task-Ref geht an den Task-Endpunkt und darf ohne Zustaendigen kommen`() {
|
||||
val result = client.get()
|
||||
.uri("/api/v1/taiga/tasks/1234?slug=mi-kunde")
|
||||
.header("X-Taiga-Token", "tok-abc123")
|
||||
.exchange()
|
||||
.returnResult(String::class.java)
|
||||
result.status.value() shouldBe 200
|
||||
result.responseBody!! shouldContain "\"status\":\"Done\""
|
||||
result.responseBody!! shouldContain "\"statusClosed\":true"
|
||||
// Niemand zugewiesen: ausdrücklich null, nicht geraten.
|
||||
result.responseBody!! shouldContain "\"assignee\":null"
|
||||
}
|
||||
|
||||
companion object {
|
||||
private lateinit var stub: HttpServer
|
||||
@Volatile private var stubStatus = 200
|
||||
@@ -129,6 +157,9 @@ class TaigaApiTest {
|
||||
"/api/v1/projects" -> TaigaClientTestData.PROJECTS_OK
|
||||
"/api/v1/userstories" -> TaigaClientTestData.STORY_OK
|
||||
"/api/v1/tasks" -> TaigaClientTestData.TASK_OK
|
||||
"/api/v1/projects/by_slug" -> TaigaClientTestData.PROJECT_OK
|
||||
"/api/v1/userstories/by_ref" -> TaigaClientTestData.STORY_DETAIL
|
||||
"/api/v1/tasks/by_ref" -> TaigaClientTestData.TASK_DETAIL
|
||||
else -> "{}"
|
||||
}
|
||||
val status = if (stubBody != null) stubStatus
|
||||
@@ -175,4 +206,13 @@ object TaigaClientTestData {
|
||||
"""{"id": 1234, "ref": 123, "subject": "Backend bauen", "project": 7}"""
|
||||
const val TASK_OK =
|
||||
"""{"id": 5678, "ref": 124, "subject": "API-Teil", "project": 7, "user_story": 1234}"""
|
||||
const val PROJECT_OK =
|
||||
"""{"id": 7, "name": "Kunde", "slug": "mi-kunde"}"""
|
||||
const val STORY_DETAIL =
|
||||
"""{"id": 1234, "ref": 123, "subject": "Login bauen", "project": 7,
|
||||
"status_extra_info": {"name": "In progress", "is_closed": false},
|
||||
"assigned_to_extra_info": {"full_name_display": "Anna Beispiel"}}"""
|
||||
const val TASK_DETAIL =
|
||||
"""{"id": 5678, "ref": 1234, "subject": "API-Teil", "project": 7,
|
||||
"status_extra_info": {"name": "Done", "is_closed": true}}"""
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ class TaigaClientTest {
|
||||
@BeforeEach
|
||||
fun reset() {
|
||||
recorded = null
|
||||
requests.clear()
|
||||
routes.clear()
|
||||
responseStatus = 200
|
||||
responseBody = "{}"
|
||||
}
|
||||
@@ -96,6 +98,70 @@ class TaigaClientTest {
|
||||
recorded!!.body shouldContain "\"user_story\":1234"
|
||||
}
|
||||
|
||||
/* ---- Lesen über die Ref (D91-Nachtrag 6) ---- */
|
||||
|
||||
@Test
|
||||
fun `ticket loest erst den Slug zur Projekt-Id auf und liest dann per by_ref`() {
|
||||
routes["/api/v1/projects/by_slug"] = 200 to PROJECT_OK
|
||||
routes["/api/v1/userstories/by_ref"] = 200 to STORY_DETAIL
|
||||
val d = client().ticket("tok-abc123", "mi-kunde", 123, task = false)
|
||||
|
||||
d.ref shouldBe 123L
|
||||
d.id shouldBe 1234L
|
||||
d.subject shouldBe "Login bauen"
|
||||
d.status shouldBe "In progress"
|
||||
d.statusClosed shouldBe false
|
||||
d.assignee shouldBe "Anna Beispiel"
|
||||
|
||||
requests.map { it.path } shouldBe
|
||||
listOf("/api/v1/projects/by_slug", "/api/v1/userstories/by_ref")
|
||||
requests[0].query shouldBe "slug=mi-kunde"
|
||||
requests[1].query shouldBe "project=7&ref=123"
|
||||
requests.all { it.auth == "Bearer tok-abc123" } shouldBe true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `eine Task wird ueber ihren eigenen by_ref-Endpunkt gelesen`() {
|
||||
routes["/api/v1/projects/by_slug"] = 200 to PROJECT_OK
|
||||
routes["/api/v1/tasks/by_ref"] = 200 to TASK_DETAIL
|
||||
val d = client().ticket("tok-abc123", "mi-kunde", 1234, task = true)
|
||||
|
||||
d.ref shouldBe 1234L
|
||||
d.status shouldBe "Done"
|
||||
requests[1].path shouldBe "/api/v1/tasks/by_ref"
|
||||
requests[1].query shouldBe "project=7&ref=1234"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ohne extra_info bleiben Status und Zustaendiger leer statt geraten`() {
|
||||
routes["/api/v1/projects/by_slug"] = 200 to PROJECT_OK
|
||||
routes["/api/v1/userstories/by_ref"] = 200 to STORY_BARE
|
||||
val d = client().ticket("tok-abc123", "mi-kunde", 123, task = false)
|
||||
|
||||
d.subject shouldBe "Login bauen"
|
||||
d.status shouldBe null
|
||||
d.statusClosed shouldBe null
|
||||
d.assignee shouldBe null
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ein unbekanntes Projekt wird als 404 durchgereicht, ohne zweiten Umlauf`() {
|
||||
routes["/api/v1/projects/by_slug"] = 404 to NOT_FOUND
|
||||
val ex = shouldThrow<TaigaUpstreamException> {
|
||||
client().ticket("tok-abc123", "gibt-es-nicht", 123, task = false)
|
||||
}
|
||||
ex.status shouldBe 404
|
||||
requests.map { it.path } shouldBe listOf("/api/v1/projects/by_slug")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `der Slug wird kodiert - ein & haengt keinen weiteren Filter an`() {
|
||||
routes["/api/v1/projects/by_slug"] = 200 to PROJECT_OK
|
||||
routes["/api/v1/userstories/by_ref"] = 200 to STORY_DETAIL
|
||||
client().ticket("tok-abc123", "a&member=1", 123, task = false)
|
||||
requests[0].query shouldBe "slug=a%26member%3D1"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ohne konfigurierte Instanz gibt es kein Ziel`() {
|
||||
val bare = TaigaClient(TaigaProperties(apiUrl = ""))
|
||||
@@ -131,6 +197,12 @@ class TaigaClientTest {
|
||||
@Volatile var responseStatus = 200
|
||||
@Volatile var responseBody = "{}"
|
||||
|
||||
/* Das Lesen eines Tickets braucht ZWEI Umläufe (Slug -> Id, dann
|
||||
by_ref) — deshalb Antworten je Pfad und alle Anfragen der Reihe
|
||||
nach, statt nur der letzten. */
|
||||
val routes = mutableMapOf<String, Pair<Int, String>>()
|
||||
val requests = mutableListOf<Recorded>()
|
||||
|
||||
/* Aufgezeichnete Antwortformen (gekuerzt auf die gebrauchten Felder
|
||||
plus typisches Beiwerk, damit der Client Unbekanntes ignoriert). */
|
||||
const val AUTH_OK =
|
||||
@@ -143,6 +215,23 @@ class TaigaClientTest {
|
||||
"""{"id": 1234, "ref": 123, "subject": "Backend bauen", "project": 7, "status": 1}"""
|
||||
const val TASK_OK =
|
||||
"""{"id": 5678, "ref": 124, "subject": "API-Teil", "project": 7, "user_story": 1234}"""
|
||||
const val PROJECT_OK =
|
||||
"""{"id": 7, "name": "Kunde", "slug": "mi-kunde"}"""
|
||||
/* Form der by_ref-Antwort: die Namen stehen in den
|
||||
`*_extra_info`-Blöcken, die Ids daneben (Taiga-API). */
|
||||
const val STORY_DETAIL =
|
||||
"""{"id": 1234, "ref": 123, "subject": "Login bauen", "project": 7, "status": 3,
|
||||
"status_extra_info": {"name": "In progress", "color": "#ff9900", "is_closed": false},
|
||||
"assigned_to": 42,
|
||||
"assigned_to_extra_info": {"username": "anna", "full_name_display": "Anna Beispiel"}}"""
|
||||
const val TASK_DETAIL =
|
||||
"""{"id": 5678, "ref": 1234, "subject": "API-Teil", "project": 7,
|
||||
"status_extra_info": {"name": "Done", "is_closed": true},
|
||||
"assigned_to_extra_info": null}"""
|
||||
const val STORY_BARE =
|
||||
"""{"id": 1234, "ref": 123, "subject": "Login bauen", "project": 7, "status": 3}"""
|
||||
const val NOT_FOUND =
|
||||
"""{"_error_message": "Not found.", "_error_type": "taiga.base.exceptions.NotFound"}"""
|
||||
|
||||
@JvmStatic
|
||||
@BeforeAll
|
||||
@@ -156,9 +245,12 @@ class TaigaClientTest {
|
||||
noPagination = ex.requestHeaders.getFirst("x-disable-pagination"),
|
||||
body = ex.requestBody.readBytes().decodeToString(),
|
||||
)
|
||||
val bytes = responseBody.encodeToByteArray()
|
||||
requests += recorded!!
|
||||
val route = routes[ex.requestURI.path]
|
||||
val status = route?.first ?: responseStatus
|
||||
val bytes = (route?.second ?: responseBody).encodeToByteArray()
|
||||
ex.responseHeaders.set("Content-Type", "application/json")
|
||||
ex.sendResponseHeaders(responseStatus, bytes.size.toLong())
|
||||
ex.sendResponseHeaders(status, bytes.size.toLong())
|
||||
ex.responseBody.use { it.write(bytes) }
|
||||
}
|
||||
server.start()
|
||||
|
||||
Reference in New Issue
Block a user