Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents
Submodule .agents updated from bc9da0 to 132d85
2 changes: 1 addition & 1 deletion .github/workflows/verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,4 @@ jobs:
run: ./gradlew detekt

- name: Check tests
run: ./gradlew allTests -Pkover koverVerify
run: ./gradlew jvmTest -Pkover koverVerify
3 changes: 0 additions & 3 deletions .gitmodules

This file was deleted.

1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ Dokka and Maven publish require `--no-configuration-cache` despite it being glob
- General mocking: Mokkery.
- Test names use backtick style: `` fun `should do something`() = runTest { ... } ``
- Tests live in `commonTest` only — no platform-specific test source sets.
- **Sample data**: use fixtures (see the `fixtures` skill in `.agents/skills/fixtures/`) instead of inline object literals whenever a test needs a domain model. A fixture is an extension on a model's `Companion` (e.g. `Pixel.fixture()`) living in `commonTest` under the model's own package. When a requested fixture references other domain models, the skill creates those in cascade and reuses any existing fixture rather than duplicating. See `umami-api/src/commonTest/.../domain/PixelFixture.kt` and `PixelsTest.kt` for the canonical pattern.
- See `umami-api/src/commonTest/AGENTS.md` for detailed test-writing patterns (MockEngine helpers, `getUmamiInstance`, `respond<T>`, `body<T>()`).

Key difference between the two `getUmamiInstance` copies: `:umami-api` sets `enableEventQueue = false`; `:umami` leaves it enabled.
Expand Down
18 changes: 18 additions & 0 deletions docs/pixels.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,24 @@ This method returns the details of a specific pixel.
val pixel = api.pixels().getPixel("pixel-id")
```

## Create a pixel

This method creates a new tracking pixel.

```kotlin
val pixel = api.pixels().createPixel(
name = "New Pixel",
slug = "new-pixel-slug",
teamId = "team-id"
)
```

### Parameters

- `name` (required): The name for the pixel.
- `slug` (required): The slug for the pixel.
- `teamId` (optional): The ID of the team to associate the pixel with.

## Update a pixel

This method updates the details of a specific pixel.
Expand Down
48 changes: 0 additions & 48 deletions docs/release-notes.md

This file was deleted.

2 changes: 1 addition & 1 deletion docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ These endpoints are only available for admin users on self-hosted instances.
## Pixels

- [x] Returns all user pixels - `GET /api/pixels`
- [ ] Creates a new pixel - `POST /api/pixels`
- [x] Creates a new pixel - `POST /api/pixels`
- [x] Gets a pixel by ID - `GET /api/pixels/:pixelId`
- [x] Updates a pixel - `POST /api/pixels/:pixelId`
- [x] Deletes a pixel - `DELETE /api/pixels/:pixelId`
Expand Down
1 change: 0 additions & 1 deletion mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ nav:
- Teams: teams.md
- Users: users.md
- Roadmap: roadmap.md
- Release notes: release-notes.md
- API Reference: reference

markdown_extensions:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,30 @@ class Pixels(private val api: UmamiApi) {
return api.httpClient.get("pixels/$pixelId").body()
}

/**
* Creates a new tracking pixel.
*
* @param name The name for the pixel.
* @param slug The slug for the pixel.
* @param teamId The ID of the team to associate the pixel with. Optional.
* @return The created [Pixel] object.
*/
suspend fun createPixel(
name: String,
slug: String,
teamId: String? = null,
): Pixel {
val request = CreatePixelRequest(
name = name,
slug = slug,
teamId = teamId,
)

return api.httpClient.post("pixels") {
setBody(request)
}.body()
}

/**
* Updates an existing pixel.
*
Expand Down Expand Up @@ -79,7 +103,14 @@ class Pixels(private val api: UmamiApi) {
}

@Serializable
private data class UpdatePixelRequest(
internal data class CreatePixelRequest(
val name: String,
val slug: String,
val teamId: String? = null,
)

@Serializable
internal data class UpdatePixelRequest(
val name: String? = null,
val slug: String? = null
)
Expand Down
112 changes: 67 additions & 45 deletions umami-api/src/commonTest/kotlin/dev/appoutlet/umami/api/PixelsTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,32 +2,22 @@ package dev.appoutlet.umami.api

import dev.appoutlet.umami.domain.Pixel
import dev.appoutlet.umami.domain.SearchResponse
import dev.appoutlet.umami.domain.fixture
import dev.appoutlet.umami.testing.getUmamiApiInstance
import dev.appoutlet.umami.testing.respond
import io.kotest.matchers.shouldBe
import io.ktor.http.content.OutgoingContent
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.time.Instant

class PixelsTest {
@Test
fun `getPixels returns search response`() = runTest {
val mockPixel = Pixel(
id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
name = "Umami Pixel",
slug = "xxxxxxxx",
userId = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
teamId = null,
createdAt = Instant.parse("2025-10-27T18:50:54.079Z"),
updatedAt = Instant.parse("2025-10-27T18:50:54.079Z"),
deletedAt = null
)
val fixturePixel = Pixel.fixture()

val mockResponse = SearchResponse(
data = listOf(mockPixel),
data = listOf(fixturePixel),
count = 1,
page = 1,
pageSize = 10
Expand Down Expand Up @@ -69,26 +59,73 @@ class PixelsTest {
@Test
fun `getPixel returns pixel`() = runTest {
val pixelId = "pixel-id"
val mockPixel = Pixel(
id = pixelId,
name = "Umami Pixel",
slug = "xxxxxxxx",
userId = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
teamId = null,
createdAt = Instant.parse("2025-10-27T18:50:54.079Z"),
updatedAt = Instant.parse("2025-10-27T18:50:54.079Z"),
deletedAt = null
)

val fixturePixel = Pixel.fixture()

val api = getUmamiApiInstance(
"/api/pixels/$pixelId" to { request ->
request.url.encodedPath shouldBe "/api/pixels/$pixelId"
respond(mockPixel)

respond(fixturePixel)
}
)

val response = api.pixels().getPixel(pixelId)
response shouldBe mockPixel

response shouldBe fixturePixel
}

@Test
fun `createPixel creates pixel`() = runTest {
val requestName = "Umami Pixel"
val requestSlug = "pixel-slug"
val requestTeamId = "team-id"

val fixturePixel = Pixel.fixture()

val api = getUmamiApiInstance(
"/api/pixels" to { request ->
request.url.encodedPath shouldBe "/api/pixels"
val bodyText = (request.body as OutgoingContent.ByteArrayContent).bytes().decodeToString()
val createRequest = Json.decodeFromString<Pixels.CreatePixelRequest>(bodyText)
createRequest.name shouldBe requestName
createRequest.slug shouldBe requestSlug
createRequest.teamId shouldBe requestTeamId
respond(fixturePixel)
}
)

val response = api.pixels().createPixel(
name = requestName,
slug = requestSlug,
teamId = requestTeamId,
)

response shouldBe fixturePixel
}

@Test
fun `createPixel sends null teamId by default`() = runTest {
val fixturePixel = Pixel.fixture()

val api = getUmamiApiInstance(
"/api/pixels" to { request ->
request.url.encodedPath shouldBe "/api/pixels"
val bodyText = (request.body as OutgoingContent.ByteArrayContent).bytes().decodeToString()
val createRequest = Json.decodeFromString<Pixels.CreatePixelRequest>(bodyText)
createRequest.name shouldBe "Umami Pixel"
createRequest.slug shouldBe "pixel-slug"
createRequest.teamId shouldBe null
respond(fixturePixel)
}
)

val response = api.pixels().createPixel(
name = "Umami Pixel",
slug = "pixel-slug",
)

response shouldBe fixturePixel
}

@Test
Expand All @@ -97,32 +134,16 @@ class PixelsTest {
val requestName = "Umami Pixel Updated"
val requestSlug = "updated-slug"

val mockPixel = Pixel(
id = pixelId,
name = requestName,
slug = requestSlug,
userId = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
teamId = null,
createdAt = Instant.parse("2025-10-27T18:50:54.079Z"),
updatedAt = Instant.parse("2025-10-27T18:50:54.079Z"),
deletedAt = null
)
val fixturePixel = Pixel.fixture()

val api = getUmamiApiInstance(
"/api/pixels/$pixelId" to { request ->
request.url.encodedPath shouldBe "/api/pixels/$pixelId"
val bodyText = (request.body as OutgoingContent.ByteArrayContent).bytes().decodeToString()

@Serializable
data class UpdatePixelRequest(
val name: String? = null,
val slug: String? = null
)

val updateRequest = Json.decodeFromString<UpdatePixelRequest>(bodyText)
val updateRequest = Json.decodeFromString<Pixels.UpdatePixelRequest>(bodyText)
updateRequest.name shouldBe requestName
updateRequest.slug shouldBe requestSlug
respond(mockPixel)
respond(fixturePixel)
}
)

Expand All @@ -131,7 +152,8 @@ class PixelsTest {
name = requestName,
slug = requestSlug
)
response shouldBe mockPixel

response shouldBe fixturePixel
}

@Test
Expand Down
Loading
Loading