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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,11 @@ mdcli entries list --account mp --from 2024-01-01 --to 2024-01-31
# List entries using the account's exact name (case-insensitive)
mdcli entries list --account "Nubank - Conta Corrente" --from 2024-01-01 --to 2024-01-31

# Meu Dinheiro loads entries up to 2031-12-31; a later --to is refused before
# any request. Errors go to stderr with exit code 1; under --json they are
# JSON too ({"error": "..."}), so stdout only ever holds the result.
mdcli entries list --account mp --from 2026-11-01 --to 2031-12-31 --json

# Export entries to CSV (all pages, with account and category names)
mdcli entries list --account mp --from 2026-01-01 --to 2026-03-31 --csv > entries.csv

Expand Down
15 changes: 15 additions & 0 deletions scripts/mock-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,18 @@ function cardsRoute(req: Request, path: string): Response {
return notFound();
}

/** GET /v2/lancamentos: the seeded entries of the requested accounts and dates. */
function entriesListRoute(url: URL): Response {
const ids = (JSON.parse(url.searchParams.get('contas') ?? '{}') as { ids?: number[] }).ids ?? [];
const from = url.searchParams.get('inicio') ?? '';
const to = url.searchParams.get('fim') ?? '';
const list = Object.values(SEEDED_ENTRIES).filter((e) => {
const date = String(e.dataPrevista);
return ids.includes(Number(e.conta)) && date >= from && date <= to;
});
return Response.json({ list, meta: { total: list.length, page: 1, pageSize: 200 } });
}

async function entriesRoute(req: Request, path: string): Promise<Response> {
if (path === ENTRIES_PATH) {
if (req.method !== 'POST') return notFound();
Expand Down Expand Up @@ -266,6 +278,9 @@ const server = Bun.serve({
if (url.pathname.startsWith('/api/v1/cartoes/')) {
return cardsRoute(req, url.pathname);
}
if (url.pathname === '/api/v2/lancamentos' && req.method === 'GET') {
return entriesListRoute(url);
}
if (url.pathname === ENTRIES_PATH || url.pathname.startsWith(`${ENTRIES_PATH}/`)) {
return entriesRoute(req, url.pathname);
}
Expand Down
16 changes: 16 additions & 0 deletions scripts/verify.sh
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,16 @@ expect_fail() { # expect_fail <regex> <args...>
grep -Eq "$re" <<<"$out" || { echo "esperava /$re/ em: $out"; return 1; }
}

expect_json_error() { # expect_json_error <regex on .error> <args...> — fails, empty stdout, stderr = {"error": ...}
local re="$1"; shift
local out err
if out=$("${CLI[@]}" "$@" 2>"$SANDBOX/stderr" </dev/null); then echo "deveria falhar: $out"; return 1; fi
err=$(cat "$SANDBOX/stderr")
[ -z "$out" ] || { echo "stdout deveria estar vazio: $out"; return 1; }
ERR="$err" RE="$re" bun -e 'const d = JSON.parse(process.env.ERR); if (!new RegExp(process.env.RE).test(d.error)) process.exit(1);' \
|| { echo "esperava erro JSON /$re/ no stderr: $err"; return 1; }
}

# --version must match package.json exactly (single source of truth).
PKG_VERSION=$(bun -e 'console.log((await Bun.file("package.json").json()).version)')
step "cli --version" expect_ok "^${PKG_VERSION//./\\.}\$" --version
Expand Down Expand Up @@ -119,6 +129,12 @@ step "cli entries update --scope following (mock)" expect_json 'd.status === "ag
step "cli entries update --scope following texto (mock)" expect_ok 'Status: Scheduled' entries update 80 -D 2027-01-25 --scope following
step "cli entries update --pending confirma (mock)" expect_json 'd.status === "pendente"' entries update 80 --pending --scope this --json
step "cli entries update --scope fora de série (mock)" expect_fail 'not part of a recurring series' entries update 70 -d X --scope following
# entries list: the API refuses end dates past 2031-12-31 (#65), so the CLI
# checks first; under --json any error is {"error": ...} on stderr, exit 1.
step "cli entries list --json (mock)" expect_json 'Array.isArray(d) && d.some((e) => e.id === 70 && e.description === "Café")' entries list -a 1001 -f 2026-01-01 -t 2026-01-31 --json
step "cli entries list --to > 2031 (mock)" expect_fail 'is past 2031-12-31' entries list -a 1001 -f 2026-11-01 -t 2035-12-31
step "cli entries list --to > 2031 --json (mock)" expect_json_error 'is past 2031-12-31' entries list -a 1001 -f 2026-11-01 -t 2035-12-31 --json
step "cli erro da API em --json (mock)" expect_json_error 'GET /v1/lancamentos/9999\): 404' entries delete 9999 --dangerously-skip-confirmation --json
step "cli entries delete (mock)" expect_ok 'Entry 70 deleted' entries delete 70 --dangerously-skip-confirmation
step "cli entries delete --json (mock)" expect_json 'd.deleted === true && d.id === 70' entries delete 70 --dangerously-skip-confirmation --json
step "cli entries delete inexistente (mock)" expect_fail 'GET /v1/lancamentos/9999\): 404' entries delete 9999 --dangerously-skip-confirmation
Expand Down
20 changes: 19 additions & 1 deletion src/commands/entries/filters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,24 @@ import { exitWithError } from '../../utils/action.js';
import { fetchCategories } from '../../lib/api.js';
import { resolveIds } from '../../lib/aliases.js';
import type { EntriesParams } from '../../types/index.js';
import { getCurrentMonthRange } from '../../lib/dates.js';
import { getCurrentMonthRange, isCalendarDate } from '../../lib/dates.js';

// The API answers 400 "A data limite de carregamento dos lançamentos é
// 31/12/2031" for any later end date.
const LAST_LOADABLE_DATE = '2031-12-31';

function checkDateRange(from: string | undefined, to: string | undefined): void {
for (const [flag, value] of [['--from', from], ['--to', to]] as const) {
if (value !== undefined && !isCalendarDate(value)) {
exitWithError(`Invalid ${flag} date: ${value}. Use YYYY-MM-DD.`);
}
}
if (to !== undefined && to > LAST_LOADABLE_DATE) {
exitWithError(
`--to ${to} is past ${LAST_LOADABLE_DATE}, the last date Meu Dinheiro loads entries for. Use --to ${LAST_LOADABLE_DATE} or earlier.`
);
}
}

const STATUS_FLAGS: Record<string, number> = {
pending: 1,
Expand Down Expand Up @@ -72,6 +89,7 @@ export async function resolveEntryFilters(options: FilterOptions): Promise<Omit<
if (!options.account) {
exitWithError('At least one account ID or alias is required. Use --account <id> or --account <id1,id2,...>');
}
checkDateRange(options.from, options.to);

const accountResult = await resolveIds('accounts', options.account);
if (accountResult.unresolved.length > 0) {
Expand Down
21 changes: 18 additions & 3 deletions src/utils/action.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,34 @@
import { Command } from 'commander';
import { logger } from './logger.js';

/** Log an error and exit. Shared by command actions for validation failures. */
// Set by action() for the command being run: with --json, errors are written
// as JSON too, so a caller parsing the output never gets plain text.
let jsonErrors = false;

/**
* Report an error on stderr and exit 1. Shared by command actions for
* validation failures. Under --json the line is `{"error": "<message>"}`.
*/
export function exitWithError(message: string): never {
logger.error(message);
if (jsonErrors) {
console.error(JSON.stringify({ error: message }));
} else {
logger.error(message);
}
process.exit(1);
}

/**
* Wrap a Commander action so thrown errors become logger.error + exit 1.
* Wrap a Commander action so thrown errors become exitWithError (exit 1).
* Keeps command bodies free of identical try/catch blocks.
*/
export function action<Args extends unknown[]>(
fn: (...args: Args) => void | Promise<void>
): (...args: Args) => Promise<void> {
return async (...args: Args) => {
// Commander passes the Command itself as the last argument.
const command = args.at(-1);
jsonErrors = command instanceof Command && command.opts().json === true;
try {
await fn(...args);
} catch (error) {
Expand Down
92 changes: 92 additions & 0 deletions test/entries-list-errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { createHome, MANUAL_AUTH, runCli, startMockApi, type MockApi, type TestHome } from './helpers.js';
import { ACCOUNTS_RESPONSE, CATEGORIES_RESPONSE, EMPTY_ENTRIES_RESPONSE } from './fixtures.js';

let api: MockApi;
let home: TestHome;

beforeEach(() => {
api = startMockApi();
api.on('GET /v1/cadastros/contas', ACCOUNTS_RESPONSE);
api.on('GET /v1/cadastros/categorias', CATEGORIES_RESPONSE);
api.on('GET /v2/lancamentos', EMPTY_ENTRIES_RESPONSE);
home = createHome(MANUAL_AUTH);
});

afterEach(() => {
api.stop();
home.cleanup();
});

const entriesCalls = () => api.requests.filter((r) => r.path === '/v2/lancamentos');

describe('entries list date range', () => {
test('rejects --to past 2031-12-31 before calling the API, as JSON under --json', async () => {
const result = await runCli(
['entries', 'list', '-a', '1001', '-f', '2026-11-01', '-t', '2035-12-31', '--json'],
{ home, api }
);

expect(result.exitCode).toBe(1);
expect(result.stdout).toBe('');
expect(JSON.parse(result.stderr)).toEqual({
error: '--to 2035-12-31 is past 2031-12-31, the last date Meu Dinheiro loads entries for. Use --to 2031-12-31 or earlier.',
});
expect(entriesCalls()).toHaveLength(0);
});

test('without --json the same error is text on stderr', async () => {
const result = await runCli(['entries', 'summary', '-a', '1001', '-t', '2032-01-01'], { home, api });

expect(result.exitCode).toBe(1);
expect(result.stdout).toBe('');
expect(result.stderr).toContain('✗ --to 2032-01-01 is past 2031-12-31');
expect(entriesCalls()).toHaveLength(0);
});

test('accepts 2031-12-31 itself', async () => {
const result = await runCli(['entries', 'list', '-a', '1001', '-f', '2031-01-01', '-t', '2031-12-31', '--json'], { home, api });

expect(result.exitCode).toBe(0);
expect(JSON.parse(result.stdout)).toEqual([]);
expect(entriesCalls()[0].query.get('fim')).toBe('2031-12-31');
});

test('rejects a date that is not YYYY-MM-DD', async () => {
const result = await runCli(['entries', 'list', '-a', '1001', '-f', '2026-13-01', '--json'], { home, api });

expect(result.exitCode).toBe(1);
expect(JSON.parse(result.stderr)).toEqual({ error: 'Invalid --from date: 2026-13-01. Use YYYY-MM-DD.' });
expect(entriesCalls()).toHaveLength(0);
});
});

describe('errors under --json', () => {
test('an API error becomes a JSON error on stderr with nothing on stdout', async () => {
api.on('GET /v2/lancamentos', { type: 2, error: 'A data limite de carregamento dos lançamentos é 31/12/2031' }, 400);

const result = await runCli(['entries', 'list', '-a', '1001', '--json'], { home, api });

expect(result.exitCode).toBe(1);
expect(result.stdout).toBe('');
const { error } = JSON.parse(result.stderr) as { error: string };
expect(error).toStartWith('API request failed (GET /v2/lancamentos?');
expect(error).toEndWith(': 400 Bad Request - {"type":2,"error":"A data limite de carregamento dos lançamentos é 31/12/2031"}');
});

test('applies to every command wrapped by action(), not just entries', async () => {
api.on('GET /v1/cadastros/contas', { message: 'boom' }, 500);

const json = await runCli(['accounts', 'list', '--json'], { home, api });
expect(json.exitCode).toBe(1);
expect(json.stdout).toBe('');
expect(JSON.parse(json.stderr)).toEqual({
error: 'API request failed (GET /v1/cadastros/contas?meta=true&paginate=false): 500 Internal Server Error - {"message":"boom"}',
});

const text = await runCli(['accounts', 'list'], { home, api });
expect(text.exitCode).toBe(1);
expect(text.stderr).toContain('✗ API request failed (GET /v1/cadastros/contas?meta=true&paginate=false): 500');
expect(() => JSON.parse(text.stderr)).toThrow();
});
});
Loading