All preference toggles showed as off regardless of server state, and saving notificationBadge 400'd with "notificationBadge: Invalid Input" — the guessed wire keys/values from the earlier speculative commit were wrong. Fixed against a live network capture of Outline's own web app toggling every one of these settings: - separateEditing -> seamlessEdit, and inverted (seamlessEdit is separate editing's negation, confirmed by toggling it live) - showCommentMarker -> commentsInGutter - smartText -> enableSmartText - notificationBadge values -> "disabled"/"indicator"/"count", not the guessed "none"/"unread"/"all" - rememberLastPath/useCursorPointer/codeBlockLineNumbers were already correct Also preserves fullWidthDocuments (a real preference this app has no UI for) on round-trip, since the client always sends the whole preferences object back on save — dropping an unrecognized key during decode would otherwise silently clear it the next time any toggle here gets saved.
1305 lines
48 KiB
Swift
1305 lines
48 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 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 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
|
|
}
|
|
}
|
|
}
|