pins.list's real response is {data: {pins: [...], documents: [...]}},
not a bare array — confirmed against a live server's network traffic.
Decoding straight to [OutlinePin] threw on every call, and call sites
swallow that with try?, so pins never showed up anywhere (including
docs already pinned via the real web app).
Also: the reader's Pin action was sending the document's own
collectionId, which is "Pin to Collection" — a different action from
"Pin to Home" (collectionId: null), which is what the web app's "Pin
to Home" menu item actually does and what the Home page's pinned
section filters for.
800 lines
28 KiB
Swift
800 lines
28 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 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
|
|
}
|
|
}
|
|
}
|