wrolpi/app/src/components/SearchSuggestions.test.js
lrnselfreliance 93d878952c
Add a Repos module that mirrors git repositories (#783)
Repos keeps local mirrors of git repositories, cloned over https and kept up to date on a schedule.

- A Repo is a Collection (kind='repo') plus a `repository` row.  The new `git` downloader clones it, then updates it
  (weekly by default, or Never: the clone is kept as it is and only updated on request).
- Repos are saved in `repos/<tag>/<name>` (the Repos Directory, ignored by default) or a directory the user chooses.
  An existing clone can be imported where it is.  repos.yaml is the source of truth.
- A GitHub-like page shows each repo's README, files, and history, with a ZIP download.  READMEs are searchable
  under the global search's Other tab.
- git is hardened against hostile repos and configs: https only, pinned addresses with no private addresses (for the
  repo and its submodules), sanitized configs, no hooks or links, and only clones WROLPi made or imported are
  updated.
- Tagging a Repo moves its clone as one directory.  Repo changes wait for repos.yaml to be imported at startup.
- The file mover groups unindexed files with no primary file as one FileGroup, and never replaces a file at the
  destination.  A failed tag move restores the Collection's tag.  A Collection's directory cannot leave the media
  directory.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:16:58 -06:00

139 lines
4.4 KiB
JavaScript

import React from 'react';
import {act, render, screen} from '../test-utils';
import {useSearchSuggestions} from './Search';
import {TagsContext} from '../Tags';
import {tagsContextFixture} from '../test-fixtures';
import {
searchEstimateFiles, searchEstimateMap, searchEstimateOthers, searchEstimateZims, searchSuggestions,
} from '../api';
// The global search dropdown's groups are built from useSuggestions. A "Channels: No results" row
// may only appear once the suggestion request has come back empty, never before the user has
// typed, and never while the request is pending.
jest.mock('../api', () => ({
...jest.requireActual('../api'),
searchSuggestions: jest.fn(),
searchEstimateFiles: jest.fn(),
searchEstimateMap: jest.fn(),
searchEstimateOthers: jest.fn(),
searchEstimateZims: jest.fn(),
}));
const DEBOUNCE = 500;
const pending = () => new Promise(() => {
});
let hook;
function Probe() {
hook = useSearchSuggestions('', [], false);
return <pre data-testid='results'>{JSON.stringify(hook.suggestionsResults)}</pre>;
}
const results = () => JSON.parse(screen.getByTestId('results').textContent);
const renderProbe = () => render(<Probe/>, {
contexts: [[TagsContext, tagsContextFixture({fuzzyMatchTagsByName: () => []})]],
});
beforeEach(() => {
jest.useFakeTimers();
searchEstimateFiles.mockReturnValue(pending());
searchEstimateMap.mockReturnValue(pending());
searchEstimateOthers.mockReturnValue(pending());
searchEstimateZims.mockReturnValue(pending());
});
afterEach(() => {
jest.useRealTimers();
jest.clearAllMocks();
});
test('no Channels group before anything has been searched', () => {
searchSuggestions.mockReturnValue(pending());
renderProbe();
expect(results().channels).toBeUndefined();
});
test('no Channels group while the suggestion request is pending', () => {
searchSuggestions.mockReturnValue(pending());
renderProbe();
act(() => {
hook.setSearchStr('foo');
});
expect(results().channels).toBeUndefined();
act(() => {
jest.advanceTimersByTime(DEBOUNCE);
});
expect(searchSuggestions).toHaveBeenCalledWith('foo');
expect(results().channels).toBeUndefined();
expect(hook.loading).toBe(true);
});
test('a Channels "No results" row once the server said there are none', async () => {
let resolve;
searchSuggestions.mockReturnValue(new Promise(res => resolve = res));
renderProbe();
act(() => {
hook.setSearchStr('foo');
});
act(() => {
jest.advanceTimersByTime(DEBOUNCE);
});
await act(async () => {
resolve({channels: [], domains: [], authors: [], subjects: []});
});
expect(results().channels.results).toEqual([{title: 'No results'}]);
});
test('Channels rows once the server found some', async () => {
let resolve;
searchSuggestions.mockReturnValue(new Promise(res => resolve = res));
renderProbe();
act(() => {
hook.setSearchStr('foo');
});
act(() => {
jest.advanceTimersByTime(DEBOUNCE);
});
await act(async () => {
resolve({channels: [{id: 3, name: 'Food'}], domains: [], authors: [], subjects: []});
});
expect(results().channels.results.map(i => i.title)).toEqual(['Food']);
});
test('Repos rows link to each repo, and the Other estimate gets the search', async () => {
let resolve;
searchSuggestions.mockReturnValue(new Promise(res => resolve = res));
renderProbe();
act(() => {
hook.setSearchStr('kiwix');
});
act(() => {
jest.advanceTimersByTime(DEBOUNCE);
});
await act(async () => {
resolve({channels: [], domains: [], authors: [], subjects: [], repos: [{id: 4, name: 'kiwix-tools'}]});
});
expect(results().repos.results).toEqual([{type: 'repo', title: 'kiwix-tools', id: 4, location: '/repos/4'}]);
// Repos count toward the Other tab by their name and README, not only by tag.
expect(searchEstimateOthers).toHaveBeenCalledWith([], 'kiwix');
});
test('no Repos group when none match', async () => {
let resolve;
searchSuggestions.mockReturnValue(new Promise(res => resolve = res));
renderProbe();
act(() => {
hook.setSearchStr('foo');
});
act(() => {
jest.advanceTimersByTime(DEBOUNCE);
});
await act(async () => {
resolve({channels: [], domains: [], authors: [], subjects: [], repos: []});
});
expect(results().repos).toBeUndefined();
});