✨ (search): Add simple search for repos and persons (UI + API)
Changes
15 files changed, +533 -0
MODIFY
README.md
+5 -0
@@ -38,6 +38,11 @@
38
38
managing mirrors, and managing collaborators stay owner-only. Guides:
39
39
[for users](docs/users/collaborators.md), [for admins](docs/admins/collaborators.md)
40
40
- Per-repository images: the repo owner can upload a custom image (same PNG/JPEG/GIF/WebP, ≤ 2 MB, validated rules as avatars) on a dedicated owner-only repository **Settings** page (`/repos/{owner}/{name}/settings`), stored on the filesystem keyed by repo UUID. It replaces the owner's avatar wherever the repository is shown (repo lists, repo sidebar); a repository with no custom image falls back to its owner's avatar. Served at `GET /repos/{owner}/{name}/image`, visibility-guarded so a private repo's image never leaks (`404` for non-viewers), and removable back to the fallback
41
+- **Search** — a simple case-insensitive substring search over repositories (owner, name,
42
+ description) and people (username, display name), from the header search box or `GET /search`,
43
+ and as JSON at `GET /api/v1/search?q=<term>`. Repository hits obey the same visibility rule as
44
+ everywhere else, so a private repo never surfaces to someone who cannot already read it. Guides:
45
+ [for users](docs/users/search.md), [for admins](docs/admins/search.md)
41
46
- Single access policy on all paths: owner read/write, collaborators read/write, org roles
42
47
(guest read / member write / owner admin) on org repositories, public world-readable,
43
48
private repositories visible only to whoever holds a read grant
MODIFY
docs/README.md
+4 -0
@@ -8,6 +8,8 @@
8
8
9
9
### For users
10
10
11
+- **[Search](users/search.md)** — the header search box: case-insensitive
12
+ substring search over repositories and people, and how visibility is honored.
11
13
- **[Profile settings](users/profile.md)** — change your username and display
12
14
name, upload or remove a profile picture.
13
15
- **[Repository image](users/repository-image.md)** — give a repository its own
@@ -33,6 +35,8 @@
33
35
- **[Getting Started](admins/getting-started.md)** — deploy git-shark with Docker
34
36
Compose, from zero to a running instance behind TLS with OIDC login and SSH git
35
37
access.
38
+- **[Search](admins/search.md)** — the `/search` and `/api/v1/search` endpoints,
39
+ JSON shape, matching semantics, visibility enforcement (no configuration).
36
40
- **[Persistent data](admins/persistent-data.md)** — every store that must survive
37
41
container recreation (database, repositories, avatars, SSH host key), what breaks
38
42
when each is lost, and how to retrofit older deployments.
ADD
docs/admins/search.md
+54 -0
@@ -0,0 +1,54 @@
1
+# Search
2
+
3
+Search spans repositories and people and is exposed both as a page and as JSON.
4
+It needs **no configuration** — there are no `GITSHARK_*` properties, no new
5
+tables, and no background jobs. It reads existing `repositories` and `users`
6
+rows directly.
7
+
8
+## Endpoints
9
+
10
+| Method & path | Auth | Returns |
11
+|---|---|---|
12
+| `GET /search?q=<term>` | Optional (session) | HTML results page |
13
+| `GET /api/v1/search?q=<term>` | Optional (Bearer token) | JSON hits |
14
+
15
+Both endpoints are open to anonymous callers. When a personal access token (API)
16
+or session (UI) is present, the caller's own private repositories become
17
+eligible; otherwise only public repositories are searched. People results are
18
+unaffected by authentication.
19
+
20
+## JSON shape
21
+
22
+```json
23
+{
24
+ "repositories": [
25
+ { "owner": "alice", "name": "widgets", "visibility": "PUBLIC",
26
+ "description": "gadgets", "createdAt": "2026-07-14T08:00:00Z" }
27
+ ],
28
+ "persons": [
29
+ { "username": "alice", "displayName": "Alice Example" }
30
+ ]
31
+}
32
+```
33
+
34
+A blank or missing `q` returns `200` with two empty arrays — never an error.
35
+
36
+## Matching semantics
37
+
38
+- Case-insensitive **substring** match (SQL `LIKE '%term%'` for people; in-memory
39
+ substring for repositories). No ranking, no full-text.
40
+- Repositories match on owner handle, name, and description; people on username
41
+ and display name.
42
+- Only **onboarded** users (those who have chosen a handle) appear in people
43
+ results.
44
+- Repository visibility is enforced by reusing the same "visible to this user"
45
+ query the rest of the platform uses, so search cannot leak a private
46
+ repository.
47
+
48
+## Operational notes
49
+
50
+- No caching and no dedicated index: each search is a live query. For the
51
+ repository side it lists the caller's visible repositories and filters them in
52
+ memory, which is fine at the scale git-shark targets. If a deployment grows to
53
+ a very large repository count, this is the first place to add a query-side
54
+ filter or an index.
ADD
docs/users/search.md
+38 -0
@@ -0,0 +1,38 @@
1
+# Search
2
+
3
+git-shark has a single search box in the header, on every page. Type a term and
4
+press Enter to land on the results page at `/search?q=<term>`.
5
+
6
+## What it searches
7
+
8
+Search looks at two kinds of things at once:
9
+
10
+- **Repositories** — matched on the owner handle, the repository name, and the
11
+ description.
12
+- **People** — matched on the username (handle) and the display name.
13
+
14
+Matching is a plain **case-insensitive substring** match: searching `ship`
15
+finds `airship`, `Shipping`, and a person whose display name is `Shipwright`.
16
+There is no ranking, fuzzy matching, or full-text search — hits are grouped by
17
+kind (repositories first, then people) and repositories are ordered by name,
18
+people by handle.
19
+
20
+## What you see
21
+
22
+- Repository hits link straight to the repository page and show the same
23
+ name, visibility badge, and description you see in repository lists.
24
+- People hits show the avatar, handle, and display name.
25
+
26
+You only ever see repositories you are allowed to see: **public repositories,
27
+plus your own private ones** (and private repositories shared with you as a
28
+collaborator or through an organisation). A private repository never appears in
29
+search results for someone who cannot already open it — whether they are logged
30
+in or not.
31
+
32
+An empty or blank query is not an error: the page simply prompts you to type
33
+something and shows no results.
34
+
35
+## Searching from the API
36
+
37
+The same search is available as JSON for scripts and tools — see the
38
+[admin search reference](../admins/search.md) and the REST API docs.
MODIFY
src/main/java/de/workaround/api/ApiModels.java
+5 -0
@@ -1,6 +1,7 @@
1
1
package de.workaround.api;
2
2
3
3
import java.time.Instant;
4
+import java.util.List;
4
5
5
6
import de.workaround.model.Issue;
6
7
import de.workaround.model.MergeRequest;
@@ -64,6 +65,10 @@
64
65
{
65
66
}
66
67
68
+ public record SearchView(List<RepositoryView> repositories, List<UserView> persons)
69
+ {
70
+ }
71
+
67
72
// -- requests --
68
73
69
74
public record NewRepository(String name, Repository.Visibility visibility, String description)
ADD
src/main/java/de/workaround/api/SearchApiResource.java
+35 -0
@@ -0,0 +1,35 @@
1
+package de.workaround.api;
2
+
3
+import de.workaround.search.SearchResults;
4
+import de.workaround.search.SearchService;
5
+import jakarta.inject.Inject;
6
+import jakarta.ws.rs.GET;
7
+import jakarta.ws.rs.Path;
8
+import jakarta.ws.rs.Produces;
9
+import jakarta.ws.rs.QueryParam;
10
+import jakarta.ws.rs.core.MediaType;
11
+
12
+/**
13
+ * JSON search over repositories and persons at {@code /api/v1/search?q=<term>}. Open to anonymous
14
+ * callers; repository hits follow the same visibility rule as the rest of the API (public repos plus
15
+ * the caller's own private ones). A blank query returns empty arrays rather than an error.
16
+ */
17
+@Path("/api/v1/search")
18
+@Produces(MediaType.APPLICATION_JSON)
19
+public class SearchApiResource
20
+{
21
+ @Inject
22
+ SearchService search;
23
+
24
+ @Inject
25
+ ApiPrincipal principal;
26
+
27
+ @GET
28
+ public ApiModels.SearchView search(@QueryParam("q") String q)
29
+ {
30
+ SearchResults results = search.search(principal.orNull(), q);
31
+ return new ApiModels.SearchView(
32
+ results.repositories().stream().map(ApiModels.RepositoryView::of).toList(),
33
+ results.persons().stream().map(user -> new ApiModels.UserView(user.username, user.displayName)).toList());
34
+ }
35
+}
MODIFY
src/main/java/de/workaround/model/User.java
+8 -0
@@ -1,10 +1,12 @@
1
1
package de.workaround.model;
2
2
3
3
import java.time.Instant;
4
+import java.util.List;
4
5
import java.util.Optional;
5
6
import java.util.UUID;
6
7
7
8
import org.hibernate.annotations.processing.Find;
9
+import org.hibernate.annotations.processing.HQL;
8
10
9
11
import io.quarkus.hibernate.panache.PanacheEntity;
10
12
import io.quarkus.hibernate.panache.PanacheRepository;
@@ -77,6 +79,12 @@
77
79
78
80
@Find
79
81
Optional<User> findByUsername(String username);
82
+
83
+ // Onboarded users only (username set); case-insensitive LIKE on handle and display name. The
84
+ // caller supplies the already-lowercased %pattern% so the wildcards live in one place.
85
+ @HQL("where username is not null and (lower(username) like :pattern or lower(displayName) like :pattern)"
86
+ + " order by username")
87
+ List<User> search(String pattern);
80
88
}
81
89
82
90
}
ADD
src/main/java/de/workaround/search/SearchResults.java
+18 -0
@@ -0,0 +1,18 @@
1
+package de.workaround.search;
2
+
3
+import java.util.List;
4
+
5
+import de.workaround.model.Repository;
6
+import de.workaround.model.User;
7
+
8
+/**
9
+ * The hits of a single search, split by kind. Repositories are already filtered to what the
10
+ * searching user may read; persons are onboarded users only. Both lists are empty for a blank query.
11
+ */
12
+public record SearchResults(List<Repository> repositories, List<User> persons)
13
+{
14
+ public static SearchResults empty()
15
+ {
16
+ return new SearchResults(List.of(), List.of());
17
+ }
18
+}
ADD
src/main/java/de/workaround/search/SearchService.java
+55 -0
@@ -0,0 +1,55 @@
1
+package de.workaround.search;
2
+
3
+import java.util.List;
4
+import java.util.Locale;
5
+
6
+import de.workaround.git.GitRepositoryService;
7
+import de.workaround.model.Repository;
8
+import de.workaround.model.User;
9
+import jakarta.enterprise.context.ApplicationScoped;
10
+import jakarta.inject.Inject;
11
+
12
+/**
13
+ * Simple case-insensitive substring search across repositories and persons, shared by the REST API
14
+ * and the server-rendered UI so both return the same hits. Repository visibility is enforced by
15
+ * reusing {@link GitRepositoryService#listVisibleTo(User)} before filtering, so a search can never
16
+ * surface a private repository the searcher cannot already read. No ranking or full-text in v1.
17
+ */
18
+@ApplicationScoped
19
+public class SearchService
20
+{
21
+ @Inject
22
+ GitRepositoryService repositories;
23
+
24
+ @Inject
25
+ User.Repo users;
26
+
27
+ /**
28
+ * @param actor the searching user, or {@code null} for an anonymous request (public repos only)
29
+ * @param query the raw search term; a null or blank query yields {@link SearchResults#empty()}
30
+ */
31
+ public SearchResults search(User actor, String query)
32
+ {
33
+ if (query == null || query.isBlank())
34
+ {
35
+ return SearchResults.empty();
36
+ }
37
+ String needle = query.trim().toLowerCase(Locale.ROOT);
38
+ List<Repository> repos = repositories.listVisibleTo(actor).stream()
39
+ .filter(repo -> matches(repo, needle))
40
+ .toList();
41
+ List<User> persons = users.search("%" + needle + "%");
42
+ return new SearchResults(repos, persons);
43
+ }
44
+
45
+ private static boolean matches(Repository repo, String needle)
46
+ {
47
+ return contains(repo.ownerHandle(), needle) || contains(repo.name, needle)
48
+ || contains(repo.description, needle);
49
+ }
50
+
51
+ private static boolean contains(String field, String needle)
52
+ {
53
+ return field != null && field.toLowerCase(Locale.ROOT).contains(needle);
54
+ }
55
+}
ADD
src/main/java/de/workaround/web/SearchResource.java
+48 -0
@@ -0,0 +1,48 @@
1
+package de.workaround.web;
2
+
3
+import java.util.List;
4
+
5
+import de.workaround.account.CurrentUser;
6
+import de.workaround.model.Repository;
7
+import de.workaround.model.User;
8
+import de.workaround.search.SearchResults;
9
+import de.workaround.search.SearchService;
10
+import io.quarkus.qute.CheckedTemplate;
11
+import io.quarkus.qute.TemplateInstance;
12
+import jakarta.inject.Inject;
13
+import jakarta.ws.rs.GET;
14
+import jakarta.ws.rs.Path;
15
+import jakarta.ws.rs.Produces;
16
+import jakarta.ws.rs.QueryParam;
17
+import jakarta.ws.rs.core.MediaType;
18
+
19
+/**
20
+ * The server-rendered search page at {@code /search?q=<term>}. Groups repository and person hits;
21
+ * repository visibility follows the same rule as everywhere else. A blank query renders the empty
22
+ * results page rather than an error.
23
+ */
24
+@Path("/search")
25
+@Produces(MediaType.TEXT_HTML)
26
+public class SearchResource
27
+{
28
+ @CheckedTemplate
29
+ static class Templates
30
+ {
31
+ static native TemplateInstance results(String query, List<Repository> repositories, List<User> persons,
32
+ User user);
33
+ }
34
+
35
+ @Inject
36
+ CurrentUser currentUser;
37
+
38
+ @Inject
39
+ SearchService search;
40
+
41
+ @GET
42
+ public TemplateInstance search(@QueryParam("q") String q)
43
+ {
44
+ User user = currentUser.get();
45
+ SearchResults results = search.search(user, q);
46
+ return Templates.results(q == null ? "" : q, results.repositories(), results.persons(), user);
47
+ }
48
+}
ADD
src/main/resources/templates/SearchResource/results.html
+37 -0
@@ -0,0 +1,37 @@
1
+{#include layout}
2
+{#title}Search – git-shark{/title}
3
+<h1>Search</h1>
4
+<form class="search" method="get" action="/search">
5
+ <input type="search" name="q" value="{query}" placeholder="Search repositories and people" aria-label="Search">
6
+ <button class="btn btn-primary">Search</button>
7
+</form>
8
+{#if query.isBlank()}
9
+<p class="muted">Type a query to search repositories and people.</p>
10
+{#else}
11
+<h2>Repositories</h2>
12
+{#if repositories.isEmpty()}
13
+<p class="muted">No repositories match “{query}”.</p>
14
+{#else}
15
+<table>
16
+ <tr><th>Repository</th><th>Visibility</th><th>Description</th></tr>
17
+ {#for repo in repositories}
18
+ <tr class="row-link">
19
+ <td class="cell-link"><a class="mono" href="/repos/{repo.ownerHandle}/{repo.name}">{#repoAvatar repo=repo /} {repo.ownerHandle}/{repo.name}</a></td>
20
+ <td><span class="badge badge-{repo.visibility.name().toLowerCase()}">{repo.visibility.name().toLowerCase()}</span></td>
21
+ <td class="muted">{repo.description ?: ''}</td>
22
+ </tr>
23
+ {/for}
24
+</table>
25
+{/if}
26
+<h2>People</h2>
27
+{#if persons.isEmpty()}
28
+<p class="muted">No people match “{query}”.</p>
29
+{#else}
30
+<ul class="person-list">
31
+ {#for person in persons}
32
+ <li>{#avatar user=person /} <span class="mono">{person.username}</span>{#if person.displayName} <span class="muted">{person.displayName}</span>{/if}</li>
33
+ {/for}
34
+</ul>
35
+{/if}
36
+{/if}
37
+{/include}
MODIFY
src/main/resources/templates/layout.html
+3 -0
@@ -11,6 +11,9 @@
11
11
<body class="{cdi:currentUser.contentWidthClass}">
12
12
<header class="site">
13
13
<a class="brand" href="/"><svg class="bmark" viewBox="0 0 64 64" width="26" height="26" aria-hidden="true"><rect width="64" height="64" rx="15" fill="#168b85"></rect><path d="M12 42 H52 M24 42 C29 42 31 50 38 50" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round"></path><circle cx="16" cy="42" r="3.2" fill="#fff"></circle><circle cx="38" cy="50" r="3.2" fill="#fff"></circle><path d="M26 42 C28 32 32 24 39 14 C40 22 42 33 45 42 Z" fill="#fff"></path></svg><span class="g">git</span><span class="s">shark</span></a>
14
+ <form class="nav-search" method="get" action="/search" role="search">
15
+ <input type="search" name="q" placeholder="Search" aria-label="Search repositories and people">
16
+ </form>
14
17
<nav>
15
18
{#insert nav}
16
19
{#if cdi:currentUser.loggedIn}
ADD
src/test/java/de/workaround/api/SearchApiTest.java
+66 -0
@@ -0,0 +1,66 @@
1
+package de.workaround.api;
2
+
3
+import java.util.UUID;
4
+
5
+import org.hamcrest.Matchers;
6
+import org.junit.jupiter.api.Test;
7
+
8
+import de.workaround.git.GitRepositoryService;
9
+import de.workaround.model.Repository;
10
+import de.workaround.model.User;
11
+import io.quarkus.test.junit.QuarkusTest;
12
+import jakarta.inject.Inject;
13
+import jakarta.transaction.Transactional;
14
+
15
+import static io.restassured.RestAssured.given;
16
+import static org.hamcrest.CoreMatchers.hasItem;
17
+import static org.hamcrest.CoreMatchers.not;
18
+
19
+@QuarkusTest
20
+class SearchApiTest
21
+{
22
+ @Inject
23
+ GitRepositoryService service;
24
+
25
+ @Inject
26
+ User.Repo userRepo;
27
+
28
+ @Test
29
+ void searchReturnsRepositoriesAndPersonsAsJson()
30
+ {
31
+ String tok = "apisrch" + shortId();
32
+ User owner = persistUser("owner-" + tok);
33
+ service.create(owner, "repo-" + tok, Repository.Visibility.PUBLIC, null);
34
+ service.create(owner, "priv-" + tok, Repository.Visibility.PRIVATE, null);
35
+
36
+ given().when().get("/api/v1/search?q=" + tok)
37
+ .then().statusCode(200)
38
+ .body("repositories.name", hasItem("repo-" + tok))
39
+ .body("repositories.name", not(hasItem("priv-" + tok)))
40
+ .body("persons.username", hasItem("owner-" + tok));
41
+ }
42
+
43
+ @Test
44
+ void blankQueryReturnsEmptyArrays()
45
+ {
46
+ given().when().get("/api/v1/search?q=")
47
+ .then().statusCode(200)
48
+ .body("repositories", Matchers.hasSize(0))
49
+ .body("persons", Matchers.hasSize(0));
50
+ }
51
+
52
+ private static String shortId()
53
+ {
54
+ return UUID.randomUUID().toString().substring(0, 8);
55
+ }
56
+
57
+ @Transactional
58
+ User persistUser(String name)
59
+ {
60
+ User user = new User();
61
+ user.oidcSub = name;
62
+ user.username = name;
63
+ user.persist();
64
+ return user;
65
+ }
66
+}
ADD
src/test/java/de/workaround/search/SearchServiceTest.java
+93 -0
@@ -0,0 +1,93 @@
1
+package de.workaround.search;
2
+
3
+import java.util.List;
4
+import java.util.UUID;
5
+
6
+import org.junit.jupiter.api.Test;
7
+
8
+import de.workaround.git.GitRepositoryService;
9
+import de.workaround.model.Repository;
10
+import de.workaround.model.User;
11
+import io.quarkus.test.junit.QuarkusTest;
12
+import jakarta.inject.Inject;
13
+import jakarta.transaction.Transactional;
14
+
15
+import static org.junit.jupiter.api.Assertions.assertFalse;
16
+import static org.junit.jupiter.api.Assertions.assertTrue;
17
+
18
+@QuarkusTest
19
+class SearchServiceTest
20
+{
21
+ @Inject
22
+ SearchService search;
23
+
24
+ @Inject
25
+ GitRepositoryService repositories;
26
+
27
+ @Inject
28
+ User.Repo userRepo;
29
+
30
+ @Test
31
+ void findsPublicRepoAndPersonButHidesPrivateFromStrangers()
32
+ {
33
+ String tok = "srch" + shortId();
34
+ User owner = persistUser("owner-" + tok, null);
35
+ repositories.create(owner, "repo-" + tok, Repository.Visibility.PUBLIC, "the " + tok + " project");
36
+ repositories.create(owner, "priv-" + tok, Repository.Visibility.PRIVATE, null);
37
+
38
+ // anonymous: public repo + person (username carries the token), private repo excluded
39
+ SearchResults anon = search.search(null, tok);
40
+ assertTrue(repoNames(anon).contains("repo-" + tok), "public repo should match");
41
+ assertFalse(repoNames(anon).contains("priv-" + tok), "private repo must be hidden from anonymous");
42
+ assertTrue(usernames(anon).contains("owner-" + tok), "person should match by username");
43
+
44
+ // owner sees their own private repo too
45
+ SearchResults asOwner = search.search(owner, tok);
46
+ assertTrue(repoNames(asOwner).contains("priv-" + tok), "owner should see their private repo");
47
+ }
48
+
49
+ @Test
50
+ void matchesPersonByDisplayNameCaseInsensitively()
51
+ {
52
+ String tok = "disp" + shortId();
53
+ persistUser("plainhandle-" + shortId(), "Zed " + tok + " Person");
54
+
55
+ SearchResults results = search.search(null, tok.toUpperCase());
56
+ assertTrue(results.persons().stream().anyMatch(u -> u.displayName != null && u.displayName.contains(tok)),
57
+ "person should match by display name, case-insensitively");
58
+ }
59
+
60
+ @Test
61
+ void blankQueryReturnsEmpty()
62
+ {
63
+ assertTrue(search.search(null, " ").repositories().isEmpty());
64
+ assertTrue(search.search(null, " ").persons().isEmpty());
65
+ assertTrue(search.search(null, null).persons().isEmpty());
66
+ }
67
+
68
+ private static List<String> repoNames(SearchResults r)
69
+ {
70
+ return r.repositories().stream().map(repo -> repo.name).toList();
71
+ }
72
+
73
+ private static List<String> usernames(SearchResults r)
74
+ {
75
+ return r.persons().stream().map(u -> u.username).toList();
76
+ }
77
+
78
+ private static String shortId()
79
+ {
80
+ return UUID.randomUUID().toString().substring(0, 8);
81
+ }
82
+
83
+ @Transactional
84
+ User persistUser(String name, String displayName)
85
+ {
86
+ User user = new User();
87
+ user.oidcSub = name;
88
+ user.username = name;
89
+ user.displayName = displayName;
90
+ user.persist();
91
+ return user;
92
+ }
93
+}
ADD
src/test/java/de/workaround/web/SearchUiTest.java
+64 -0
@@ -0,0 +1,64 @@
1
+package de.workaround.web;
2
+
3
+import java.util.UUID;
4
+
5
+import org.junit.jupiter.api.Test;
6
+
7
+import de.workaround.git.GitRepositoryService;
8
+import de.workaround.model.Repository;
9
+import de.workaround.model.User;
10
+import io.quarkus.test.junit.QuarkusTest;
11
+import jakarta.inject.Inject;
12
+import jakarta.transaction.Transactional;
13
+
14
+import static io.restassured.RestAssured.given;
15
+import static org.hamcrest.CoreMatchers.containsString;
16
+import static org.hamcrest.CoreMatchers.not;
17
+
18
+@QuarkusTest
19
+class SearchUiTest
20
+{
21
+ @Inject
22
+ GitRepositoryService service;
23
+
24
+ @Inject
25
+ User.Repo userRepo;
26
+
27
+ @Test
28
+ void resultsPageShowsRepoAndPersonHits()
29
+ {
30
+ String tok = "uisrch" + shortId();
31
+ User owner = persistUser("owner-" + tok);
32
+ service.create(owner, "repo-" + tok, Repository.Visibility.PUBLIC, null);
33
+ service.create(owner, "priv-" + tok, Repository.Visibility.PRIVATE, null);
34
+
35
+ given().when().get("/search?q=" + tok)
36
+ .then().statusCode(200)
37
+ .body(containsString("/repos/owner-" + tok + "/repo-" + tok))
38
+ .body(not(containsString("priv-" + tok)))
39
+ .body(containsString("owner-" + tok));
40
+ }
41
+
42
+ @Test
43
+ void blankQueryRendersPromptWithoutError()
44
+ {
45
+ given().when().get("/search")
46
+ .then().statusCode(200)
47
+ .body(containsString("Type a query to search"));
48
+ }
49
+
50
+ private static String shortId()
51
+ {
52
+ return UUID.randomUUID().toString().substring(0, 8);
53
+ }
54
+
55
+ @Transactional
56
+ User persistUser(String name)
57
+ {
58
+ User user = new User();
59
+ user.oidcSub = name;
60
+ user.username = name;
61
+ user.persist();
62
+ return user;
63
+ }
64
+}