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",
"urlId": null, "documentTitle": "Test Document 3",
"url": "https://example.com", "documentUrl": "/doc/test-document-3-VHxABl5RaD",
"domain": null, "published": false,
"title": null, "url": "https://docs.psmattas.com/s/861559ac-906b-4dec-9c1f-3a2d2000745d",
"iconUrl": null, "urlId": null,
"published": false, "createdBy": {
"includeChildDocuments": true, "id": "1e2ef39c-aa82-475b-b5af-d76bb4f023ed",
"allowSubscriptions": true, "name": "Puranjay Savar Mattas",
"allowIndexing": true, "avatarUrl": "/api/files.get?key=public/avatar.png",
"showLastUpdated": true, "color": "#1c9152",
"showTOC": true, "role": "admin",
"views": 1, "isSuspended": false,
"createdAt": "2026-08-12T17:41:28.333Z", "createdAt": "2025-07-30T17:36:58.560Z",
"createdBy": { "updatedAt": "2026-08-14T16:47:45.037Z",
"id": "123e4567-e89b-12d3-a456-426614174000", "deletedAt": null,
"name": "Jane Doe", "lastActiveAt": "2026-08-14T16:47:45.037Z",
"avatarUrl": "https://example.com", "timezone": "Europe/Dublin"
"color": "string", },
"email": "hello@example.com", "includeChildDocuments": false,
"role": "admin", "allowIndexing": false,
"isSuspended": true, "allowSubscriptions": true,
"lastActiveAt": null, "showLastUpdated": false,
"timezone": null, "showTOC": false,
"createdAt": "2026-08-12T17:41:28.333Z", "title": null,
"updatedAt": "2026-08-12T17:41:28.333Z", "iconUrl": null,
"deletedAt": null "views": 0,
}, "domain": null,
"updatedAt": "2026-08-12T17:41:28.333Z", "createdAt": "2026-08-14T16:49:40.146Z",
"lastAccessedAt": null "updatedAt": "2026-08-14T16:49:40.146Z"
}
]
}, },
"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 {