From f7e4100aba78c46bb4124366da682336024db7cb Mon Sep 17 00:00:00 2001 From: John McLear Date: Sat, 4 Apr 2026 09:28:09 +0100 Subject: [PATCH] fix: appendText API now attributes text to the specified author (#7446) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: appendText API now attributes text to the specified author spliceText() was calling makeSplice() without passing author attributes, so inserted text had no authorship attribution in the changeset — even though the authorId was recorded in the revision metadata. Now passes [['author', authorId]] and the pool to makeSplice() so the changeset ops carry the author attribute, making the text show the author's color in the editor and appear in listAuthorsOfPad. Also fixed the same issue in pad init (first changeset creation) and updated PadType interface to include the authorId parameter. Fixes #6873 Co-Authored-By: Claude Opus 4.6 (1M context) * test: assert API response code on createPad and anonymous appendText Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- src/node/db/Pad.ts | 6 +- src/node/types/PadType.ts | 2 +- .../backend/specs/api/appendTextAuthor.ts | 96 +++++++++++++++++++ 3 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 src/tests/backend/specs/api/appendTextAuthor.ts diff --git a/src/node/db/Pad.ts b/src/node/db/Pad.ts index 003ec0831..0fded8975 100644 --- a/src/node/db/Pad.ts +++ b/src/node/db/Pad.ts @@ -294,7 +294,8 @@ class Pad { (!ins && start > 0 && orig[start - 1] === '\n'); if (!willEndWithNewline) ins += '\n'; if (ndel === 0 && ins.length === 0) return; - const changeset = makeSplice(orig, start, ndel, ins); + const attribs = authorId ? [['author', authorId] as [string, string]] : undefined; + const changeset = makeSplice(orig, start, ndel, ins, attribs, this.pool); await this.appendRevision(changeset, authorId); } @@ -394,7 +395,8 @@ class Pad { if (context.type !== 'text') throw new Error(`unsupported content type: ${context.type}`); text = exports.cleanText(context.content); } - const firstChangeset = makeSplice('\n', 0, 0, text); + const firstAttribs = authorId ? [['author', authorId] as [string, string]] : undefined; + const firstChangeset = makeSplice('\n', 0, 0, text, firstAttribs, this.pool); await this.appendRevision(firstChangeset, authorId); } await hooks.aCallAll('padLoad', {pad: this}); diff --git a/src/node/types/PadType.ts b/src/node/types/PadType.ts index a715426c8..61ca306bb 100644 --- a/src/node/types/PadType.ts +++ b/src/node/types/PadType.ts @@ -15,7 +15,7 @@ export type PadType = { remove: ()=>Promise, text: ()=>string, setText: (text: string, authorId?: string)=>Promise, - appendText: (text: string)=>Promise, + appendText: (text: string, authorId?: string)=>Promise, getHeadRevisionNumber: ()=>number, getRevisionDate: (rev: number)=>Promise, getRevisionChangeset: (rev: number)=>Promise, diff --git a/src/tests/backend/specs/api/appendTextAuthor.ts b/src/tests/backend/specs/api/appendTextAuthor.ts new file mode 100644 index 000000000..e1f4281cb --- /dev/null +++ b/src/tests/backend/specs/api/appendTextAuthor.ts @@ -0,0 +1,96 @@ +'use strict'; + +const assert = require('assert').strict; +const common = require('../../common'); + +let agent: any; +let apiVersion = 1; +const testPadId = `appendTextAuthor_${makeid()}`; + +const endPoint = (point: string) => `/api/${apiVersion}/${point}`; + +function makeid() { + let text = ''; + const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + for (let i = 0; i < 10; i++) { + text += possible.charAt(Math.floor(Math.random() * possible.length)); + } + return text; +} + +describe(__filename, function () { + let authorId: string; + + before(async function () { + agent = await common.init(); + const res = await agent.get('/api/') + .expect(200) + .expect('Content-Type', /json/); + apiVersion = res.body.currentVersion; + assert(apiVersion); + + // Create an author + const authorRes = await agent.get(`${endPoint('createAuthor')}?name=TestAuthor`) + .set('Authorization', await common.generateJWTToken()) + .expect(200); + assert.equal(authorRes.body.code, 0); + authorId = authorRes.body.data.authorID; + assert(authorId); + + // Create a pad + await agent.get(`${endPoint('createPad')}?padID=${testPadId}`) + .set('Authorization', await common.generateJWTToken()) + .expect(200); + }); + + it('appendText with authorId attributes the text to that author', async function () { + // Append text with an authorId + const res = await agent.post(endPoint('appendText')) + .set('Authorization', await common.generateJWTToken()) + .send({padID: testPadId, text: 'authored text', authorId}) + .expect(200) + .expect('Content-Type', /json/); + assert.equal(res.body.code, 0); + + // Verify the author appears in the pad's author list + const authorsRes = await agent.get( + `${endPoint('listAuthorsOfPad')}?padID=${testPadId}`) + .set('Authorization', await common.generateJWTToken()) + .expect(200); + assert.equal(authorsRes.body.code, 0); + assert(authorsRes.body.data.authorIDs.includes(authorId), + `Expected authorId ${authorId} in pad authors: ${authorsRes.body.data.authorIDs}`); + }); + + it('appendText without authorId does not attribute to any author', async function () { + const newPadId = `appendTextNoAuthor_${makeid()}`; + const createRes = await agent.get(`${endPoint('createPad')}?padID=${newPadId}`) + .set('Authorization', await common.generateJWTToken()) + .expect(200); + assert.equal(createRes.body.code, 0); + + const appendRes = await agent.post(endPoint('appendText')) + .set('Authorization', await common.generateJWTToken()) + .send({padID: newPadId, text: 'anonymous text'}) + .expect(200); + assert.equal(appendRes.body.code, 0); + + const authorsRes = await agent.get( + `${endPoint('listAuthorsOfPad')}?padID=${newPadId}`) + .set('Authorization', await common.generateJWTToken()) + .expect(200); + assert.equal(authorsRes.body.code, 0); + // No authors should be listed for anonymous text + assert.equal(authorsRes.body.data.authorIDs.length, 0); + + await agent.get(`${endPoint('deletePad')}?padID=${newPadId}`) + .set('Authorization', await common.generateJWTToken()) + .expect(200); + }); + + after(async function () { + await agent.get(`${endPoint('deletePad')}?padID=${testPadId}`) + .set('Authorization', await common.generateJWTToken()) + .expect(200); + }); +});