diff --git a/components/review/Discussion.vue b/components/review/Discussion.vue index 716b360d..458550bd 100644 --- a/components/review/Discussion.vue +++ b/components/review/Discussion.vue @@ -20,6 +20,7 @@ import { toast } from 'vue3-toastify'; import type { ReviewListItem } from '~/services/review'; import { osmClient } from '~/services/index'; +import { resolveHttpErrorMessage } from '~/services/http'; import type ChatBox from '~/components/ChatBox.vue'; import type { ChatMessage } from '~/components/ChatBox.vue'; @@ -77,9 +78,27 @@ function noteComments(note: OsmNote): ChatMessage[] { async function send(message: string) { try { if (props.item.isChangeset) { + const idsBefore = new Set(messages.value.map(m => m.id)); await osmClient.postChangesetComment(workspaceId, props.item.id, message); // Re-fetch so the newly posted comment (and its server timestamp) appears. await refreshChangeset(); + + // OSM answers 200 even when it fails to save the comment (it uses + // `create`, not `create!`, so a comment whose author fails validation is + // dropped silently). Check it actually landed rather than trusting the + // status, and keep the user's text if it did not. Look for a new comment + // with this text rather than a higher count, which someone else's + // comment could also cause. Not by author: OSM display names need not + // match the TDEI name we hold (some are the account's email address). + const landed = messages.value.some(m => + !idsBefore.has(m.id) && m.text.trim() === message.trim()); + + if (!landed) { + toast.error('Your comment was not saved. The server accepted the request but did not ' + + 'record the comment, which usually means a problem with your account. Please ' + + 'contact your workspace administrator.'); + return; + } } else if (props.item.isNote) { await osmClient.postNoteComment(workspaceId, props.item.id, message); @@ -92,8 +111,12 @@ async function send(message: string) { // user's text. chat.value?.clear(); } - catch { - toast.error('Failed to post your comment. Please try again.'); + catch (e: unknown) { + // Show why the server refused -- e.g. the changeset is still open, the + // hourly comment limit was hit, or the proxy denied workspace access -- + // since "try again" does not help with any of those. + const reason = await resolveHttpErrorMessage(e, 'Please try again.'); + toast.error(`Failed to post your comment. ${reason}`); } } diff --git a/test/e2e/review.spec.ts b/test/e2e/review.spec.ts index 7a3ff549..87ccba65 100644 --- a/test/e2e/review.spec.ts +++ b/test/e2e/review.spec.ts @@ -495,6 +495,126 @@ test.describe('workspace review', () => { await expect(discussion.locator('.message-list')).not.toContainText('This should fail to post.'); }); + // Opens changeset 4242's discussion. Its comments come from + // changeset/4242.json?include_discussion=true, which answers with `comments` + // every time, so a test can make a post appear to succeed without landing. + async function openChangesetDiscussion(page: Page, comments: object[]) { + await seedAuthenticatedSession(page); + await stubReviewApis(page); + await page.route('**/workspaces/1/changesets/*/adiff', route => + route.fulfill({ json: { actions: [] } }) + ); + await page.route('https://ecn.*.tiles.virtualearth.net/**', route => route.abort()); + await page.route('**/osm/api/0.6/changeset/4242.json**', route => + route.fulfill({ json: { changeset: { ...changesets.changesets[0], comments } } }) + ); + + await page.goto('/workspace/1/review'); + await sidebar(page).locator('.review-item', { hasText: '#4242' }).click(); + + const overlay = page.locator('.review-overlay'); + await expect(overlay.locator('.review-toolbar')).toContainText('#4242'); + await overlay.locator('.review-toolbar button:has(.md-chat_bubble_outline)').click(); + + const discussion = page.locator('.review-discussion'); + await expect(discussion).toContainText('Looks good to me.'); + return discussion; + } + + const existingChangesetComment = { + id: 1, + text: 'Looks good to me.', + visible: true, + user: 'mapper_jane', + uid: 7, + date: '2026-06-11T09:00:00.000Z' + }; + + // @test e2e: if the server refuses a changeset comment, the toast gives the + // server's reason rather than a generic "try again" + test('a refused changeset comment shows the server reason', async ({ page }) => { + // OSM refuses comments on a changeset that is still open, as plain text. + await page.route('**/osm/api/0.6/changeset/4242/comment**', route => + route.fulfill({ + status: 409, + contentType: 'text/plain', + body: 'The changeset 4242 was not yet closed at 2026-06-10 10:05:00 UTC' + }) + ); + const discussion = await openChangesetDiscussion(page, [existingChangesetComment]); + + await discussion.locator('textarea').fill('Please fix the crossing.'); + await discussion.locator('button[title="Send"]').click(); + + const errorToast = page.locator('.Toastify__toast--error'); + await expect(errorToast).toContainText('was not yet closed'); + await expect(discussion.locator('textarea')).toHaveValue('Please fix the crossing.'); + }); + + // @test e2e: a posted changeset comment appears and clears the draft, even + // when OSM's author name differs from the signed-in user's name + test('a posted changeset comment appears and clears the draft', async ({ page }) => { + await page.route('**/osm/api/0.6/changeset/4242/comment**', route => + route.fulfill({ contentType: 'application/xml', body: '' }) + ); + const discussion = await openChangesetDiscussion(page, [existingChangesetComment]); + + await page.route('**/osm/api/0.6/changeset/4242.json**', route => + route.fulfill({ + json: { + changeset: { + ...changesets.changesets[0], + comments: [ + existingChangesetComment, + { ...existingChangesetComment, id: 2, text: 'Please fix the crossing.', user: 'tester@example.com', uid: 42 } + ] + } + } + }) + ); + + await discussion.locator('textarea').fill('Please fix the crossing.'); + await discussion.locator('button[title="Send"]').click(); + + await expect(discussion.locator('.message-list')).toContainText('Please fix the crossing.'); + await expect(discussion.locator('textarea')).toHaveValue(''); + await expect(page.locator('.Toastify__toast--error')).toHaveCount(0); + }); + + // @test e2e: a changeset comment the server accepts but does not save is + // reported, and the draft is kept + test('a changeset comment that does not land is reported', async ({ page }) => { + // OSM answers 200 but drops the comment when its author fails validation. + // The re-fetched discussion is unchanged. + await page.route('**/osm/api/0.6/changeset/4242/comment**', route => + route.fulfill({ contentType: 'application/xml', body: '' }) + ); + const discussion = await openChangesetDiscussion(page, [existingChangesetComment]); + + // Someone else's comment lands meanwhile, so the count still goes up: only + // a comment with the submitted text counts as this one landing. + await page.route('**/osm/api/0.6/changeset/4242.json**', route => + route.fulfill({ + json: { + changeset: { + ...changesets.changesets[0], + comments: [ + existingChangesetComment, + { ...existingChangesetComment, id: 2, text: 'Crossing fixed.', user: 'mapper_joe', uid: 8 } + ] + } + } + }) + ); + + await discussion.locator('textarea').fill('Please fix the crossing.'); + await discussion.locator('button[title="Send"]').click(); + + const errorToast = page.locator('.Toastify__toast--error'); + await expect(errorToast).toContainText('was not saved'); + await expect(discussion.locator('textarea')).toHaveValue('Please fix the crossing.'); + }); + // @test e2e: validate that all the API calls used on this page match the Swagger spec test('new-API calls conform to the OpenAPI spec', async ({ page }) => { await seedAuthenticatedSession(page);