Repository: mdn/kuma

Issue:
New frontend: empty page for /search route (no redirect / no 404)
Steps to reproduce:
* Went to https://developer.mozilla.org/en-US/search

Current result:
* A semi blank page with the footer of the site

Expected result:
* A redirect to https://wiki.developer.mozilla.org/en-US/search or a 404


Discussion:

Resolving patch:
diff --git a/kuma/api/v1/views.py b/kuma/api/v1/views.py
--- a/kuma/api/v1/views.py
+++ b/kuma/api/v1/views.py
@@ -4,18 +4,24 @@
 from django.utils.translation import activate, ugettext as _
 from django.views.decorators.cache import never_cache
 from django.views.decorators.http import require_GET
-from elasticsearch_dsl import Q, query
-from rest_framework import status
+from rest_framework import serializers, status
 from rest_framework.decorators import api_view
+from rest_framework.renderers import JSONRenderer
 from rest_framework.response import Response
 from waffle.decorators import waffle_flag
 from waffle.models import Flag, Sample, Switch
 
 from kuma.api.v1.serializers import BCSignalSerializer
 from kuma.core.urlresolvers import reverse
+from kuma.search.filters import (
+    HighlightFilterBackend,
+    KeywordQueryBackend,
+    LanguageFilterBackend,
+    SearchQueryBackend,
+    TagGroupFilterBackend)
+from kuma.search.views import SearchView
 from kuma.users.templatetags.jinja_helpers import gravatar_url
 from kuma.wiki.models import Document
-from kuma.wiki.search import WikiDocumentType
 from kuma.wiki.templatetags.jinja_helpers import absolutify
 
 
@@ -226,50 +232,58 @@ def whoami(request):
     return JsonResponse(data)
 
 
-@never_cache
-@require_GET
-def search(request, locale):
-    """ An API endpoint to return search results as a JSON blob.
-    This endpoint makes a relatively simple ElasticSearch query
-    for documents matching the value of the q parameter.
+class APIDocumentSerializer(serializers.Serializer):
+    title = serializers.CharField(read_only=True, max_length=255)
+    slug = serializers.CharField(read_only=True, max_length=255)
+    locale = serializers.CharField(read_only=True, max_length=7)
+    excerpt = serializers.ReadOnlyField(source='get_excerpt')
+
+
+class APILanguageFilterBackend(LanguageFilterBackend):
+    """Override of kuma.search.filters:LanguageFilterBackend that is almost
+    exactly the same except the locale comes from custom code rather than
+    via kuma.core.i18n.get_language_from_request because that can't be used
+    in the API.
+
+    Basically, it's the same exact functionality but ...
     """
-    # TODO: I'm betting that a simple search like this will be faster
-    # and just as good as the more complex searches implemented by the
-    # code in kuma/search/. Peter disagrees and thinks that we might
-    # eventually want to make this endpoint use code from kuma/search/.
-    # An alternative is to just abandon this API endpoint and have
-    # the frontend call wiki.d.m.o/locale/search.json?q=query. On the
-    # other hand, if we're ever going to implement any kind of
-    # search-as-you-type interface, we'll need a super-fast custom
-    # endpoint like this one.
-    query_string = request.GET.get('q')
-    if locale == 'en-US':
-        search = (WikiDocumentType.search()
-                  .filter('term', locale=locale)
-                  .source(['slug', 'title', 'summary'])
-                  .query('multi_match', query=query_string,
-                         fields=['title^7', 'summary^2', 'content']))
-    else:
-        search = (WikiDocumentType.search()
-                  .filter('terms', locale=[locale, 'en-US'])
-                  .source(['slug', 'title', 'summary', 'locale'])
-                  .query(query.Bool(
-                      must=Q('multi_match', query=query_string,
-                             fields=['title^7', 'summary^2', 'content']),
-                      should=[
-                          # boost the score if the document is translated
-                          Q('term', locale={'value': locale, 'boost': 8}),
-                      ])))
-
-    # Add excerpts with search results highlighted
-    search = search.highlight('content')
-    search = search.highlight_options(order='score',
-                                      pre_tags=['<mark>'],
-                                      post_tags=['</mark>'])
-
-    # Return as many as 40 matches, since we're not implementing pagination yet
-    response = search[0:40].execute()
-    return JsonResponse(response.to_dict())
+
+    def filter_queryset(self, request, queryset, view):
+        locale = request.GET.get('locale') or settings.LANGUAGE_CODE
+        if locale not in settings.ACCEPTED_LOCALES:
+            raise serializers.ValidationError(
+                {'error': 'Not a valid locale code'})
+        request.LANGUAGE_CODE = locale
+        return super(APILanguageFilterBackend, self).filter_queryset(
+            request, queryset, view)
+
+
+class APISearchQueryBackend(SearchQueryBackend):
+    """Override of kuma.search.filters.SearchQueryBackend that makes a
+    stink if the 'q' query parameter is falsy."""
+
+    def filter_queryset(self, request, queryset, view):
+        search_term = (view.query_params.get('q') or '').strip()
+        if not search_term:
+            raise serializers.ValidationError(
+                {'error': "Search term 'q' must be set"})
+        return super(APISearchQueryBackend, self).filter_queryset(
+            request, queryset, view)
+
+
+class APISearchView(SearchView):
+    serializer_class = APIDocumentSerializer
+    renderer_classes = [JSONRenderer]
+    filter_backends = (
+        APISearchQueryBackend,
+        KeywordQueryBackend,
+        TagGroupFilterBackend,
+        APILanguageFilterBackend,
+        HighlightFilterBackend,
+    )
+
+
+search = never_cache(APISearchView.as_view())
 
 
 @waffle_flag('bc-signals')
diff --git a/kuma/core/i18n.py b/kuma/core/i18n.py
--- a/kuma/core/i18n.py
+++ b/kuma/core/i18n.py
@@ -159,7 +159,7 @@ def get_language_from_request(request):
     * Use LANGUAGE_CODE as the fallback language code, instead of passing it
       through get_supported_language_variant first.
     """
-    # Kuma: Always use the URL's langauge (force check_path=True)
+    # Kuma: Always use the URL's language (force check_path=True)
     lang_code = get_language_from_path(request.path_info)
     if lang_code is not None:
         return lang_code


Test patch:
diff --git a/kuma/api/v1/tests/test_views.py b/kuma/api/v1/tests/test_views.py
--- a/kuma/api/v1/tests/test_views.py
+++ b/kuma/api/v1/tests/test_views.py
@@ -7,6 +7,7 @@
                                get_s3_key)
 from kuma.core.tests import assert_no_cache_header
 from kuma.core.urlresolvers import reverse
+from kuma.search.tests import ElasticTestCase
 from kuma.users.templatetags.jinja_helpers import gravatar_url
 from kuma.wiki.templatetags.jinja_helpers import absolutify
 
@@ -288,3 +289,48 @@ def test_whoami(user_client, api_settings, wiki_user, beta_testers_group,
         }
     }
     assert_no_cache_header(response)
+
+
+@pytest.mark.django_db
+def test_search_validation_problems(user_client, api_settings):
+    url = reverse('api.v1.search', args=['en-US'])
+
+    # 'q' not present
+    response = user_client.get(url, HTTP_HOST=api_settings.BETA_HOST)
+    assert response.status_code == 400
+    assert response.json()['error'] == "Search term 'q' must be set"
+
+    # 'q' present but falsy
+    response = user_client.get(url, {'q': ''}, HTTP_HOST=api_settings.BETA_HOST)
+    assert response.status_code == 400
+    assert response.json()['error'] == "Search term 'q' must be set"
+
+    # 'q' present but locale invalid
+    response = user_client.get(
+        url, {'q': 'x', 'locale': 'xxx'}, HTTP_HOST=api_settings.BETA_HOST)
+    assert response.status_code == 400
+    assert response.json()['error'] == 'Not a valid locale code'
+
+
+class SearchViewTests(ElasticTestCase):
+    fixtures = ElasticTestCase.fixtures + ['wiki/documents.json',
+                                           'search/filters.json']
+
+    def test_search_basic(self):
+        url = reverse('api.v1.search', args=['en-US'])
+        response = self.client.get(url, {'q': 'article'})
+        assert response.status_code == 200
+        assert response['content-type'] == 'application/json'
+        data = response.json()
+        assert data['documents']
+        assert data['count'] == 4
+        assert data['locale'] == 'en-US'
+
+        # Now search in a non-en-US locale
+        response = self.client.get(url, {'q': 'title', 'locale': 'fr'})
+        assert response.status_code == 200
+        assert response['content-type'] == 'application/json'
+        data = response.json()
+        assert data['documents']
+        assert data['count'] == 5
+        assert data['locale'] == 'fr'
diff --git a/kuma/javascript/src/search-results-page.test.js b/kuma/javascript/src/search-results-page.test.js
--- a/kuma/javascript/src/search-results-page.test.js
+++ b/kuma/javascript/src/search-results-page.test.js
@@ -5,30 +5,59 @@ import { create } from 'react-test-renderer';
 import Header from './header/header.jsx';
 import SearchResultsPage, { SearchRoute } from './search-results-page.jsx';
 import Titlebar from './titlebar.jsx';
-import type { SearchResults } from './search-results-page.jsx';
-
-const fakeResults = [
-    {
-        slug: 'slug1',
-        title: 'title1',
-        summary: 'summary1',
-        score: 10,
-        excerpts: ['Test <mark>result</mark>', 'Excerpt <mark>2</mark>']
-    },
-    {
-        slug: 'slug2',
-        title: 'title2',
-        summary: 'summary2',
-        score: 5,
-        excerpts: []
-    }
-];
-
-const fakeSearchResults: SearchResults = {
+import type { SearchResultsResponse } from './search-results-page.jsx';
+
+const fakeResults = {
+    count: 2,
+    documents: [
+        {
+            slug: 'slug1',
+            title: 'title1',
+            locale: 'en-US',
+            excerpt: 'Test <mark>result</mark> Excerpt <mark>2</mark>'
+        },
+        {
+            slug: 'slug2',
+            title: 'title2',
+            locale: 'en-US',
+            excerpt: 'empty'
+        }
+    ],
+    start: 0,
+    end: 10,
+    filters: {},
+    locale: 'en-US',
+    next: null,
+    page: 1,
+    pages: 0,
+    previous: null,
+    query: 'q'
+};
+
+const fakeEmptyResults = {
+    count: 0,
+    documents: [],
+    start: 0,
+    end: 0,
+    filters: {},
+    locale: 'en-US',
+    next: null,
+    page: 0,
+    pages: 0,
+    previous: null,
+    query: 'empty'
+};
+
+const fakeSearchResults: SearchResultsResponse = {
     results: fakeResults,
     error: null
 };
 
+const fakeEmptySearchResults: SearchResultsResponse = {
+    results: fakeEmptyResults,
+    error: null
+};
+
 describe('SearchResultsPage component', () => {
     const page = create(
         <SearchResultsPage locale="en-US" query="qq" data={fakeSearchResults} />
@@ -53,27 +82,50 @@ describe('SearchResultsPage component', () => {
     // but we expect the strings of the fake search data to appear
     // in the page somewhere
     test('results', () => {
-        for (const hit of fakeResults) {
-            expect(snapshot).toContain(hit.title);
-            expect(snapshot).toContain(hit.summary);
-            expect(snapshot).toContain(hit.slug);
-            for (const excerpt of hit.excerpts) {
-                expect(snapshot).toContain(excerpt);
-            }
+        for (const document of fakeResults.documents) {
+            expect(snapshot).toContain(document.title);
+            expect(snapshot).toContain(document.slug);
+            expect(snapshot).toContain(document.excerpt);
         }
     });
 });
 
-describe('SearchResultsPage with error', () => {
-    const error = new Error('SearchError');
-
+describe('SearchResultsPage without query', () => {
     const page = create(
         <SearchResultsPage
             locale="en-US"
-            query="qq"
-            data={{ error, results: null }}
+            query=""
+            data={fakeEmptySearchResults}
         />
     );
+    const root = page.root;
+
+    // The page should have exactly one Header
+    test('header', () => {
+        const headers = root.findAllByType(Header);
+        expect(headers.length).toBe(1);
+    });
+
+    // The page should one Titlebar, with the expected title
+    test('titlebar', () => {
+        const titlebars = root.findAllByType(Titlebar);
+        expect(titlebars.length).toBe(0);
+    });
+
+    // Should not
+    test('results', () => {
+        const hint = root.findByType('i');
+        expect(hint.children[0]).toBe('Nothing found if nothing searched.');
+    });
+});
+
+describe('SearchResultsPage with error', () => {
+    const error = new Error('SearchError');
+
+    const data = { error, results: fakeEmptyResults };
+    const page = create(
+        <SearchResultsPage locale="en-US" query="qq" data={data} />
+    );
     const snapshot = JSON.stringify(page.toJSON());
 
     test('Page displays error message', () => {
@@ -87,7 +139,7 @@ describe('SearchResultsPage with no results found', () => {
         <SearchResultsPage
             locale="en-US"
             query="qq"
-            data={{ error: null, results: [] }}
+            data={fakeEmptySearchResults}
         />
     );
     const snapshot = JSON.stringify(page.toJSON());
@@ -106,20 +158,24 @@ describe('SearchRoute', () => {
     test('match() extracts the query string', () => {
         expect(route.match('/en-US/search?q=qq')).toEqual({
             locale: 'en-US',
+            page: 1,
             query: 'qq'
         });
         expect(route.match('/en-US/search?foo=bar&q=qq')).toEqual({
             locale: 'en-US',
+            page: 1,
             query: 'qq'
         });
         expect(route.match('http://mdn.dev/en-US/search?q=qq')).toEqual({
             locale: 'en-US',
+            page: 1,
             query: 'qq'
         });
         expect(
             route.match('https://mdn.dev/en-US/search?foo=bar&q=qq')
         ).toEqual({
             locale: 'en-US',
+            page: 1,
             query: 'qq'
         });
     });
@@ -129,11 +185,6 @@ describe('SearchRoute', () => {
         expect(route.match('/fr/search?q=qq')).toBe(null);
     });
 
-    test('match() does not match if no query string', () => {
-        expect(route.match('/en-US/search')).toBe(null);
-        expect(route.match('/en-US/search?p=qq')).toBe(null);
-    });
-
     test('match() does not match if no /search in URL', () => {
         expect(route.match('/en-US/?q=qq')).toBe(null);
         expect(route.match('/en-US/searching?q=qq')).toBe(null);
@@ -143,38 +194,23 @@ describe('SearchRoute', () => {
         global.fetch = jest.fn(() => {
             return Promise.resolve({
                 ok: true,
-                json: () =>
-                    Promise.resolve({
-                        // ElasticSearch buries the results in layers of
-                        // other stuff that we fake out here
-                        hits: {
-                            hits: fakeResults.map(r => ({
-                                _source: {
-                                    slug: r.slug,
-                                    title: r.title,
-                                    summary: r.summary
-                                },
-                                _score: r.score,
-                                highlight: {
-                                    content: r.excerpts
-                                }
-                            }))
-                        }
-                    })
+                json: () => Promise.resolve(fakeResults)
             });
         });
 
-        route.fetch({ locale: 'en-US', query: 'foo bar#' }).then(results => {
-            expect(results).toEqual(fakeSearchResults);
-            expect(global.fetch.mock.calls[0][0]).toBe(
-                '/api/v1/search/en-US?q=foo%20bar%23'
-            );
-            done();
-        });
+        route
+            .fetch({ locale: 'en-US', query: 'foo bar#', page: null })
+            .then(results => {
+                expect(results).toEqual(fakeSearchResults);
+                expect(global.fetch.mock.calls[0][0]).toBe(
+                    '/api/v1/search/en-US?q=foo%20bar%23&locale=en-US'
+                );
+                done();
+            });
     });
 
     test('getTitle()', () => {
-        expect(route.getTitle({ locale: '', query: 'qq' })).toBe(
+        expect(route.getTitle({ locale: '', query: 'qq', page: null })).toBe(
             'Search results for "qq" | MDN'
         );
     });