From 5001aedcf9f0c9a53ae99ba844f49acdd7b1724b Mon Sep 17 00:00:00 2001 From: cproudlock Date: Fri, 21 Aug 2026 11:14:56 -0400 Subject: [PATCH] Offer a spelling on every list, not just the global search Correction only helped on the global search page, and people search from the list they are already on. Extending it turned out to be a shape question rather than a volume one: thirty routes take a search parameter across a dozen files, so carrying a suggestion in each of their responses is a large change today and one more thing every future plugin author has to remember. So the suggestion moved to ITS OWN ENDPOINT, /api/search/suggest, which any page can call after rendering no rows. A page that has not adopted it shows nothing, which is exactly what it showed before - nothing breaks by omission. The plumbing lives in useListQuery, which already owned the search term, so a list needs three lines: take `suggestion` and `reportCount` from the composable, call reportCount(rows.length) after a fetch, and drop SearchSuggestion into the empty state it already has. A list that never calls reportCount never offers a suggestion. Wired: global search, machines, printers, PCs, network devices, measuring tools, knowledge base, vendors. NOT wired, deliberately: the type and reference lists (machine types, PC types, VLANs, subnets, operating systems and the rest), which are small controlled vocabularies nobody typo-searches, and USB, whose empty state has a different shape and wants doing by hand rather than by pattern. TRAP FOUND WHILE WIRING IT, and left commented in every page: applying a suggestion by calling setSearch alone updates the box and the URL and does NOT reload the list. setSearch only syncs the URL, and the watcher that would reload is suppressed because search.value already holds the new term - the same trap the global search page documents in performSearch. Each page calls its own load function directly. The composable guards a stale answer arriving after a newer search was typed, never offers back the word that was typed, and swallows its own errors: a search that found nothing is already the answer, and failing to improve on it is not worth an error in front of anyone. The route-parity gate caught the new endpoint being served without an entry in docs/api-inventory.json, which is hand-written on purpose; added, and the spec regenerated from it (283 paths, 418 operations). That regeneration also carries the openapi version to 0.12.0, left over from the release. --- docs/api-inventory.json | 8 +++ docs/openapi.json | 35 +++++++++++- frontend/src/components/SearchSuggestion.vue | 42 ++++++++++++++ frontend/src/composables/listQuery.js | 27 ++++++++- frontend/src/composables/searchSuggestion.js | 56 +++++++++++++++++++ frontend/src/views/SearchResults.vue | 16 ++++++ frontend/src/views/vendors/VendorsList.vue | 14 ++++- plugins/computers/frontend/views/PCsList.vue | 14 ++++- .../frontend/views/KnowledgeBaseList.vue | 14 ++++- .../machines/frontend/views/MachinesList.vue | 16 +++++- .../frontend/views/MeasuringToolsList.vue | 14 ++++- .../frontend/views/NetworkDevicesList.vue | 14 ++++- .../printers/frontend/views/PrintersList.vue | 16 +++++- shopdb/core/api/search.py | 24 ++++++++ tests/test_core/test_spellfix.py | 29 ++++++++++ 15 files changed, 330 insertions(+), 9 deletions(-) create mode 100644 frontend/src/components/SearchSuggestion.vue create mode 100644 frontend/src/composables/searchSuggestion.js diff --git a/docs/api-inventory.json b/docs/api-inventory.json index 38f7717..e342067 100644 --- a/docs/api-inventory.json +++ b/docs/api-inventory.json @@ -351,6 +351,14 @@ "params": "q (required, 2-200 chars)", "example": "curl 'http://localhost:5001/api/search?q=WKSTN0042'" }, + { + "method": "GET", + "path": "/api/search/suggest", + "purpose": "A better spelling for a query that found nothing, or null. Its own endpoint so any list page can ask after rendering no rows, rather than a suggestion key added to every search route. The vocabulary is harvested from searched columns (asset numbers and names, vendors, models, applications, asset types) and cached, so site-specific words are correctable; digits must match exactly, which keeps CSF16 from being 'corrected' to CSF17. Suggests, never rewrites", + "auth": "jwt-optional", + "params": "q (required, 2-200 chars)", + "example": "curl 'http://localhost:5001/api/search/suggest?q=keyecne'" + }, { "method": "GET", "path": "/api/dashboard", diff --git a/docs/openapi.json b/docs/openapi.json index bbf1b17..340f6de 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "ShopDB Flask API", - "version": "0.11.3", + "version": "0.12.0", "description": "Asset-management API (core + plugins). Responses use a `success_response` envelope: `{status, data, meta}`. Auth: Bearer JWT (login or a managed PAT) for `jwt`/`admin`/`permission:*`; `X-API-Key` for collector/managed-token endpoints; public endpoints need neither." }, "servers": [ @@ -2092,6 +2092,39 @@ } } }, + "/api/search/suggest": { + "get": { + "tags": [ + "core-platform" + ], + "summary": "A better spelling for a query that found nothing, or null. Its own endpoint so any list page can ask after rendering no...", + "description": "A better spelling for a query that found nothing, or null. Its own endpoint so any list page can ask after rendering no rows, rather than a suggestion key added to every search route. The vocabulary is harvested from searched columns (asset numbers and names, vendors, models, applications, asset types) and cached, so site-specific words are correctable; digits must match exactly, which keeps CSF16 from being 'corrected' to CSF17. Suggests, never rewrites\n\n**Auth:** jwt-optional\n\n**Params:** q (required, 2-200 chars)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/search/suggest?q=keyecne'\n```", + "security": [ + {}, + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + } + } + } + }, "/api/dashboard": { "get": { "tags": [ diff --git a/frontend/src/components/SearchSuggestion.vue b/frontend/src/components/SearchSuggestion.vue new file mode 100644 index 0000000..0e8c9e9 --- /dev/null +++ b/frontend/src/components/SearchSuggestion.vue @@ -0,0 +1,42 @@ + + + + + diff --git a/frontend/src/composables/listQuery.js b/frontend/src/composables/listQuery.js index cee6155..205dba4 100644 --- a/frontend/src/composables/listQuery.js +++ b/frontend/src/composables/listQuery.js @@ -1,5 +1,6 @@ import { ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' +import { useSearchSuggestion } from './searchSuggestion' // Keep a list page's current page and search term in the URL query so the // browser Back button restores them. Without this a list keeps page in local @@ -16,6 +17,18 @@ import { useRoute, useRouter } from 'vue-router' // deep link) so the list reloads at the restored page. // extraKeys optional extra query keys to persist (e.g. ['typeid']); each // gets a ref exposed under the returned `extras` object. +// +// SPELLING SUGGESTIONS come along for free, because this composable already +// owns the search term. A list reports how many rows it got and gets back a +// suggestion to offer when that number is zero: +// +// const { search, suggestion, reportCount } = useListQuery({ onChange: load }) +// // at the end of load(): reportCount(rows.value.length) +// // in the empty state: +// +// A list that never calls reportCount simply never offers one, which is what +// every list did before. export function useListQuery(options = {}) { const route = useRoute() const router = useRouter() @@ -98,5 +111,17 @@ export function useListQuery(options = {}) { if (changed) onChange() }) - return { page, search, extras, setPage, setSearch, setExtra } + const { suggestion, checkSuggestion, clearSuggestion } = useSearchSuggestion() + + // Called by the list after a fetch. Zero rows for a non-empty search is the + // only moment a suggestion is worth asking for. + function reportCount(count) { + if (count > 0 || !search.value) clearSuggestion() + else checkSuggestion(search.value) + } + + return { + page, search, extras, setPage, setSearch, setExtra, + suggestion, reportCount, + } } diff --git a/frontend/src/composables/searchSuggestion.js b/frontend/src/composables/searchSuggestion.js new file mode 100644 index 0000000..9242c9a --- /dev/null +++ b/frontend/src/composables/searchSuggestion.js @@ -0,0 +1,56 @@ +import { ref } from 'vue' +import api from '../api' + +/** + * "Did you mean" for a search that found nothing. + * + * ONE composable for every list in the product. Thirty routes take a search + * parameter across a dozen files, so carrying a suggestion in each of their + * responses would be a large change now and a thing every future plugin has to + * remember. Instead a page that renders no rows asks for a spelling, and a page + * that has not adopted this shows nothing - exactly what it showed before. + * + * Usage in a list view: + * + * const { suggestion, checkSuggestion, clearSuggestion } = useSearchSuggestion() + * // after a fetch: + * items.value.length ? clearSuggestion() : checkSuggestion(searchTerm.value) + * + * and in the template, beside the empty state: + * + * + */ +export function useSearchSuggestion() { + const suggestion = ref(null) + // Guards a slow answer arriving after a newer search has already been typed, + // which would offer a spelling for a query nobody is looking at any more. + let latest = 0 + + function clearSuggestion() { + suggestion.value = null + latest += 1 + } + + async function checkSuggestion(query) { + const term = (query || '').trim() + if (term.length < 2) { + clearSuggestion() + return + } + const ticket = ++latest + try { + const response = await api.get('/search/suggest', { params: { q: term } }) + if (ticket !== latest) return + const offered = response.data?.data?.suggestion || null + // Never offer back what was typed: a suggestion identical to the query + // reads as a broken feature. + suggestion.value = offered && offered !== term.toLowerCase() ? offered : null + } catch (err) { + // A search that found nothing is already the answer. Failing to improve + // on it is not worth an error in front of anyone. + if (ticket === latest) suggestion.value = null + } + } + + return { suggestion, checkSuggestion, clearSuggestion } +} diff --git a/frontend/src/views/SearchResults.vue b/frontend/src/views/SearchResults.vue index 48d35d5..80d262f 100644 --- a/frontend/src/views/SearchResults.vue +++ b/frontend/src/views/SearchResults.vue @@ -42,6 +42,7 @@
No results found for "{{ query }}" +
@@ -94,12 +95,14 @@ import { ref, computed, onMounted, watch, nextTick } from 'vue' import { useRoute, useRouter } from 'vue-router' import { searchApi, knowledgebaseApi } from '../api' +import SearchSuggestion from '../components/SearchSuggestion.vue' const route = useRoute() const router = useRouter() const loading = ref(false) const results = ref([]) +const suggestion = ref(null) const query = ref('') const searchInput = ref('') const activeFilter = ref('all') @@ -175,6 +178,7 @@ const filteredResults = computed(() => { async function search(q) { if (!q || q.length < 2) { results.value = [] + suggestion.value = null return } @@ -184,6 +188,10 @@ async function search(q) { try { const response = await searchApi.search(q) const data = response.data.data + // The global endpoint carries the suggestion with the results, so no second + // call is needed here. A list page that has no such key uses the + // useSearchSuggestion composable against /search/suggest instead. + suggestion.value = data?.suggestion || null // Handle ServiceNOW redirect if (data?.redirect?.type === 'servicenow') { @@ -212,6 +220,14 @@ async function search(q) { } } +// Take the offered spelling: put it in the box and search it, so the person +// sees what was searched rather than results for a word they did not type. +function applySuggestion(word) { + searchInput.value = word + suggestion.value = null + performSearch() +} + function performSearch() { const term = searchInput.value.trim() if (!term) return diff --git a/frontend/src/views/vendors/VendorsList.vue b/frontend/src/views/vendors/VendorsList.vue index 734d551..1b08da3 100644 --- a/frontend/src/views/vendors/VendorsList.vue +++ b/frontend/src/views/vendors/VendorsList.vue @@ -55,6 +55,7 @@ No vendors found + @@ -189,11 +190,21 @@ import PaginationBar from '../../components/PaginationBar.vue' import { useToast } from '../../composables/toast' import { apiError } from '../../utils/apiError' import { useListQuery } from '@/composables/listQuery' +import SearchSuggestion from '@/components/SearchSuggestion.vue' const toast = useToast() const vendors = ref([]) const loading = ref(true) -const { page, search, setPage, setSearch } = useListQuery({ onChange: loadVendors }) +const { page, search, setPage, setSearch, suggestion, reportCount } = useListQuery({ onChange: loadVendors }) + +// Take the offered spelling. setSearch only syncs the URL and the watcher +// that reloads is suppressed because search.value already holds the new +// term, so load directly. +function applySuggestion(word) { + search.value = word + setSearch(word) + loadVendors() +} const totalPages = ref(1) const perPage = ref(20) @@ -232,6 +243,7 @@ async function loadVendors() { const response = await vendorsApi.list(params) vendors.value = response.data.data || [] + reportCount(vendors.value.length) totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1 } catch (err) { console.error('Error loading vendors:', err) diff --git a/plugins/computers/frontend/views/PCsList.vue b/plugins/computers/frontend/views/PCsList.vue index d5d8567..361c2c2 100644 --- a/plugins/computers/frontend/views/PCsList.vue +++ b/plugins/computers/frontend/views/PCsList.vue @@ -66,6 +66,7 @@ No computers found + @@ -91,10 +92,20 @@ import { computersApi } from '@/api' import PaginationBar from '@/components/PaginationBar.vue' import { colorStyle } from '@/utils/colorStyle' import { useListQuery } from '@/composables/listQuery' +import SearchSuggestion from '@/components/SearchSuggestion.vue' const computers = ref([]) const loading = ref(true) -const { page, search, setPage, setSearch } = useListQuery({ onChange: loadComputers }) +const { page, search, setPage, setSearch, suggestion, reportCount } = useListQuery({ onChange: loadComputers }) + +// Take the offered spelling. setSearch only syncs the URL and the watcher +// that reloads is suppressed because search.value already holds the new +// term, so load directly. +function applySuggestion(word) { + search.value = word + setSearch(word) + loadComputers() +} const totalPages = ref(1) const perPage = ref(20) @@ -115,6 +126,7 @@ async function loadComputers() { const response = await computersApi.list(params) computers.value = response.data.data || [] + reportCount(computers.value.length) totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1 } catch (error) { console.error('Error loading computers:', error) diff --git a/plugins/knowledgebase/frontend/views/KnowledgeBaseList.vue b/plugins/knowledgebase/frontend/views/KnowledgeBaseList.vue index 01c048c..dcbbfc6 100644 --- a/plugins/knowledgebase/frontend/views/KnowledgeBaseList.vue +++ b/plugins/knowledgebase/frontend/views/KnowledgeBaseList.vue @@ -88,6 +88,7 @@ No articles found + @@ -112,12 +113,22 @@ import { ref, onMounted } from 'vue' import { knowledgebaseApi, applicationsApi } from '@/api' import PaginationBar from '@/components/PaginationBar.vue' import { useListQuery } from '@/composables/listQuery' +import SearchSuggestion from '@/components/SearchSuggestion.vue' const loading = ref(true) const articles = ref([]) const topics = ref([]) const stats = ref(null) -const { page, search, setPage, setSearch } = useListQuery({ onChange: loadArticles }) +const { page, search, setPage, setSearch, suggestion, reportCount } = useListQuery({ onChange: loadArticles }) + +// Take the offered spelling. setSearch only syncs the URL and the watcher +// that reloads is suppressed because search.value already holds the new +// term, so load directly. +function applySuggestion(word) { + search.value = word + setSearch(word) + loadArticles() +} const perPage = ref(20) const totalPages = ref(1) const topicFilter = ref('') @@ -161,6 +172,7 @@ async function loadArticles() { const response = await knowledgebaseApi.list(params) articles.value = response.data.data || [] + reportCount(articles.value.length) totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1 } catch (error) { console.error('Error loading articles:', error) diff --git a/plugins/machines/frontend/views/MachinesList.vue b/plugins/machines/frontend/views/MachinesList.vue index 19732aa..158d45a 100644 --- a/plugins/machines/frontend/views/MachinesList.vue +++ b/plugins/machines/frontend/views/MachinesList.vue @@ -79,6 +79,7 @@ No machines found + @@ -105,10 +106,22 @@ import { colorStyle } from "@/utils/colorStyle" import { machinesApi } from '@/api' import PaginationBar from '@/components/PaginationBar.vue' import { useListQuery } from '@/composables/listQuery' +import SearchSuggestion from '@/components/SearchSuggestion.vue' const machines = ref([]) const loading = ref(true) -const { page, search, setPage, setSearch } = useListQuery({ onChange: loadMachines }) +const { page, search, setPage, setSearch, suggestion, reportCount } = useListQuery({ onChange: loadMachines }) + +// Take the offered spelling: put it in the box and search it, so what was +// searched is what the person sees. +function applySuggestion(word) { + search.value = word + setSearch(word) + // setSearch only syncs the URL, and the watcher that reloads is suppressed + // because search.value already holds the new term - the same trap the global + // search page documents. Load directly. + loadMachines() +} const totalPages = ref(1) const perPage = ref(20) @@ -129,6 +142,7 @@ async function loadMachines() { const response = await machinesApi.list(params) machines.value = response.data.data || [] + reportCount(machines.value.length) totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1 } catch (error) { console.error('Error loading machines:', error) diff --git a/plugins/measuringtools/frontend/views/MeasuringToolsList.vue b/plugins/measuringtools/frontend/views/MeasuringToolsList.vue index f0ea431..856c8fc 100644 --- a/plugins/measuringtools/frontend/views/MeasuringToolsList.vue +++ b/plugins/measuringtools/frontend/views/MeasuringToolsList.vue @@ -73,6 +73,7 @@ No measuring tools found + @@ -97,11 +98,21 @@ import { colorStyle } from '@/utils/colorStyle' import { measuringtoolsApi } from '@/api' import PaginationBar from '@/components/PaginationBar.vue' import { useListQuery } from '@/composables/listQuery' +import SearchSuggestion from '@/components/SearchSuggestion.vue' const tools = ref([]) const types = ref([]) const loading = ref(true) -const { page, search, setPage, setSearch } = useListQuery({ onChange: loadTools }) +const { page, search, setPage, setSearch, suggestion, reportCount } = useListQuery({ onChange: loadTools }) + +// Take the offered spelling. setSearch only syncs the URL and the watcher +// that reloads is suppressed because search.value already holds the new +// term, so load directly. +function applySuggestion(word) { + search.value = word + setSearch(word) + loadTools() +} const typeid = ref('') const calibrationstatus = ref('') const totalPages = ref(1) @@ -133,6 +144,7 @@ async function loadTools() { const response = await measuringtoolsApi.list(params) tools.value = response.data.data || [] + reportCount(tools.value.length) totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1 } catch (err) { console.error('Error loading measuring tools:', err) diff --git a/plugins/network/frontend/views/NetworkDevicesList.vue b/plugins/network/frontend/views/NetworkDevicesList.vue index 8195a9a..e6e5719 100644 --- a/plugins/network/frontend/views/NetworkDevicesList.vue +++ b/plugins/network/frontend/views/NetworkDevicesList.vue @@ -97,6 +97,7 @@ No network devices found + @@ -122,13 +123,23 @@ import { colorStyle } from "@/utils/colorStyle" import { networkApi, vendorsApi, locationsApi } from '@/api' import PaginationBar from '@/components/PaginationBar.vue' import { useListQuery } from '@/composables/listQuery' +import SearchSuggestion from '@/components/SearchSuggestion.vue' const devices = ref([]) const deviceTypes = ref([]) const vendors = ref([]) const locations = ref([]) const loading = ref(true) -const { page, search, setPage, setSearch } = useListQuery({ onChange: loadDevices }) +const { page, search, setPage, setSearch, suggestion, reportCount } = useListQuery({ onChange: loadDevices }) + +// Take the offered spelling. setSearch only syncs the URL and the watcher +// that reloads is suppressed because search.value already holds the new +// term, so load directly. +function applySuggestion(word) { + search.value = word + setSearch(word) + loadDevices() +} const selectedType = ref(null) const vendorFilter = ref('') const locationFilter = ref('') @@ -220,6 +231,7 @@ async function loadDevices() { const response = await networkApi.list(params) devices.value = response.data.data || [] + reportCount(devices.value.length) totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1 } catch (error) { console.error('Error loading network devices:', error) diff --git a/plugins/printers/frontend/views/PrintersList.vue b/plugins/printers/frontend/views/PrintersList.vue index 36cacdf..adc9267 100644 --- a/plugins/printers/frontend/views/PrintersList.vue +++ b/plugins/printers/frontend/views/PrintersList.vue @@ -68,6 +68,7 @@ No printers found + @@ -92,12 +93,24 @@ import { ref, onMounted } from 'vue' import { printersApi } from '@/api' import PaginationBar from '@/components/PaginationBar.vue' import { useListQuery } from '@/composables/listQuery' +import SearchSuggestion from '@/components/SearchSuggestion.vue' const printers = ref([]) const printerTypes = ref([]) const typeFilter = ref('') const loading = ref(true) -const { page, search, setPage, setSearch } = useListQuery({ onChange: loadPrinters }) +const { page, search, setPage, setSearch, suggestion, reportCount } = useListQuery({ onChange: loadPrinters }) + +// Take the offered spelling: put it in the box and search it, so what was +// searched is what the person sees. +function applySuggestion(word) { + search.value = word + setSearch(word) + // setSearch only syncs the URL, and the watcher that reloads is suppressed + // because search.value already holds the new term - the same trap the global + // search page documents. Load directly. + loadPrinters() +} const totalPages = ref(1) const perPage = ref(20) @@ -125,6 +138,7 @@ async function loadPrinters() { const response = await printersApi.list(params) printers.value = response.data.data || [] + reportCount(printers.value.length) totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1 } catch (error) { console.error('Error loading printers:', error) diff --git a/shopdb/core/api/search.py b/shopdb/core/api/search.py index 90dd66e..a308bc5 100644 --- a/shopdb/core/api/search.py +++ b/shopdb/core/api/search.py @@ -1068,6 +1068,30 @@ def _suggestion_for(query): return None +@search_bp.route('/suggest', methods=['GET']) +@jwt_required(optional=True) +def search_suggest(): + """A better spelling for a query that found nothing, or null. + + ITS OWN ENDPOINT, deliberately, rather than a `suggestion` key added to + every list response. Thirty routes take a search parameter across a dozen + files, so folding it into each one is a large change today and a thing every + future plugin has to remember. A page that finds no rows calls this instead; + a page that has not adopted it simply shows no suggestion, which is what it + showed before. + + Returns null rather than a poor guess. The caller offers "did you mean" and + a person decides - nothing searches on their behalf. + """ + query = request.args.get('q', '').strip() + if not query or len(query) < 2 or len(query) > 200: + return success_response({'query': query, 'suggestion': None}) + return success_response({ + 'query': query, + 'suggestion': _suggestion_for(query), + }) + + @search_bp.route('', methods=['GET']) @jwt_required(optional=True) def global_search(): diff --git a/tests/test_core/test_spellfix.py b/tests/test_core/test_spellfix.py index 1f8b756..1426f4e 100644 --- a/tests/test_core/test_spellfix.py +++ b/tests/test_core/test_spellfix.py @@ -160,3 +160,32 @@ def test_a_mistyped_asset_number_is_not_corrected_to_a_real_one( data = resp.get_json()['data'] assert data['results'] == [] assert 'suggestion' not in data + + +def test_the_suggest_endpoint_serves_any_page(client, db, auth_headers): + """The shared endpoint every list uses, rather than a suggestion key added + to thirty search routes.""" + from shopdb.core.models import Vendor + from shopdb.extensions import cache + + db.session.add(Vendor(vendor='Keyence')) + db.session.commit() + cache.delete('search_vocabulary') + + resp = client.get('/api/search/suggest?q=keyecne', headers=auth_headers) + assert resp.status_code == 200 + assert resp.get_json()['data']['suggestion'] == 'keyence' + + +def test_the_suggest_endpoint_says_null_rather_than_guessing(client, db, + auth_headers): + from shopdb.extensions import cache + cache.delete('search_vocabulary') + resp = client.get('/api/search/suggest?q=helicopter', headers=auth_headers) + assert resp.get_json()['data']['suggestion'] is None + + +def test_the_suggest_endpoint_ignores_a_too_short_query(client, auth_headers): + resp = client.get('/api/search/suggest?q=c', headers=auth_headers) + assert resp.status_code == 200 + assert resp.get_json()['data']['suggestion'] is None