Repository: neet/masto.js (ts)

Issue:
Repeated "MastoUnprocessableEntityError" on upgrade from masto.js 2.x to 3.x
Hi, I upgraded from `masto@2.5.4` to `masto@3.5.1` and renamed the `uploadMediaAttachment` method and response object parameters as indicated by the [breaking changes in the release notes for 3.0.0](https://github.com/neet/masto.js/releases/tag/v3.0.0), but now I'm getting a `MastoUnprocessableEntityError` whenever I try to make a post with uploaded media. Previously it used to work fine.

The full error:

> MastoUnprocessableEntityError: Cannot attach files that have not finished processing. Try again in a moment!

The code I was using with `masto@2.5.4`, which worked fine:

```ts
// ...

const { id } = await masto.uploadMediaAttachment({
  file: createReadStream(filename),
  description: title
});

const { created_at, uri } = await masto.createStatus({
  status: title,
  visibility: "public",
  media_ids: [id]
});
```

The code I am using with `masto@3.5.1`, which throws the exception:

```ts
// ...

const { id } = await masto.createMediaAttachment({
  file: createReadStream(filename),
  description: title,
});

const { createdAt, uri } = await masto.createStatus({
  status: title,
  visibility: "public",
  mediaIds: [id],
});
```

Is it possible this is a change in the Mastodon API behaviour [introduced in 3.1.0](https://github.com/neet/masto.js/releases/tag/v3.1.0) when using the `v2/media` endpoint? If so, that seems like a breaking change.

Pull request description:
Add Masto#fetchMediaAttachment and Masto#waitForMediaAttachment
Partly resolves, #303 

This PR makes `Attachment.url` optional but it should've been optional since v3. thus I will not bump the major version.

Patch:
diff --git a/.eslintrc.json b/.eslintrc.json
index c0ef3023..807ad1fa 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -21,6 +21,7 @@
     "jest": true
   },
   "rules": {
+    "@typescript-eslint/explicit-module-boundary-types": "off",
     "@typescript-eslint/explicit-function-return-type": "off",
     "@typescript-eslint/explicit-member-accessibility": [
       "error",
diff --git a/examples/create-new-status-with-image.ts b/examples/create-new-status-with-image.ts
index 0029141e..dd6b867b 100644
--- a/examples/create-new-status-with-image.ts
+++ b/examples/create-new-status-with-image.ts
@@ -8,10 +8,12 @@ import { Masto } from 'masto';
   });
 
   // Upload the image
-  const attachment = await masto.createMediaAttachment({
-    file: fs.createReadStream('../some_image.png'),
-    description: 'Some image',
-  });
+  const attachment = await masto
+    .createMediaAttachment({
+      file: fs.createReadStream('../some_image.png'),
+      description: 'Some image',
+    })
+    .then(({ id }) => masto.waitForMediaAttachment(id));
 
   // Toot!
   masto.createStatus({
diff --git a/src/clients/masto/masto.ts b/src/clients/masto/masto.ts
index 8f91896f..e8109120 100644
--- a/src/clients/masto/masto.ts
+++ b/src/clients/masto/masto.ts
@@ -30,6 +30,7 @@ import {
   Token,
 } from '../../entities';
 import { available, GatewayImpl } from '../../gateway';
+import { delay } from '../../utils/delay';
 import {
   CreateAccountNoteParams,
   CreateAccountParams,
@@ -780,6 +781,16 @@ export class Masto extends GatewayImpl {
     });
   }
 
+  /**
+   * Fetches an attachment to be used with a new status.
+   * @param id ID of the attachment
+   * @see https://github.com/tootsuite/mastodon/pull/13210
+   */
+  @available({ since: '3.1.3' })
+  fetchMediaAttachment(id: string) {
+    return this.get<Attachment>(`/api/v1/media/${id}`);
+  }
+
   /**
    * Update an Attachment, before it is attached to a status and posted.
    * @param id The id of the Attachment entity to be updated
@@ -1466,4 +1477,25 @@ export class Masto extends GatewayImpl {
   createAccountNote(id: string, params: CreateAccountNoteParams) {
     return this.post<Relationship>(`/api/v1/accounts/${id}/note`, params);
   }
+
+  /**
+   * @experimental
+   * @param id ID of the media
+   * @param interval interval of polling
+   * @returns Media attachment that has done processing
+   */
+  async waitForMediaAttachment(id: string, interval = 1000) {
+    let media: Attachment | null = null;
+
+    while (media == null) {
+      await delay(interval);
+      const processing = await this.fetchMediaAttachment(id);
+
+      if (processing.url != null) {
+        media = processing;
+      }
+    }
+
+    return media;
+  }
 }
diff --git a/src/entities/attachment.ts b/src/entities/attachment.ts
index 652508f9..18e1b190 100644
--- a/src/entities/attachment.ts
+++ b/src/entities/attachment.ts
@@ -44,7 +44,7 @@ export interface Attachment {
   /** The type of the attachment. */
   type: AttachmentType;
   /** The location of the original full-size attachment. */
-  url: string;
+  url?: string | null;
   /** The location of a scaled-down preview of the attachment. */
   previewUrl: string;
 
diff --git a/src/utils/delay.ts b/src/utils/delay.ts
new file mode 100644
index 00000000..37289523
--- /dev/null
+++ b/src/utils/delay.ts
@@ -0,0 +1,2 @@
+export const delay = (ms: number) =>
+  new Promise((resolve) => setTimeout(() => resolve(), ms));


Test patch:
diff --git a/src/clients/masto/__tests__/__snapshots__/masto.spec.ts.snap b/src/clients/masto/__tests__/__snapshots__/masto.spec.ts.snap
index 5d13abfa..8d8b585e 100644
--- a/src/clients/masto/__tests__/__snapshots__/masto.spec.ts.snap
+++ b/src/clients/masto/__tests__/__snapshots__/masto.spec.ts.snap
@@ -1103,6 +1103,22 @@ exports[`Masto fetchMarkers 1`] = `
 }
 `;
 
+exports[`Masto fetchMediaAttachment 1`] = `
+[MockFunction] {
+  "calls": Array [
+    Array [
+      "/api/v1/media/123",
+    ],
+  ],
+  "results": Array [
+    Object {
+      "type": "return",
+      "value": undefined,
+    },
+  ],
+}
+`;
+
 exports[`Masto fetchMutes 1`] = `
 [MockFunction] {
   "calls": Array [
diff --git a/src/clients/masto/__tests__/masto.spec.ts b/src/clients/masto/__tests__/masto.spec.ts
index 750cc52e..49bdde2b 100644
--- a/src/clients/masto/__tests__/masto.spec.ts
+++ b/src/clients/masto/__tests__/masto.spec.ts
@@ -1009,4 +1009,10 @@ describe('Masto', () => {
     expect(mockPost).toBeCalledTimes(1);
     expect(mockPost).toMatchSnapshot();
   });
+
+  test('fetchMediaAttachment', async () => {
+    await masto.fetchMediaAttachment('123');
+    expect(mockGet).toBeCalledTimes(1);
+    expect(mockGet).toMatchSnapshot();
+  });
 });