Files
Outpost/OutlineKit/Tests/OutlineKitTests/LiveOutlineAPIClientTests.swift
T
Puranjay Savar Mattas 43f9d6052f fix(shares): match documented API shape, fill in list/revoke, harden share sheet
Share sheet errored "Got an unexpected response from the server" (a
client-side decode failure) on every open. OutlineShare only declared
5 fields against the real response's ~20, with url non-optional —
something in a real payload came back null and blew up the strict
decode, same class of bug as OutlinePin's history: self-hosted
responses keep diverging from what the hosted-app docs imply is
non-nullable.

- Rewrote OutlineShare to match the full documented shares.* response
  shape. Only id/published are trusted non-optional; everything else
  (documentTitle, sourceTitle, urlId, domain, title, iconUrl,
  includeChildDocuments, allowSubscriptions, allowIndexing,
  showLastUpdated, showTOC, views, createdBy, createdAt, updatedAt,
  lastAccessedAt) is optional so an unexpectedly-null field can't crash
  the decode again.
- shares.list and shares.revoke were never wrapped at all — added both.
- UpdateShareRequest was missing the documented title/iconUrl overrides.
- DocumentShareSheet: handles share.url being optional, adds a
  public-page title override field, adds a Revoke Link button
  (confirmation dialog, resets back to "Create Share Link" after).
- Removed dead DocumentReaderViewModel.share/loadShare()/
  createOrLoadShare() — never called by anything; DocumentShareSheet
  manages its own share state independently.
- Added a decode test using the exact payload from Outline's official
  shares.info docs, plus tests for shares.list and shares.revoke.

Branched fresh off main (post home-page merge) rather than continuing
on feature/home-page, to keep this its own PR.
2026-08-14 17:41:28 +01:00

905 lines
32 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 testShareInfoDecodesFullDocumentedShape() async throws {
// Matches the exact response shape from Outline's official
// shares.info docs, including every field the hosted app can send —
// the model must tolerate all of this without throwing.
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "123e4567-e89b-12d3-a456-426614174000",
"documentTitle": "React best practices",
"documentUrl": "https://example.com",
"sourceTitle": "string",
"sourcePath": "string",
"documentId": null,
"collectionId": null,
"urlId": null,
"url": "https://example.com",
"domain": null,
"title": null,
"iconUrl": null,
"published": false,
"includeChildDocuments": true,
"allowSubscriptions": true,
"allowIndexing": true,
"showLastUpdated": true,
"showTOC": true,
"views": 1,
"createdAt": "2026-08-12T17:41:28.333Z",
"createdBy": {
"id": "123e4567-e89b-12d3-a456-426614174000",
"name": "Jane Doe",
"avatarUrl": "https://example.com",
"color": "string",
"email": "hello@example.com",
"role": "admin",
"isSuspended": true,
"lastActiveAt": null,
"timezone": null,
"createdAt": "2026-08-12T17:41:28.333Z",
"updatedAt": "2026-08-12T17:41:28.333Z",
"deletedAt": null
},
"updatedAt": "2026-08-12T17:41:28.333Z",
"lastAccessedAt": null
},
"policies": [
{ "id": "123e4567-e89b-12d3-a456-426614174000", "abilities": { "read": true, "update": true, "delete": 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.shareInfo(documentId: "doc-1")
XCTAssertEqual(share?.published, false)
XCTAssertEqual(share?.views, 1)
XCTAssertEqual(share?.createdBy?.name, "Jane Doe")
XCTAssertNil(share?.documentId)
}
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 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 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 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
}
}
}