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? {
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 {
@@ -354,6 +360,10 @@ private struct PinsListPayload: Decodable {
let documents: [OutlineDocument]
}
private struct SharesInfoPayload: Decodable {
let shares: [OutlineShare]
}
private struct ListStarsResponse: Decodable {
let stars: [OutlineStar]
}
@@ -669,54 +669,59 @@ final class LiveOutlineAPIClientTests: XCTestCase {
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.
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": {
"id": "123e4567-e89b-12d3-a456-426614174000",
"documentTitle": "React best practices",
"documentUrl": "https://example.com",
"sourceTitle": "string",
"sourcePath": "string",
"documentId": null,
"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,
"url": "https://example.com",
"domain": 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,
"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
"views": 0,
"domain": null,
"createdAt": "2026-08-14T16:49:40.146Z",
"updatedAt": "2026-08-14T16:49:40.146Z"
}
]
},
"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)!
@@ -728,10 +733,11 @@ final class LiveOutlineAPIClientTests: XCTestCase {
let share = try await client.shareInfo(documentId: "doc-1")
XCTAssertEqual(share?.id, "861559ac-906b-4dec-9c1f-3a2d2000745d")
XCTAssertEqual(share?.published, false)
XCTAssertEqual(share?.views, 1)
XCTAssertEqual(share?.createdBy?.name, "Jane Doe")
XCTAssertNil(share?.documentId)
XCTAssertEqual(share?.views, 0)
XCTAssertEqual(share?.createdBy?.name, "Puranjay Savar Mattas")
XCTAssertEqual(share?.documentId, "b1196971-4239-4e35-970f-7fbbc60af044")
}
func testListSharesDecodesSharesArray() async throws {