fix(shares): shares.info wraps the share in {shares: [...]}, not bare

Empty-body handling fixed "no share yet" but re-opening the share
sheet for a document that already had one still errored — confirmed
via a raw response capture that data is { shares: [...] }, a
one-element array, not the bare share object the docs show. Same
pattern as pins.list.

Added SharesInfoPayload (mirrors PinsListPayload), shareInfo now takes
.shares.first. createShare/updateShare are unaffected — the user's
earlier successful create-and-copy confirms those aren't wrapped this
way, only shares.info. Test rewritten with the exact captured payload.
This commit is contained in:
2026-08-14 17:55:07 +01:00
parent 1476c6c6ba
commit 013df89b4f
2 changed files with 62 additions and 46 deletions
@@ -121,7 +121,13 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
} }
public func shareInfo(documentId: String) async throws -> OutlineShare? { public func shareInfo(documentId: String) async throws -> OutlineShare? {
try await postOptional("shares.info", body: ShareInfoRequest(documentId: documentId)) // Real shape confirmed against a live server: `data` is
// `{ shares: [...] }`, not the bare share object the docs imply
// same pattern as `pins.list`. A document could in principle have
// more than one share record; the first is what the reader's
// share sheet cares about.
let payload: SharesInfoPayload? = try await postOptional("shares.info", body: ShareInfoRequest(documentId: documentId))
return payload?.shares.first
} }
public func updateShare(_ request: UpdateShareRequest) async throws -> OutlineShare { public func updateShare(_ request: UpdateShareRequest) async throws -> OutlineShare {
@@ -354,6 +360,10 @@ private struct PinsListPayload: Decodable {
let documents: [OutlineDocument] let documents: [OutlineDocument]
} }
private struct SharesInfoPayload: Decodable {
let shares: [OutlineShare]
}
private struct ListStarsResponse: Decodable { private struct ListStarsResponse: Decodable {
let stars: [OutlineStar] let stars: [OutlineStar]
} }
@@ -669,54 +669,59 @@ final class LiveOutlineAPIClientTests: XCTestCase {
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/shares.create") XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/shares.create")
} }
func testShareInfoDecodesFullDocumentedShape() async throws { func testShareInfoDecodesRealShareArrayShape() async throws {
// Matches the exact response shape from Outline's official // Real shape confirmed against a live server `data` is
// shares.info docs, including every field the hosted app can send // `{ shares: [...] }`, not the bare share object the official docs
// the model must tolerate all of this without throwing. // imply (same pattern as `pins.list`). This is the exact payload
// captured for an existing, unpublished share.
let httpClient = MockHTTPClient() let httpClient = MockHTTPClient()
httpClient.responseData = """ httpClient.responseData = """
{ {
"data": { "data": {
"id": "123e4567-e89b-12d3-a456-426614174000", "shares": [
"documentTitle": "React best practices", {
"documentUrl": "https://example.com", "id": "861559ac-906b-4dec-9c1f-3a2d2000745d",
"sourceTitle": "string", "sourceTitle": "Test Document 3",
"sourcePath": "string", "sourcePath": "/doc/test-document-3-VHxABl5RaD",
"documentId": null,
"collectionId": null, "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, "urlId": null,
"url": "https://example.com", "createdBy": {
"domain": null, "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, "title": null,
"iconUrl": null, "iconUrl": null,
"published": false, "views": 0,
"includeChildDocuments": true, "domain": null,
"allowSubscriptions": true, "createdAt": "2026-08-14T16:49:40.146Z",
"allowIndexing": true, "updatedAt": "2026-08-14T16:49:40.146Z"
"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": [ "policies": [
{ "id": "123e4567-e89b-12d3-a456-426614174000", "abilities": { "read": true, "update": true, "delete": false } } { "id": "861559ac-906b-4dec-9c1f-3a2d2000745d", "abilities": { "read": true, "update": false, "revoke": true } }
] ],
"status": 200,
"ok": true
} }
""".data(using: .utf8)! """.data(using: .utf8)!
@@ -728,10 +733,11 @@ final class LiveOutlineAPIClientTests: XCTestCase {
let share = try await client.shareInfo(documentId: "doc-1") let share = try await client.shareInfo(documentId: "doc-1")
XCTAssertEqual(share?.id, "861559ac-906b-4dec-9c1f-3a2d2000745d")
XCTAssertEqual(share?.published, false) XCTAssertEqual(share?.published, false)
XCTAssertEqual(share?.views, 1) XCTAssertEqual(share?.views, 0)
XCTAssertEqual(share?.createdBy?.name, "Jane Doe") XCTAssertEqual(share?.createdBy?.name, "Puranjay Savar Mattas")
XCTAssertNil(share?.documentId) XCTAssertEqual(share?.documentId, "b1196971-4239-4e35-970f-7fbbc60af044")
} }
func testListSharesDecodesSharesArray() async throws { func testListSharesDecodesSharesArray() async throws {