Files
Outpost/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift
Puranjay Savar Mattas b31d49bb7f feat: drafts, publish flow, and full comment threading
Drafts + Publish:
- New Home tab backed by documents.drafts (undocumented request shape
  confirmed from a live network capture, not the OpenAPI spec).
- Reader toolbar menu now shows Publish...  for an unpublished
  document instead of an unconditional Unpublish (which could
  previously be tapped on a draft at all). Publish opens a
  MoveDocumentSheet-style collection/parent picker, pre-filled from
  the draft's own collectionId/parentDocumentId when it already has
  one. Publishing itself is documents.update(publish: true,
  collectionId:) for the collection placement, plus a second
  documents.move call only when a specific parent document was also
  picked (documents.update has no parentDocumentId field).
- New Document defaults to Draft (collectionId/publish both now
  optional on CreateDocumentRequest, previously collectionId was
  required so a draft couldn't be created from this sheet at all).
  Contextual entry points (right-click a collection/document) still
  pre-fill that location, but now show a warning that doing so
  auto-publishes.
- Fixed onDeleted only popping the reader's nav path without telling
  the sidebar to refresh - Delete/Archive/Unpublish/Move all left the
  sidebar showing stale state until an unrelated trigger (the 45s
  poll, navigating away and back) happened to catch it up.

Comments:
- Replies (comments.create with parentCommentId, one level of nesting
  same as Outline's own limit) and emoji reactions
  (comments.add_reaction/remove_reaction, confirmed against Outline's
  server source - not in the spec, and return {success: true} rather
  than the updated comment, so a toggle refetches via comments.info
  for the real post-toggle state) plus a document-level "new comment"
  composer, since replying needs something to reply to.
- Inline anchor markers: a new engine-side mechanism
  (CommentAnchorQuery/CommentAnchorRect/onCommentAnchorRectsChange)
  resolves an anchored comment's anchorText to an on-screen rect via
  the same viewRect utility the code-block copy button uses, kept in
  sync on typing/resize/reflow the same way the code-block and image
  positioning fixes earlier this session are. Renders as a thin blue
  bar next to the commented text; tapping it opens the comments sheet
  scrolled and highlighted to that thread. First-occurrence text
  search only (Outline's API returns no position data, and no
  prefix/suffix on read) - creating new anchored comments from this
  app still isn't supported.
- Toolbar badge: tighter offset so the count doesn't clip past the
  icon, caps at "10+".
2026-08-20 21:31:36 +01:00

1627 lines
61 KiB
Swift

import XCTest
@testable import OutlineKit
private final class MockHTTPClient: HTTPClient, @unchecked Sendable {
var lastRequest: URLRequest?
var responseData: Data = Data()
var statusCode: Int = 200
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
lastRequest = request
let response = HTTPURLResponse(
url: request.url!,
statusCode: statusCode,
httpVersion: nil,
headerFields: nil
)!
return (responseData, response)
}
}
private final class StaticTokenStore: TokenStoring, @unchecked Sendable {
private var stored: String?
init(token: String? = "test-token") {
self.stored = token
}
func token() throws -> String {
guard let stored else { throw TokenStoreError.notFound }
return stored
}
func store(_ token: String) throws { stored = token }
func clear() throws { stored = nil }
}
final class LiveOutlineAPIClientTests: XCTestCase {
func testDeleteCollectionSucceedsOnSuccessTrue() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{ "success": true }
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
try await client.deleteCollection(id: "col-1")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/collections.delete")
}
func testDeleteCollectionThrowsOnSuccessFalse() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{ "success": false }
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
do {
try await client.deleteCollection(id: "col-1")
XCTFail("Expected an error when success is false")
} catch {
// expected
}
}
func testExportCollectionDecodesFileOperation() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"fileOperation": { "id": "op-1", "state": "creating" }
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let operation = try await client.exportCollection(ExportCollectionRequest(id: "col-1"))
XCTAssertEqual(operation.id, "op-1")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/collections.export")
}
func testStarCollectionDecodesStar() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "star-1",
"collectionId": "col-1",
"documentId": null
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let star = try await client.starCollection(StarCollectionRequest(collectionId: "col-1"))
XCTAssertEqual(star.id, "star-1")
XCTAssertEqual(star.collectionId, "col-1")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/stars.create")
}
func testSearchDocumentsDecodesContextAndRanking() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": [
{
"context": "At Acme Inc our hiring practices are inclusive",
"ranking": 1.1844109,
"document": {
"id": "doc-1",
"title": "Welcome to Acme Inc",
"text": "",
"url": "/doc/welcome-doc-1",
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-02T00:00:00.000Z"
}
}
]
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let results = try await client.searchDocuments(DocumentSearchRequest(query: "hiring"))
XCTAssertEqual(results.count, 1)
XCTAssertEqual(results[0].context, "At Acme Inc our hiring practices are inclusive")
XCTAssertEqual(results[0].document.title, "Welcome to Acme Inc")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.search")
}
func testSearchDocumentTitlesReturnsDocumentsDirectly() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": [
{
"id": "doc-1",
"title": "Welcome to Acme Inc",
"text": "",
"url": "/doc/welcome-doc-1",
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-02T00:00:00.000Z"
}
]
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let results = try await client.searchDocumentTitles(DocumentSearchTitlesRequest(query: "Welcome"))
XCTAssertEqual(results.count, 1)
XCTAssertEqual(results[0].title, "Welcome to Acme Inc")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.search_titles")
}
func testAuthInfoDecodesUserAndTeam() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"user": {
"id": "123e4567-e89b-12d3-a456-426614174000",
"name": "Jane Doe",
"email": "hello@example.com",
"role": "admin"
},
"team": {
"id": "123e4567-e89b-12d3-a456-426614174000",
"name": "Acme Inc",
"url": "https://acme-inc.getoutline.com",
"subdomain": "acme-inc"
}
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let auth = try await client.authInfo()
XCTAssertEqual(auth.user.name, "Jane Doe")
XCTAssertEqual(auth.team.name, "Acme Inc")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/auth.info")
XCTAssertEqual(httpClient.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer test-token")
}
func testUpdateCollectionSendsDescriptionAndDecodesResult() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "col-1",
"name": "Engineering",
"description": "Updated overview text.",
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-02T00:00:00.000Z"
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let collection = try await client.updateCollection(
UpdateCollectionRequest(id: "col-1", description: "Updated overview text.")
)
XCTAssertEqual(collection.description, "Updated overview text.")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/collections.update")
struct SentBody: Decodable {
let id: String
let description: String?
}
let sentBody = try XCTUnwrap(httpClient.lastRequest?.httpBody)
let decodedBody = try JSONDecoder().decode(SentBody.self, from: sentBody)
XCTAssertEqual(decodedBody.id, "col-1")
XCTAssertEqual(decodedBody.description, "Updated overview text.")
}
func testListDocumentsSendsParentDocumentIdFilter() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{ "data": [] }
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
_ = try await client.listDocuments(collectionId: nil, parentDocumentId: "doc-1", offset: 0, limit: 100)
struct SentBody: Decodable {
let parentDocumentId: String?
}
let sentBody = try XCTUnwrap(httpClient.lastRequest?.httpBody)
let decodedBody = try JSONDecoder().decode(SentBody.self, from: sentBody)
XCTAssertEqual(decodedBody.parentDocumentId, "doc-1")
}
func testDocumentsListSendsSortDirectionAndUserId() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{ "data": [] }
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
_ = try await client.documentsList(
DocumentsListRequest(userId: "user-1", sort: "updatedAt", direction: "DESC")
)
struct SentBody: Decodable {
let userId: String?
let sort: String?
let direction: String?
}
let sentBody = try XCTUnwrap(httpClient.lastRequest?.httpBody)
let decodedBody = try JSONDecoder().decode(SentBody.self, from: sentBody)
XCTAssertEqual(decodedBody.userId, "user-1")
XCTAssertEqual(decodedBody.sort, "updatedAt")
XCTAssertEqual(decodedBody.direction, "DESC")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.list")
}
func testListViewedDocumentsDecodesDocuments() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": [
{
"id": "doc-1",
"title": "Hello",
"text": "World",
"url": "/doc/hello-doc-1",
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-02T00:00:00.000Z"
}
]
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let documents = try await client.listViewedDocuments(offset: 0, limit: 25)
XCTAssertEqual(documents.first?.id, "doc-1")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.viewed")
}
func testDocumentInfoDecodesEnvelopeAndSetsAuthHeader() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "doc-1",
"title": "Hello",
"text": "World",
"url": "/doc/hello-doc-1",
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-02T00:00:00.000Z"
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let document = try await client.documentInfo(id: "doc-1")
XCTAssertEqual(document.id, "doc-1")
XCTAssertEqual(document.title, "Hello")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.info")
XCTAssertEqual(httpClient.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer test-token")
}
func testUnauthorizedStatusMapsToUnauthorizedError() async throws {
let httpClient = MockHTTPClient()
httpClient.statusCode = 401
httpClient.responseData = """
{ "ok": false, "error": "authentication_required", "message": "Authentication required" }
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
do {
_ = try await client.documentInfo(id: "doc-1")
XCTFail("Expected unauthorized error")
} catch OutlineAPIError.unauthorized {
// expected
}
}
func testStarDocumentDecodesStar() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "star-1",
"collectionId": null,
"documentId": "doc-1"
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let star = try await client.starDocument(StarDocumentRequest(documentId: "doc-1"))
XCTAssertEqual(star.documentId, "doc-1")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/stars.create")
}
func testDuplicateDocumentDecodesDocumentsArray() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"documents": [
{
"id": "doc-2",
"title": "Hello (copy)",
"text": "World",
"url": "/doc/hello-copy-doc-2",
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-02T00:00:00.000Z"
}
]
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let documents = try await client.duplicateDocument(DuplicateDocumentRequest(id: "doc-1"))
XCTAssertEqual(documents.first?.id, "doc-2")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.duplicate")
}
func testMoveDocumentSendsDestination() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{ "data": { "documents": [], "collections": [] } }
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
try await client.moveDocument(MoveDocumentRequest(id: "doc-1", collectionId: "col-2", parentDocumentId: "doc-9"))
struct SentBody: Decodable {
let id: String
let collectionId: String
let parentDocumentId: String?
}
let sentBody = try XCTUnwrap(httpClient.lastRequest?.httpBody)
let decodedBody = try JSONDecoder().decode(SentBody.self, from: sentBody)
XCTAssertEqual(decodedBody.collectionId, "col-2")
XCTAssertEqual(decodedBody.parentDocumentId, "doc-9")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.move")
}
func testDeleteDocumentSucceedsOnSuccessTrue() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{ "success": true }
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
try await client.deleteDocument(DeleteDocumentRequest(id: "doc-1"))
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.delete")
}
func testArchiveDocumentDecodesDocument() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "doc-1",
"title": "Hello",
"text": "World",
"url": "/doc/hello-doc-1",
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-02T00:00:00.000Z",
"archivedAt": "2026-01-03T00:00:00.000Z"
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let document = try await client.archiveDocument(id: "doc-1")
XCTAssertNotNil(document.archivedAt)
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.archive")
}
func testExportDocumentReturnsMarkdownString() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{ "data": "# Hello\\n\\nWorld" }
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let markdown = try await client.exportDocument(id: "doc-1")
XCTAssertEqual(markdown, "# Hello\n\nWorld")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.export")
}
func testListRevisionsDecodesRevisions() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": [
{
"id": "rev-1",
"documentId": "doc-1",
"title": "Hello",
"name": null,
"icon": null,
"collaborators": [
{ "id": "user-1", "name": "Jane Doe" }
],
"createdAt": "2026-01-01T00:00:00.000Z"
}
]
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let revisions = try await client.listRevisions(ListRevisionsRequest(documentId: "doc-1"))
XCTAssertEqual(revisions.first?.id, "rev-1")
XCTAssertEqual(revisions.first?.collaborators.first?.name, "Jane Doe")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/revisions.list")
}
func testDocumentInsightsDecodesRollups() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": [
{
"date": "2026-01-01",
"period": "day",
"viewCount": 3,
"viewerCount": 2,
"commentCount": 0,
"reactionCount": 0,
"revisionCount": 1,
"editorCount": 1
}
]
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let insights = try await client.documentInsights(DocumentInsightsRequest(id: "doc-1"))
XCTAssertEqual(insights.first?.viewCount, 3)
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.insights")
}
func testListStarsDecodesStarsArray() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"stars": [
{ "id": "star-1", "collectionId": "col-1", "documentId": null },
{ "id": "star-2", "collectionId": null, "documentId": "doc-1" }
],
"documents": []
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let stars = try await client.listStars(ListStarsRequest())
XCTAssertEqual(stars.count, 2)
XCTAssertEqual(stars.first?.collectionId, "col-1")
XCTAssertEqual(stars.last?.documentId, "doc-1")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/stars.list")
}
func testDeleteStarSucceedsOnSuccessTrue() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{ "success": true }
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
try await client.deleteStar(id: "star-1")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/stars.delete")
}
func testCreateShareDecodesShare() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "share-1",
"documentId": "doc-1",
"collectionId": null,
"url": "https://outline.example.com/s/share-1",
"published": false
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let share = try await client.createShare(CreateShareRequest(documentId: "doc-1"))
XCTAssertEqual(share.url, "https://outline.example.com/s/share-1")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/shares.create")
}
func testShareInfoDecodesRealShareArrayShape() async throws {
// Real shape confirmed against a live server — `data` is
// `{ shares: [...] }`, not the bare share object the official docs
// imply (same pattern as `pins.list`). This is the exact payload
// captured for an existing, unpublished share.
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"shares": [
{
"id": "861559ac-906b-4dec-9c1f-3a2d2000745d",
"sourceTitle": "Test Document 3",
"sourcePath": "/doc/test-document-3-VHxABl5RaD",
"collectionId": null,
"documentId": "b1196971-4239-4e35-970f-7fbbc60af044",
"documentTitle": "Test Document 3",
"documentUrl": "/doc/test-document-3-VHxABl5RaD",
"published": false,
"url": "https://docs.psmattas.com/s/861559ac-906b-4dec-9c1f-3a2d2000745d",
"urlId": null,
"createdBy": {
"id": "1e2ef39c-aa82-475b-b5af-d76bb4f023ed",
"name": "Puranjay Savar Mattas",
"avatarUrl": "/api/files.get?key=public/avatar.png",
"color": "#1c9152",
"role": "admin",
"isSuspended": false,
"createdAt": "2025-07-30T17:36:58.560Z",
"updatedAt": "2026-08-14T16:47:45.037Z",
"deletedAt": null,
"lastActiveAt": "2026-08-14T16:47:45.037Z",
"timezone": "Europe/Dublin"
},
"includeChildDocuments": false,
"allowIndexing": false,
"allowSubscriptions": true,
"showLastUpdated": false,
"showTOC": false,
"title": null,
"iconUrl": null,
"views": 0,
"domain": null,
"createdAt": "2026-08-14T16:49:40.146Z",
"updatedAt": "2026-08-14T16:49:40.146Z"
}
]
},
"policies": [
{ "id": "861559ac-906b-4dec-9c1f-3a2d2000745d", "abilities": { "read": true, "update": false, "revoke": true } }
],
"status": 200,
"ok": true
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let share = try await client.shareInfo(documentId: "doc-1")
XCTAssertEqual(share?.id, "861559ac-906b-4dec-9c1f-3a2d2000745d")
XCTAssertEqual(share?.published, false)
XCTAssertEqual(share?.views, 0)
XCTAssertEqual(share?.createdBy?.name, "Puranjay Savar Mattas")
XCTAssertEqual(share?.documentId, "b1196971-4239-4e35-970f-7fbbc60af044")
}
func testListSharesDecodesSharesArray() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": [
{ "id": "share-1", "documentId": "doc-1", "collectionId": null, "url": "https://outline.example.com/s/share-1", "published": true }
],
"pagination": { "offset": 0, "limit": 25 }
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let shares = try await client.listShares(ListSharesRequest())
XCTAssertEqual(shares.first?.id, "share-1")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/shares.list")
}
func testRevokeShareSendsRequest() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{ "success": true }
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
try await client.revokeShare(id: "share-1")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/shares.revoke")
}
func testShareInfoReturnsNilOnNotFound() async throws {
let httpClient = MockHTTPClient()
httpClient.statusCode = 404
httpClient.responseData = """
{ "ok": false, "error": "not_found", "message": "Not Found" }
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let share = try await client.shareInfo(documentId: "doc-1")
XCTAssertNil(share)
}
func testShareInfoReturnsNilOnEmptyBody() async throws {
// Confirmed against a live server: shares.info returns a 200 with a
// completely empty body (not a 404) when no share exists yet for a
// given document.
let httpClient = MockHTTPClient()
httpClient.responseData = Data()
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let share = try await client.shareInfo(documentId: "doc-1")
XCTAssertNil(share)
}
func testListViewsDecodesViewsAndFiltersNilLastViewedAt() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": [
{
"id": "view-1",
"documentId": "doc-1",
"userId": "user-1",
"user": { "id": "user-1", "name": "Jane Doe" },
"count": 3,
"firstViewedAt": "2026-01-01T00:00:00.000Z",
"lastViewedAt": "2026-01-02T00:00:00.000Z"
}
]
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let views = try await client.listViews(ListViewsRequest(documentId: "doc-1"))
XCTAssertEqual(views.first?.user.name, "Jane Doe")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/views.list")
}
func testListCommentsDecodesCommentsAndExtractsPlainTextFromProseMirrorData() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": [
{
"id": "comment-1",
"documentId": "doc-1",
"parentCommentId": null,
"createdAt": "2026-01-01T00:00:00.000Z",
"createdBy": { "id": "user-1", "name": "Jane Doe" },
"updatedAt": "2026-01-01T00:00:00.000Z",
"resolvedAt": null,
"resolvedBy": null,
"reactions": [
{ "emoji": "\u{1F44D}", "userIds": ["user-2", "user-3"] }
],
"data": {
"type": "doc",
"content": [
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Sounds great" }
]
}
]
}
}
]
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let comments = try await client.listComments(ListCommentsRequest(documentId: "doc-1"))
XCTAssertEqual(comments.first?.id, "comment-1")
XCTAssertEqual(comments.first?.bodyText, "Sounds great")
XCTAssertFalse(comments.first?.isResolved ?? true)
XCTAssertEqual(comments.first?.reactions.first?.emoji, "\u{1F44D}")
XCTAssertEqual(comments.first?.reactions.first?.userIds, ["user-2", "user-3"])
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/comments.list")
}
func testResolveCommentDecodesResolvedComment() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "comment-1",
"documentId": "doc-1",
"parentCommentId": null,
"createdAt": "2026-01-01T00:00:00.000Z",
"createdBy": { "id": "user-1", "name": "Jane Doe" },
"updatedAt": "2026-01-01T00:00:00.000Z",
"resolvedAt": "2026-01-02T00:00:00.000Z",
"resolvedBy": { "id": "user-2", "name": "John Roe" },
"reactions": [],
"data": { "type": "doc", "content": [] }
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let comment = try await client.resolveComment(id: "comment-1")
XCTAssertTrue(comment.isResolved)
XCTAssertEqual(comment.resolvedBy?.name, "John Roe")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/comments.resolve")
}
func testCreateCommentSendsParentCommentIdForAReply() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "comment-2",
"documentId": "doc-1",
"parentCommentId": "comment-1",
"createdAt": "2026-01-01T00:00:00.000Z",
"createdBy": { "id": "user-1", "name": "Jane Doe" },
"updatedAt": "2026-01-01T00:00:00.000Z",
"resolvedAt": null,
"resolvedBy": null,
"reactions": [],
"data": { "type": "doc", "content": [] }
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let reply = try await client.createComment(
CreateCommentRequest(documentId: "doc-1", parentCommentId: "comment-1", text: "Agreed")
)
XCTAssertEqual(reply.parentCommentId, "comment-1")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/comments.create")
}
func testAddReactionPostsIdAndEmoji() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{ "success": true }
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
try await client.addReaction(commentId: "comment-1", emoji: "\u{1F44D}")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/comments.add_reaction")
}
func testCreatePinDecodesPin() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{ "data": { "id": "pin-1", "documentId": "doc-1", "collectionId": "col-1", "index": "a" } }
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let pin = try await client.createPin(CreatePinRequest(documentId: "doc-1", collectionId: "col-1"))
XCTAssertEqual(pin.id, "pin-1")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/pins.create")
}
func testListPinsDecodesNestedPinsArray() async throws {
// Real shape confirmed against a live server: `data` is
// `{ pins: [...], documents: [...] }`, not a bare array.
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"pagination": { "limit": 25, "offset": 0 },
"data": {
"pins": [
{ "id": "pin-1", "documentId": "doc-1", "collectionId": null, "index": "h" }
],
"documents": []
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let pins = try await client.listPins(ListPinsRequest(collectionId: nil))
XCTAssertEqual(pins.first?.id, "pin-1")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/pins.list")
}
func testCreateSubscriptionDecodesSubscription() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{ "data": { "id": "sub-1", "documentId": "doc-1", "collectionId": null, "event": "documents.update" } }
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let subscription = try await client.createSubscription(CreateSubscriptionRequest(documentId: "doc-1"))
XCTAssertEqual(subscription.id, "sub-1")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/subscriptions.create")
}
func testAddDocumentUserDecodesMembership() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{ "data": { "id": "mem-1", "userId": "user-1", "documentId": "doc-1", "permission": "read" } }
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let membership = try await client.addDocumentUser(
AddDocumentUserRequest(id: "doc-1", userId: "user-1", permission: "read")
)
XCTAssertEqual(membership.id, "mem-1")
XCTAssertEqual(membership.permission, "read")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.add_user")
}
func testRemoveDocumentUserSendsRequest() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{ "success": true }
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
try await client.removeDocumentUser(RemoveDocumentUserRequest(id: "doc-1", userId: "user-1"))
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.remove_user")
}
func testDocumentUsersDecodesMembersArray() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": [
{ "id": "user-1", "name": "Jane Doe", "email": "jane@example.com", "avatarUrl": null, "permission": "read_write" }
]
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let members = try await client.documentUsers(ListDocumentUsersRequest(id: "doc-1"))
XCTAssertEqual(members.first?.name, "Jane Doe")
XCTAssertEqual(members.first?.permission, "read_write")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/documents.users")
}
func testListUsersDecodesUsersArray() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": [
{ "id": "user-1", "name": "Jane Doe", "email": "jane@example.com", "role": "member" }
]
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let users = try await client.listUsers(ListUsersRequest(query: "Jane"))
XCTAssertEqual(users.first?.name, "Jane Doe")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.list")
}
func testUpdateUserAvatarSendsExplicitNullWhenRemoving() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "user-1",
"name": "Jane Doe",
"avatarUrl": null
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let user = try await client.updateUserAvatar(UpdateUserAvatarRequest(id: "user-1", avatarUrl: nil))
XCTAssertNil(user.avatarUrl)
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.update")
let sentBody = try XCTUnwrap(httpClient.lastRequest?.httpBody)
let sentJSON = try XCTUnwrap(String(data: sentBody, encoding: .utf8))
// The whole point of UpdateUserAvatarRequest's custom encode(to:) —
// Swift's synthesized Encodable would have omitted the key entirely
// for a nil Optional instead of sending a literal null.
XCTAssertTrue(sentJSON.contains("\"avatarUrl\":null"), "expected an explicit null, got: \(sentJSON)")
}
func testUpdateUserAvatarSendsTheNewURLWhenSet() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "user-1",
"name": "Jane Doe",
"avatarUrl": "/api/attachments.redirect?id=abc"
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let user = try await client.updateUserAvatar(
UpdateUserAvatarRequest(id: "user-1", avatarUrl: "/api/attachments.redirect?id=abc")
)
XCTAssertEqual(user.avatarUrl, "/api/attachments.redirect?id=abc")
let sentBody = try XCTUnwrap(httpClient.lastRequest?.httpBody)
let sentJSON = try XCTUnwrap(String(data: sentBody, encoding: .utf8))
XCTAssertTrue(sentJSON.contains("\"id\":\"user-1\""))
}
func testUpdateUserNameSendsRequestAndDecodesResult() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "user-1",
"name": "New Name"
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let user = try await client.updateUserName(UpdateUserNameRequest(id: "user-1", name: "New Name"))
XCTAssertEqual(user.name, "New Name")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.update")
}
func testUpdateUserLanguageSendsRequestAndDecodesResult() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "user-1",
"name": "Jane Doe",
"language": "fr_FR"
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let user = try await client.updateUserLanguage(UpdateUserLanguageRequest(id: "user-1", language: "fr_FR"))
XCTAssertEqual(user.language, "fr_FR")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.update")
}
func testUpdateUserPreferencesSendsWholeObjectAndDecodesResult() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "user-1",
"name": "Jane Doe",
"preferences": { "rememberLastPath": true, "useCursorPointer": true }
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let preferences = OutlineUserPreferences(rememberLastPath: true, useCursorPointer: true)
let user = try await client.updateUserPreferences(UpdateUserPreferencesRequest(id: "user-1", preferences: preferences))
XCTAssertEqual(user.preferences?.rememberLastPath, true)
XCTAssertEqual(user.preferences?.useCursorPointer, true)
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
let sentPreferences = sentBody?["preferences"] as? [String: Any]
XCTAssertEqual(sentPreferences?["rememberLastPath"] as? Bool, true)
XCTAssertEqual(sentPreferences?["useCursorPointer"] as? Bool, true)
}
/// Locks in the wire mapping confirmed against a live server's own web
/// app traffic: `seamlessEdit`/`commentsInGutter`/`enableSmartText` are
/// the real keys (not `separateEditing`/`showCommentMarker`/`smartText`
/// this struct exposes), and `seamlessEdit` is the *negation* of this
/// app's `separateEditing`.
func testOutlineUserPreferencesDecodesRealWireKeys() throws {
let json = """
{
"seamlessEdit": false,
"commentsInGutter": true,
"enableSmartText": true,
"rememberLastPath": true,
"useCursorPointer": true,
"codeBlockLineNumbers": false,
"notificationBadge": "indicator",
"fullWidthDocuments": true
}
""".data(using: .utf8)!
let preferences = try JSONDecoder().decode(OutlineUserPreferences.self, from: json)
XCTAssertEqual(preferences.separateEditing, true, "seamlessEdit: false means separate editing is ON")
XCTAssertEqual(preferences.showCommentMarker, true)
XCTAssertEqual(preferences.smartText, true)
XCTAssertEqual(preferences.rememberLastPath, true)
XCTAssertEqual(preferences.useCursorPointer, true)
XCTAssertEqual(preferences.codeBlockLineNumbers, false)
XCTAssertEqual(preferences.notificationBadge, "indicator")
XCTAssertEqual(preferences.fullWidthDocuments, true)
}
func testOutlineUserPreferencesEncodesRealWireKeysAndInvertsSeparateEditing() throws {
var preferences = OutlineUserPreferences()
preferences.separateEditing = true
preferences.showCommentMarker = false
preferences.smartText = true
preferences.fullWidthDocuments = true
let data = try JSONEncoder().encode(preferences)
let object = try JSONSerialization.jsonObject(with: data) as? [String: Any]
XCTAssertEqual(object?["seamlessEdit"] as? Bool, false, "separateEditing: true must encode as seamlessEdit: false")
XCTAssertEqual(object?["commentsInGutter"] as? Bool, false)
XCTAssertEqual(object?["enableSmartText"] as? Bool, true)
XCTAssertEqual(object?["fullWidthDocuments"] as? Bool, true)
XCTAssertNil(object?["separateEditing"], "must not leak this app's own field name onto the wire")
XCTAssertNil(object?["showCommentMarker"])
XCTAssertNil(object?["smartText"])
}
func testSubscribeToNotificationsSendsEventTypeAndDecodesResult() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "user-1",
"name": "Jane Doe",
"notificationSettings": { "documents.publish": true }
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let user = try await client.subscribeToNotifications(eventType: .documentPublish)
XCTAssertEqual(user.notificationSettings?["documents.publish"], true)
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.notificationsSubscribe")
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
XCTAssertEqual(sentBody?["eventType"] as? String, "documents.publish")
}
func testUnsubscribeFromNotificationsWithNilEventTypeTargetsAll() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "user-1",
"name": "Jane Doe",
"notificationSettings": { "documents.publish": false }
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
_ = try await client.unsubscribeFromNotifications(eventType: nil)
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.notificationsUnsubscribe")
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
XCTAssertNil(sentBody?["eventType"])
}
func testListApiKeysDecodesResult() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"pagination": { "limit": 25, "offset": 0 },
"data": [
{
"id": "c3eec545-6d38-4065-90dc-b6c96a551445",
"name": "Outpost",
"scope": null,
"last4": "dl1h",
"createdAt": "2026-08-12T18:44:32.467Z",
"updatedAt": "2026-08-12T18:44:32.467Z",
"expiresAt": null,
"lastActiveAt": "2026-08-18T01:01:36.553Z"
}
]
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let keys = try await client.listApiKeys(ListApiKeysRequest())
XCTAssertEqual(keys.count, 1)
XCTAssertEqual(keys.first?.name, "Outpost")
XCTAssertEqual(keys.first?.last4, "dl1h")
XCTAssertNil(keys.first?.expiresAt)
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/apiKeys.list")
}
func testInstallationInfoDecodesResult() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": { "version": "1.9.2", "latestVersion": "1.9.2", "versionsBehind": 0 },
"policies": []
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let info = try await client.installationInfo()
XCTAssertEqual(info.version, "1.9.2")
XCTAssertEqual(info.versionsBehind, 0)
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/installation.info")
}
func testCreateApiKeyOmitsExpiresAtWhenNilAndDecodesValue() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "5655fb3e-2a8c-4f2c-9cae-bbd910ef8683",
"name": "test",
"scope": null,
"value": "ol_api_Xqx9Jti7xUunb5b8bXh29vHmBngqjJl3Id0DGv",
"last4": "0DGv",
"createdAt": "2026-08-18T15:39:29.872Z",
"expiresAt": null,
"lastActiveAt": null
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let key = try await client.createApiKey(CreateApiKeyRequest(name: "test"))
XCTAssertEqual(key.value, "ol_api_Xqx9Jti7xUunb5b8bXh29vHmBngqjJl3Id0DGv")
XCTAssertNil(key.expiresAt)
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/apiKeys.create")
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
XCTAssertEqual(sentBody?["name"] as? String, "test")
XCTAssertNil(sentBody?["expiresAt"], "omitting expiresAt (not sending null) is what produces a non-expiring key")
XCTAssertNil(sentBody?["scope"])
}
func testCreateApiKeySendsExpiresAtWhenProvided() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "04e204fb-51a4-4b54-aed1-2056dfd576d7",
"name": "Test",
"scope": null,
"value": "ol_api_aeYcOts7I2sJXw3zaztjydRM3W3W89TBAtcLts",
"last4": "cLts",
"createdAt": "2026-08-18T15:38:22.928Z",
"expiresAt": "2026-11-16T23:59:59.999Z",
"lastActiveAt": null
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let expiresAt = Date(timeIntervalSince1970: 1_795_000_000)
_ = try await client.createApiKey(CreateApiKeyRequest(name: "Test", expiresAt: expiresAt))
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
XCTAssertNotNil(sentBody?["expiresAt"])
}
func testDeleteApiKeySendsRequest() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{ "success": true }
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
try await client.deleteApiKey(id: "5655fb3e-2a8c-4f2c-9cae-bbd910ef8683")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/apiKeys.delete")
let sentBody = try JSONSerialization.jsonObject(with: httpClient.lastRequest!.httpBody!) as? [String: Any]
XCTAssertEqual(sentBody?["id"] as? String, "5655fb3e-2a8c-4f2c-9cae-bbd910ef8683")
}
func testDeleteAccountSendsRequest() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{ "success": true }
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
try await client.deleteAccount()
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/users.delete")
}
func testDeleteAttachmentSendsRequest() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{ "success": true }
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
try await client.deleteAttachment(id: "attach-1")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/attachments.delete")
}
func testCreateAttachmentDecodesUploadTargetAndFormFields() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"attachment": {
"id": "attach-1",
"documentId": null,
"contentType": "image/jpeg",
"name": "avatar.jpg",
"url": "/api/attachments.redirect?id=attach-1",
"size": "59304"
},
"uploadUrl": "/api/files.create",
"form": {
"Content-Type": "image/jpeg",
"key": "uploads/user-1/attach-1/avatar.jpg",
"acl": "public-read"
}
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let result = try await client.createAttachment(
CreateAttachmentRequest(name: "avatar.jpg", contentType: "image/jpeg", size: 59304)
)
XCTAssertEqual(result.attachment.id, "attach-1")
XCTAssertEqual(result.attachment.size, "59304")
XCTAssertEqual(result.uploadUrl, "/api/files.create")
XCTAssertEqual(result.form["acl"], "public-read")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/attachments.create")
}
func testUploadAttachmentFilePostsToTheResolvedRelativeURL() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{ "success": true }
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let uploadTarget = CreateAttachmentResult(
attachment: OutlineAttachment(
id: "attach-1",
documentId: nil,
contentType: "image/jpeg",
name: "avatar.jpg",
url: "/api/attachments.redirect?id=attach-1",
size: "3"
),
uploadUrl: "/api/files.create",
form: ["Content-Type": "image/jpeg", "key": "uploads/attach-1"]
)
try await client.uploadAttachmentFile(uploadTarget, fileData: Data("abc".utf8))
// Resolved against baseURL's host, not appended onto "/api/<baseURL-relative-path>".
XCTAssertEqual(httpClient.lastRequest?.url?.absoluteString, "https://outline.example.com/api/files.create")
XCTAssertNil(httpClient.lastRequest?.value(forHTTPHeaderField: "Authorization"))
let contentType = httpClient.lastRequest?.value(forHTTPHeaderField: "Content-Type")
XCTAssertTrue(contentType?.hasPrefix("multipart/form-data; boundary=") ?? false)
}
func testMissingTokenThrowsTokenUnavailable() async throws {
let httpClient = MockHTTPClient()
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(token: nil),
httpClient: httpClient
)
do {
_ = try await client.documentInfo(id: "doc-1")
XCTFail("Expected tokenUnavailable error")
} catch OutlineAPIError.tokenUnavailable {
// expected
}
}
}