Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

🐛 Fixed incorrect slug increments for post title changes #22618

Merged
merged 2 commits into from
Mar 27, 2025

Conversation

allouis
Copy link
Contributor

@allouis allouis commented Mar 25, 2025

ref https://linear.app/ghost/issue/ONC-794

When checking slug availability without specifying the ID of the specific resource
being modified, the system can't distinguish between a new resource and an existing
one. This causes false "slug already in use" results when a client checks availability
of a slug that's already assigned to the resource being updated.

For example, if post ID "123" has slug "snowman" and the client checks if the slug
"snowman" is available (without specifying it's for post "123"), the system returns
"snowman-2" unnecessarily. This commonly happens during title edits that would
generate the same slug, like adding a trailing space.

This allows clients to ask "is the slug 'snowman' available for resource ID
'123'?" which handles the case where the resource already owns that slug.

This is a non-breaking change - the existing API continues to work at both the
HTTP and method call levels, with the modelId being optional.

By passing the post ID when generating a slug, the system can now properly
determine if the slug is already assigned to the post being edited. This
correctly handles cases like whitespace-only title changes where the same
slug should be maintained rather than being incremented unnecessarily.

Copy link
Contributor

coderabbitai bot commented Mar 25, 2025

Walkthrough

The changes update the slug generation process across multiple parts of the system. In the LexicalEditorController, both the updateSlugTask and generateSlugTask methods now pass the post’s identifier (using this.get('post.id')) when calling the slugGenerator service. The SlugGeneratorService’s generateSlug method signature has been updated to accept an additional model identifier parameter and conditionally build the API URL based on its presence. Additionally, a new route has been added in the Mirage configuration to handle GET requests that include both a slug and an id, without affecting the original route. API endpoints and the base model’s slug generation plugin have been adjusted to incorporate the id parameter for improved logic in collision checks. Integration and unit tests have also been updated and expanded to verify the new behavior when an id is provided.

Possibly related PRs

Suggested labels

browser-tests

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it’s a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ghost/admin/app/controllers/lexical-editor.js

Oops! Something went wrong! :(

ESLint: 8.44.0

Error: Failed to load parser '@babel/eslint-parser' declared in 'ghost/admin/.eslintrc.js': Cannot find module '@babel/eslint-parser'
Require stack:

  • /ghost/admin/.eslintrc.js
    at Module._resolveFilename (node:internal/modules/cjs/loader:1248:15)
    at Function.resolve (node:internal/modules/helpers:145:19)
    at Object.resolve (/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:2346:46)
    at ConfigArrayFactory._loadParser (/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:3325:39)
    at ConfigArrayFactory._normalizeObjectConfigDataBody (/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:3099:43)
    at _normalizeObjectConfigDataBody.next ()
    at ConfigArrayFactory._normalizeObjectConfigData (/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:3040:20)
    at _normalizeObjectConfigData.next ()
    at ConfigArrayFactory.loadInDirectory (/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:2886:28)
    at CascadingConfigArrayFactory._loadConfigInAncestors (/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:3871:46)
ghost/admin/tests/unit/controllers/editor-test.js

Oops! Something went wrong! :(

ESLint: 8.44.0

Error: Failed to load parser '@babel/eslint-parser' declared in 'ghost/admin/.eslintrc.js': Cannot find module '@babel/eslint-parser'
Require stack:

  • /ghost/admin/.eslintrc.js
    at Module._resolveFilename (node:internal/modules/cjs/loader:1248:15)
    at Function.resolve (node:internal/modules/helpers:145:19)
    at Object.resolve (/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:2346:46)
    at ConfigArrayFactory._loadParser (/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:3325:39)
    at ConfigArrayFactory._normalizeObjectConfigDataBody (/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:3099:43)
    at _normalizeObjectConfigDataBody.next ()
    at ConfigArrayFactory._normalizeObjectConfigData (/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:3040:20)
    at _normalizeObjectConfigData.next ()
    at ConfigArrayFactory.loadInDirectory (/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:2886:28)
    at CascadingConfigArrayFactory._loadConfigInAncestors (/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:3871:46)
ghost/admin/mirage/config/slugs.js

Oops! Something went wrong! :(

ESLint: 8.44.0

Error: Failed to load parser '@babel/eslint-parser' declared in 'ghost/admin/.eslintrc.js': Cannot find module '@babel/eslint-parser'
Require stack:

  • /ghost/admin/.eslintrc.js
    at Module._resolveFilename (node:internal/modules/cjs/loader:1248:15)
    at Function.resolve (node:internal/modules/helpers:145:19)
    at Object.resolve (/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:2346:46)
    at ConfigArrayFactory._loadParser (/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:3325:39)
    at ConfigArrayFactory._normalizeObjectConfigDataBody (/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:3099:43)
    at _normalizeObjectConfigDataBody.next ()
    at ConfigArrayFactory._normalizeObjectConfigData (/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:3040:20)
    at _normalizeObjectConfigData.next ()
    at ConfigArrayFactory.loadInDirectory (/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:2886:28)
    at CascadingConfigArrayFactory._loadConfigInAncestors (/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:3871:46)
  • 2 others

📜 Recent review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3b1c93d and d4a5d4e.

⛔ Files ignored due to path filters (1)
  • ghost/core/test/e2e-api/admin/__snapshots__/slugs.test.js.snap is excluded by !**/*.snap
📒 Files selected for processing (10)
  • ghost/admin/app/controllers/lexical-editor.js (2 hunks)
  • ghost/admin/app/services/slug-generator.js (1 hunks)
  • ghost/admin/mirage/config/slugs.js (1 hunks)
  • ghost/admin/tests/integration/services/slug-generator-test.js (2 hunks)
  • ghost/admin/tests/unit/controllers/editor-test.js (1 hunks)
  • ghost/core/core/server/api/endpoints/slugs.js (3 hunks)
  • ghost/core/core/server/models/base/plugins/generate-slug.js (1 hunks)
  • ghost/core/core/server/web/api/endpoints/admin/routes.js (1 hunks)
  • ghost/core/test/e2e-api/admin/slugs.test.js (1 hunks)
  • ghost/core/test/unit/server/models/base/index.test.js (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (6)
  • ghost/core/core/server/api/endpoints/slugs.js
  • ghost/admin/mirage/config/slugs.js
  • ghost/admin/app/controllers/lexical-editor.js
  • ghost/core/core/server/models/base/plugins/generate-slug.js
  • ghost/admin/app/services/slug-generator.js
  • ghost/core/core/server/web/api/endpoints/admin/routes.js
🧰 Additional context used
🧬 Code Definitions (2)
ghost/admin/tests/unit/controllers/editor-test.js (1)
ghost/admin/app/services/slug-generator.js (1)
  • RSVP (6-6)
ghost/core/test/unit/server/models/base/index.test.js (3)
ghost/core/core/server/models/base/plugins/generate-slug.js (2)
  • security (2-2)
  • slug (20-20)
ghost/core/core/server/models/user.js (1)
  • security (8-8)
ghost/core/test/unit/server/models/user.test.js (1)
  • security (7-7)
⏰ Context from checks skipped due to timeout of 90000ms (5)
  • GitHub Check: Ghost-CLI tests
  • GitHub Check: Database tests (Node 22.13.1, mysql8)
  • GitHub Check: Database tests (Node 20.11.1, mysql8)
  • GitHub Check: Database tests (Node 18.12.1, mysql8)
  • GitHub Check: Admin tests - Chrome
🔇 Additional comments (8)
ghost/core/test/unit/server/models/base/index.test.js (2)

42-58: Well-implemented test case for slug handling with ID mismatch.

This test verifies that when a slug already exists but is associated with a different model ID than the one provided, the system correctly increments the slug (adding "-2"). This is a key part of fixing the issue where posts would get unnecessary slug increments during edits.


60-76: Good test case for slug collision with matching ID.

This test validates that when a slug exists and belongs to the model with the provided ID, the system correctly maintains the original slug without modifications. This is essential to prevent unnecessary slug increments when editing a post without changing its title substantially.

ghost/admin/tests/unit/controllers/editor-test.js (1)

26-46: Thorough test for ID-aware slug generation.

This test properly verifies that the controller passes the post's ID to the slug generator service. The implementation includes a smart validation that throws an error if the wrong ID is passed, ensuring the test will fail if the ID parameter isn't correctly handled.

ghost/admin/tests/integration/services/slug-generator-test.js (2)

8-33: Well-designed backward-compatible function update.

The stubSlugEndpoint function has been neatly modified to accept an optional id parameter while maintaining backward compatibility. The implementation handles both cases (with and without ID) appropriately, setting up the correct endpoints and validations for each scenario.


69-79: Good test for ID-based slug generation.

This test properly verifies that the slug generator service calls the correct endpoint with the ID when one is provided, ensuring the complete end-to-end flow works as expected.

ghost/core/test/e2e-api/admin/slugs.test.js (3)

25-34: Helpful test for basic slug collision.

This test verifies the baseline behavior - a slug should be incremented if it already exists and no resource ID is provided. This establishes the foundation for comparing with the ID-aware behavior.


36-46: Good test for handling same-resource slug collisions.

Tests that when a slug exists and is associated with the same resource ID that's provided in the request, the API correctly returns the original slug without incrementing it. This directly addresses the core issue in the PR.


47-56: Comprehensive test for different-resource slug collisions.

This test confirms that when a slug exists but is associated with a different resource than the one specified by the provided ID, the API correctly increments the slug. This completes the test coverage for all possible scenarios.

✨ Finishing Touches
  • 📝 Generate Docstrings

🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Generate unit testing code for this file.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query. Examples:
    • @coderabbitai generate unit testing code for this file.
    • @coderabbitai modularize this function.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read src/utils.ts and generate unit testing code.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.
    • @coderabbitai help me debug CodeRabbit configuration file.

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

CodeRabbit Commands (Invoked using PR comments)

  • @coderabbitai pause to pause the reviews on a PR.
  • @coderabbitai resume to resume the paused reviews.
  • @coderabbitai review to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
  • @coderabbitai full review to do a full review from scratch and review all the files again.
  • @coderabbitai summary to regenerate the summary of the PR.
  • @coderabbitai generate docstrings to generate docstrings for this PR.
  • @coderabbitai resolve resolve all the CodeRabbit review comments.
  • @coderabbitai plan to trigger planning for file edits and PR creation.
  • @coderabbitai configuration to show the current CodeRabbit configuration for the repository.
  • @coderabbitai help to get help.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Documentation and Community

  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

@allouis allouis force-pushed the onc-794-improve-slug-generation-collision-detection branch from 9b8aa66 to 3b1c93d Compare March 25, 2025 06:43
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (1)
ghost/core/test/e2e-api/admin/slugs.test.js (1)

36-45: Test case for handling same-resource slug collision looks good!

This test verifies the new functionality where a resource can retain its slug when the ID is provided. It uses a specific ID that appears to be from the fixtures (6194d3ce51e2700162531a71).

Consider using a fixture reference rather than a hard-coded ID to improve maintainability:

- .get('slugs/post/integrations/6194d3ce51e2700162531a71')
+ .get(`slugs/post/integrations/${fixtureManager.get('integrations', 0).id}`)
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 38eaab7 and 3b1c93d.

⛔ Files ignored due to path filters (1)
  • ghost/core/test/e2e-api/admin/__snapshots__/slugs.test.js.snap is excluded by !**/*.snap
📒 Files selected for processing (9)
  • ghost/admin/app/controllers/lexical-editor.js (2 hunks)
  • ghost/admin/app/services/slug-generator.js (1 hunks)
  • ghost/admin/mirage/config/slugs.js (1 hunks)
  • ghost/admin/tests/integration/services/slug-generator-test.js (2 hunks)
  • ghost/core/core/server/api/endpoints/slugs.js (3 hunks)
  • ghost/core/core/server/models/base/plugins/generate-slug.js (1 hunks)
  • ghost/core/core/server/web/api/endpoints/admin/routes.js (1 hunks)
  • ghost/core/test/e2e-api/admin/slugs.test.js (1 hunks)
  • ghost/core/test/unit/server/models/base/index.test.js (1 hunks)
🧰 Additional context used
🧬 Code Definitions (2)
ghost/core/test/unit/server/models/base/index.test.js (3)
ghost/core/core/server/models/base/plugins/generate-slug.js (2)
  • security (2-2)
  • slug (20-20)
ghost/core/core/server/models/user.js (1)
  • security (8-8)
ghost/core/test/unit/server/models/user.test.js (1)
  • security (7-7)
ghost/core/test/e2e-api/admin/slugs.test.js (2)
ghost/core/test/e2e-api/admin/settings-files.test.js (1)
  • agent (6-6)
ghost/core/test/e2e-api/admin/custom-theme-settings.test.js (1)
  • agent (8-8)
⏰ Context from checks skipped due to timeout of 90000ms (5)
  • GitHub Check: Ghost-CLI tests
  • GitHub Check: Database tests (Node 22.13.1, mysql8)
  • GitHub Check: Database tests (Node 20.11.1, mysql8)
  • GitHub Check: Database tests (Node 18.12.1, mysql8)
  • GitHub Check: Admin tests - Chrome
🔇 Additional comments (15)
ghost/core/core/server/web/api/endpoints/admin/routes.js (1)

171-171: Looks good! Adding new route to support slug generation with resource ID.

This change adds a new route that includes a resource ID parameter for slug generation while maintaining the existing route for backward compatibility. This is the right approach to maintain non-breaking changes as mentioned in the PR objective.

ghost/core/core/server/models/base/plugins/generate-slug.js (1)

42-46: Nice solution for preventing unnecessary slug increments.

This change properly implements the logic to avoid slug increments when the slug belongs to the same post being edited. The conditional check verifies if the model ID matches the found slug's ID before applying any increments, which addresses the core issue described in the PR.

ghost/core/test/unit/server/models/base/index.test.js (1)

42-76: Well-crafted tests for the new slug generation behavior.

These two test cases thoroughly validate the new slug generation logic:

  1. First test confirms that when a slug exists but for a different model ID, it correctly increments the slug.
  2. Second test verifies that when a slug exists for the same model ID, it correctly returns the original slug without increments.

The stubbing approach with Model.findOne is elegant and clearly demonstrates both scenarios.

ghost/admin/mirage/config/slugs.js (1)

12-18: Good implementation of Mirage route to match backend changes.

This new route handler correctly mirrors the backend API changes by supporting the ID parameter in the slug generation endpoint. The implementation maintains consistency with the existing route handler logic, which is appropriate since the behavior should be similar.

ghost/core/core/server/api/endpoints/slugs.js (3)

22-26: Appropriate addition of the ID parameter to the options array

The addition of the id option to the options array is well-implemented. This change aligns with the PR objectives by allowing clients to query slug availability with a resource ID parameter.


36-40: Well-structured validation for the new ID parameter

The validation for the id option is correctly implemented as not required (required: false), which maintains backward compatibility with existing API calls. This ensures that clients can still query slug availability without specifying an ID.


47-49: Correctly passing the ID to the model's generateSlug method

The query method has been appropriately updated to pass the modelId parameter to the generateSlug function. This correctly implements the fix for slug availability checks by allowing the model to know which resource is being edited.

ghost/admin/app/services/slug-generator.js (2)

13-13: Method signature updated to support the new modelId parameter

The method signature has been appropriately extended to include the optional modelId parameter, maintaining backward compatibility with existing calls while adding the new functionality.


21-26: Well-implemented conditional URL construction

The implementation correctly constructs different API URLs based on whether a modelId is provided. This change properly supports the backend API changes and ensures that slug checks can optionally include the resource ID.

ghost/admin/app/controllers/lexical-editor.js (2)

787-787: Correctly passing post ID to the slugGenerator in updateSlugTask

The updateSlugTask method now properly passes the post ID as an additional parameter to the generateSlug method. This ensures that when a post's slug is being updated, the system can identify that this is an existing post checking its own slug availability.


964-964: Correctly passing post ID to the slugGenerator in generateSlugTask

The generateSlugTask method also properly passes the post ID to the generateSlug method. This ensures consistent behavior between manual slug updates and automatic slug generation based on title changes.

ghost/admin/tests/integration/services/slug-generator-test.js (2)

8-33: Well-structured test endpoint stub with ID support

The stubSlugEndpoint function has been effectively updated to handle both cases - with and without an ID parameter. The conditional logic properly sets up different endpoint stubs and includes appropriate assertions to verify the correct parameters are passed.


69-79: Good test coverage for the new functionality

The new test case properly verifies that the slug generator service works correctly when an ID is provided. This ensures that the changes are properly tested and the functionality works as expected.

ghost/core/test/e2e-api/admin/slugs.test.js (2)

25-34: Test case for slug collision without ID looks good!

This test case correctly verifies the default behavior when there's a slug collision without providing a resource ID. It ensures that the API increments slugs when a collision is detected and no specific resource ID is provided.


47-56: Test case for handling different-resource slug collision looks good!

This test properly verifies what happens when an ID that doesn't match the resource is provided. Using 000000000000000000000000 as a non-existent ID is a good approach for testing this scenario.

Copy link
Member

@ronaldlangeveld ronaldlangeveld left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One small comment, but overall, LGTM.

if (modelId) {
url = this.get('ghostPaths.url').api('slugs', slugType, name, modelId);
} else {
url = this.get('ghostPaths.url').api('slugs', slugType, name);
Copy link
Member

@ronaldlangeveld ronaldlangeveld Mar 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are there reproducible scenarios where modelId may not exist given the changes?

Copy link
Contributor Author

@allouis allouis Mar 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, this service is used for generating slugs for tags and users too, and we don't pass the modelId there!

@cmraible
Copy link
Collaborator

Blowing my mind a bit, I was so tunnel-visioned on the frontend that I hadn't considered this option at all 👀

allouis added 2 commits March 27, 2025 15:56
ref https://linear.app/ghost/issue/ONC-794

When checking slug availability without specifying the ID of the specific resource
being modified, the system can't distinguish between a new resource and an existing
one. This causes false "slug already in use" results when a client checks availability
of a slug that's already assigned to the resource being updated.

For example, if post ID "123" has slug "snowman" and the client checks if the slug
"snowman" is available (without specifying it's for post "123"), the system returns
"snowman-2" unnecessarily. This commonly happens during title edits that would
generate the same slug, like adding a trailing space.

This allows clients to ask "is the slug 'snowman' available for resource ID
'123'?" which handles the case where the resource already owns that slug.

This is a non-breaking change - the existing API continues to work at both the
HTTP and method call levels, with the modelId being optional.
closes https://linear.app/ghost/issue/ONC-794

By passing the post ID when generating a slug, the system can now properly
determine if the slug is already assigned to the post being edited. This
correctly handles cases like whitespace-only title changes where the same
slug should be maintained rather than being incremented unnecessarily.
@allouis allouis force-pushed the onc-794-improve-slug-generation-collision-detection branch from 3b1c93d to d4a5d4e Compare March 27, 2025 09:06
@allouis
Copy link
Contributor Author

allouis commented Mar 27, 2025

I've added a test to ensure that the id is passed to the generateSlug method when it exists as discussed in the Clean Code session.

@allouis allouis enabled auto-merge (rebase) March 27, 2025 09:12
@allouis allouis merged commit 6ef835b into main Mar 27, 2025
26 checks passed
@allouis allouis deleted the onc-794-improve-slug-generation-collision-detection branch March 27, 2025 09:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants