Find a knowledge base article by words from different fields
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:
128
tests/test_plugins/test_knowledgebase_search_words.py
Normal file
128
tests/test_plugins/test_knowledgebase_search_words.py
Normal 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') == []
|
||||
Reference in New Issue
Block a user