oxynote/web/app/composables/api/useDocumentCommentAPI.nuxt.test.ts
dependabot[bot] 496fb67870
deps(web): bump the safe group across 1 directory with 29 updates (#119)
* deps(web): bump the safe group across 1 directory with 29 updates

Bumps the safe group with 29 updates in the /web directory:

| Package | From | To |
| --- | --- | --- |
| [@internationalized/date](https://github.com/adobe/react-spectrum) | `3.12.3` | `3.12.4` |
| [@pinia/colada](https://github.com/posva/pinia-colada) | `1.4.2` | `1.4.4` |
| [@pinia/colada-nuxt](https://github.com/posva/pinia-colada) | `1.0.2` | `1.1.0` |
| [@sentry/nuxt](https://github.com/getsentry/sentry-javascript) | `10.72.0` | `10.73.0` |
| [@tiptap/extension-code-block-lowlight](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-code-block-lowlight) | `3.30.5` | `3.31.3` |
| [@tiptap/extension-collaboration](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-collaboration) | `3.30.5` | `3.31.3` |
| [@tiptap/extension-collaboration-caret](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-collaboration-caret) | `3.30.5` | `3.31.3` |
| [@tiptap/extension-file-handler](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-file-handler) | `3.30.5` | `3.31.3` |
| [@tiptap/extension-list](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-list) | `3.30.5` | `3.31.3` |
| [@tiptap/extension-node-range](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-node-range) | `3.30.5` | `3.31.3` |
| [@tiptap/extension-text-style](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-text-style) | `3.30.5` | `3.31.3` |
| [@tiptap/extension-typography](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-typography) | `3.30.5` | `3.31.3` |
| [@tiptap/extensions](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension) | `3.30.5` | `3.31.3` |
| [@tiptap/pm](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/pm) | `3.30.5` | `3.31.3` |
| [@tiptap/starter-kit](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/starter-kit) | `3.30.5` | `3.31.3` |
| [@tiptap/suggestion](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/suggestion) | `3.30.5` | `3.31.3` |
| [@tiptap/vue-3](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/vue-3) | `3.30.5` | `3.31.3` |
| [vue-codemirror6](https://github.com/logue/vue-codemirror6) | `1.6.1` | `1.6.2` |
| [vue-echarts](https://github.com/ecomfe/vue-echarts) | `8.1.0` | `8.3.0` |
| [@iconify-json/hugeicons](https://github.com/iconify/icon-sets) | `1.2.33` | `1.2.34` |
| [@iconify-json/lucide](https://github.com/iconify/icon-sets) | `1.2.126` | `1.2.129` |
| [@iconify-json/simple-icons](https://github.com/iconify/icon-sets) | `1.2.93` | `1.2.94` |
| [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) | `8.68.0` | `8.69.0` |
| [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) | `8.68.0` | `8.69.0` |
| [electron](https://github.com/electron/electron) | `44.0.0` | `44.2.0` |
| [eslint](https://github.com/eslint/eslint) | `10.9.1` | `10.10.0` |
| [happy-dom](https://github.com/capricorn86/happy-dom) | `20.11.13` | `20.14.0` |
| [knip](https://github.com/webpro-nl/knip/tree/HEAD/packages/knip) | `6.33.0` | `6.34.0` |
| [playwright](https://github.com/microsoft/playwright) | `1.62.1` | `1.63.0` |

Updates `@internationalized/date` from 3.12.3 to 3.12.4
- [Release notes](https://github.com/adobe/react-spectrum/releases)
- [Commits](https://github.com/adobe/react-spectrum/compare/@internationalized/date@3.12.3...@internationalized/date@3.12.4)

Updates `@pinia/colada` from 1.4.2 to 1.4.4
- [Release notes](https://github.com/posva/pinia-colada/releases)
- [Changelog](https://github.com/posva/pinia-colada/blob/main/CHANGELOG.md)
- [Commits](https://github.com/posva/pinia-colada/compare/v1.4.2...v1.4.4)

Updates `@pinia/colada-nuxt` from 1.0.2 to 1.1.0
- [Release notes](https://github.com/posva/pinia-colada/releases)
- [Changelog](https://github.com/posva/pinia-colada/blob/main/CHANGELOG.md)
- [Commits](https://github.com/posva/pinia-colada/compare/@pinia/colada-nuxt@1.0.2...@pinia/colada-nuxt@1.1.0)

Updates `@sentry/nuxt` from 10.72.0 to 10.73.0
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/10.73.0/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/10.72.0...10.73.0)

Updates `@tiptap/extension-code-block-lowlight` from 3.30.5 to 3.31.3
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/v3.31.3/packages/extension-code-block-lowlight/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.31.3/packages/extension-code-block-lowlight)

Updates `@tiptap/extension-collaboration` from 3.30.5 to 3.31.3
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/v3.31.3/packages/extension-collaboration/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.31.3/packages/extension-collaboration)

Updates `@tiptap/extension-collaboration-caret` from 3.30.5 to 3.31.3
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/v3.31.3/packages/extension-collaboration-caret/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.31.3/packages/extension-collaboration-caret)

Updates `@tiptap/extension-file-handler` from 3.30.5 to 3.31.3
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/v3.31.3/packages/extension-file-handler/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.31.3/packages/extension-file-handler)

Updates `@tiptap/extension-list` from 3.30.5 to 3.31.3
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/v3.31.3/packages/extension-list/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.31.3/packages/extension-list)

Updates `@tiptap/extension-node-range` from 3.30.5 to 3.31.3
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/v3.31.3/packages/extension-node-range/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.31.3/packages/extension-node-range)

Updates `@tiptap/extension-text-style` from 3.30.5 to 3.31.3
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/v3.31.3/packages/extension-text-style/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.31.3/packages/extension-text-style)

Updates `@tiptap/extension-typography` from 3.30.5 to 3.31.3
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/v3.31.3/packages/extension-typography/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.31.3/packages/extension-typography)

Updates `@tiptap/extensions` from 3.30.5 to 3.31.3
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.31.3/packages/extension)

Updates `@tiptap/pm` from 3.30.5 to 3.31.3
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/v3.31.3/packages/pm/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.31.3/packages/pm)

Updates `@tiptap/starter-kit` from 3.30.5 to 3.31.3
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/v3.31.3/packages/starter-kit/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.31.3/packages/starter-kit)

Updates `@tiptap/suggestion` from 3.30.5 to 3.31.3
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/v3.31.3/packages/suggestion/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.31.3/packages/suggestion)

Updates `@tiptap/vue-3` from 3.30.5 to 3.31.3
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/v3.31.3/packages/vue-3/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.31.3/packages/vue-3)

Updates `vue-codemirror6` from 1.6.1 to 1.6.2
- [Release notes](https://github.com/logue/vue-codemirror6/releases)
- [Changelog](https://github.com/logue/vue-codemirror6/blob/master/CHANGELOG.md)
- [Commits](https://github.com/logue/vue-codemirror6/commits)

Updates `vue-echarts` from 8.1.0 to 8.3.0
- [Release notes](https://github.com/ecomfe/vue-echarts/releases)
- [Changelog](https://github.com/ecomfe/vue-echarts/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ecomfe/vue-echarts/compare/v8.1.0...v8.3.0)

Updates `@iconify-json/hugeicons` from 1.2.33 to 1.2.34
- [Commits](https://github.com/iconify/icon-sets/commits)

Updates `@iconify-json/lucide` from 1.2.126 to 1.2.129
- [Commits](https://github.com/iconify/icon-sets/commits)

Updates `@iconify-json/simple-icons` from 1.2.93 to 1.2.94
- [Commits](https://github.com/iconify/icon-sets/commits)

Updates `@typescript-eslint/eslint-plugin` from 8.68.0 to 8.69.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.69.0/packages/eslint-plugin)

Updates `@typescript-eslint/parser` from 8.68.0 to 8.69.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.69.0/packages/parser)

Updates `electron` from 44.0.0 to 44.2.0
- [Release notes](https://github.com/electron/electron/releases)
- [Commits](https://github.com/electron/electron/compare/v44.0.0...v44.2.0)

Updates `eslint` from 10.9.1 to 10.10.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.9.1...v10.10.0)

Updates `happy-dom` from 20.11.13 to 20.14.0
- [Release notes](https://github.com/capricorn86/happy-dom/releases)
- [Commits](https://github.com/capricorn86/happy-dom/compare/v20.11.13...v20.14.0)

Updates `knip` from 6.33.0 to 6.34.0
- [Release notes](https://github.com/webpro-nl/knip/releases)
- [Commits](https://github.com/webpro-nl/knip/commits/knip@6.34.0/packages/knip)

Updates `playwright` from 1.62.1 to 1.63.0
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.62.1...v1.63.0)

---
updated-dependencies:
- dependency-name: "@iconify-json/hugeicons"
  dependency-version: 1.2.34
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: safe
- dependency-name: "@iconify-json/lucide"
  dependency-version: 1.2.129
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: safe
- dependency-name: "@iconify-json/simple-icons"
  dependency-version: 1.2.94
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: safe
- dependency-name: "@internationalized/date"
  dependency-version: 3.12.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: safe
- dependency-name: "@pinia/colada"
  dependency-version: 1.4.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: safe
- dependency-name: "@pinia/colada-nuxt"
  dependency-version: 1.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: safe
- dependency-name: "@sentry/nuxt"
  dependency-version: 10.73.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: safe
- dependency-name: "@tiptap/extension-code-block-lowlight"
  dependency-version: 3.31.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: safe
- dependency-name: "@tiptap/extension-collaboration"
  dependency-version: 3.31.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: safe
- dependency-name: "@tiptap/extension-collaboration-caret"
  dependency-version: 3.31.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: safe
- dependency-name: "@tiptap/extension-file-handler"
  dependency-version: 3.31.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: safe
- dependency-name: "@tiptap/extension-list"
  dependency-version: 3.31.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: safe
- dependency-name: "@tiptap/extension-node-range"
  dependency-version: 3.31.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: safe
- dependency-name: "@tiptap/extension-text-style"
  dependency-version: 3.31.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: safe
- dependency-name: "@tiptap/extension-typography"
  dependency-version: 3.31.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: safe
- dependency-name: "@tiptap/extensions"
  dependency-version: 3.31.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: safe
- dependency-name: "@tiptap/pm"
  dependency-version: 3.31.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: safe
- dependency-name: "@tiptap/starter-kit"
  dependency-version: 3.31.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: safe
- dependency-name: "@tiptap/suggestion"
  dependency-version: 3.31.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: safe
- dependency-name: "@tiptap/vue-3"
  dependency-version: 3.31.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: safe
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-version: 8.69.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: safe
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.69.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: safe
- dependency-name: electron
  dependency-version: 44.2.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: safe
- dependency-name: eslint
  dependency-version: 10.10.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: safe
- dependency-name: happy-dom
  dependency-version: 20.14.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: safe
- dependency-name: knip
  dependency-version: 6.34.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: safe
- dependency-name: playwright
  dependency-version: 1.63.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: safe
- dependency-name: vue-codemirror6
  dependency-version: 1.6.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: safe
- dependency-name: vue-echarts
  dependency-version: 8.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: safe
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix web lints

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: swithek <52840391+swithek@users.noreply.github.com>
2026-09-12 14:20:27 +03:00

1343 lines
40 KiB
TypeScript

import { afterEach, beforeEach, describe, it } from "vitest"
import type {
DocumentComment,
DocumentCommentReply,
DocumentCommentsResponse,
} from "~/utils/api/comment"
import {
ANY_DATE,
ANY_STRING,
clearQueryCache,
disposeMockEndpoints,
mockEndpoint,
readQueryData,
runInApp,
seedAuthOrganization,
seedAuthSession,
seedQueryData,
} from "./test-helpers"
import useDocumentCommentAPI from "./useDocumentCommentAPI"
// isXid() only checks for a 20-character length, so these constants pass
// the mutations' id guards; NON_XID_ID fails them like a nanoid would
const DOC_ID = "docid123456789012345"
const BRANCH_ID = "branchid123456789012"
const COMMENT_ID = "commentid10000000001"
const COMMENT_ID_2 = "commentid20000000002"
const REPLY_ID = "replyid1000000000001"
const REPLY_ID_2 = "replyid2000000000002"
const USER_ID = "userid10000000000001"
const USER_ID_2 = "userid20000000000002"
const ORG_ID = "orgid100000000000001"
const NON_XID_ID = "nano1"
const COMMENTS_KEY = ["documents", DOC_ID, "comments", BRANCH_ID] as const
const COMMENTS_URL = `/api/documents/${DOC_ID}/comments`
const COMMENT_URL = `${COMMENTS_URL}/${COMMENT_ID}`
const RESOLVE_URL = `${COMMENT_URL}/resolve`
const REPLIES_URL = `${COMMENT_URL}/replies`
const REPLY_URL = `${REPLIES_URL}/${REPLY_ID}`
function makeCommentAPI() {
return runInApp(() => useDocumentCommentAPI())
}
type DocumentCommentAPI = ReturnType<typeof makeCommentAPI>
function seedAuth() {
seedAuthSession(USER_ID)
seedAuthOrganization(ORG_ID)
}
function seedComments(comments: DocumentCommentsResponse) {
seedQueryData(COMMENTS_KEY, comments)
}
function readComments() {
return readQueryData(COMMENTS_KEY) as DocumentCommentsResponse | undefined
}
// dates are ISO strings because clone() is JSON-based — cache contents
// round-trip through it, so string dates keep the comparisons simple
function makeComment(
id: string,
overrides: Partial<DocumentComment> = {},
): DocumentComment {
return {
id,
organizationId: ORG_ID,
documentId: DOC_ID,
branchId: BRANCH_ID,
anchorBlockId: "block-1",
userId: USER_ID,
resolved: false,
resolvedBy: null,
content: { text: `comment ${id}` },
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: null,
diffDeletionContext: null,
...overrides,
}
}
function makeReply(
id: string,
overrides: Partial<DocumentCommentReply> = {},
): DocumentCommentReply {
return {
id,
organizationId: ORG_ID,
commentId: COMMENT_ID,
userId: USER_ID,
content: { text: `reply ${id}` },
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: null,
...overrides,
}
}
// creating the composable eagerly loads its queries once; refresh() joins
// that in-flight load (or reuses its fresh result) instead of forcing a
// second request, which keeps the call accounting deterministic
describe("useDocumentCommentAPI", { concurrent: false }, () => {
// the tests share the app-wide query cache and the test-time endpoint
// registry, so they cannot interleave
beforeEach(clearQueryCache)
afterEach(disposeMockEndpoints)
describe("useFetchDocumentCommentsByDocId", () => {
it.for([
{ name: "a document id", docId: null, branchId: BRANCH_ID },
{ name: "a branch id", docId: DOC_ID, branchId: null },
])(
"returns no comments without $name",
async ({ docId, branchId }, { expect }) => {
seedAuth()
const getCalls = mockEndpoint("GET", COMMENTS_URL, () => [
makeComment(COMMENT_ID),
])
const api = makeCommentAPI()
const comments = runInApp(() =>
api.useFetchDocumentCommentsByDocId(docId, branchId),
)
const result = await comments.refresh()
expect(result.data).toEqual([])
expect(getCalls).toHaveLength(0)
},
)
it("fetches the comments of the branch", async ({ expect }) => {
seedAuth()
const serverComments = [makeComment(COMMENT_ID)]
const getCalls = mockEndpoint("GET", COMMENTS_URL, () => serverComments)
const api = makeCommentAPI()
const comments = runInApp(() =>
api.useFetchDocumentCommentsByDocId(DOC_ID, BRANCH_ID),
)
const result = await comments.refresh()
expect(result.data).toEqual(serverComments)
expect(getCalls).toHaveLength(1)
expect(getCalls[0]?.query).toEqual({ branchId: BRANCH_ID })
})
})
// every mutation guards each of its ids with isXid() in every hook, so
// one short id makes the whole mutation a no-op: no optimistic write,
// no request, no invalidation
it.for([
{
name: "createDocumentCommentByDocId",
method: "POST",
url: `/api/documents/${NON_XID_ID}/comments`,
invoke: (api: DocumentCommentAPI) =>
api.createDocumentCommentByDocId.mutateAsync({
docId: NON_XID_ID,
req: {
content: { text: "new" },
anchorBlockID: "block-9",
branchId: BRANCH_ID,
},
}),
},
{
name: "updateDocumentCommentByCommentId",
method: "PUT",
url: `/api/documents/${NON_XID_ID}/comments/${COMMENT_ID}`,
invoke: (api: DocumentCommentAPI) =>
api.updateDocumentCommentByCommentId.mutateAsync({
docId: NON_XID_ID,
branchId: BRANCH_ID,
commentId: COMMENT_ID,
req: {
content: { text: "edited" },
anchorBlockID: "block-9",
branchId: BRANCH_ID,
},
}),
},
{
name: "updateDocumentCommentResolveByCommentId",
method: "PUT",
url: `/api/documents/${NON_XID_ID}/comments/${COMMENT_ID}/resolve`,
invoke: (api: DocumentCommentAPI) =>
api.updateDocumentCommentResolveByCommentId.mutateAsync({
docId: NON_XID_ID,
branchId: BRANCH_ID,
commentId: COMMENT_ID,
}),
},
{
name: "deleteDocumentCommentByCommentId",
method: "DELETE",
url: `/api/documents/${NON_XID_ID}/comments/${COMMENT_ID}`,
invoke: (api: DocumentCommentAPI) =>
api.deleteDocumentCommentByCommentId.mutateAsync({
docId: NON_XID_ID,
branchId: BRANCH_ID,
commentId: COMMENT_ID,
}),
},
{
name: "createDocumentCommentReplyByCommentId",
method: "POST",
url: `/api/documents/${NON_XID_ID}/comments/${COMMENT_ID}/replies`,
invoke: (api: DocumentCommentAPI) =>
api.createDocumentCommentReplyByCommentId.mutateAsync({
docId: NON_XID_ID,
branchId: BRANCH_ID,
commentId: COMMENT_ID,
req: { content: { text: "reply" } },
}),
},
{
name: "updateDocumentCommentReplyByReplyId",
method: "PUT",
url: `/api/documents/${NON_XID_ID}/comments/${COMMENT_ID}/replies/${REPLY_ID}`,
invoke: (api: DocumentCommentAPI) =>
api.updateDocumentCommentReplyByReplyId.mutateAsync({
docId: NON_XID_ID,
branchId: BRANCH_ID,
commentId: COMMENT_ID,
replyId: REPLY_ID,
req: { content: { text: "edited" } },
}),
},
{
name: "deleteDocumentCommentReplyByReplyId",
method: "DELETE",
url: `/api/documents/${NON_XID_ID}/comments/${COMMENT_ID}/replies/${REPLY_ID}`,
invoke: (api: DocumentCommentAPI) =>
api.deleteDocumentCommentReplyByReplyId.mutateAsync({
docId: NON_XID_ID,
branchId: BRANCH_ID,
commentId: COMMENT_ID,
replyId: REPLY_ID,
}),
},
] as {
name: string
method: "GET" | "POST" | "PUT" | "DELETE"
url: string
invoke: (api: DocumentCommentAPI) => Promise<unknown>
}[])(
"$name sends no request and keeps the cache when the document id is not an xid",
async ({ method, url, invoke }, { expect }) => {
seedAuth()
const existing = makeComment(COMMENT_ID)
seedComments([existing])
const requestCalls = mockEndpoint(method, url, () => ({}))
const api = makeCommentAPI()
const result = await invoke(api)
expect(result).toBeUndefined()
expect(requestCalls).toHaveLength(0)
expect(readComments()).toEqual([existing])
},
)
describe("createDocumentCommentByDocId", () => {
it.for([
{
name: "prepends an optimistic comment and refetches once the create succeeds",
makeReq: () => ({
content: { text: "new" },
anchorBlockID: "block-9",
branchId: BRANCH_ID,
}),
expectedDiffCtx: null,
},
{
name: "keeps the given diff deletion context on the optimistic comment",
makeReq: () => ({
content: { text: "new" },
anchorBlockID: "block-9",
branchId: BRANCH_ID,
diffDeletionContext: {
textAnchors: [{ nodeUid: "uid-1", fromOffset: 1, toOffset: 3 }],
},
}),
expectedDiffCtx: {
textAnchors: [{ nodeUid: "uid-1", fromOffset: 1, toOffset: 3 }],
},
},
])("$name", async ({ makeReq, expectedDiffCtx }, { expect }) => {
seedAuth()
const existing = makeComment(COMMENT_ID)
seedComments([existing])
const serverComments = [
makeComment(COMMENT_ID_2, { content: { text: "server" } }),
]
const getCalls = mockEndpoint("GET", COMMENTS_URL, () => serverComments)
const serverComment = makeComment(COMMENT_ID_2)
let commentsAtRequest: DocumentCommentsResponse | undefined
const postCalls = mockEndpoint("POST", COMMENTS_URL, () => {
commentsAtRequest = readComments()
return serverComment
})
const api = makeCommentAPI()
runInApp(() => api.useFetchDocumentCommentsByDocId(DOC_ID, BRANCH_ID))
const req = makeReq()
const result = await api.createDocumentCommentByDocId.mutateAsync({
docId: DOC_ID,
req,
})
expect(result).toEqual(serverComment)
expect(postCalls).toHaveLength(1)
expect(postCalls[0]?.body).toEqual(req)
expect(commentsAtRequest).toEqual([
expect.objectContaining({
id: ANY_STRING,
organizationId: ORG_ID,
documentId: DOC_ID,
branchId: BRANCH_ID,
anchorBlockId: "block-9",
userId: USER_ID,
resolved: false,
resolvedBy: null,
content: { text: "new" },
createdAt: ANY_DATE,
updatedAt: null,
diffDeletionContext: expectedDiffCtx,
}),
existing,
])
expect(getCalls).toHaveLength(1)
expect(readComments()).toEqual(serverComments)
})
it("sends the create without an optimistic insert when session data is missing and refreshes the comments", async ({
expect,
}) => {
const existing = makeComment(COMMENT_ID)
seedComments([existing])
const getCalls = mockEndpoint("GET", COMMENTS_URL, () => [])
let commentsAtRequest: DocumentCommentsResponse | undefined
const postCalls = mockEndpoint("POST", COMMENTS_URL, () => {
commentsAtRequest = readComments()
return makeComment(COMMENT_ID_2)
})
const api = makeCommentAPI()
runInApp(() => api.useFetchDocumentCommentsByDocId(DOC_ID, BRANCH_ID))
await api.createDocumentCommentByDocId.mutateAsync({
docId: DOC_ID,
req: {
content: { text: "new" },
anchorBlockID: "block-9",
branchId: BRANCH_ID,
},
})
expect(postCalls).toHaveLength(1)
expect(commentsAtRequest).toEqual([existing])
// the request went out, so the active comments query is refetched
// even though the optimistic insert was skipped
expect(getCalls).toHaveLength(1)
expect(readComments()).toEqual([])
})
it("rolls back the optimistic comment when the create fails", async ({
expect,
}) => {
seedAuth()
const existing = makeComment(COMMENT_ID)
seedComments([existing])
const getCalls = mockEndpoint("GET", COMMENTS_URL, () => [])
const postCalls = mockEndpoint("POST", COMMENTS_URL, () => {
throw createError({ statusCode: 500 })
})
const api = makeCommentAPI()
runInApp(() => api.useFetchDocumentCommentsByDocId(DOC_ID, BRANCH_ID))
await expect(
api.createDocumentCommentByDocId.mutateAsync({
docId: DOC_ID,
req: {
content: { text: "new" },
anchorBlockID: "block-9",
branchId: BRANCH_ID,
},
}),
).rejects.toThrow()
expect(postCalls).toHaveLength(1)
expect(getCalls).toHaveLength(0)
expect(readComments()).toEqual([existing])
})
it("skips the rollback when the cache changed after the optimistic insert", async ({
expect,
}) => {
let rejectPost: (err: unknown) => void = () => undefined
let postReached: () => void = () => undefined
const postReachedSignal = new Promise<void>((resolve) => {
postReached = resolve
})
seedAuth()
seedComments([makeComment(COMMENT_ID)])
const getCalls = mockEndpoint("GET", COMMENTS_URL, () => [])
const postCalls = mockEndpoint("POST", COMMENTS_URL, () => {
postReached()
return new Promise((_resolve, reject) => {
rejectPost = reject
})
})
const api = makeCommentAPI()
runInApp(() => api.useFetchDocumentCommentsByDocId(DOC_ID, BRANCH_ID))
const pending = api.createDocumentCommentByDocId.mutateAsync({
docId: DOC_ID,
req: {
content: { text: "new" },
anchorBlockID: "block-9",
branchId: BRANCH_ID,
},
})
await postReachedSignal
// the optimistic insert landed; divergent data written afterwards
// must survive the failure
expect(readComments()).toHaveLength(2)
const divergent = makeComment(COMMENT_ID_2, {
content: { text: "divergent" },
})
seedComments([divergent])
rejectPost(createError({ statusCode: 500 }))
await expect(pending).rejects.toThrow()
expect(postCalls).toHaveLength(1)
expect(getCalls).toHaveLength(0)
expect(readComments()).toEqual([divergent])
})
})
describe("updateDocumentCommentByCommentId", () => {
it("updates the matching comment optimistically and refetches once the update succeeds", async ({
expect,
}) => {
seedAuth()
const target = makeComment(COMMENT_ID)
const other = makeComment(COMMENT_ID_2)
seedComments([target, other])
const serverComments = [
makeComment(COMMENT_ID, { content: { text: "server" } }),
]
const getCalls = mockEndpoint("GET", COMMENTS_URL, () => serverComments)
let commentsAtRequest: DocumentCommentsResponse | undefined
const putCalls = mockEndpoint("PUT", COMMENT_URL, () => {
commentsAtRequest = readComments()
return {}
})
const api = makeCommentAPI()
runInApp(() => api.useFetchDocumentCommentsByDocId(DOC_ID, BRANCH_ID))
const req = {
content: { text: "edited" },
anchorBlockID: "block-9",
branchId: BRANCH_ID,
}
await api.updateDocumentCommentByCommentId.mutateAsync({
docId: DOC_ID,
branchId: BRANCH_ID,
commentId: COMMENT_ID,
req,
})
expect(putCalls).toHaveLength(1)
expect(putCalls[0]?.body).toEqual(req)
expect(commentsAtRequest).toEqual([
{
...target,
content: { text: "edited" },
anchorBlockId: "block-9",
updatedAt: ANY_DATE,
},
other,
])
expect(getCalls).toHaveLength(1)
expect(readComments()).toEqual(serverComments)
})
it("rolls back the optimistic update when the update fails", async ({
expect,
}) => {
seedAuth()
const target = makeComment(COMMENT_ID)
seedComments([target])
const getCalls = mockEndpoint("GET", COMMENTS_URL, () => [])
const putCalls = mockEndpoint("PUT", COMMENT_URL, () => {
throw createError({ statusCode: 500 })
})
const api = makeCommentAPI()
runInApp(() => api.useFetchDocumentCommentsByDocId(DOC_ID, BRANCH_ID))
await expect(
api.updateDocumentCommentByCommentId.mutateAsync({
docId: DOC_ID,
branchId: BRANCH_ID,
commentId: COMMENT_ID,
req: {
content: { text: "edited" },
anchorBlockID: "block-9",
branchId: BRANCH_ID,
},
}),
).rejects.toThrow()
expect(putCalls).toHaveLength(1)
expect(getCalls).toHaveLength(0)
expect(readComments()).toEqual([target])
})
it("skips the rollback when the cache changed after the optimistic update", async ({
expect,
}) => {
let rejectPut: (err: unknown) => void = () => undefined
let putReached: () => void = () => undefined
const putReachedSignal = new Promise<void>((resolve) => {
putReached = resolve
})
seedAuth()
seedComments([makeComment(COMMENT_ID)])
const getCalls = mockEndpoint("GET", COMMENTS_URL, () => [])
const putCalls = mockEndpoint("PUT", COMMENT_URL, () => {
putReached()
return new Promise((_resolve, reject) => {
rejectPut = reject
})
})
const api = makeCommentAPI()
runInApp(() => api.useFetchDocumentCommentsByDocId(DOC_ID, BRANCH_ID))
const pending = api.updateDocumentCommentByCommentId.mutateAsync({
docId: DOC_ID,
branchId: BRANCH_ID,
commentId: COMMENT_ID,
req: {
content: { text: "edited" },
anchorBlockID: "block-9",
branchId: BRANCH_ID,
},
})
await putReachedSignal
// the optimistic update landed; divergent data written afterwards
// must survive the failure
expect(readComments()?.[0]?.content).toEqual({ text: "edited" })
const divergent = makeComment(COMMENT_ID_2, {
content: { text: "divergent" },
})
seedComments([divergent])
rejectPut(createError({ statusCode: 500 }))
await expect(pending).rejects.toThrow()
expect(putCalls).toHaveLength(1)
expect(getCalls).toHaveLength(0)
expect(readComments()).toEqual([divergent])
})
})
describe("updateDocumentCommentResolveByCommentId", () => {
it("removes the resolved comment optimistically and refetches once the resolve succeeds", async ({
expect,
}) => {
seedAuth()
const target = makeComment(COMMENT_ID)
const other = makeComment(COMMENT_ID_2)
seedComments([target, other])
const serverComments = [makeComment(COMMENT_ID_2)]
const getCalls = mockEndpoint("GET", COMMENTS_URL, () => serverComments)
let commentsAtRequest: DocumentCommentsResponse | undefined
const putCalls = mockEndpoint("PUT", RESOLVE_URL, () => {
commentsAtRequest = readComments()
return {}
})
const api = makeCommentAPI()
runInApp(() => api.useFetchDocumentCommentsByDocId(DOC_ID, BRANCH_ID))
await api.updateDocumentCommentResolveByCommentId.mutateAsync({
docId: DOC_ID,
branchId: BRANCH_ID,
commentId: COMMENT_ID,
})
expect(putCalls).toHaveLength(1)
expect(commentsAtRequest).toEqual([other])
expect(getCalls).toHaveLength(1)
expect(readComments()).toEqual(serverComments)
})
it("rolls back the optimistic removal when the resolve fails", async ({
expect,
}) => {
seedAuth()
const target = makeComment(COMMENT_ID)
seedComments([target])
const getCalls = mockEndpoint("GET", COMMENTS_URL, () => [])
const putCalls = mockEndpoint("PUT", RESOLVE_URL, () => {
throw createError({ statusCode: 500 })
})
const api = makeCommentAPI()
runInApp(() => api.useFetchDocumentCommentsByDocId(DOC_ID, BRANCH_ID))
await expect(
api.updateDocumentCommentResolveByCommentId.mutateAsync({
docId: DOC_ID,
branchId: BRANCH_ID,
commentId: COMMENT_ID,
}),
).rejects.toThrow()
expect(putCalls).toHaveLength(1)
expect(getCalls).toHaveLength(0)
expect(readComments()).toEqual([target])
})
it("skips the rollback when the cache changed after the optimistic removal", async ({
expect,
}) => {
let rejectPut: (err: unknown) => void = () => undefined
let putReached: () => void = () => undefined
const putReachedSignal = new Promise<void>((resolve) => {
putReached = resolve
})
seedAuth()
seedComments([makeComment(COMMENT_ID), makeComment(COMMENT_ID_2)])
const getCalls = mockEndpoint("GET", COMMENTS_URL, () => [])
const putCalls = mockEndpoint("PUT", RESOLVE_URL, () => {
putReached()
return new Promise((_resolve, reject) => {
rejectPut = reject
})
})
const api = makeCommentAPI()
runInApp(() => api.useFetchDocumentCommentsByDocId(DOC_ID, BRANCH_ID))
const pending = api.updateDocumentCommentResolveByCommentId.mutateAsync({
docId: DOC_ID,
branchId: BRANCH_ID,
commentId: COMMENT_ID,
})
await putReachedSignal
// the optimistic removal landed; divergent data written afterwards
// must survive the failure
expect(readComments()).toHaveLength(1)
const divergent = makeComment(COMMENT_ID_2, {
content: { text: "divergent" },
})
seedComments([divergent])
rejectPut(createError({ statusCode: 500 }))
await expect(pending).rejects.toThrow()
expect(putCalls).toHaveLength(1)
expect(getCalls).toHaveLength(0)
expect(readComments()).toEqual([divergent])
})
})
describe("deleteDocumentCommentByCommentId", () => {
it.for([
{
name: "replaces the deleted comment with its promoted oldest reply",
makeSeed: () => [
makeComment(COMMENT_ID, {
replies: [
makeReply(REPLY_ID_2, { createdAt: "2024-01-04T00:00:00.000Z" }),
makeReply(REPLY_ID, {
userId: USER_ID_2,
content: { text: "first reply" },
createdAt: "2024-01-02T00:00:00.000Z",
updatedAt: "2024-01-03T00:00:00.000Z",
}),
],
}),
makeComment(COMMENT_ID_2),
],
makeExpectedAtRequest: () => [
{
...makeComment(COMMENT_ID),
userId: USER_ID_2,
content: { text: "first reply" },
createdAt: "2024-01-02T00:00:00.000Z",
updatedAt: "2024-01-03T00:00:00.000Z",
replies: [
makeReply(REPLY_ID_2, { createdAt: "2024-01-04T00:00:00.000Z" }),
],
},
makeComment(COMMENT_ID_2),
],
},
{
name: "removes the deleted comment when it has no replies",
makeSeed: () => [makeComment(COMMENT_ID), makeComment(COMMENT_ID_2)],
makeExpectedAtRequest: () => [makeComment(COMMENT_ID_2)],
},
])("$name", async ({ makeSeed, makeExpectedAtRequest }, { expect }) => {
seedAuth()
seedComments(makeSeed())
const serverComments = [
makeComment(COMMENT_ID_2, { content: { text: "server" } }),
]
const getCalls = mockEndpoint("GET", COMMENTS_URL, () => serverComments)
let commentsAtRequest: DocumentCommentsResponse | undefined
const deleteCalls = mockEndpoint("DELETE", COMMENT_URL, () => {
commentsAtRequest = readComments()
return {}
})
const api = makeCommentAPI()
runInApp(() => api.useFetchDocumentCommentsByDocId(DOC_ID, BRANCH_ID))
await api.deleteDocumentCommentByCommentId.mutateAsync({
docId: DOC_ID,
branchId: BRANCH_ID,
commentId: COMMENT_ID,
})
expect(deleteCalls).toHaveLength(1)
expect(commentsAtRequest).toEqual(makeExpectedAtRequest())
expect(getCalls).toHaveLength(1)
expect(readComments()).toEqual(serverComments)
})
it("rolls back the optimistic removal when the delete fails", async ({
expect,
}) => {
seedAuth()
const target = makeComment(COMMENT_ID)
seedComments([target])
const getCalls = mockEndpoint("GET", COMMENTS_URL, () => [])
const deleteCalls = mockEndpoint("DELETE", COMMENT_URL, () => {
throw createError({ statusCode: 500 })
})
const api = makeCommentAPI()
runInApp(() => api.useFetchDocumentCommentsByDocId(DOC_ID, BRANCH_ID))
await expect(
api.deleteDocumentCommentByCommentId.mutateAsync({
docId: DOC_ID,
branchId: BRANCH_ID,
commentId: COMMENT_ID,
}),
).rejects.toThrow()
expect(deleteCalls).toHaveLength(1)
expect(getCalls).toHaveLength(0)
expect(readComments()).toEqual([target])
})
it("skips the rollback when the cache changed after the optimistic removal", async ({
expect,
}) => {
let rejectDelete: (err: unknown) => void = () => undefined
let deleteReached: () => void = () => undefined
const deleteReachedSignal = new Promise<void>((resolve) => {
deleteReached = resolve
})
seedAuth()
seedComments([makeComment(COMMENT_ID), makeComment(COMMENT_ID_2)])
const getCalls = mockEndpoint("GET", COMMENTS_URL, () => [])
const deleteCalls = mockEndpoint("DELETE", COMMENT_URL, () => {
deleteReached()
return new Promise((_resolve, reject) => {
rejectDelete = reject
})
})
const api = makeCommentAPI()
runInApp(() => api.useFetchDocumentCommentsByDocId(DOC_ID, BRANCH_ID))
const pending = api.deleteDocumentCommentByCommentId.mutateAsync({
docId: DOC_ID,
branchId: BRANCH_ID,
commentId: COMMENT_ID,
})
await deleteReachedSignal
// the optimistic removal landed; divergent data written afterwards
// must survive the failure
expect(readComments()).toHaveLength(1)
const divergent = makeComment(COMMENT_ID_2, {
content: { text: "divergent" },
})
seedComments([divergent])
rejectDelete(createError({ statusCode: 500 }))
await expect(pending).rejects.toThrow()
expect(deleteCalls).toHaveLength(1)
expect(getCalls).toHaveLength(0)
expect(readComments()).toEqual([divergent])
})
})
describe("createDocumentCommentReplyByCommentId", () => {
it("prepends an optimistic reply and refetches once the create succeeds", async ({
expect,
}) => {
seedAuth()
const existingReply = makeReply(REPLY_ID)
const target = makeComment(COMMENT_ID, { replies: [existingReply] })
const other = makeComment(COMMENT_ID_2)
seedComments([target, other])
const serverComments = [
makeComment(COMMENT_ID, { content: { text: "server" } }),
]
const getCalls = mockEndpoint("GET", COMMENTS_URL, () => serverComments)
const serverReply = makeReply(REPLY_ID_2)
let commentsAtRequest: DocumentCommentsResponse | undefined
const postCalls = mockEndpoint("POST", REPLIES_URL, () => {
commentsAtRequest = readComments()
return serverReply
})
const api = makeCommentAPI()
runInApp(() => api.useFetchDocumentCommentsByDocId(DOC_ID, BRANCH_ID))
const result =
await api.createDocumentCommentReplyByCommentId.mutateAsync({
docId: DOC_ID,
branchId: BRANCH_ID,
commentId: COMMENT_ID,
req: { content: { text: "reply" } },
})
expect(result).toEqual(serverReply)
expect(postCalls).toHaveLength(1)
expect(postCalls[0]?.body).toEqual({ content: { text: "reply" } })
expect(commentsAtRequest).toEqual([
{
...target,
replies: [
expect.objectContaining({
id: ANY_STRING,
organizationId: ORG_ID,
commentId: COMMENT_ID,
userId: USER_ID,
content: { text: "reply" },
createdAt: ANY_DATE,
updatedAt: null,
}),
existingReply,
],
},
other,
])
expect(getCalls).toHaveLength(1)
expect(readComments()).toEqual(serverComments)
})
it("sends the create without an optimistic insert when session data is missing and refreshes the comments", async ({
expect,
}) => {
const target = makeComment(COMMENT_ID, { replies: [] })
seedComments([target])
const getCalls = mockEndpoint("GET", COMMENTS_URL, () => [])
let commentsAtRequest: DocumentCommentsResponse | undefined
const postCalls = mockEndpoint("POST", REPLIES_URL, () => {
commentsAtRequest = readComments()
return makeReply(REPLY_ID)
})
const api = makeCommentAPI()
runInApp(() => api.useFetchDocumentCommentsByDocId(DOC_ID, BRANCH_ID))
await api.createDocumentCommentReplyByCommentId.mutateAsync({
docId: DOC_ID,
branchId: BRANCH_ID,
commentId: COMMENT_ID,
req: { content: { text: "reply" } },
})
expect(postCalls).toHaveLength(1)
expect(commentsAtRequest).toEqual([target])
// the request went out, so the active comments query is refetched
// even though the optimistic insert was skipped
expect(getCalls).toHaveLength(1)
expect(readComments()).toEqual([])
})
it("skips the optimistic insert but still refreshes the comments when the comment is not cached", async ({
expect,
}) => {
seedAuth()
const other = makeComment(COMMENT_ID_2)
seedComments([other])
const getCalls = mockEndpoint("GET", COMMENTS_URL, () => [])
let commentsAtRequest: DocumentCommentsResponse | undefined
const postCalls = mockEndpoint("POST", REPLIES_URL, () => {
commentsAtRequest = readComments()
return makeReply(REPLY_ID)
})
const api = makeCommentAPI()
runInApp(() => api.useFetchDocumentCommentsByDocId(DOC_ID, BRANCH_ID))
await api.createDocumentCommentReplyByCommentId.mutateAsync({
docId: DOC_ID,
branchId: BRANCH_ID,
commentId: COMMENT_ID,
req: { content: { text: "reply" } },
})
expect(postCalls).toHaveLength(1)
expect(commentsAtRequest).toEqual([other])
// the request went out, so the active comments query is refetched
// even though the target comment was not cached
expect(getCalls).toHaveLength(1)
expect(readComments()).toEqual([])
})
it("rolls back the optimistic reply when the create fails", async ({
expect,
}) => {
seedAuth()
const target = makeComment(COMMENT_ID, { replies: [makeReply(REPLY_ID)] })
seedComments([target])
const getCalls = mockEndpoint("GET", COMMENTS_URL, () => [])
const postCalls = mockEndpoint("POST", REPLIES_URL, () => {
throw createError({ statusCode: 500 })
})
const api = makeCommentAPI()
runInApp(() => api.useFetchDocumentCommentsByDocId(DOC_ID, BRANCH_ID))
await expect(
api.createDocumentCommentReplyByCommentId.mutateAsync({
docId: DOC_ID,
branchId: BRANCH_ID,
commentId: COMMENT_ID,
req: { content: { text: "reply" } },
}),
).rejects.toThrow()
expect(postCalls).toHaveLength(1)
expect(getCalls).toHaveLength(0)
expect(readComments()).toEqual([target])
})
it("skips the rollback when the cache changed after the optimistic reply", async ({
expect,
}) => {
let rejectPost: (err: unknown) => void = () => undefined
let postReached: () => void = () => undefined
const postReachedSignal = new Promise<void>((resolve) => {
postReached = resolve
})
seedAuth()
seedComments([
makeComment(COMMENT_ID, { replies: [makeReply(REPLY_ID)] }),
])
const getCalls = mockEndpoint("GET", COMMENTS_URL, () => [])
const postCalls = mockEndpoint("POST", REPLIES_URL, () => {
postReached()
return new Promise((_resolve, reject) => {
rejectPost = reject
})
})
const api = makeCommentAPI()
runInApp(() => api.useFetchDocumentCommentsByDocId(DOC_ID, BRANCH_ID))
const pending = api.createDocumentCommentReplyByCommentId.mutateAsync({
docId: DOC_ID,
branchId: BRANCH_ID,
commentId: COMMENT_ID,
req: { content: { text: "reply" } },
})
await postReachedSignal
// the optimistic reply landed; divergent data written afterwards
// must survive the failure
expect(readComments()?.[0]?.replies).toHaveLength(2)
const divergent = makeComment(COMMENT_ID_2, {
content: { text: "divergent" },
})
seedComments([divergent])
rejectPost(createError({ statusCode: 500 }))
await expect(pending).rejects.toThrow()
expect(postCalls).toHaveLength(1)
expect(getCalls).toHaveLength(0)
expect(readComments()).toEqual([divergent])
})
})
describe("updateDocumentCommentReplyByReplyId", () => {
it("updates the matching reply optimistically and refetches once the update succeeds", async ({
expect,
}) => {
seedAuth()
const targetReply = makeReply(REPLY_ID)
const otherReply = makeReply(REPLY_ID_2)
const target = makeComment(COMMENT_ID, {
replies: [targetReply, otherReply],
})
const other = makeComment(COMMENT_ID_2)
seedComments([target, other])
const serverComments = [
makeComment(COMMENT_ID, { content: { text: "server" } }),
]
const getCalls = mockEndpoint("GET", COMMENTS_URL, () => serverComments)
let commentsAtRequest: DocumentCommentsResponse | undefined
const putCalls = mockEndpoint("PUT", REPLY_URL, () => {
commentsAtRequest = readComments()
return {}
})
const api = makeCommentAPI()
runInApp(() => api.useFetchDocumentCommentsByDocId(DOC_ID, BRANCH_ID))
const req = { content: { text: "edited" } }
await api.updateDocumentCommentReplyByReplyId.mutateAsync({
docId: DOC_ID,
branchId: BRANCH_ID,
commentId: COMMENT_ID,
replyId: REPLY_ID,
req,
})
expect(putCalls).toHaveLength(1)
expect(putCalls[0]?.body).toEqual(req)
expect(commentsAtRequest).toEqual([
{
...target,
replies: [
{
...targetReply,
content: { text: "edited" },
updatedAt: ANY_DATE,
},
otherReply,
],
},
other,
])
expect(getCalls).toHaveLength(1)
expect(readComments()).toEqual(serverComments)
})
it("rolls back the optimistic update when the update fails", async ({
expect,
}) => {
seedAuth()
const target = makeComment(COMMENT_ID, { replies: [makeReply(REPLY_ID)] })
seedComments([target])
const getCalls = mockEndpoint("GET", COMMENTS_URL, () => [])
const putCalls = mockEndpoint("PUT", REPLY_URL, () => {
throw createError({ statusCode: 500 })
})
const api = makeCommentAPI()
runInApp(() => api.useFetchDocumentCommentsByDocId(DOC_ID, BRANCH_ID))
await expect(
api.updateDocumentCommentReplyByReplyId.mutateAsync({
docId: DOC_ID,
branchId: BRANCH_ID,
commentId: COMMENT_ID,
replyId: REPLY_ID,
req: { content: { text: "edited" } },
}),
).rejects.toThrow()
expect(putCalls).toHaveLength(1)
expect(getCalls).toHaveLength(0)
expect(readComments()).toEqual([target])
})
it("skips the rollback when the cache changed after the optimistic update", async ({
expect,
}) => {
let rejectPut: (err: unknown) => void = () => undefined
let putReached: () => void = () => undefined
const putReachedSignal = new Promise<void>((resolve) => {
putReached = resolve
})
seedAuth()
seedComments([
makeComment(COMMENT_ID, { replies: [makeReply(REPLY_ID)] }),
])
const getCalls = mockEndpoint("GET", COMMENTS_URL, () => [])
const putCalls = mockEndpoint("PUT", REPLY_URL, () => {
putReached()
return new Promise((_resolve, reject) => {
rejectPut = reject
})
})
const api = makeCommentAPI()
runInApp(() => api.useFetchDocumentCommentsByDocId(DOC_ID, BRANCH_ID))
const pending = api.updateDocumentCommentReplyByReplyId.mutateAsync({
docId: DOC_ID,
branchId: BRANCH_ID,
commentId: COMMENT_ID,
replyId: REPLY_ID,
req: { content: { text: "edited" } },
})
await putReachedSignal
// the optimistic update landed; divergent data written afterwards
// must survive the failure
expect(readComments()?.[0]?.replies?.[0]?.content).toEqual({
text: "edited",
})
const divergent = makeComment(COMMENT_ID_2, {
content: { text: "divergent" },
})
seedComments([divergent])
rejectPut(createError({ statusCode: 500 }))
await expect(pending).rejects.toThrow()
expect(putCalls).toHaveLength(1)
expect(getCalls).toHaveLength(0)
expect(readComments()).toEqual([divergent])
})
})
describe("deleteDocumentCommentReplyByReplyId", () => {
it("removes the matching reply optimistically and refetches once the delete succeeds", async ({
expect,
}) => {
seedAuth()
const otherReply = makeReply(REPLY_ID_2)
const target = makeComment(COMMENT_ID, {
replies: [makeReply(REPLY_ID), otherReply],
})
const other = makeComment(COMMENT_ID_2)
seedComments([target, other])
const serverComments = [
makeComment(COMMENT_ID, { content: { text: "server" } }),
]
const getCalls = mockEndpoint("GET", COMMENTS_URL, () => serverComments)
let commentsAtRequest: DocumentCommentsResponse | undefined
const deleteCalls = mockEndpoint("DELETE", REPLY_URL, () => {
commentsAtRequest = readComments()
return {}
})
const api = makeCommentAPI()
runInApp(() => api.useFetchDocumentCommentsByDocId(DOC_ID, BRANCH_ID))
await api.deleteDocumentCommentReplyByReplyId.mutateAsync({
docId: DOC_ID,
branchId: BRANCH_ID,
commentId: COMMENT_ID,
replyId: REPLY_ID,
})
expect(deleteCalls).toHaveLength(1)
expect(commentsAtRequest).toEqual([
{ ...target, replies: [otherReply] },
other,
])
expect(getCalls).toHaveLength(1)
expect(readComments()).toEqual(serverComments)
})
it("leaves the replies unchanged when the reply is not cached", async ({
expect,
}) => {
seedAuth()
const target = makeComment(COMMENT_ID, { replies: [makeReply(REPLY_ID)] })
seedComments([target])
const serverComments = [
makeComment(COMMENT_ID, { content: { text: "server" } }),
]
const getCalls = mockEndpoint("GET", COMMENTS_URL, () => serverComments)
let commentsAtRequest: DocumentCommentsResponse | undefined
const deleteCalls = mockEndpoint(
"DELETE",
`${REPLIES_URL}/${REPLY_ID_2}`,
() => {
commentsAtRequest = readComments()
return {}
},
)
const api = makeCommentAPI()
runInApp(() => api.useFetchDocumentCommentsByDocId(DOC_ID, BRANCH_ID))
await api.deleteDocumentCommentReplyByReplyId.mutateAsync({
docId: DOC_ID,
branchId: BRANCH_ID,
commentId: COMMENT_ID,
replyId: REPLY_ID_2,
})
expect(deleteCalls).toHaveLength(1)
expect(commentsAtRequest).toEqual([target])
// the target comment is cached, so onMutate still returns a
// context and onSuccess refetches even though nothing changed
expect(getCalls).toHaveLength(1)
expect(readComments()).toEqual(serverComments)
})
it("rolls back the optimistic removal when the delete fails", async ({
expect,
}) => {
seedAuth()
const target = makeComment(COMMENT_ID, { replies: [makeReply(REPLY_ID)] })
seedComments([target])
const getCalls = mockEndpoint("GET", COMMENTS_URL, () => [])
const deleteCalls = mockEndpoint("DELETE", REPLY_URL, () => {
throw createError({ statusCode: 500 })
})
const api = makeCommentAPI()
runInApp(() => api.useFetchDocumentCommentsByDocId(DOC_ID, BRANCH_ID))
await expect(
api.deleteDocumentCommentReplyByReplyId.mutateAsync({
docId: DOC_ID,
branchId: BRANCH_ID,
commentId: COMMENT_ID,
replyId: REPLY_ID,
}),
).rejects.toThrow()
expect(deleteCalls).toHaveLength(1)
expect(getCalls).toHaveLength(0)
expect(readComments()).toEqual([target])
})
it("skips the rollback when the cache changed after the optimistic removal", async ({
expect,
}) => {
let rejectDelete: (err: unknown) => void = () => undefined
let deleteReached: () => void = () => undefined
const deleteReachedSignal = new Promise<void>((resolve) => {
deleteReached = resolve
})
seedAuth()
seedComments([
makeComment(COMMENT_ID, {
replies: [makeReply(REPLY_ID), makeReply(REPLY_ID_2)],
}),
])
const getCalls = mockEndpoint("GET", COMMENTS_URL, () => [])
const deleteCalls = mockEndpoint("DELETE", REPLY_URL, () => {
deleteReached()
return new Promise((_resolve, reject) => {
rejectDelete = reject
})
})
const api = makeCommentAPI()
runInApp(() => api.useFetchDocumentCommentsByDocId(DOC_ID, BRANCH_ID))
const pending = api.deleteDocumentCommentReplyByReplyId.mutateAsync({
docId: DOC_ID,
branchId: BRANCH_ID,
commentId: COMMENT_ID,
replyId: REPLY_ID,
})
await deleteReachedSignal
// the optimistic removal landed; divergent data written afterwards
// must survive the failure
expect(readComments()?.[0]?.replies).toHaveLength(1)
const divergent = makeComment(COMMENT_ID_2, {
content: { text: "divergent" },
})
seedComments([divergent])
rejectDelete(createError({ statusCode: 500 }))
await expect(pending).rejects.toThrow()
expect(deleteCalls).toHaveLength(1)
expect(getCalls).toHaveLength(0)
expect(readComments()).toEqual([divergent])
})
})
describe("patchOptimisticCommentEntry", () => {
it("does nothing when no comments are cached", ({ expect }) => {
seedAuth()
const getCalls = mockEndpoint("GET", COMMENTS_URL, () => [])
const api = makeCommentAPI()
api.patchOptimisticCommentEntry(
DOC_ID,
BRANCH_ID,
makeComment(COMMENT_ID),
)
expect(readComments()).toBeUndefined()
expect(getCalls).toHaveLength(0)
})
it.for([
{
name: "leaves the cached comments untouched when no optimistic entry exists",
makeSeed: () => [makeComment(COMMENT_ID), makeComment(COMMENT_ID_2)],
makeExpected: () => [
makeComment(COMMENT_ID),
makeComment(COMMENT_ID_2),
],
},
{
name: "replaces the optimistic entry with the server comment",
makeSeed: () => [makeComment(NON_XID_ID), makeComment(COMMENT_ID_2)],
makeExpected: (server: DocumentComment) => [
server,
makeComment(COMMENT_ID_2),
],
},
])("$name", ({ makeSeed, makeExpected }, { expect }) => {
seedAuth()
seedComments(makeSeed())
const getCalls = mockEndpoint("GET", COMMENTS_URL, () => [])
const api = makeCommentAPI()
const serverComment = makeComment(COMMENT_ID, {
content: { text: "server" },
})
api.patchOptimisticCommentEntry(DOC_ID, BRANCH_ID, serverComment)
expect(readComments()).toEqual(makeExpected(serverComment))
expect(getCalls).toHaveLength(0)
})
})
})