Find a knowledge base article by words from different fields
Some checks failed
CI / backend (push) Failing after 7m15s
CI / frontend (push) Has been cancelled
CI / migrations-mysql (push) Has been cancelled
CI / naming (push) Has been cancelled

An article's searchable text lives in three places: its title, its keywords,
and the name of its topic. Nobody typing a search knows or cares which word
came from which, so 'CMM Community' means "the article tagged community, under
the CMM topic". Both search paths returned nothing for it.

The plugin's own listing, which is what the KB page calls, built a single
ilike('%CMM Community%'). That needs the whole phrase contiguous in ONE field,
and no article has it: 'CMM' is only in the topic's name, 'Community' only in
the keywords. Global search already split the query into words but only ever
looked at title and keywords, so a word that only the topic could satisfy
failed there too - the same bug wearing a different face.

Every word must now be found somewhere across all three fields, in any order.
_word_match gains an `extra` hook for a word that a RELATED row satisfies
rather than a column of this table, which is how the topic joins in without
colliding with the sort=='topic' join.

The retired-topic rule is unchanged and pinned on both paths: matching more
words is not a way past it.

Verified against the 235-article dev library, where all three of these
returned nothing before: 'Fieldglass Jefferson' (title word plus topic word),
'outage notification' (two keywords, not adjacent), 'compucom Jefferson'.

The same phrase-only pattern is repeated in about ten other list endpoints
(computers, machines, printers, network, printedparts, measuringtools, usb,
notifications). Left alone here - fixing them properly means promoting the
word-match helper onto the shopdb.api contract surface rather than copying it
per plugin, which is a contract bump.
This commit is contained in:
cproudlock
2026-08-21 09:39:46 -04:00
parent 5de3fe4b40
commit d0eeaa08d5
3 changed files with 198 additions and 25 deletions

View File

@@ -0,0 +1,128 @@
"""Searching a knowledge base article by words that live in different fields.
An article's searchable text is spread across three places: its title, its
keywords, and the name of its topic. People do not know or care which word came
from which, so they type the two words that identify the article and expect it
back - 'CMM Community' for an article tagged `community` under the CMM topic.
Both search paths used one `ilike('%CMM Community%')`, which needs that exact
phrase contiguous in a SINGLE field. No article has it, so the search returned
nothing at all and the article looked absent rather than unfindable. Every word
must be found somewhere, in any order, across all three fields.
Covers the plugin's own listing (`GET /api/knowledgebase?search=`, what the KB
page uses) and global search (`GET /api/search?q=`), which had the fault in two
different shapes: the listing did not split words, and global search split them
but never looked at the topic.
"""
import pytest
from shopdb.core.models import Application
from plugins.knowledgebase.models import KnowledgeBase
@pytest.fixture
def library(db):
"""Articles whose identifying words are deliberately spread across fields."""
topics = {
'cmm': Application(appname='Zzcmm', isactive=True),
'other': Application(appname='Zzunrelated', isactive=True),
'retired': Application(appname='Zzretired Zzcmm', isactive=False),
}
db.session.add_all(topics.values())
db.session.flush()
articles = {
# The reported case: neither word is in the same field as the other.
'split': KnowledgeBase(
shortdescription='Zzprotect viewer notes', linkurl='u1',
keywords='zzcommunity license', appid=topics['cmm'].appid,
isactive=True),
# Both words present but not adjacent, inside one field.
'gapped': KnowledgeBase(
shortdescription='Zzpower outage plan alert zznotification',
linkurl='u2', keywords='', appid=None, isactive=True),
# Holds only one of the two words; must NOT come back.
'halfmatch': KnowledgeBase(
shortdescription='Zzprotect viewer only', linkurl='u3',
keywords='license', appid=topics['other'].appid, isactive=True),
# Retired topic, matches both words. The existing rule still wins.
'retired': KnowledgeBase(
shortdescription='Zzprotect viewer retired', linkurl='u4',
keywords='zzcommunity', appid=topics['retired'].appid,
isactive=True),
}
db.session.add_all(articles.values())
db.session.commit()
return {'topics': topics, 'articles': articles}
def listed(client, search):
"""Titles from the plugin's own listing, the one the KB page calls."""
resp = client.get('/api/knowledgebase', query_string={'search': search})
assert resp.status_code == 200, resp.get_json()
return [row['shortdescription'] for row in resp.get_json()['data']]
def globally(client, search):
"""Knowledge base titles from global search."""
resp = client.get('/api/search', query_string={'q': search})
assert resp.status_code == 200, resp.get_json()
return [r['title'] for r in resp.get_json()['data']['results']
if r['type'] == 'knowledgebase']
# --- the reported case ------------------------------------------------------
def test_listing_finds_an_article_by_its_topic_and_its_keyword(client, library):
"""'Zzcmm Zzcommunity': one word from the topic's name, one from the keywords.
This is the bug as reported. A phrase match could satisfy neither field.
"""
assert 'Zzprotect viewer notes' in listed(client, 'Zzcmm Zzcommunity')
def test_global_search_finds_an_article_by_its_topic_and_its_keyword(client, library):
"""Global search split the words but only ever looked at title and keywords,
so a word that only the topic could satisfy failed there too."""
assert 'Zzprotect viewer notes' in globally(client, 'Zzcmm Zzcommunity')
# --- word order and adjacency ----------------------------------------------
def test_word_order_does_not_matter(client, library):
assert 'Zzprotect viewer notes' in listed(client, 'Zzcommunity Zzcmm')
def test_words_need_not_be_adjacent_within_one_field(client, library):
"""'plan' and 'alert' sit between them in the title."""
assert 'Zzpower outage plan alert zznotification' in listed(
client, 'Zzpower zznotification')
def test_the_whole_phrase_still_matches(client, library):
"""Splitting must not break the searches that already worked."""
assert 'Zzprotect viewer notes' in listed(client, 'Zzprotect viewer')
# --- what must still be excluded -------------------------------------------
def test_an_article_holding_only_one_word_is_not_returned(client, library):
"""EVERY word must be found. ORing them would return most of the library and
bury the article the words actually describe."""
found = listed(client, 'Zzprotect Zzcommunity')
assert 'Zzprotect viewer only' not in found
assert 'Zzprotect viewer notes' in found
def test_a_retired_topic_still_hides_the_article(client, library):
"""Matching more words must not become a way past the retired rule."""
assert 'Zzprotect viewer retired' not in listed(client, 'Zzprotect Zzcommunity')
assert 'Zzprotect viewer retired' not in globally(client, 'Zzprotect Zzcommunity')
def test_a_retired_topic_name_is_not_a_way_in(client, library):
"""The retired application is named 'Zzretired Zzcmm'; its name matching a word
must not surface its articles."""
assert listed(client, 'Zzretired') == []