You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@grails.apache.org by ja...@apache.org on 2026/05/31 01:09:48 UTC

(grails-static-website) 02/03: Re-vendor 7 v8 guide snippets from CI-green upstream sample apps

This is an automated email from the ASF dual-hosted git repository.

jamesfredley pushed a commit to branch revendor-v8-guide-fixes
in repository https://gitbox.apache.org/repos/asf/grails-static-website.git

commit d28303cbc674850b64f479b5cf916017a2b3a730
Author: James Fredley <ja...@users.noreply.github.com>
AuthorDate: Sat May 30 20:35:59 2026 -0400

    Re-vendor 7 v8 guide snippets from CI-green upstream sample apps
    
    Sync vendored snippets byte-identical with the upstream grails-guides
    sample apps whose test-coverage PRs are now CI-green. Functional specs
    use the canonical ContainerGebSpec (Testcontainers); no webdriver-binaries.
    
    - rest-library: UrlMappings root-mapping fix + expanded BookFunctionalSpec
    - spock-test-tour: BookController show fix + BookControllerSpec + BookServiceSpec + BookFunctionalSpec
    - htmx: TaskController + TaskIntegrationSpec + TaskTrackerFunctionalSpec
    - tailwindcss: TailwindFunctionalSpec
    - vite-spa: ApiFunctionalSpec + BookControllerSpec
    - docker-bootbuildimage: build.gradle testcontainers-bom platform line
    - github-actions-cicd: HomePageFunctionalSpec + MessageIntegrationSpec + MessageSpec
    
    All seven guides render clean (renderGuide_* BUILD SUCCESSFUL).
    
    Assisted-by: claude-code:claude-opus-4-8
---
 .../v8/snippets/build.gradle                       |   1 +
 .../groovy/example/HomePageFunctionalSpec.groovy   |   9 +
 .../groovy/example/MessageIntegrationSpec.groovy   |  18 ++
 .../src/test/groovy/example/MessageSpec.groovy     |   5 +
 .../controllers/example/TaskController.groovy      |   6 +-
 .../groovy/example/TaskIntegrationSpec.groovy      |  41 +++++
 .../example/TaskTrackerFunctionalSpec.groovy       |  45 +++++
 .../controllers/example/UrlMappings.groovy         |   2 +-
 .../groovy/example/BookFunctionalSpec.groovy       | 192 +++++++++++++++++++++
 .../controllers/example/BookController.groovy      |   8 +
 .../groovy/example/BookFunctionalSpec.groovy       |   1 +
 .../test/groovy/example/BookControllerSpec.groovy  | 103 +++++++++++
 .../src/test/groovy/example/BookServiceSpec.groovy |  49 ++++++
 .../groovy/example/TailwindFunctionalSpec.groovy   |  17 ++
 .../groovy/example/ApiFunctionalSpec.groovy        | 160 +++++++++++++++++
 .../test/groovy/example/BookControllerSpec.groovy  |   6 +-
 16 files changed, 658 insertions(+), 5 deletions(-)

diff --git a/guides/grails-docker-bootbuildimage/v8/snippets/build.gradle b/guides/grails-docker-bootbuildimage/v8/snippets/build.gradle
index ed823c45151..9478a6c5268 100644
--- a/guides/grails-docker-bootbuildimage/v8/snippets/build.gradle
+++ b/guides/grails-docker-bootbuildimage/v8/snippets/build.gradle
@@ -120,6 +120,7 @@ dependencies {
     testImplementation "org.apache.grails:grails-testing-support-web"
     testImplementation "org.mockito:mockito-core"
     testImplementation "org.spockframework:spock-core"
+    testImplementation platform("org.testcontainers:testcontainers-bom:$testcontainersVersion")
     testImplementation "org.testcontainers:postgresql"
     testImplementation "org.testcontainers:spock"
     testImplementation "org.testcontainers:testcontainers"
diff --git a/guides/grails-github-actions-cicd/v8/snippets/src/integration-test/groovy/example/HomePageFunctionalSpec.groovy b/guides/grails-github-actions-cicd/v8/snippets/src/integration-test/groovy/example/HomePageFunctionalSpec.groovy
index a3f098c08ed..cd84a6bf2c5 100644
--- a/guides/grails-github-actions-cicd/v8/snippets/src/integration-test/groovy/example/HomePageFunctionalSpec.groovy
+++ b/guides/grails-github-actions-cicd/v8/snippets/src/integration-test/groovy/example/HomePageFunctionalSpec.groovy
@@ -19,4 +19,13 @@ class HomePageFunctionalSpec extends ContainerGebSpec {
         then: 'the page title is correct'
         title == 'Welcome to Grails'
     }
+
+    void 'a non-existent page renders a 404 response'() {
+        when: 'visiting a non-existent URL'
+        go '/doesNotExist'
+
+        then: 'the 404 page is displayed'
+        title == 'Page Not Found'
+        $('h1').text().contains('Page Not Found (404)')
+    }
 }
diff --git a/guides/grails-github-actions-cicd/v8/snippets/src/integration-test/groovy/example/MessageIntegrationSpec.groovy b/guides/grails-github-actions-cicd/v8/snippets/src/integration-test/groovy/example/MessageIntegrationSpec.groovy
index 670a3eb2f59..c868713c9af 100644
--- a/guides/grails-github-actions-cicd/v8/snippets/src/integration-test/groovy/example/MessageIntegrationSpec.groovy
+++ b/guides/grails-github-actions-cicd/v8/snippets/src/integration-test/groovy/example/MessageIntegrationSpec.groovy
@@ -33,4 +33,22 @@ class MessageIntegrationSpec extends Specification {
         m.hasErrors()
         Message.get(m.id) == null
     }
+
+    void "toString returns the message content"() {
+        given:
+        Message m = new Message(content: 'hello world').save(flush: true, failOnError: true)
+
+        expect:
+        m.toString() == 'hello world'
+    }
+
+    void "a message exceeding maxSize is rejected"() {
+        when:
+        Message m = new Message(content: 'x' * 501)
+        m.save(flush: true)
+
+        then:
+        m.hasErrors()
+        m.errors.getFieldError('content').code == 'maxSize.exceeded' || m.errors.getFieldError('content').code == 'maxSize'
+    }
 }
diff --git a/guides/grails-github-actions-cicd/v8/snippets/src/test/groovy/example/MessageSpec.groovy b/guides/grails-github-actions-cicd/v8/snippets/src/test/groovy/example/MessageSpec.groovy
index c55990b44c0..c3c0fd949b0 100644
--- a/guides/grails-github-actions-cicd/v8/snippets/src/test/groovy/example/MessageSpec.groovy
+++ b/guides/grails-github-actions-cicd/v8/snippets/src/test/groovy/example/MessageSpec.groovy
@@ -31,4 +31,9 @@ class MessageSpec extends Specification implements DomainUnitTest<Message> {
         expect:
         new Message(content: 'hello').validate()
     }
+
+    void "toString returns the message content"() {
+        expect:
+        new Message(content: 'display me').toString() == 'display me'
+    }
 }
diff --git a/guides/grails-htmx/v8/snippets/grails-app/controllers/example/TaskController.groovy b/guides/grails-htmx/v8/snippets/grails-app/controllers/example/TaskController.groovy
index 4f9d64eee78..aad3522679a 100644
--- a/guides/grails-htmx/v8/snippets/grails-app/controllers/example/TaskController.groovy
+++ b/guides/grails-htmx/v8/snippets/grails-app/controllers/example/TaskController.groovy
@@ -87,6 +87,10 @@ class TaskController {
             return
         }
         task.delete(flush: true)
-        render ''
+        if (Task.count() == 0) {
+            render template: 'taskRows', model: [tasks: []]
+        } else {
+            render ''
+        }
     }
 }
diff --git a/guides/grails-htmx/v8/snippets/src/integration-test/groovy/example/TaskIntegrationSpec.groovy b/guides/grails-htmx/v8/snippets/src/integration-test/groovy/example/TaskIntegrationSpec.groovy
index cfa01ecee8a..8d3a83fca84 100644
--- a/guides/grails-htmx/v8/snippets/src/integration-test/groovy/example/TaskIntegrationSpec.groovy
+++ b/guides/grails-htmx/v8/snippets/src/integration-test/groovy/example/TaskIntegrationSpec.groovy
@@ -45,4 +45,45 @@ class TaskIntegrationSpec extends Specification {
         then:
         Task.get(t.id).done
     }
+
+    void "get returns null for non-existent id"() {
+        expect:
+        Task.get(999) == null
+    }
+
+    void "delete removes the entity from the database"() {
+        given:
+        Task t = new Task(title: 'Delete me').save(flush: true, failOnError: true)
+
+        when:
+        t.delete(flush: true)
+
+        then:
+        Task.get(t.id) == null
+    }
+
+    void "blank title is rejected at the database constraint level"() {
+        when:
+        new Task(title: '').save(flush: true, failOnError: true)
+
+        then:
+        thrown(Exception)
+    }
+
+    void "null title is rejected at the database constraint level"() {
+        when:
+        new Task(title: null).save(flush: true, failOnError: true)
+
+        then:
+        thrown(Exception)
+    }
+
+    void "search with ilike handles special regex characters safely"() {
+        given:
+        new Task(title: 'Foo (bar)').save(flush: true, failOnError: true)
+
+        expect:
+        Task.findAllByTitleIlike('%foo%')*.title == ['Foo (bar)']
+        Task.findAllByTitleIlike('%(bar)%')*.title == ['Foo (bar)']
+    }
 }
diff --git a/guides/grails-htmx/v8/snippets/src/integration-test/groovy/example/TaskTrackerFunctionalSpec.groovy b/guides/grails-htmx/v8/snippets/src/integration-test/groovy/example/TaskTrackerFunctionalSpec.groovy
index 2b5ea6a1f40..f14050829c6 100644
--- a/guides/grails-htmx/v8/snippets/src/integration-test/groovy/example/TaskTrackerFunctionalSpec.groovy
+++ b/guides/grails-htmx/v8/snippets/src/integration-test/groovy/example/TaskTrackerFunctionalSpec.groovy
@@ -10,6 +10,8 @@ import grails.testing.mixin.integration.Integration
  *
  * Each htmx swap type is exercised: the create OOB prepend, the toggle/edit
  * outerHTML swaps, the live-search innerHTML swap, and the delete removal.
+ * Additional tests cover validation error paths (create/update with blank title),
+ * the inline edit cancel button, and the show fragment endpoint.
  * @Rollback is not used (the endpoints commit); tests use unique titles so
  * they stay independent of rows left by earlier feature methods.
  */
@@ -112,4 +114,47 @@ class TaskTrackerFunctionalSpec extends ContainerGebSpec {
         then:
         waitFor { !$('#taskList').text().contains('Deletable task') }
     }
+
+    void 'submitting the empty add form leaves the page intact and focused'() {
+        given:
+        go '/tasks'
+
+        when: 'try to submit with empty title (HTML5 validation blocks it)'
+        $('#taskFormContainer').find('button', type: 'submit').click()
+
+        then: 'the page stays on the same form, no task list change'
+        $('#taskFormContainer').size() == 1
+        !$('#taskList').text().contains('oops')
+    }
+
+    void 'cancelling an inline edit reverts to the task display'() {
+        given:
+        go '/tasks'
+        addTaskThroughForm('Cancel test task')
+        Long id = Task.withNewTransaction { Task.findByTitle('Cancel test task').id }
+
+        when: 'open the edit form'
+        $("#task-${id}").find('button', title: 'Click to edit').click()
+        waitFor { $("#task-${id}").find('input', name: 'title').size() == 1 }
+
+        and: 'click the Cancel button'
+        $("#task-${id}").find('button', text: 'Cancel').click()
+
+        then: 'the task display is restored with the original title visible'
+        waitFor { $("#task-${id}").find('button', title: 'Click to edit').size() == 1 }
+    }
+
+    void 'the show endpoint returns a rendered task fragment'() {
+        given:
+        go '/tasks'
+        addTaskThroughForm('Show endpoint task')
+        Long id = Task.withNewTransaction { Task.findByTitle('Show endpoint task').id }
+
+        when:
+        go "/tasks/${id}"
+
+        then: 'the raw fragment contains the task title'
+        waitFor { $('li[id="task-' + id + '"]').size() == 1 }
+        $('li[id="task-' + id + '"]').text().contains('Show endpoint task')
+    }
 }
diff --git a/guides/grails-rest-library/v8/snippets/grails-app/controllers/example/UrlMappings.groovy b/guides/grails-rest-library/v8/snippets/grails-app/controllers/example/UrlMappings.groovy
index 928225125ff..4c29415dc47 100644
--- a/guides/grails-rest-library/v8/snippets/grails-app/controllers/example/UrlMappings.groovy
+++ b/guides/grails-rest-library/v8/snippets/grails-app/controllers/example/UrlMappings.groovy
@@ -11,7 +11,7 @@ class UrlMappings {
         }
         '/v1/authors'(resources: 'author')
 
-        '/'(view: '/index')
+        '/'(controller: 'application', action: 'index')
 
         '500'(view: '/error')
         '404'(view: '/notFound')
diff --git a/guides/grails-rest-library/v8/snippets/src/integration-test/groovy/example/BookFunctionalSpec.groovy b/guides/grails-rest-library/v8/snippets/src/integration-test/groovy/example/BookFunctionalSpec.groovy
index 5abd3999696..c3cf0fac22b 100644
--- a/guides/grails-rest-library/v8/snippets/src/integration-test/groovy/example/BookFunctionalSpec.groovy
+++ b/guides/grails-rest-library/v8/snippets/src/integration-test/groovy/example/BookFunctionalSpec.groovy
@@ -124,4 +124,196 @@ class BookFunctionalSpec extends Specification {
         json.total >= 1
         json.items.any { it.name == 'Functional Author' }
     }
+
+    // -------------------------------------------------------------------------
+    // Show (GET /v1/books/{id})
+    // -------------------------------------------------------------------------
+
+    void "GET /v1/books/{id} returns a single book"() {
+        given:
+        Long bookId = Book.withNewTransaction {
+            Book.findByIsbn('9781111111113').id
+        }
+
+        when:
+        HttpResponse<String> resp = getJson("/v1/books/${bookId}")
+        Map json = new JsonSlurper().parseText(resp.body()) as Map
+
+        then:
+        resp.statusCode() == 200
+        json.title == 'Seeded One'
+        json.isbn == '9781111111113'
+        json.pageCount == 100
+        json.author.name == 'Functional Author'
+        json._links.self.href.contains("id=${bookId}")
+    }
+
+    void "GET /v1/books/{id} returns 404 for non-existent book"() {
+        when:
+        HttpResponse<String> resp = getJson('/v1/books/999999')
+
+        then:
+        resp.statusCode() == 404
+    }
+
+    // -------------------------------------------------------------------------
+    // Save (POST /v1/books)
+    // -------------------------------------------------------------------------
+
+    void "POST /v1/books creates a new book"() {
+        given:
+        String body = JsonOutput.toJson([
+            title: 'Functional Created', isbn: '9780000000003', pageCount: 250,
+            author: [id: authorId]
+        ])
+
+        when:
+        HttpResponse<String> resp = postJson('/v1/books', body)
+        Map json = new JsonSlurper().parseText(resp.body()) as Map
+
+        then:
+        resp.statusCode() == 201
+        json.title == 'Functional Created'
+        json.isbn == '9780000000003'
+
+        cleanup:
+        Book.withNewTransaction { Book.findByIsbn('9780000000003')?.delete(flush: true) }
+    }
+
+    void "POST /v1/books returns 422 when validation fails"() {
+        given:
+        String body = JsonOutput.toJson([
+            title: '', isbn: 'bad-isbn', author: [id: authorId]
+        ])
+
+        when:
+        HttpResponse<String> resp = postJson('/v1/books', body)
+        Map json = new JsonSlurper().parseText(resp.body()) as Map
+
+        then:
+        resp.statusCode() == 422
+        json.errors.any { it.field == 'title' }
+        json.errors.any { it.field == 'isbn' }
+    }
+
+    // -------------------------------------------------------------------------
+    // Update (PUT /v1/books/{id})
+    // -------------------------------------------------------------------------
+
+    private HttpResponse<String> putJson(String path, String body) {
+        client.send(HttpRequest.newBuilder(uri(path))
+                .header('Content-Type', 'application/json')
+                .PUT(HttpRequest.BodyPublishers.ofString(body)).build(),
+                HttpResponse.BodyHandlers.ofString())
+    }
+
+    void "PUT /v1/books/{id} updates an existing book"() {
+        given:
+        Long bookId = Book.withNewTransaction { Book.findByIsbn('9782222222226').id }
+        String body = JsonOutput.toJson([title: 'Updated Title', pageCount: 999])
+
+        when:
+        HttpResponse<String> resp = putJson("/v1/books/${bookId}", body)
+        Map json = new JsonSlurper().parseText(resp.body()) as Map
+
+        then:
+        resp.statusCode() == 200
+        json.title == 'Updated Title'
+        json.pageCount == 999
+
+        cleanup:
+        Book.withNewTransaction {
+            Book b = Book.findByIsbn('9782222222226')
+            if (b) {
+                b.title = 'Seeded Two'
+                b.pageCount = 200
+                b.save(flush: true, failOnError: true)
+            }
+        }
+    }
+
+    void "PUT /v1/books/{id} returns 422 when validation fails"() {
+        given:
+        Long bookId = Book.withNewTransaction { Book.findByIsbn('9781111111113').id }
+        String body = JsonOutput.toJson([title: ''])
+
+        when:
+        HttpResponse<String> resp = putJson("/v1/books/${bookId}", body)
+
+        then:
+        resp.statusCode() == 422
+    }
+
+    void "PUT /v1/books/{id} returns 404 for non-existent book"() {
+        when:
+        HttpResponse<String> resp = putJson('/v1/books/999999', JsonOutput.toJson([title: 'Nope']))
+
+        then:
+        resp.statusCode() == 404
+    }
+
+    // -------------------------------------------------------------------------
+    // Delete (DELETE /v1/books/{id})
+    // -------------------------------------------------------------------------
+
+    private HttpResponse<String> deleteResource(String path) {
+        client.send(HttpRequest.newBuilder(uri(path)).DELETE().build(),
+                HttpResponse.BodyHandlers.ofString())
+    }
+
+    void "DELETE /v1/books/{id} deletes a book"() {
+        given: 'a disposable book not used by other tests'
+        Long disposableId = Book.withNewTransaction {
+            new Book(author: Author.findByName('Functional Author'),
+                     title: 'Delete Me Book', isbn: '9789999999997', pageCount: 1)
+                .save(flush: true, failOnError: true).id
+        }
+
+        when:
+        HttpResponse<String> resp = deleteResource("/v1/books/${disposableId}")
+
+        then:
+        resp.statusCode() == 204
+        Book.withNewTransaction { Book.get(disposableId) == null }
+    }
+
+    void "DELETE /v1/books/{id} returns 404 for non-existent book"() {
+        when:
+        HttpResponse<String> resp = deleteResource('/v1/books/999999')
+
+        then:
+        resp.statusCode() == 404
+    }
+
+    // -------------------------------------------------------------------------
+    // Author filter on GET /v1/books
+    // -------------------------------------------------------------------------
+
+    void "GET /v1/books?author= filters by author"() {
+        given:
+        Long firstAuthorId = Book.withNewTransaction { Author.list().first().id }
+
+        when:
+        HttpResponse<String> resp = getJson("/v1/books?author=${firstAuthorId}")
+        Map json = new JsonSlurper().parseText(resp.body()) as Map
+
+        then:
+        resp.statusCode() == 200
+        json.items.size() > 0
+        json.items.every { it.author.id == firstAuthorId }
+    }
+
+    // -------------------------------------------------------------------------
+    // Negative offset clamping
+    // -------------------------------------------------------------------------
+
+    void "GET /v1/books?offset= clamps negative offset to 0"() {
+        when:
+        HttpResponse<String> resp = getJson('/v1/books?offset=-5')
+        Map json = new JsonSlurper().parseText(resp.body()) as Map
+
+        then:
+        resp.statusCode() == 200
+        json.page == 0
+    }
 }
diff --git a/guides/grails-spock-test-tour/v8/snippets/grails-app/controllers/example/BookController.groovy b/guides/grails-spock-test-tour/v8/snippets/grails-app/controllers/example/BookController.groovy
index 25801a70624..72a1cba3bc4 100644
--- a/guides/grails-spock-test-tour/v8/snippets/grails-app/controllers/example/BookController.groovy
+++ b/guides/grails-spock-test-tour/v8/snippets/grails-app/controllers/example/BookController.groovy
@@ -10,6 +10,13 @@ class BookController extends RestfulController<Book> {
     BookService bookService
     BookController() { super(Book) }
 
+    // Minimal override so the show action is explicitly registered as a
+    // controller action (sibling project debugging revealed that inherited
+    // RestfulController actions can be invisible to the URL-mapping layer).
+    def show() {
+        respond queryForResource(params.get('id'))
+    }
+
     // Override index so the HTML format renders the GSP table view (through the
     // sitemesh layout) while JSON clients still get the REST collection.
     def index(Integer max) {
@@ -20,4 +27,5 @@ class BookController extends RestfulController<Book> {
             json { respond books, [model: [bookCount: countResources()]] }
         }
     }
+
 }
diff --git a/guides/grails-spock-test-tour/v8/snippets/src/integration-test/groovy/example/BookFunctionalSpec.groovy b/guides/grails-spock-test-tour/v8/snippets/src/integration-test/groovy/example/BookFunctionalSpec.groovy
index 7039114d9d4..752bb3edae4 100644
--- a/guides/grails-spock-test-tour/v8/snippets/src/integration-test/groovy/example/BookFunctionalSpec.groovy
+++ b/guides/grails-spock-test-tour/v8/snippets/src/integration-test/groovy/example/BookFunctionalSpec.groovy
@@ -38,4 +38,5 @@ class BookFunctionalSpec extends ContainerGebSpec {
         $('table tbody tr').size() > 0
         $('table tbody').text().contains('The Hobbit')
     }
+
 }
diff --git a/guides/grails-spock-test-tour/v8/snippets/src/test/groovy/example/BookControllerSpec.groovy b/guides/grails-spock-test-tour/v8/snippets/src/test/groovy/example/BookControllerSpec.groovy
index 442aa5159ff..d46a918edce 100644
--- a/guides/grails-spock-test-tour/v8/snippets/src/test/groovy/example/BookControllerSpec.groovy
+++ b/guides/grails-spock-test-tour/v8/snippets/src/test/groovy/example/BookControllerSpec.groovy
@@ -53,4 +53,107 @@ class BookControllerSpec extends Specification
         then:
         response.status == HttpStatus.UNPROCESSABLE_ENTITY.value()
     }
+
+    void "GET /books returns the book list as JSON"() {
+        given:
+        new Book(title: 'First', isbn: '9780547928227', pageCount: 100)
+            .save(flush: true, failOnError: true)
+        new Book(title: 'Second', isbn: '9780547928228', pageCount: 200)
+            .save(flush: true, failOnError: true)
+
+        when:
+        request.method = 'GET'
+        request.format = 'json'
+        controller.index(10)
+
+        then:
+        response.status == HttpStatus.OK.value()
+        response.contentAsString != null
+    }
+
+    void "POST /books with valid data saves and returns 201"() {
+        given:
+        request.method = 'POST'
+        request.json = [title: 'Valid Book', isbn: '9780547928228', pageCount: 150]
+
+        when:
+        controller.save()
+
+        then:
+        response.status == HttpStatus.CREATED.value()
+        response.json.title == 'Valid Book'
+        response.json.isbn  == '9780547928228'
+    }
+
+    void "GET /books/create returns a new transient instance"() {
+        when:
+        request.method = 'GET'
+        request.format = 'json'
+        controller.create()
+
+        then:
+        response.status == HttpStatus.OK.value()
+    }
+
+    void "GET /books/{id}/edit returns the book for editing"() {
+        given:
+        Book b = new Book(title: 'Editable', isbn: '9780547928228', pageCount: 100)
+            .save(flush: true, failOnError: true)
+
+        when:
+        request.method = 'GET'
+        request.format = 'json'
+        params.id = b.id
+        controller.edit()
+
+        then:
+        response.status == HttpStatus.OK.value()
+        response.json.title == 'Editable'
+    }
+
+    void "PUT /books/{id} updates the book"() {
+        given:
+        Book b = new Book(title: 'Original', isbn: '9780547928228', pageCount: 100)
+            .save(flush: true, failOnError: true)
+
+        when:
+        request.method = 'PUT'
+        request.json = [title: 'Updated']
+        params.id = b.id
+        controller.update()
+
+        then:
+        response.status == HttpStatus.OK.value()
+        response.json.title == 'Updated'
+    }
+
+    void "PUT /books/{id} with invalid data returns 422"() {
+        given:
+        Book b = new Book(title: 'Original', isbn: '9780547928229', pageCount: 100)
+            .save(flush: true, failOnError: true)
+
+        when:
+        request.method = 'PUT'
+        request.json = [title: '']
+        params.id = b.id
+        controller.update()
+
+        then:
+        response.status == HttpStatus.UNPROCESSABLE_ENTITY.value()
+    }
+
+    void "DELETE /books/{id} deletes the book"() {
+        given:
+        Book b = new Book(title: 'Deletable', isbn: '9780547928228', pageCount: 100)
+            .save(flush: true, failOnError: true)
+
+        when:
+        request.method = 'DELETE'
+        params.id = b.id
+        controller.delete()
+
+        then:
+        response.status == HttpStatus.NO_CONTENT.value()
+        Book.get(b.id) == null
+    }
 }
diff --git a/guides/grails-spock-test-tour/v8/snippets/src/test/groovy/example/BookServiceSpec.groovy b/guides/grails-spock-test-tour/v8/snippets/src/test/groovy/example/BookServiceSpec.groovy
index fc9a625e386..e9aee914c51 100644
--- a/guides/grails-spock-test-tour/v8/snippets/src/test/groovy/example/BookServiceSpec.groovy
+++ b/guides/grails-spock-test-tour/v8/snippets/src/test/groovy/example/BookServiceSpec.groovy
@@ -43,4 +43,53 @@ class BookServiceSpec extends Specification
         expect:
         service.findByIsbn('9999999999999') == null
     }
+
+    void "get returns the book when it exists"() {
+        given: 'the isbn from the setup fixture'
+        Book b = Book.findByIsbn('9780000000001')
+
+        expect:
+        service.get(b.id)?.title == 'Short Story'
+    }
+
+    void "get returns null when the id does not exist"() {
+        expect:
+        service.get(99999L) == null
+    }
+
+    void "save persists a book and returns it with an id"() {
+        when:
+        Book saved = service.save(new Book(
+            title: 'New Book', isbn: '9780000000005', pageCount: 200))
+
+        then:
+        saved.id != null
+        saved.title == 'New Book'
+
+        and: 'it is visible to a subsequent query'
+        service.countByPageCountGreaterThanEquals(0) == 5
+    }
+
+    void "save rejects a duplicate isbn with ValidationException"() {
+        given:
+        service.save(new Book(title: 'Original', isbn: '9780000000005', pageCount: 100))
+        Book.withSession { it.flush() }
+
+        when:
+        service.save(new Book(title: 'Duplicate', isbn: '9780000000005', pageCount: 200))
+
+        then:
+        def ex = thrown(grails.validation.ValidationException)
+        ex.message.contains('unique')
+    }
+
+    void "list returns all books when called without constraints"() {
+        expect:
+        service.list([:]).size() == 4
+    }
+
+    void "list respects the max parameter"() {
+        expect:
+        service.list([max: 2]).size() == 2
+    }
 }
diff --git a/guides/grails-tailwindcss/v8/snippets/src/integration-test/groovy/example/TailwindFunctionalSpec.groovy b/guides/grails-tailwindcss/v8/snippets/src/integration-test/groovy/example/TailwindFunctionalSpec.groovy
index 3df792977dd..813e53a6365 100644
--- a/guides/grails-tailwindcss/v8/snippets/src/integration-test/groovy/example/TailwindFunctionalSpec.groovy
+++ b/guides/grails-tailwindcss/v8/snippets/src/integration-test/groovy/example/TailwindFunctionalSpec.groovy
@@ -53,6 +53,23 @@ class TailwindFunctionalSpec extends ContainerGebSpec {
         bg && bg != 'rgba(0, 0, 0, 0)' && bg != 'transparent'
     }
 
+    void 'the 404 page renders when navigating to an unknown path'() {
+        when:
+        go '/nonexistent-route'
+
+        then:
+        title == 'Page Not Found'
+        $('h1').text().contains('Page Not Found (404)')
+    }
+
+    void 'the 404 page shows the requested path'() {
+        when:
+        go '/some/missing/page'
+
+        then:
+        $('body').text().contains('/some/missing/page')
+    }
+
     void 'the dark-mode toggle adds the dark class and persists the choice'() {
         given:
         go '/'
diff --git a/guides/grails-vite-spa/v8/snippets/backend/src/integration-test/groovy/example/ApiFunctionalSpec.groovy b/guides/grails-vite-spa/v8/snippets/backend/src/integration-test/groovy/example/ApiFunctionalSpec.groovy
index 842425639cb..5439b000900 100644
--- a/guides/grails-vite-spa/v8/snippets/backend/src/integration-test/groovy/example/ApiFunctionalSpec.groovy
+++ b/guides/grails-vite-spa/v8/snippets/backend/src/integration-test/groovy/example/ApiFunctionalSpec.groovy
@@ -127,4 +127,164 @@ class ApiFunctionalSpec extends Specification {
         then: 'the static bundle is served from the same origin'
         asset.statusCode() == 200
     }
+
+    // ---------------------------------------------------------------------------
+    // Missing CRUD endpoint coverage
+    // ---------------------------------------------------------------------------
+
+    void "GET /api/books/:id returns a single book"() {
+        given:
+        Book.withNewTransaction {
+            if (!Book.findByIsbn('9780553380954')) {
+                new Book(title: 'Foundation', author: 'Isaac Asimov', isbn: '9780553380954').save(failOnError: true)
+            }
+        }
+        Long id = Book.withNewTransaction { Book.findByIsbn('9780553380954').id }
+
+        when:
+        HttpResponse<String> resp = client.send(
+                HttpRequest.newBuilder(uri("/api/books/${id}")).GET().build(),
+                HttpResponse.BodyHandlers.ofString())
+        Map json = new JsonSlurper().parseText(resp.body()) as Map
+
+        then:
+        resp.statusCode() == 200
+        json.title == 'Foundation'
+        json.author == 'Isaac Asimov'
+        json.isbn == '9780553380954'
+    }
+
+    void "GET /api/books/:id returns 404 for a non-existent book"() {
+        when:
+        HttpResponse<String> resp = client.send(
+                HttpRequest.newBuilder(uri('/api/books/999999')).GET().build(),
+                HttpResponse.BodyHandlers.ofString())
+
+        then:
+        resp.statusCode() == 404
+    }
+
+    void "PUT /api/books/:id updates an existing book"() {
+        given:
+        Book.withNewTransaction {
+            if (!Book.findByIsbn('9780345391803')) {
+                new Book(title: 'Hitchhiker', author: 'Douglas Adams', isbn: '9780345391803').save(failOnError: true)
+            }
+        }
+        Long id = Book.withNewTransaction { Book.findByIsbn('9780345391803').id }
+        String body = JsonOutput.toJson([title: 'Hitchhikers Guide', author: 'Douglas Adams', isbn: '9780345391803'])
+
+        when:
+        HttpResponse<String> resp = client.send(
+                HttpRequest.newBuilder(uri("/api/books/${id}"))
+                        .header('Content-Type', 'application/json')
+                        .PUT(HttpRequest.BodyPublishers.ofString(body)).build(),
+                HttpResponse.BodyHandlers.ofString())
+
+        then:
+        resp.statusCode() == 200
+        Book.withNewTransaction {
+            Book.findByIsbn('9780345391803').title == 'Hitchhikers Guide'
+        }
+    }
+
+    void "PUT /api/books/:id with invalid data returns 422"() {
+        given:
+        Book.withNewTransaction {
+            if (!Book.findByIsbn('9780451524935')) {
+                new Book(title: '1984', author: 'George Orwell', isbn: '9780451524935').save(failOnError: true)
+            }
+        }
+        Long id = Book.withNewTransaction { Book.findByIsbn('9780451524935').id }
+        String body = JsonOutput.toJson([title: '', author: '', isbn: '9780451524935'])
+
+        when:
+        HttpResponse<String> resp = client.send(
+                HttpRequest.newBuilder(uri("/api/books/${id}"))
+                        .header('Content-Type', 'application/json')
+                        .PUT(HttpRequest.BodyPublishers.ofString(body)).build(),
+                HttpResponse.BodyHandlers.ofString())
+
+        then:
+        resp.statusCode() == 422
+    }
+
+    void "PUT /api/books/:id for non-existent returns 404"() {
+        given:
+        String body = JsonOutput.toJson([title: 'Ghost', author: 'Nobody', isbn: '9780000000000'])
+
+        when:
+        HttpResponse<String> resp = client.send(
+                HttpRequest.newBuilder(uri('/api/books/999999'))
+                        .header('Content-Type', 'application/json')
+                        .PUT(HttpRequest.BodyPublishers.ofString(body)).build(),
+                HttpResponse.BodyHandlers.ofString())
+
+        then:
+        resp.statusCode() == 404
+    }
+
+    void "DELETE /api/books/:id deletes a book and returns 204"() {
+        given:
+        Book.withNewTransaction {
+            if (!Book.findByIsbn('9780439023481')) {
+                new Book(title: 'Mockingjay', author: 'Suzanne Collins', isbn: '9780439023481').save(failOnError: true)
+            }
+        }
+        Long id = Book.withNewTransaction { Book.findByIsbn('9780439023481').id }
+
+        when:
+        HttpResponse<Void> resp = client.send(
+                HttpRequest.newBuilder(uri("/api/books/${id}")).DELETE().build(),
+                HttpResponse.BodyHandlers.discarding())
+
+        then:
+        resp.statusCode() == 204
+        Book.withNewTransaction { Book.findByIsbn('9780439023481') == null }
+    }
+
+    void "DELETE /api/books/:id for non-existent returns 404"() {
+        when:
+        HttpResponse<Void> resp = client.send(
+                HttpRequest.newBuilder(uri('/api/books/999999')).DELETE().build(),
+                HttpResponse.BodyHandlers.discarding())
+
+        then:
+        resp.statusCode() == 404
+    }
+
+    void "POST /api/books with blank title returns 422"() {
+        given:
+        String body = JsonOutput.toJson([title: '', author: 'Someone', isbn: '9780140449266'])
+
+        when:
+        HttpResponse<String> resp = client.send(
+                HttpRequest.newBuilder(uri('/api/books'))
+                        .header('Content-Type', 'application/json')
+                        .POST(HttpRequest.BodyPublishers.ofString(body)).build(),
+                HttpResponse.BodyHandlers.ofString())
+
+        then:
+        resp.statusCode() == 422
+    }
+
+    void "GET /assets with path traversal returns 400"() {
+        when:
+        HttpResponse<String> resp = client.send(
+                HttpRequest.newBuilder(uri('/assets/../../etc/passwd')).GET().build(),
+                HttpResponse.BodyHandlers.ofString())
+
+        then:
+        resp.statusCode() == 400
+    }
+
+    void "GET /assets for a non-existent file returns 404"() {
+        when:
+        HttpResponse<String> resp = client.send(
+                HttpRequest.newBuilder(uri('/assets/nonexistent-bundle.js')).GET().build(),
+                HttpResponse.BodyHandlers.ofString())
+
+        then:
+        resp.statusCode() == 404
+    }
 }
diff --git a/guides/grails-vite-spa/v8/snippets/backend/src/test/groovy/example/BookControllerSpec.groovy b/guides/grails-vite-spa/v8/snippets/backend/src/test/groovy/example/BookControllerSpec.groovy
index a6622864096..ddb0636504c 100644
--- a/guides/grails-vite-spa/v8/snippets/backend/src/test/groovy/example/BookControllerSpec.groovy
+++ b/guides/grails-vite-spa/v8/snippets/backend/src/test/groovy/example/BookControllerSpec.groovy
@@ -6,9 +6,9 @@ import spock.lang.Specification
 
 /**
  * Controller unit test for the page-size clamp the overridden
- * listAllResources applies. It reads params.int('max', 25), so the spec
- * drives it through the controller's GrailsParameterMap (a plain Map has
- * no int() accessor). No Spring context boots.
+ * listAllResources applies and other controller behaviours that
+ * do not require GSON view rendering (which is covered by the
+ * functional HTTP tests in ApiFunctionalSpec).
  */
 class BookControllerSpec extends Specification
         implements ControllerUnitTest<BookController>, DataTest {