Search the public site through the API
Full-text on Postgres, stemmed in the language searched; word matching elsewhere. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
16
README.md
16
README.md
@@ -1348,6 +1348,22 @@ cliente en vez de comprobar nombres de rol.
|
|||||||
|
|
||||||
Quien no tenga `pages.publish` solo ve páginas publicadas en el listado.
|
Quien no tenga `pages.publish` solo ve páginas publicadas en el listado.
|
||||||
|
|
||||||
|
### Búsqueda
|
||||||
|
|
||||||
|
`GET /api/public/v1/search?q=…` busca en lo que la web enseña —páginas,
|
||||||
|
proyectos y servicios publicados, convocatorias abiertas, miembros públicos y
|
||||||
|
tecnologías en uso— en el idioma de la petición, lo mejor primero, con el `path`
|
||||||
|
de cada resultado en la web.
|
||||||
|
|
||||||
|
En Postgres es **búsqueda de texto completo**: las palabras se reducen a su raíz
|
||||||
|
en el idioma que se busca («proyectos» encuentra «proyecto»; el catalán, sin
|
||||||
|
diccionario en un Postgres de serie, se compara palabra a palabra), el título
|
||||||
|
pesa más que el cuerpo y las etiquetas del texto enriquecido no cuentan como
|
||||||
|
palabras. En cualquier otra base —el SQLite de los tests— cae a comparar las
|
||||||
|
palabras tal cual: encuentra menos y ordena a grandes rasgos, pero responde con
|
||||||
|
la misma forma. Los tests cubren esa segunda rama; la de Postgres no tiene base
|
||||||
|
contra la que correr en la suite.
|
||||||
|
|
||||||
## Documentación navegable
|
## Documentación navegable
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
41
app/Http/Controllers/Api/PublicApi/V1/SearchController.php
Normal file
41
app/Http/Controllers/Api/PublicApi/V1/SearchController.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\PublicApi\V1;
|
||||||
|
|
||||||
|
use App\Support\Search\SiteSearch;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /public/v1/search?q=…
|
||||||
|
*
|
||||||
|
* What the site shows that matches some words, in the request's language,
|
||||||
|
* best match first.
|
||||||
|
*/
|
||||||
|
class SearchController extends PublicController
|
||||||
|
{
|
||||||
|
public function __invoke(Request $request, SiteSearch $search): JsonResponse
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'q' => ['required', 'string', 'min:2', 'max:120'],
|
||||||
|
'type' => ['sometimes', 'array'],
|
||||||
|
'type.*' => [Rule::in(SiteSearch::TYPES)],
|
||||||
|
'limit' => ['sometimes', 'integer', 'min:1', 'max:50'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$results = $search->search(
|
||||||
|
$data['q'],
|
||||||
|
app()->getLocale(),
|
||||||
|
$data['type'] ?? SiteSearch::TYPES,
|
||||||
|
(int) ($data['limit'] ?? 20),
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => $results,
|
||||||
|
'meta' => ['query' => $data['q'], 'locale' => app()->getLocale(), 'count' => $results->count()],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
201
app/Support/Search/SiteSearch.php
Normal file
201
app/Support/Search/SiteSearch.php
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Search;
|
||||||
|
|
||||||
|
use App\Models\Call;
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Models\Page;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Service;
|
||||||
|
use App\Models\Technology;
|
||||||
|
use App\Support\SiteUrls;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Searching what the public site shows, in one language.
|
||||||
|
*
|
||||||
|
* On Postgres it is full-text search: words are stemmed in the language being
|
||||||
|
* searched, so "proyectos" finds "proyecto", and results are ranked with the
|
||||||
|
* title weighing more than the body. Anywhere else — SQLite in the test suite,
|
||||||
|
* a laptop without Postgres — it falls back to matching the words as written,
|
||||||
|
* which finds less and ranks crudely but answers the same shape.
|
||||||
|
*
|
||||||
|
* Only what is published: a search is a way of reaching the site's pages, and
|
||||||
|
* a draft has none.
|
||||||
|
*/
|
||||||
|
final class SiteSearch
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Postgres text search configuration per language. Catalan has no stemmer
|
||||||
|
* in a stock Postgres, so it is matched word for word.
|
||||||
|
*
|
||||||
|
* @var array<string, string>
|
||||||
|
*/
|
||||||
|
protected const DICTIONARIES = ['es' => 'spanish', 'en' => 'english'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type => what is searched in it: the title field, the fields with
|
||||||
|
* weight B, and whether those fields are translatable.
|
||||||
|
*
|
||||||
|
* @var array<string, array{model: class-string<Model>, title: string, translatedTitle: bool, text: array<int, string>}>
|
||||||
|
*/
|
||||||
|
protected const SOURCES = [
|
||||||
|
'page' => ['model' => Page::class, 'title' => 'title', 'translatedTitle' => true, 'text' => ['excerpt', 'body']],
|
||||||
|
'project' => ['model' => Project::class, 'title' => 'title', 'translatedTitle' => true, 'text' => ['tagline', 'description']],
|
||||||
|
'service' => ['model' => Service::class, 'title' => 'title', 'translatedTitle' => true, 'text' => ['tagline', 'description']],
|
||||||
|
'call' => ['model' => Call::class, 'title' => 'title', 'translatedTitle' => true, 'text' => ['description']],
|
||||||
|
'member' => ['model' => Member::class, 'title' => 'name', 'translatedTitle' => false, 'text' => ['headline', 'bio']],
|
||||||
|
'technology' => ['model' => Technology::class, 'title' => 'name', 'translatedTitle' => false, 'text' => ['description']],
|
||||||
|
];
|
||||||
|
|
||||||
|
public const TYPES = ['page', 'project', 'service', 'call', 'member', 'technology'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $types
|
||||||
|
* @return Collection<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function search(string $query, string $locale, array $types = self::TYPES, int $limit = 20): Collection
|
||||||
|
{
|
||||||
|
$query = trim($query);
|
||||||
|
|
||||||
|
if (mb_strlen($query) < 2) {
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
$postgres = DB::connection()->getDriverName() === 'pgsql';
|
||||||
|
|
||||||
|
return collect(self::SOURCES)
|
||||||
|
->only($types)
|
||||||
|
->flatMap(fn (array $source, string $type) => $this->searchIn($type, $source, $query, $locale, $postgres, $limit))
|
||||||
|
->sortByDesc('score')
|
||||||
|
->take($limit)
|
||||||
|
->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{model: class-string<Model>, title: string, translatedTitle: bool, text: array<int, string>} $source
|
||||||
|
* @return Collection<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
protected function searchIn(string $type, array $source, string $query, string $locale, bool $postgres, int $limit): Collection
|
||||||
|
{
|
||||||
|
$builder = $this->visible($source['model']::query(), $source['model']);
|
||||||
|
|
||||||
|
$title = $source['translatedTitle'] ? $this->json($source['title'], $locale, $postgres) : $source['title'];
|
||||||
|
$text = collect($source['text'])->map(fn (string $field) => $this->json($field, $locale, $postgres));
|
||||||
|
|
||||||
|
if ($postgres) {
|
||||||
|
$dictionary = self::DICTIONARIES[$locale] ?? 'simple';
|
||||||
|
// Tags out of rich text, so `<strong>` is not a word anyone finds.
|
||||||
|
$body = $text->map(fn (string $sql) => "regexp_replace(coalesce({$sql}, ''), '<[^>]+>', ' ', 'g')")->implode(" || ' ' || ");
|
||||||
|
$document = "setweight(to_tsvector('{$dictionary}', coalesce({$title}, '')), 'A') || setweight(to_tsvector('{$dictionary}', {$body}), 'B')";
|
||||||
|
$tsquery = "websearch_to_tsquery('{$dictionary}', ?)";
|
||||||
|
|
||||||
|
$builder
|
||||||
|
->select('*')
|
||||||
|
->selectRaw("ts_rank({$document}, {$tsquery}) as search_score", [$query])
|
||||||
|
->whereRaw("{$document} @@ {$tsquery}", [$query])
|
||||||
|
->orderByDesc('search_score');
|
||||||
|
} else {
|
||||||
|
$words = collect(preg_split('/\s+/', $query))->filter()->map(fn ($w) => '%'.mb_strtolower($w).'%');
|
||||||
|
|
||||||
|
foreach ($words as $word) {
|
||||||
|
$builder->where(function (Builder $q) use ($title, $text, $word): void {
|
||||||
|
$q->whereRaw("lower({$title}) like ?", [$word]);
|
||||||
|
|
||||||
|
foreach ($text as $sql) {
|
||||||
|
$q->orWhereRaw("lower({$sql}) like ?", [$word]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $builder->limit($limit)->get()->map(fn (Model $record) => $this->result($type, $source, $record, $query, $locale));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{model: class-string<Model>, title: string, translatedTitle: bool, text: array<int, string>} $source
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected function result(string $type, array $source, Model $record, string $query, string $locale): array
|
||||||
|
{
|
||||||
|
$title = $source['translatedTitle']
|
||||||
|
? (string) $record->getTranslation($source['title'], $locale, false)
|
||||||
|
: (string) $record->getAttribute($source['title']);
|
||||||
|
|
||||||
|
$text = collect($source['text'])
|
||||||
|
->map(fn (string $field) => (string) $record->getTranslation($field, $locale, false))
|
||||||
|
->implode(' ');
|
||||||
|
|
||||||
|
// Crude where Postgres did not rank: a title match counts more.
|
||||||
|
$score = $record->getAttribute('search_score')
|
||||||
|
?? (Str::contains(mb_strtolower($title), mb_strtolower($query)) ? 2.0 : 1.0);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'type' => $type,
|
||||||
|
'id' => $record->getKey(),
|
||||||
|
'title' => $title,
|
||||||
|
'excerpt' => $this->excerpt($text, $query),
|
||||||
|
'path' => $this->path($type, $record, $locale),
|
||||||
|
'score' => round((float) $score, 4),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Published, open or public, whichever each kind calls it.
|
||||||
|
*
|
||||||
|
* @param class-string<Model> $model
|
||||||
|
*/
|
||||||
|
protected function visible(Builder $query, string $model): Builder
|
||||||
|
{
|
||||||
|
return match ($model) {
|
||||||
|
Call::class => $query->open(),
|
||||||
|
Member::class => $query->public(),
|
||||||
|
// A technology is shown on the site once something uses it.
|
||||||
|
Technology::class => $query->whereHas('projects', fn (Builder $q) => $q->published()),
|
||||||
|
default => $query->published(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The text in one language of a translatable JSON column.
|
||||||
|
*/
|
||||||
|
protected function json(string $field, string $locale, bool $postgres): string
|
||||||
|
{
|
||||||
|
return $postgres
|
||||||
|
? "({$field}->>'{$locale}')"
|
||||||
|
: "json_extract({$field}, '$.{$locale}')";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Some words either side of the first match, out of plain text.
|
||||||
|
*/
|
||||||
|
protected function excerpt(string $html, string $query): string
|
||||||
|
{
|
||||||
|
$text = trim((string) preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags($html))));
|
||||||
|
$first = collect(preg_split('/\s+/', $query))->filter()->first() ?? $query;
|
||||||
|
$at = mb_stripos($text, (string) $first);
|
||||||
|
|
||||||
|
if ($at === false || $at < 60) {
|
||||||
|
return Str::limit($text, 180);
|
||||||
|
}
|
||||||
|
|
||||||
|
return '…'.Str::limit(mb_substr($text, $at - 60), 180);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function path(string $type, Model $record, string $locale): ?string
|
||||||
|
{
|
||||||
|
return match ($type) {
|
||||||
|
'member' => SiteUrls::path('member', $locale, (string) $record->slug),
|
||||||
|
// The site gives technologies pages, but this side has no address
|
||||||
|
// for them: the slug is enough to build one.
|
||||||
|
'technology' => null,
|
||||||
|
default => SiteUrls::alternates($record, $type)[$locale] ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
16
public/api-types.d.ts
vendored
16
public/api-types.d.ts
vendored
@@ -268,6 +268,21 @@ export interface PublicRepository {
|
|||||||
good_first_issues: PublicIssue[];
|
good_first_issues: PublicIssue[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PublicSearchResult {
|
||||||
|
type: 'page' | 'project' | 'service' | 'call' | 'member' | 'technology';
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
/**
|
||||||
|
* Plain text around the first match.
|
||||||
|
*/
|
||||||
|
excerpt: string;
|
||||||
|
path?: string;
|
||||||
|
/**
|
||||||
|
* Higher is a better match. Only comparable within one response.
|
||||||
|
*/
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PublicService {
|
export interface PublicService {
|
||||||
id: number;
|
id: number;
|
||||||
locale: string;
|
locale: string;
|
||||||
@@ -382,6 +397,7 @@ export interface PublicApiPayloads {
|
|||||||
'/public/v1/site': { locales: ({ code: string; name: string; flag: string; is_default: boolean })[]; default_locale: string; content_version: number; submissions_enabled: boolean; stats: { projects: number; active_projects: number; members: number; open_calls: number; good_first_issues: number; stars: number } };
|
'/public/v1/site': { locales: ({ code: string; name: string; flag: string; is_default: boolean })[]; default_locale: string; content_version: number; submissions_enabled: boolean; stats: { projects: number; active_projects: number; members: number; open_calls: number; good_first_issues: number; stars: number } };
|
||||||
'/public/v1/slugs': Record<string, ({ id: number; updated_at: string; alternates: Record<string, string> })[]>;
|
'/public/v1/slugs': Record<string, ({ id: number; updated_at: string; alternates: Record<string, string> })[]>;
|
||||||
'/public/v1/redirects': ({ from: string; to: string; status: 301 | 302; locale?: string })[];
|
'/public/v1/redirects': ({ from: string; to: string; status: 301 | 302; locale?: string })[];
|
||||||
|
'/public/v1/search': PublicSearchResult[];
|
||||||
'/public/v1/projects': PublicProject[];
|
'/public/v1/projects': PublicProject[];
|
||||||
'/public/v1/projects/{slug}': PublicProject;
|
'/public/v1/projects/{slug}': PublicProject;
|
||||||
'/public/v1/members': PublicMember[];
|
'/public/v1/members': PublicMember[];
|
||||||
|
|||||||
@@ -267,6 +267,55 @@ paths:
|
|||||||
description: Null applies in every language.
|
description: Null applies in every language.
|
||||||
"503": { $ref: "#/components/responses/Maintenance" }
|
"503": { $ref: "#/components/responses/Maintenance" }
|
||||||
|
|
||||||
|
/public/v1/search:
|
||||||
|
get:
|
||||||
|
tags: [Public]
|
||||||
|
summary: Search the site
|
||||||
|
description: |
|
||||||
|
What the site shows that matches some words, in the request's
|
||||||
|
language, best match first: published pages, projects and services,
|
||||||
|
open calls, public members, and technologies in use.
|
||||||
|
|
||||||
|
Full-text in production — words are stemmed in the language searched,
|
||||||
|
so "proyectos" finds "proyecto", and a title match ranks above one in
|
||||||
|
the body. `path` is where the result lives on the site; null for a
|
||||||
|
technology, which has no address on this side.
|
||||||
|
security: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/Lang"
|
||||||
|
- name: q
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
schema: { type: string, minLength: 2, maxLength: 120 }
|
||||||
|
- name: type[]
|
||||||
|
in: query
|
||||||
|
description: "Limit to some kinds: `page`, `project`, `service`, `call`, `member`, `technology`."
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items: { type: string, enum: [page, project, service, call, member, technology] }
|
||||||
|
- name: limit
|
||||||
|
in: query
|
||||||
|
schema: { type: integer, minimum: 1, maximum: 50, default: 20 }
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Results
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
type: array
|
||||||
|
items: { $ref: "#/components/schemas/PublicSearchResult" }
|
||||||
|
meta:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
query: { type: string }
|
||||||
|
locale: { type: string }
|
||||||
|
count: { type: integer }
|
||||||
|
"422": { $ref: "#/components/responses/ValidationError" }
|
||||||
|
"503": { $ref: "#/components/responses/Maintenance" }
|
||||||
|
|
||||||
/public/v1/projects:
|
/public/v1/projects:
|
||||||
get:
|
get:
|
||||||
tags: [Public]
|
tags: [Public]
|
||||||
@@ -4342,6 +4391,16 @@ components:
|
|||||||
alternates: { $ref: "#/components/schemas/Alternates" }
|
alternates: { $ref: "#/components/schemas/Alternates" }
|
||||||
path: { type: string, nullable: true }
|
path: { type: string, nullable: true }
|
||||||
|
|
||||||
|
PublicSearchResult:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
type: { type: string, enum: [page, project, service, call, member, technology] }
|
||||||
|
id: { type: integer }
|
||||||
|
title: { type: string }
|
||||||
|
excerpt: { type: string, description: Plain text around the first match. }
|
||||||
|
path: { type: string, nullable: true, example: /proyectos/mi-proyecto }
|
||||||
|
score: { type: number, description: Higher is a better match. Only comparable within one response. }
|
||||||
|
|
||||||
PublicTechnology:
|
PublicTechnology:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use App\Http\Controllers\Api\PublicApi\V1\CallController as PublicCallController
|
|||||||
use App\Http\Controllers\Api\PublicApi\V1\MemberController as PublicMemberController;
|
use App\Http\Controllers\Api\PublicApi\V1\MemberController as PublicMemberController;
|
||||||
use App\Http\Controllers\Api\PublicApi\V1\PageController as PublicPageController;
|
use App\Http\Controllers\Api\PublicApi\V1\PageController as PublicPageController;
|
||||||
use App\Http\Controllers\Api\PublicApi\V1\ProjectController as PublicProjectController;
|
use App\Http\Controllers\Api\PublicApi\V1\ProjectController as PublicProjectController;
|
||||||
|
use App\Http\Controllers\Api\PublicApi\V1\SearchController;
|
||||||
use App\Http\Controllers\Api\PublicApi\V1\ShowcaseController;
|
use App\Http\Controllers\Api\PublicApi\V1\ShowcaseController;
|
||||||
use App\Http\Controllers\Api\PublicApi\V1\SiteController;
|
use App\Http\Controllers\Api\PublicApi\V1\SiteController;
|
||||||
use App\Http\Controllers\Api\PublicApi\V1\TelemetryController;
|
use App\Http\Controllers\Api\PublicApi\V1\TelemetryController;
|
||||||
@@ -84,6 +85,7 @@ Route::prefix('public/v1')
|
|||||||
Route::get('technologies', [SiteController::class, 'technologies'])->name('technologies');
|
Route::get('technologies', [SiteController::class, 'technologies'])->name('technologies');
|
||||||
Route::get('good-first-issues', [SiteController::class, 'goodFirstIssues'])->name('good-first-issues');
|
Route::get('good-first-issues', [SiteController::class, 'goodFirstIssues'])->name('good-first-issues');
|
||||||
Route::get('redirects', [SiteController::class, 'redirects'])->name('redirects');
|
Route::get('redirects', [SiteController::class, 'redirects'])->name('redirects');
|
||||||
|
Route::get('search', SearchController::class)->name('search');
|
||||||
|
|
||||||
Route::get('pages', [PublicPageController::class, 'index'])->name('pages.index');
|
Route::get('pages', [PublicPageController::class, 'index'])->name('pages.index');
|
||||||
Route::get('pages/{slug}', [PublicPageController::class, 'show'])->name('pages.show');
|
Route::get('pages/{slug}', [PublicPageController::class, 'show'])->name('pages.show');
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ class PublicApiContractTest extends TestCase
|
|||||||
'testimonials' => ['path' => '/public/v1/testimonials', 'detail' => false],
|
'testimonials' => ['path' => '/public/v1/testimonials', 'detail' => false],
|
||||||
'talks' => ['path' => '/public/v1/talks', 'detail' => false],
|
'talks' => ['path' => '/public/v1/talks', 'detail' => false],
|
||||||
'technologies' => ['path' => '/public/v1/technologies', 'detail' => false],
|
'technologies' => ['path' => '/public/v1/technologies', 'detail' => false],
|
||||||
|
'search' => ['path' => '/public/v1/search', 'detail' => true],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,6 +134,7 @@ class PublicApiContractTest extends TestCase
|
|||||||
'/public/v1/members/{slug}' => '/public/v1/members/ada-lovelace',
|
'/public/v1/members/{slug}' => '/public/v1/members/ada-lovelace',
|
||||||
'/public/v1/calls/{slug}' => '/public/v1/calls/backend',
|
'/public/v1/calls/{slug}' => '/public/v1/calls/backend',
|
||||||
'/public/v1/services/{slug}' => '/public/v1/services/desarrollo',
|
'/public/v1/services/{slug}' => '/public/v1/services/desarrollo',
|
||||||
|
'/public/v1/search' => '/public/v1/search?lang=es&q=proyecto',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
80
tests/Feature/Api/SearchApiTest.php
Normal file
80
tests/Feature/Api/SearchApiTest.php
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Feature\Api;
|
||||||
|
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Models\Page;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Technology;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /public/v1/search — run here on SQLite, so through the word-matching
|
||||||
|
* fallback; the shape and the rules about what is visible are the same.
|
||||||
|
*/
|
||||||
|
class SearchApiTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function it_finds_published_content_in_the_language_asked(): void
|
||||||
|
{
|
||||||
|
Project::factory()->published()->create([
|
||||||
|
'title' => ['es' => 'Catálogo abierto', 'en' => 'Open catalogue'],
|
||||||
|
'slug' => ['es' => 'catalogo', 'en' => 'catalogue'],
|
||||||
|
'tagline' => ['es' => 'Una herramienta para bibliotecas.', 'en' => 'A tool for libraries.'],
|
||||||
|
]);
|
||||||
|
Page::factory()->published()->create([
|
||||||
|
'title' => ['es' => 'Bibliotecas libres'],
|
||||||
|
'slug' => ['es' => 'bibliotecas'],
|
||||||
|
'body' => ['es' => '<p>Sobre <strong>bibliotecas</strong>.</p>'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->getJson('/api/public/v1/search?lang=es&q=bibliotecas')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonCount(2, 'data')
|
||||||
|
->assertJsonPath('meta.count', 2);
|
||||||
|
|
||||||
|
// The title match first.
|
||||||
|
$response->assertJsonPath('data.0.type', 'page')
|
||||||
|
->assertJsonPath('data.0.path', '/bibliotecas');
|
||||||
|
|
||||||
|
$this->getJson('/api/public/v1/search?q=libraries&lang=en')
|
||||||
|
->assertJsonCount(1, 'data')
|
||||||
|
->assertJsonPath('data.0.title', 'Open catalogue')
|
||||||
|
->assertJsonPath('data.0.path', '/en/projects/catalogue');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function drafts_and_private_people_are_not_found(): void
|
||||||
|
{
|
||||||
|
Page::factory()->draft()->create(['title' => ['es' => 'Borrador secreto'], 'slug' => ['es' => 'borrador']]);
|
||||||
|
Member::factory()->create(['name' => 'Secreto Oculto', 'slug' => 'oculto', 'is_public' => false]);
|
||||||
|
Technology::factory()->create(['name' => 'Secretlang']);
|
||||||
|
|
||||||
|
$this->getJson('/api/public/v1/search?lang=es&q=secret')->assertOk()->assertJsonCount(0, 'data');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function every_word_has_to_match_and_types_can_be_narrowed(): void
|
||||||
|
{
|
||||||
|
Project::factory()->published()->create(['title' => ['es' => 'Radar técnico'], 'slug' => ['es' => 'radar']]);
|
||||||
|
Page::factory()->published()->create(['title' => ['es' => 'Radar de tecnologías'], 'slug' => ['es' => 'radar-pagina']]);
|
||||||
|
|
||||||
|
$this->getJson('/api/public/v1/search?lang=es&q=radar+técnico')->assertJsonCount(1, 'data');
|
||||||
|
$this->getJson('/api/public/v1/search?lang=es&q=radar&type[]=page')
|
||||||
|
->assertJsonCount(1, 'data')
|
||||||
|
->assertJsonPath('data.0.type', 'page');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function a_query_is_required(): void
|
||||||
|
{
|
||||||
|
$this->getJson('/api/public/v1/search')->assertUnprocessable()->assertJsonValidationErrors('q');
|
||||||
|
$this->getJson('/api/public/v1/search?q=a')->assertUnprocessable();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user