| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968 |
- import sinon from 'sinon'
- import { expect } from 'chai'
- import { strict as esmock } from 'esmock'
- import Core from 'overleaf-editor-core'
- import * as Errors from '../../../../app/js/Errors.js'
- const MODULE_PATH = '../../../../app/js/SnapshotManager.js'
- describe('SnapshotManager', function () {
- beforeEach(async function () {
- this.HistoryStoreManager = {
- getBlobStore: sinon.stub(),
- promises: {
- getChunkAtVersion: sinon.stub(),
- getMostRecentChunk: sinon.stub(),
- getProjectBlobStream: sinon.stub(),
- },
- }
- this.WebApiManager = {
- promises: {
- getHistoryId: sinon.stub(),
- },
- }
- this.SnapshotManager = await esmock(MODULE_PATH, {
- 'overleaf-editor-core': Core,
- '../../../../app/js/HistoryStoreManager.js': this.HistoryStoreManager,
- '../../../../app/js/WebApiManager.js': this.WebApiManager,
- '../../../../app/js/Errors.js': Errors,
- })
- this.projectId = 'project-id-123'
- this.historyId = 'ol-project-id-123'
- this.callback = sinon.stub()
- })
- describe('getFileSnapshotStream', function () {
- beforeEach(function () {
- this.WebApiManager.promises.getHistoryId.resolves(this.historyId)
- this.ranges = {
- comments: [],
- trackedChanges: [
- {
- range: { pos: 4, length: 6 },
- tracking: {
- userId: 'user-1',
- ts: '2024-01-01T00:00:00.000Z',
- type: 'delete',
- },
- },
- {
- range: { pos: 35, length: 5 },
- tracking: {
- userId: 'user-1',
- ts: '2024-01-01T00:00:00.000Z',
- type: 'insert',
- },
- },
- ],
- }
- this.HistoryStoreManager.promises.getChunkAtVersion.resolves({
- chunk: {
- history: {
- snapshot: {
- files: {
- 'main.tex': {
- hash: '35c9bd86574d61dcadbce2fdd3d4a0684272c6ea',
- stringLength: 41,
- },
- 'file_with_ranges.tex': {
- hash: '5d2781d78fa5a97b7bafa849fe933dfc9dc93eba',
- rangesHash: '73061952d41ce54825e2fc1c36b4cf736d5fb62f',
- stringLength: 41,
- },
- 'binary.png': {
- hash: 'c6654ea913979e13e22022653d284444f284a172',
- byteLength: 41,
- },
- },
- },
- changes: [
- {
- operations: [
- {
- pathname: 'main.tex',
- textOperation: [41, '\n\nSeven eight'],
- },
- ],
- timestamp: '2017-12-04T10:29:17.786Z',
- authors: [31],
- },
- {
- operations: [
- {
- pathname: 'main.tex',
- textOperation: [54, ' nine'],
- },
- ],
- timestamp: '2017-12-04T10:29:22.905Z',
- authors: [31],
- },
- ],
- },
- startVersion: 3,
- authors: [
- {
- id: 31,
- email: 'james.allen@overleaf.com',
- name: 'James',
- },
- ],
- },
- })
- })
- describe('of a text file with no tracked changes', function () {
- beforeEach(async function () {
- this.HistoryStoreManager.getBlobStore.withArgs(this.historyId).returns({
- getString: (this.getString = sinon.stub().resolves(
- `\
- Hello world
- One two three
- Four five six\
- `.replace(/^\t/g, '')
- )),
- getObject: sinon.stub().rejects(),
- })
- this.stream = await this.SnapshotManager.promises.getFileSnapshotStream(
- this.projectId,
- 5,
- 'main.tex'
- )
- })
- it('should get the overleaf id', function () {
- this.WebApiManager.promises.getHistoryId
- .calledWith(this.projectId)
- .should.equal(true)
- })
- it('should get the chunk', function () {
- this.HistoryStoreManager.promises.getChunkAtVersion
- .calledWith(this.projectId, this.historyId, 5)
- .should.equal(true)
- })
- it('should get the blob of the starting snapshot', function () {
- this.getString
- .calledWith('35c9bd86574d61dcadbce2fdd3d4a0684272c6ea')
- .should.equal(true)
- })
- it('should return a string stream with the text content', function () {
- expect(this.stream.read().toString()).to.equal(
- `\
- Hello world
- One two three
- Four five six
- Seven eight nine\
- `.replace(/^\t/g, '')
- )
- })
- describe('on blob store error', function () {
- beforeEach(function () {
- this.error = new Error('ESOCKETTIMEDOUT')
- this.HistoryStoreManager.getBlobStore
- .withArgs(this.historyId)
- .returns({
- getString: sinon.stub().rejects(this.error),
- getObject: sinon.stub().rejects(this.error),
- })
- })
- it('should call back with error', async function () {
- await expect(
- this.SnapshotManager.promises.getFileSnapshotStream(
- this.projectId,
- 5,
- 'main.tex'
- )
- ).to.be.rejectedWith(this.error)
- })
- })
- })
- describe('of a text file with tracked changes', function () {
- beforeEach(async function () {
- this.HistoryStoreManager.getBlobStore.withArgs(this.historyId).returns({
- getString: (this.getString = sinon
- .stub()
- .resolves('the quick brown fox jumps over the lazy dog')),
- getObject: (this.getObject = sinon.stub().resolves(this.ranges)),
- })
- this.stream = await this.SnapshotManager.promises.getFileSnapshotStream(
- this.projectId,
- 5,
- 'file_with_ranges.tex'
- )
- })
- it('should get the overleaf id', function () {
- this.WebApiManager.promises.getHistoryId
- .calledWith(this.projectId)
- .should.equal(true)
- })
- it('should get the chunk', function () {
- this.HistoryStoreManager.promises.getChunkAtVersion
- .calledWith(this.projectId, this.historyId, 5)
- .should.equal(true)
- })
- it('should get the blob of the starting snapshot', function () {
- this.getString
- .calledWith('5d2781d78fa5a97b7bafa849fe933dfc9dc93eba')
- .should.equal(true)
- })
- it('should get the blob of the ranges', function () {
- this.getObject
- .calledWith('73061952d41ce54825e2fc1c36b4cf736d5fb62f')
- .should.equal(true)
- })
- it('should return a string stream with the text content without the tracked deletes', function () {
- expect(this.stream.read().toString()).to.equal(
- 'the brown fox jumps over the lazy dog'
- )
- })
- })
- describe('of a binary file', function () {
- beforeEach(async function () {
- this.HistoryStoreManager.promises.getProjectBlobStream
- .withArgs(this.historyId)
- .resolves((this.stream = 'mock-stream'))
- this.returnedStream =
- await this.SnapshotManager.promises.getFileSnapshotStream(
- this.projectId,
- 5,
- 'binary.png'
- )
- })
- it('should get the overleaf id', function () {
- this.WebApiManager.promises.getHistoryId
- .calledWith(this.projectId)
- .should.equal(true)
- })
- it('should get the chunk', function () {
- this.HistoryStoreManager.promises.getChunkAtVersion
- .calledWith(this.projectId, this.historyId, 5)
- .should.equal(true)
- })
- it('should get the blob of the starting snapshot', function () {
- this.HistoryStoreManager.promises.getProjectBlobStream
- .calledWith(
- this.historyId,
- 'c6654ea913979e13e22022653d284444f284a172'
- )
- .should.equal(true)
- })
- it('should return a stream with the blob content', function () {
- expect(this.returnedStream).to.equal(this.stream)
- })
- })
- describe("when the file doesn't exist", function () {
- it('should return a NotFoundError', async function () {
- await expect(
- this.SnapshotManager.promises.getFileSnapshotStream(
- this.projectId,
- 5,
- 'not-here.png'
- )
- ).to.be.rejectedWith(Errors.NotFoundError)
- })
- })
- })
- describe('getProjectSnapshot', function () {
- beforeEach(function () {
- this.WebApiManager.promises.getHistoryId.resolves(this.historyId)
- this.ranges = {
- comments: [],
- trackedChanges: [
- {
- range: { pos: 5, length: 6 },
- tracking: {
- userId: 'user-1',
- ts: '2024-01-01T00:00:00.000Z',
- type: 'delete',
- },
- },
- {
- range: { pos: 12, length: 5 },
- tracking: {
- userId: 'user-1',
- ts: '2024-01-01T00:00:00.000Z',
- type: 'insert',
- },
- },
- ],
- }
- this.HistoryStoreManager.promises.getChunkAtVersion.resolves({
- chunk: (this.chunk = {
- history: {
- snapshot: {
- files: {
- 'main.tex': {
- hash: '35c9bd86574d61dcadbce2fdd3d4a0684272c6ea',
- stringLength: 41,
- },
- 'unchanged.tex': {
- hash: '35c9bd86574d61dcadbce2fdd3d4a0684272c6ea',
- stringLength: 41,
- },
- 'with_ranges_unchanged.tex': {
- hash: '35c9bd86574d61dcadbce2fdd3d4a0684272c6ea',
- rangesHash: '2e59fe3dbd5310703f89236d589d0b35db169cdf',
- stringLength: 41,
- },
- 'with_ranges_changed.tex': {
- hash: '35c9bd86574d61dcadbce2fdd3d4a0684272c6ea',
- rangesHash: '2e59fe3dbd5310703f89236d589d0b35db169cdf',
- stringLength: 41,
- },
- 'binary.png': {
- hash: 'c6654ea913979e13e22022653d284444f284a172',
- byteLength: 41,
- },
- },
- },
- changes: [
- {
- operations: [
- {
- pathname: 'main.tex',
- textOperation: [41, '\n\nSeven eight'],
- },
- ],
- timestamp: '2017-12-04T10:29:17.786Z',
- authors: [31],
- },
- {
- operations: [
- {
- pathname: 'main.tex',
- textOperation: [54, ' nine'],
- },
- ],
- timestamp: '2017-12-04T10:29:22.905Z',
- authors: [31],
- },
- {
- operations: [
- {
- pathname: 'with_ranges_changed.tex',
- textOperation: [41, '\n\nSeven eight'],
- },
- ],
- timestamp: '2017-12-04T10:29:25.905Z',
- authors: [31],
- },
- ],
- },
- startVersion: 3,
- authors: [
- {
- id: 31,
- email: 'james.allen@overleaf.com',
- name: 'James',
- },
- ],
- }),
- })
- })
- describe('of project', function () {
- beforeEach(async function () {
- this.HistoryStoreManager.getBlobStore.withArgs(this.historyId).returns({
- getString: (this.getString = sinon.stub().resolves(
- `\
- Hello world
- One two three
- Four five six\
- `
- )),
- getObject: (this.getObject = sinon.stub().resolves(this.ranges)),
- })
- this.data = await this.SnapshotManager.promises.getProjectSnapshot(
- this.projectId,
- 6
- )
- })
- it('should get the overleaf id', function () {
- this.WebApiManager.promises.getHistoryId
- .calledWith(this.projectId)
- .should.equal(true)
- })
- it('should get the chunk', function () {
- this.HistoryStoreManager.promises.getChunkAtVersion
- .calledWith(this.projectId, this.historyId, 6)
- .should.equal(true)
- })
- it('should get the ranges for the file with tracked changes', function () {
- this.getObject.calledWith('2e59fe3dbd5310703f89236d589d0b35db169cdf')
- })
- it('should produce the snapshot file data', function () {
- expect(this.data).to.deep.equal({
- files: {
- 'main.tex': {
- // files with operations in the chunk should return content only
- data: {
- content:
- 'Hello world\n\nOne two three\n\nFour five six\n\nSeven eight nine',
- },
- },
- 'unchanged.tex': {
- // unchanged files in the chunk should return hash only
- data: {
- hash: '35c9bd86574d61dcadbce2fdd3d4a0684272c6ea',
- },
- },
- 'with_ranges_changed.tex': {
- // files in the chunk with tracked changes should return content
- // without the tracked deletes
- data: {
- content:
- 'Hello\n\nOne two three\n\nFour five six\n\nSeven eight',
- },
- },
- 'with_ranges_unchanged.tex': {
- // files in the chunk with tracked changes should return content
- // without the tracked deletes, even if they are unchanged
- data: {
- content: 'Hello\n\nOne two three\n\nFour five six',
- },
- },
- 'binary.png': {
- // binary files in the chunk should return hash only
- data: {
- hash: 'c6654ea913979e13e22022653d284444f284a172',
- },
- },
- },
- projectId: 'project-id-123',
- })
- })
- })
- describe('on blob store error', function () {
- beforeEach(function () {
- this.error = new Error('ESOCKETTIMEDOUT')
- this.HistoryStoreManager.getBlobStore.withArgs(this.historyId).returns({
- getString: sinon.stub().rejects(this.error),
- getObject: sinon.stub().resolves(),
- })
- })
- it('should call back with error', async function () {
- expect(
- this.SnapshotManager.promises.getProjectSnapshot(this.projectId, 5)
- ).to.be.rejectedWith(this.error.message)
- })
- })
- })
- describe('getLatestSnapshot', function () {
- describe('for a project', function () {
- beforeEach(async function () {
- this.HistoryStoreManager.promises.getMostRecentChunk.resolves({
- chunk: (this.chunk = {
- history: {
- snapshot: {
- files: {
- 'main.tex': {
- hash: '35c9bd86574d61dcadbce2fdd3d4a0684272c6ea',
- stringLength: 41,
- },
- 'binary.png': {
- hash: 'c6654ea913979e13e22022653d284444f284a172',
- byteLength: 41,
- },
- },
- },
- changes: [
- {
- operations: [
- {
- pathname: 'main.tex',
- textOperation: [41, '\n\nSeven eight'],
- },
- ],
- timestamp: '2017-12-04T10:29:17.786Z',
- authors: [31],
- },
- {
- operations: [
- {
- pathname: 'main.tex',
- textOperation: [54, ' nine'],
- },
- ],
- timestamp: '2017-12-04T10:29:22.905Z',
- authors: [31],
- },
- ],
- },
- startVersion: 3,
- authors: [
- {
- id: 31,
- email: 'james.allen@overleaf.com',
- name: 'James',
- },
- ],
- }),
- })
- this.HistoryStoreManager.getBlobStore.withArgs(this.historyId).returns({
- getString: (this.getString = sinon.stub().resolves(
- `\
- Hello world
- One two three
- Four five six\
- `.replace(/^\t/g, '')
- )),
- getObject: sinon.stub().rejects(),
- })
- this.data = await this.SnapshotManager.promises.getLatestSnapshot(
- this.projectId,
- this.historyId
- )
- })
- it('should get the chunk', function () {
- this.HistoryStoreManager.promises.getMostRecentChunk
- .calledWith(this.projectId, this.historyId)
- .should.equal(true)
- })
- it('should produce the snapshot file data', function () {
- expect(this.data).to.have.all.keys(['main.tex', 'binary.png'])
- expect(this.data['main.tex']).to.exist
- expect(this.data['binary.png']).to.exist
- expect(this.data['main.tex'].getStringLength()).to.equal(59)
- expect(this.data['binary.png'].getByteLength()).to.equal(41)
- expect(this.data['binary.png'].getHash()).to.equal(
- 'c6654ea913979e13e22022653d284444f284a172'
- )
- })
- })
- describe('when the chunk is empty', function () {
- beforeEach(async function () {
- this.HistoryStoreManager.promises.getMostRecentChunk.resolves(null)
- expect(
- this.SnapshotManager.promises.getLatestSnapshot(
- this.projectId,
- this.historyId
- )
- ).to.be.rejectedWith('undefined chunk')
- })
- })
- })
- describe('getRangesSnapshot', function () {
- beforeEach(async function () {
- this.WebApiManager.promises.getHistoryId.resolves(this.historyId)
- this.HistoryStoreManager.promises.getChunkAtVersion.resolves({
- chunk: (this.chunk = {
- history: {
- snapshot: {
- files: {
- 'main.tex': {
- hash: (this.fileHash =
- '5d2781d78fa5a97b7bafa849fe933dfc9dc93eba'),
- rangesHash: (this.rangesHash =
- '73061952d41ce54825e2fc1c36b4cf736d5fb62f'),
- stringLength: 41,
- },
- },
- },
- changes: [],
- },
- startVersion: 1,
- authors: [
- {
- id: 31,
- email: 'author@example.com',
- name: 'Author',
- },
- ],
- }),
- })
- this.HistoryStoreManager.getBlobStore.withArgs(this.historyId).returns({
- getString: (this.getString = sinon.stub()),
- getObject: (this.getObject = sinon.stub()),
- })
- this.getString.resolves('the quick brown fox jumps over the lazy dog')
- })
- describe('with tracked deletes', function () {
- beforeEach(async function () {
- this.getObject.resolves({
- trackedChanges: [
- {
- // 'quick '
- range: {
- pos: 4,
- length: 6,
- },
- tracking: {
- type: 'delete',
- userId: '31',
- ts: '2023-01-01T00:00:00.000Z',
- },
- },
- {
- // 'fox '
- range: {
- pos: 16,
- length: 4,
- },
- tracking: {
- type: 'delete',
- userId: '31',
- ts: '2023-01-01T00:00:00.000Z',
- },
- },
- {
- // 'lazy '
- range: {
- pos: 35,
- length: 5,
- },
- tracking: {
- type: 'insert',
- userId: '31',
- ts: '2023-01-01T00:00:00.000Z',
- },
- },
- {
- // 'dog'
- range: {
- pos: 40,
- length: 3,
- },
- tracking: {
- type: 'delete',
- userId: '31',
- ts: '2023-01-01T00:00:00.000Z',
- },
- },
- ],
- })
- this.data = await this.SnapshotManager.promises.getRangesSnapshot(
- this.projectId,
- 1,
- 'main.tex'
- )
- })
- it("doesn't shift the tracked delete by itself", function () {
- expect(this.data.changes[0].op.p).to.eq(4)
- })
- it('should move subsequent tracked changes by the length of previous deletes', function () {
- expect(this.data.changes[1].op.p).to.eq(16 - 6)
- expect(this.data.changes[2].op.p).to.eq(35 - 6 - 4)
- })
- it("shouldn't move subsequent tracked changes by previous inserts", function () {
- expect(this.data.changes[3].op.p).to.eq(40 - 6 - 4)
- })
- })
- describe('with comments and tracked deletes', function () {
- beforeEach(async function () {
- this.getObject.resolves({
- // the quick brown fox jumps over the lazy dog
- trackedChanges: [
- {
- // 'e qui'
- range: {
- pos: 2,
- length: 5,
- },
- tracking: {
- type: 'delete',
- userId: '31',
- ts: '2023-01-01T00:00:00.000Z',
- },
- },
- {
- // 'r'
- range: {
- pos: 11,
- length: 1,
- },
- tracking: {
- type: 'delete',
- userId: '31',
- ts: '2023-01-01T00:00:00.000Z',
- },
- },
- {
- // 'er th'
- range: {
- pos: 28,
- length: 5,
- },
- tracking: {
- type: 'delete',
- userId: '31',
- ts: '2023-01-01T00:00:00.000Z',
- },
- },
- ],
- comments: [
- {
- id: 'comment-1',
- ranges: [
- // 'quick'
- {
- pos: 4,
- length: 5,
- },
- // 'brown'
- {
- pos: 10,
- length: 5,
- },
- // 'over'
- {
- pos: 26,
- length: 4,
- },
- ],
- resolved: false,
- },
- ],
- })
- this.data = await this.SnapshotManager.promises.getRangesSnapshot(
- this.projectId,
- 1,
- 'main.tex'
- )
- })
- it('should move the comment to the start of the tracked delete and remove overlapping text', function () {
- expect(this.data.comments[0].op.p).to.eq(2)
- expect(this.data.comments[0].op.c).to.eq('ck')
- })
- it('should remove overlapping text in middle of comment', function () {
- expect(this.data.comments[1].op.p).to.eq(5)
- expect(this.data.comments[1].op.c).to.eq('bown')
- })
- it('should remove overlapping text at end of comment', function () {
- expect(this.data.comments[2].op.p).to.eq(20)
- expect(this.data.comments[2].op.c).to.eq('ov')
- })
- it('should put resolved status in op', function () {
- expect(this.data.comments[0].op.resolved).to.be.false
- expect(this.data.comments[1].op.resolved).to.be.false
- expect(this.data.comments[2].op.resolved).to.be.false
- })
- })
- describe('with multiple tracked changes and comments', function () {
- beforeEach(async function () {
- this.getObject.resolves({
- trackedChanges: [
- {
- // 'quick '
- range: {
- pos: 4,
- length: 6,
- },
- tracking: {
- type: 'delete',
- userId: '31',
- ts: '2023-01-01T00:00:00.000Z',
- },
- },
- {
- // 'brown '
- range: {
- pos: 10,
- length: 6,
- },
- tracking: {
- type: 'insert',
- userId: '31',
- ts: '2024-01-01T00:00:00.000Z',
- },
- },
- {
- // 'lazy '
- range: {
- pos: 35,
- length: 5,
- },
- tracking: {
- type: 'delete',
- userId: '31',
- ts: '2024-01-01T00:00:00.000Z',
- },
- },
- ],
- comments: [
- {
- id: 'comment-1',
- // 'quick', 'brown', 'lazy'
- ranges: [
- {
- pos: 4,
- length: 5,
- },
- {
- pos: 10,
- length: 5,
- },
- {
- pos: 35,
- length: 4,
- },
- ],
- resolved: false,
- },
- {
- id: 'comment-2',
- // 'the', 'the'
- ranges: [
- {
- pos: 0,
- length: 3,
- },
- {
- pos: 31,
- length: 3,
- },
- ],
- resolved: true,
- },
- ],
- })
- this.data = await this.SnapshotManager.promises.getRangesSnapshot(
- this.projectId,
- 1,
- 'main.tex'
- )
- })
- it('looks up ranges', function () {
- expect(this.getObject).to.have.been.calledWith(this.rangesHash)
- expect(this.getString).to.have.been.calledWith(this.fileHash)
- })
- it('should get the chunk', function () {
- expect(
- this.HistoryStoreManager.promises.getChunkAtVersion
- ).to.have.been.calledWith(this.projectId, this.historyId, 1)
- })
- it('returns the ranges with content and adjusted positions to ignore tracked deletes', function () {
- expect(this.data).to.deep.equal({
- changes: [
- {
- metadata: {
- ts: '2023-01-01T00:00:00.000Z',
- user_id: '31',
- },
- op: {
- d: 'quick ',
- p: 4,
- },
- },
- {
- metadata: {
- ts: '2024-01-01T00:00:00.000Z',
- user_id: '31',
- },
- op: {
- i: 'brown ',
- p: 4,
- },
- },
- {
- metadata: {
- ts: '2024-01-01T00:00:00.000Z',
- user_id: '31',
- },
- op: {
- d: 'lazy ',
- p: 29,
- },
- },
- ],
- comments: [
- {
- op: {
- c: '',
- p: 4,
- t: 'comment-1',
- resolved: false,
- },
- },
- {
- op: {
- c: 'brown',
- p: 4,
- t: 'comment-1',
- resolved: false,
- },
- },
- {
- op: {
- c: '',
- p: 29,
- t: 'comment-1',
- resolved: false,
- },
- },
- {
- op: {
- c: 'the',
- p: 0,
- t: 'comment-2',
- resolved: true,
- },
- },
- {
- op: {
- c: 'the',
- p: 25,
- t: 'comment-2',
- resolved: true,
- },
- },
- ],
- })
- })
- })
- })
- })
|