Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 25 additions & 13 deletions docs/architecture/testing-climb-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,14 @@ La pirámide (~50–60 % unit / ~25–30 % integración / ~10–15 % e2e) es **b

### Recuento orientativo

| Momento | Domain | Application | Contracts | E2E |
|---------|-------:|------------:|----------:|----:|
| Tras ola 1 | ~106 | ~32 | ~32 | ~8 |
| Tras ola 2 | ~106 | ~32 | ~54 | ~8 |
| Momento | Domain | Application | Contracts | E2E | Total unit* |
|---------|-------:|------------:|----------:|----:|------------:|
| Tras ola 1 | ~106 | ~32 | ~32 | ~8 | ~167 |
| Tras ola 2 | ~106 | ~32 | ~54 | ~8 | ~189 |
| Tras ola 3 | ~106 | ~32 | ~72 | ~8 | ~207 |
| Tras ola 4 | ~103 | ~52 | ~72 | ~8 | ~227 |

\* `pnpm run test:unit` (domain + application + contracts).

## Olas (módulo a módulo)

Expand Down Expand Up @@ -58,7 +62,10 @@ Cada ola → **rama + PR** dedicada. Definition of Done: tests nuevos en la carp

### Ola 3 — Storage ports (contratos con doubles)

Requiere **double in-memory** de secure/async storage (`test/support/doubles/`) y, si hace falta, inyección mínima o `mock.module` documentado.
**Estado:** entregada (`cursor/testing-climb-wave3-*`)
**Rama sugerida:** `cursor/testing-climb-wave3-*`

Introduce `KeyValueStoragePort` + `createMemoryKeyValueStorage` y factories `create*Store(storage)` (sin React Native) para contratos en `node:test`. Los singletons de producción siguen en `*.ts` con el adaptador secure.

| Módulo | Capa |
|--------|------|
Expand All @@ -71,15 +78,20 @@ Requiere **double in-memory** de secure/async storage (`test/support/doubles/`)

### Ola 4 — Casos de uso funds / rankings / home

**Estado:** en curso / entregada en `cursor/testing-climb-wave4-*`
**Rama sugerida:** `cursor/testing-climb-wave4-*`

Introduce `HttpGetPort` + `createMemoryHttpGet` y factories `create*Service(deps)` (sin React Native) para integración en `node:test`. Los singletons de producción siguen cableando `apiGet` real.

| Módulo | Capa | Enfoque |
|--------|------|---------|
| `get-funds` (mock path + shaping) | application | mock data + filters; fake `apiGet` si se introduce puerto |
| `get-rankings` | application | |
| `get-fund-by-isin` | application | |
| `get-featured-funds` | application | |
| `get-investment-news` | application | |
| `resolve-home-search` | application | |
| `load-compare-picker-funds` | application | |
| `get-funds` (mock path + shaping) | application | mock data + filters; fake `apiGet` vía puerto |
| `get-rankings` | application | mock + API parse/cache |
| `get-fund-by-isin` | application | parse detail + 404 fallback |
| `get-featured-funds` | application | carousel eligibility + fallback |
| `get-investment-news` | application | bundled vs API + fallback |
| `resolve-home-search` | application | default / fund-match / assistant answer |
| `load-compare-picker-funds` | application | mock sort + API search |

### Ola 5 — Favoritos, comparación, calculadora, feedback

Expand Down Expand Up @@ -123,7 +135,7 @@ Tras cada ola, actualizar esta tabla en el PR:
| Domain | Reglas nuevas/tocadas con `*.spec.ts` |
| Pirámide | Recuento `it/test` por carpeta vs baremo (informativo) |

Meta intermedia tras olas 1–3: **contratos de frontera del catálogo y storage locales** cubiertos; application de funds empezada.
Meta intermedia tras olas 1–4: **contratos de frontera del catálogo y storage locales** cubiertos; **application de funds/home/rankings** cubierta con factories + HTTP double.

## Anti-objetivos

Expand Down
20 changes: 20 additions & 0 deletions src/core/api/http-get-port.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* Options for a JSON GET against the Inversora API.
*/
export type HttpGetOptions = {
path: string;
searchParams?: Record<string, string | number | boolean | undefined>;
signal?: AbortSignal;
/** When true, attaches `X-Device-Token` when the installation is registered. */
withDeviceToken?: boolean;
/** Optional override for the default request timeout in milliseconds. */
timeoutMs?: number;
};

/**
* Port for JSON GET requests used by application services.
*
* Production binds {@link apiGet} from `@/core/api/client`.
* Tests inject an in-memory double so suites never load React Native.
*/
export type HttpGetPort = <T>(options: HttpGetOptions) => Promise<T>;
136 changes: 136 additions & 0 deletions src/features/comparison/services/load-compare-picker-funds.factory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import type { CatalogFund } from '@/core/domain/catalog';
import type { FundListResponse } from '@/core/api/parse-fund-list-response';
import { AppError } from '@/core/errors/app-error';
import { CATALOG_FUNDS_MOCK } from '@/features/funds/mocks/catalog-funds-mock';
import type { FundCatalogFilters } from '@/features/funds/types/fund-catalog-filters';
import { filterCatalogVisible } from '@/features/funds/utils/catalog-visibility';
import {
matchesFundSearch,
normalizeFundSearchQuery,
} from '@/features/funds/utils/fund-search';

const DEFAULT_PICKER_LIMIT = 40;

/**
* Dependencies for the compare-picker funds loader.
*/
export type ComparePickerFundsServiceDeps = {
shouldUseMockData: () => boolean;
allowsMockFallback: () => boolean;
getFundsPage: (
filters: FundCatalogFilters | undefined,
page: number,
signal?: AbortSignal,
) => Promise<FundListResponse>;
searchCatalogFunds: (
query: string,
options?: { limit?: number; signal?: AbortSignal },
) => Promise<CatalogFund[]>;
};

/**
* Matches picker search across name, ISIN, category, theme and symbol.
*
* @param fund - Catalog fund candidate.
* @param rawQuery - User search text.
*/
export function matchesComparePickerSearch(
fund: CatalogFund,
rawQuery: string,
): boolean {
const query = normalizeFundSearchQuery(rawQuery);

if (query.length === 0) {
return true;
}

if (matchesFundSearch(fund, rawQuery)) {
return true;
}

return (
fund.categoryLabel.toLowerCase().includes(query) ||
fund.themeLabel.toLowerCase().includes(query) ||
fund.symbol.toLowerCase().includes(query)
);
}

function sortPickerFunds(funds: readonly CatalogFund[]): CatalogFund[] {
return [...funds].sort((left, right) => right.inversoraScore - left.inversoraScore);
}

function filterMockPickerFunds(query: string, limit: number): CatalogFund[] {
const visibleFunds = filterCatalogVisible(CATALOG_FUNDS_MOCK);

return sortPickerFunds(visibleFunds.filter((fund) => matchesComparePickerSearch(fund, query))).slice(
0,
limit,
);
}

function isAbortError(error: unknown): boolean {
if (error instanceof DOMException && error.name === 'AbortError') {
return true;
}

return error instanceof Error && error.name === 'AbortError';
}

/**
* Creates the compare-picker funds loader without React Native imports.
*
* @param deps - Catalog loaders and environment predicates.
*/
export function createComparePickerFundsService(deps: ComparePickerFundsServiceDeps) {
const { shouldUseMockData, allowsMockFallback, getFundsPage, searchCatalogFunds } = deps;

async function loadComparePickerFunds(
query: string,
options?: { signal?: AbortSignal; limit?: number },
): Promise<CatalogFund[]> {
const trimmedQuery = query.trim();
const limit = options?.limit ?? DEFAULT_PICKER_LIMIT;

if (shouldUseMockData()) {
return filterMockPickerFunds(trimmedQuery, limit);
}

try {
const loaded =
trimmedQuery.length > 0
? await searchCatalogFunds(trimmedQuery, {
limit,
signal: options?.signal,
})
: (await getFundsPage(undefined, 1)).data.slice(0, limit);

if (loaded.length > 0) {
return loaded;
}

if (allowsMockFallback()) {
return filterMockPickerFunds(trimmedQuery, limit);
}

return loaded;
} catch (error) {
if (isAbortError(error)) {
throw error;
}

if (allowsMockFallback()) {
return filterMockPickerFunds(trimmedQuery, limit);
}

throw error instanceof AppError
? error
: new AppError(
'FUNDS_FETCH_FAILED',
'No se pudo cargar el catálogo para comparar fondos.',
error,
);
}
}

return { loadComparePickerFunds };
}
113 changes: 9 additions & 104 deletions src/features/comparison/services/load-compare-picker-funds.ts
Original file line number Diff line number Diff line change
@@ -1,121 +1,26 @@
import type { CatalogFund } from '@/core/domain/catalog';

import {
allowsMockFallback,
shouldUseMockData,
} from '@/core/config/app-environment';
import { AppError } from '@/core/errors/app-error';
import { CATALOG_FUNDS_MOCK } from '@/features/funds/mocks/catalog-funds-mock';
import { createComparePickerFundsService } from '@/features/comparison/services/load-compare-picker-funds.factory';
import {
getFundsPage,
searchCatalogFunds,
} from '@/features/funds/services/get-funds';
import { filterCatalogVisible } from '@/features/funds/utils/catalog-visibility';
import {
matchesFundSearch,
normalizeFundSearchQuery,
} from '@/features/funds/utils/fund-search';

const DEFAULT_PICKER_LIMIT = 40;

function isAbortError(error: unknown): boolean {
if (error instanceof DOMException && error.name === 'AbortError') {
return true;
}

return error instanceof Error && error.name === 'AbortError';
}

/**
* Matches picker search across name, ISIN, category, theme and symbol.
*
* @param fund - Catalog fund candidate.
* @param rawQuery - User search text.
*/
export function matchesComparePickerSearch(
fund: CatalogFund,
rawQuery: string,
): boolean {
const query = normalizeFundSearchQuery(rawQuery);

if (query.length === 0) {
return true;
}

if (matchesFundSearch(fund, rawQuery)) {
return true;
}

return (
fund.categoryLabel.toLowerCase().includes(query) ||
fund.themeLabel.toLowerCase().includes(query) ||
fund.symbol.toLowerCase().includes(query)
);
}

function sortPickerFunds(funds: readonly CatalogFund[]): CatalogFund[] {
return [...funds].sort((left, right) => right.inversoraScore - left.inversoraScore);
}
export { matchesComparePickerSearch } from '@/features/comparison/services/load-compare-picker-funds.factory';

function filterMockPickerFunds(query: string, limit: number): CatalogFund[] {
const visibleFunds = filterCatalogVisible(CATALOG_FUNDS_MOCK);

return sortPickerFunds(visibleFunds.filter((fund) => matchesComparePickerSearch(fund, query))).slice(
0,
limit,
);
}
const comparePickerFundsService = createComparePickerFundsService({
shouldUseMockData,
allowsMockFallback,
getFundsPage,
searchCatalogFunds,
});

/**
* Loads catalog funds for the compare picker with mock fallback when the API is empty or down.
*
* @param query - Optional search text.
* @param options - Optional abort signal and result limit.
*/
export async function loadComparePickerFunds(
query: string,
options?: { signal?: AbortSignal; limit?: number },
): Promise<CatalogFund[]> {
const trimmedQuery = query.trim();
const limit = options?.limit ?? DEFAULT_PICKER_LIMIT;

if (shouldUseMockData()) {
return filterMockPickerFunds(trimmedQuery, limit);
}

try {
const loaded =
trimmedQuery.length > 0
? await searchCatalogFunds(trimmedQuery, {
limit,
signal: options?.signal,
})
: (await getFundsPage(undefined, 1)).data.slice(0, limit);

if (loaded.length > 0) {
return loaded;
}

if (allowsMockFallback()) {
return filterMockPickerFunds(trimmedQuery, limit);
}

return loaded;
} catch (error) {
if (isAbortError(error)) {
throw error;
}

if (allowsMockFallback()) {
return filterMockPickerFunds(trimmedQuery, limit);
}

throw error instanceof AppError
? error
: new AppError(
'FUNDS_FETCH_FAILED',
'No se pudo cargar el catálogo para comparar fondos.',
error,
);
}
}
export const loadComparePickerFunds = comparePickerFundsService.loadComparePickerFunds;
Loading
Loading