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 {