[ranges-tracker] add legacy orderedRejections field in tracked-changes (#36565)

* [docstore] use more realistic test stubs for ranges

* [monorepo] define ranges schemas in @overleaf/ranges-tracker

* [ranges-tracker] add legacy orderedRejections field in tracked-changes

* [monorepo] resolve @overleaf/ranges-tracker subpath imports in typescript

* [real-time] validate comment thread ids and tracked change id seeds

* [ranges-tracker] require ObjectId ids and tracked change metadata in ranges

* [monorepo] require ObjectId tracked change ids on the accept routes

* [web] use ObjectId comment thread ids in the DocumentController tests

GitOrigin-RevId: 58b20dec311d85b917ef63f9efbe745a265a2de6
This commit is contained in:
Jakob Ackermann
2026-09-17 12:01:19 +00:00
committed by Copybot
parent 833e44d142
commit 4e80d65efc
23 changed files with 501 additions and 383 deletions
@@ -1,6 +1,7 @@
import AbstractMockApi from './AbstractMockApi.mjs'
import { parseReq, z, zz } from '@overleaf/validation-tools'
import editorCoreSchemas from 'overleaf-editor-core/lib/schemas.js'
import rangesSchemas from '@overleaf/ranges-tracker/schemas.js'
const docParamsSchema = z.strictObject({
projectId: zz.objectId(),
@@ -26,48 +27,6 @@ const setDocSchema = z.object({
}),
})
// Ranges data as document-updater persists it (RangesTracker format) --
// mirrors services/document-updater/app/js/schemas.js's `ranges` export
// (this mock stands in for document-updater's own API in web's acceptance
// tests).
const insertOp = z.strictObject({
i: z.string(),
p: z.number().int().min(0),
u: z.boolean().optional(),
fixedRemoveChange: z.boolean().optional(),
})
const deleteOp = z.strictObject({
d: z.string(),
p: z.number().int().min(0),
u: z.boolean().optional(),
fixedRemoveChange: z.boolean().optional(),
})
const commentOp = z.strictObject({
c: z.string(),
p: z.number().int().min(0),
t: zz.objectId().optional(),
u: z.boolean().optional(),
resolved: z.boolean().optional(),
})
const rangeMetadata = z.strictObject({
user_id: z.string(),
ts: z.string(),
})
const comment = z.strictObject({
id: zz.objectId().optional(),
op: commentOp,
metadata: rangeMetadata.optional(),
})
const trackedChange = z.strictObject({
id: z.string().optional(),
op: insertOp.or(deleteOp),
metadata: rangeMetadata,
})
const rangesSchema = z.strictObject({
comments: z.array(comment).optional(),
changes: z.array(trackedChange).optional(),
})
// Mirrors services/document-updater/app/js/HttpController.js's
// updateProjectSchema (renameUpdateSchema/addUpdateSchema) -- this mock's
// own routes use :projectId/:docId casing rather than document-updater's
@@ -85,7 +44,7 @@ const addUpdateSchema = z.strictObject({
id: zz.objectId(),
pathname: zz.safePath(),
docLines: z.string().optional(),
ranges: rangesSchema.optional(),
ranges: rangesSchemas.ranges.optional(),
historyRangesSupport: z.boolean().optional(),
// legacy filestore url for files without a created blob
url: z.string().nullish(),
@@ -1,46 +1,7 @@
import { db, ObjectId } from '../../../../app/src/infrastructure/mongodb.mjs'
import AbstractMockApi from './AbstractMockApi.mjs'
import { parseReq, z, zz } from '@overleaf/validation-tools'
// Mirrors services/docstore/app/js/schemas.js (this mock stands in for
// docstore's own API in web's acceptance tests).
const insertOp = z.strictObject({
i: z.string(),
p: z.number().int().min(0),
u: z.boolean().optional(),
fixedRemoveChange: z.boolean().optional(),
})
const deleteOp = z.strictObject({
d: z.string(),
p: z.number().int().min(0),
u: z.boolean().optional(),
fixedRemoveChange: z.boolean().optional(),
})
const commentOp = z.strictObject({
c: z.string().optional(),
p: z.number().int().min(0).optional(),
t: z.string().optional(),
u: z.boolean().optional(),
resolved: z.boolean().optional(),
})
const rangeMetadata = z.strictObject({
user_id: z.string().optional(),
ts: z.string().optional(),
})
const comment = z.strictObject({
id: z.string().optional(),
op: commentOp,
metadata: rangeMetadata.optional(),
})
const trackedChange = z.strictObject({
id: z.string().optional(),
op: insertOp.or(deleteOp).optional(),
metadata: rangeMetadata.optional(),
})
const rangesSchema = z.strictObject({
comments: z.array(comment).optional(),
changes: z.array(trackedChange).optional(),
})
import rangesSchemas from '@overleaf/ranges-tracker/schemas.js'
const docParamsSchema = z.strictObject({
projectId: zz.objectId(),
@@ -52,7 +13,7 @@ const updateDocSchema = z.object({
body: z.strictObject({
lines: z.array(z.string()),
version: z.number(),
ranges: rangesSchema,
ranges: rangesSchemas.ranges,
}),
})
@@ -15,22 +15,24 @@ describe('DocumentController', function () {
ctx.doc = { _id: '5c9a5d5b0000000000000002' }
ctx.doc_lines = ['one', 'two', 'three']
ctx.version = 42
ctx.threadId1 = '5c9a5d5b0000000000000011'
ctx.threadId2 = '5c9a5d5b0000000000000012'
ctx.ranges = {
comments: [
{
id: 'comment1',
id: ctx.threadId1,
op: {
c: 'foo',
p: 123,
t: 'comment1',
t: ctx.threadId1,
},
},
{
id: 'comment2',
id: ctx.threadId2,
op: {
c: 'bar',
p: 456,
t: 'comment2',
t: ctx.threadId2,
},
},
],
@@ -53,8 +55,8 @@ describe('DocumentController', function () {
},
}
ctx.resolvedThreadIds = [
'comment2',
'comment4', // Comment in project but not in doc
ctx.threadId2,
'5c9a5d5b0000000000000014', // Comment in project but not in doc
]
ctx.ProjectGetter = {
@@ -159,7 +161,7 @@ describe('DocumentController', function () {
pathname: ctx.pathname,
projectHistoryId: ctx.project.overleaf.history.id,
projectHistoryType: 'project-history',
resolvedCommentIds: ['comment2'],
resolvedCommentIds: [ctx.threadId2],
historyRangesSupport: false,
otMigrationStage: 0,
})