diff --git a/GLOSSARY.md b/GLOSSARY.md index 399d04a..2f5dc7d 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -19,6 +19,10 @@ A free-form label that can be attached to an entry. A single financial transaction (lancamento). Has an account, a category, optional tags, a value, and a date. _Avoid_: "transaction", "record" +**Series**: +A recurring Entry rule (agenda). Occurrences carry `agendaId` and `parcela` (e.g. `fixa`, `2/6`). On update/delete, `--scope this` (default) touches only the current occurrence; `--scope following` touches this one and the ones after it. Both map to the API flag `todos` (`false` / `true`). The web labels are "Apenas neste" / "Neste e em todos na sequência". There is no `--scope all` — the web only exposes that boolean. +_Avoid_: "recurrence" alone (prefer Series when talking about `--scope`) + **Alias**: A user-defined short string mapped to exactly one Account, Category, or Tag ID. Stored locally in `~/.config/mdcli/mdcli.config.json`. Aliases are offline — no API call needed to resolve them. _Avoid_: "shortcut", "nickname" @@ -44,6 +48,7 @@ _Avoid_: "auto-upgrade", "sync with main" - A **Category** classifies one or many **Entries** - A **Tag** may be attached to zero or many **Entries** - An **Alias** points to exactly one **Account**, **Category**, or **Tag** +- A **Series** groups one or many **Entry** occurrences under one agenda rule - An **Identifier** resolves (via the resolver pipeline) to exactly one numeric ID, which is then used in the API call ## Resolver pipeline diff --git a/scripts/verify.sh b/scripts/verify.sh index 402a6f8..10ab5ce 100755 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -123,9 +123,9 @@ step "cli entries update transferência --json (mock)" expect_json 'd.id === 50 # Recurring series (#65): a scheduled occurrence stays scheduled, --scope maps # to todos (the mock keeps it in the series only with todos: true) and editing # without --scope warns on stderr. -step "cli entries update agendado mantém status (mock)" expect_json 'd.status === "agendado" && d.agendaId === null && d.dataEfetiva === null' entries update 80 -D 2027-01-25 --json +step "cli entries update agendado mantém status (mock)" expect_json 'd.status === "agendado" && d.agendaId === null && d.dataEfetiva === null && d.scope === "this"' entries update 80 -D 2027-01-25 --json step "cli entries update agendado avisa no stderr (mock)" expect_ok 'only this occurrence will be changed' entries update 80 -D 2027-01-25 -step "cli entries update --scope following (mock)" expect_json 'd.status === "agendado" && d.agendaId === 77 && d.parcela === "fixa"' entries update 80 -D 2027-01-25 --scope following --json +step "cli entries update --scope following (mock)" expect_json 'd.status === "agendado" && d.agendaId === 77 && d.parcela === "fixa" && d.scope === "following"' entries update 80 -D 2027-01-25 --scope following --json 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 diff --git a/src/commands/entries/delete.ts b/src/commands/entries/delete.ts index aaeab51..34de86b 100644 --- a/src/commands/entries/delete.ts +++ b/src/commands/entries/delete.ts @@ -4,7 +4,7 @@ import { exitWithError } from '../../utils/action.js'; import { promptConfirmation } from '../../utils/prompt.js'; import { fetchEntry, deleteEntry } from '../../lib/api.js'; import { formatCurrency } from './format.js'; -import { resolveSeriesScope, type SeriesScope } from './series.js'; +import { describeScope, resolveSeriesScope, type SeriesScope } from './series.js'; interface DeleteOptions { dangerouslySkipConfirmation?: boolean; @@ -12,10 +12,6 @@ interface DeleteOptions { json?: boolean; } -function describeScope(todos: boolean): string { - return todos ? 'this and the following occurrences' : 'this occurrence only'; -} - export async function deleteAction(id: string, options: DeleteOptions): Promise { const entryId = Number(id); diff --git a/src/commands/entries/filters.ts b/src/commands/entries/filters.ts index 3ab23ec..30ae07b 100644 --- a/src/commands/entries/filters.ts +++ b/src/commands/entries/filters.ts @@ -167,7 +167,7 @@ export function addFilterOptions(command: Command): Command { return command .requiredOption('-a, --account ', 'Account ID(s), alias(es), or exact name(s) (case-insensitive), comma-separated') .option('-f, --from ', 'Start date (YYYY-MM-DD), defaults to first day of current month') - .option('-t, --to ', 'End date (YYYY-MM-DD), defaults to last day of current month') + .option('-t, --to ', 'End date (YYYY-MM-DD), defaults to last day of current month (API limit 2031-12-31)') .option('-s, --status ', 'Filter by status: pending, confirmed, reconciled, scheduled (or bitmask 0-15)') .option('-T, --type ', 'Filter by type: expense, income, transfer-out, transfer-in (or bitmask 0-15)') .option('-c, --category ', 'Filter by category ID(s), alias(es), or exact name(s) (case-insensitive), comma-separated') diff --git a/src/commands/entries/series.ts b/src/commands/entries/series.ts index 681064d..15e8610 100644 --- a/src/commands/entries/series.ts +++ b/src/commands/entries/series.ts @@ -5,6 +5,11 @@ import type { Entry } from '../../types/index.js'; export type SeriesScope = 'this' | 'following'; +/** Human-readable label for the API's `todos` flag. */ +export function describeScope(todos: boolean): string { + return todos ? 'this and the following occurrences' : 'this occurrence only'; +} + /** --scope for commands that act on one occurrence of a recurring series. */ export function scopeOption(verb: string): Option { return new Option( diff --git a/src/commands/entries/update.ts b/src/commands/entries/update.ts index 720e258..fa9d48b 100644 --- a/src/commands/entries/update.ts +++ b/src/commands/entries/update.ts @@ -7,7 +7,7 @@ import { resolveId, resolveIds } from '../../lib/aliases.js'; import type { CreateEntryResponse, Entry, UpdateEntryPayload } from '../../types/index.js'; import { mapTypeToApi, parseTypeOption } from './create.js'; import { formatCurrency, formatStatus } from './format.js'; -import { resolveSeriesScope, type SeriesScope } from './series.js'; +import { describeScope, resolveSeriesScope, type SeriesScope } from './series.js'; interface UpdateOptions { description?: string; @@ -39,7 +39,8 @@ function describeStatus(status: unknown): string { function reportUpdate(response: CreateEntryResponse, json?: boolean, todos?: boolean): void { if (json) { - console.log(JSON.stringify(response, null, 2)); + const scope = todos === undefined ? {} : { scope: todos ? 'following' : 'this' }; + console.log(JSON.stringify({ ...response, ...scope }, null, 2)); return; } @@ -50,7 +51,7 @@ function reportUpdate(response: CreateEntryResponse, json?: boolean, todos?: boo console.log(` ${chalk.gray('Date:')} ${response.data}`); console.log(` ${chalk.gray('Status:')} ${describeStatus(response.status)}`); if (todos !== undefined) { - console.log(` ${chalk.gray('Scope:')} ${todos ? 'this and the following occurrences' : 'this occurrence only'}`); + console.log(` ${chalk.gray('Scope:')} ${describeScope(todos)}`); } } diff --git a/src/lib/browser-session/index.ts b/src/lib/browser-session/index.ts index 584aabf..2b69eb2 100644 --- a/src/lib/browser-session/index.ts +++ b/src/lib/browser-session/index.ts @@ -4,6 +4,7 @@ import { getChromeCookieValue, getChromeProfilePath, getEdgeProfilePath } from ' import { getFirefoxProfilePath } from './firefox.js'; import { extractUidFromJwt, fetchPublicApiKey } from './jwt.js'; import { cleanupTempDir, copyProfileToTemp } from './profile-copy.js'; +import { setupAuthCapture } from '../browser-auth.js'; export { @@ -14,7 +15,6 @@ export { } from './chromium.js'; export { getFirefoxProfilePath, parseFirefoxInstallDefaults } from './firefox.js'; -const API_URL_PATTERN = 'app.meudinheiroweb.com.br/api/'; interface LoginConfigRaw { mdApiKey?: string; @@ -85,15 +85,14 @@ export async function extractSessionFromBrowser( // still carry the auth headers, so capture those too. Authorization is // required: pre-login traffic can already send mdapikey/mduid, and // accepting it would save a token-less auth from a logged-out profile. - const apiHeadersRef: { captured: Record | null } = { captured: null }; - page.on('request', (request) => { + // Context-wide, same as auth login --browser (#63): a page-level + // listener misses API traffic from another tab/popup during the wait. + const apiHeadersRef: { captured: { authorization?: string; mdapikey: string; mduid: string } | null } = { + captured: null, + }; + setupAuthCapture(context, (headers) => { if (apiHeadersRef.captured) return; - const url = request.url(); - if (!url.includes(API_URL_PATTERN)) return; - const headers = request.headers(); - if (headers['mdapikey'] && headers['mduid'] && headers['authorization']) { - apiHeadersRef.captured = headers; - } + apiHeadersRef.captured = headers; }); await page.goto('https://app.meudinheiroweb.com.br/', { waitUntil: 'domcontentloaded' }); diff --git a/test/browser-session-capture.test.ts b/test/browser-session-capture.test.ts new file mode 100644 index 0000000..2c5055f --- /dev/null +++ b/test/browser-session-capture.test.ts @@ -0,0 +1,61 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { chromium, type Browser } from 'playwright'; +import { setupAuthCapture } from '../src/lib/browser-auth.js'; + +// Regression for auth --session: extractSessionFromBrowser used to listen on +// the first page only (page.on('request')). It now reuses setupAuthCapture on +// the BrowserContext, same as auth login --browser (#63). Guard the shared +// helper with the first-wins ref the session path uses. +let browser: Browser; +let server: ReturnType; + +beforeAll(async () => { + server = Bun.serve({ + port: 0, + fetch(req) { + const { pathname } = new URL(req.url); + if (pathname.startsWith('/api/')) return Response.json({}); + if (pathname === '/') { + return new Response( + `login`, + { headers: { 'content-type': 'text/html' } } + ); + } + return new Response( + `dash`, + { headers: { 'content-type': 'text/html' } } + ); + }, + }); + browser = await chromium.launch({ channel: 'chrome', headless: true }); +}); + +afterAll(async () => { + await browser?.close(); + server?.stop(true); +}); + +describe('session auth capture (setupAuthCapture)', () => { + test('first-wins ref captures auth headers from a later tab', async () => { + const context = await browser.newContext(); + const apiHeadersRef: { captured: { authorization?: string; mdapikey: string; mduid: string } | null } = { + captured: null, + }; + setupAuthCapture(context, (headers) => { + if (apiHeadersRef.captured) return; + apiHeadersRef.captured = headers; + }, `127.0.0.1:${server.port}/api/`); + + const first = await context.newPage(); + await first.goto(`http://127.0.0.1:${server.port}/`); + await first.waitForLoadState('networkidle'); + expect(apiHeadersRef.captured).toBeNull(); + + const other = await context.newPage(); + await other.goto(`http://127.0.0.1:${server.port}/u/7`); + await other.waitForLoadState('networkidle'); + + expect(apiHeadersRef.captured).toEqual({ authorization: 'Bearer SESS', mdapikey: 'K', mduid: '7' }); + await context.close(); + }); +}); diff --git a/test/entries-update-series.test.ts b/test/entries-update-series.test.ts index d07705d..ebc2c63 100644 --- a/test/entries-update-series.test.ts +++ b/test/entries-update-series.test.ts @@ -76,7 +76,9 @@ describe('entries update on a recurring series', () => { agenda: { frequencia: 1, intervalo: 'M', quantidade: -1, primeiraData: '2027-01-25' }, }); expect(result.stderr).toContain('only this occurrence will be changed, and it will leave the series'); - expect(JSON.parse(result.stdout).status).toBe('agendado'); + const json = JSON.parse(result.stdout); + expect(json.status).toBe('agendado'); + expect(json.scope).toBe('this'); }); test('--scope following sends todos: true and prints the status the API answered', async () => { @@ -92,6 +94,27 @@ describe('entries update on a recurring series', () => { expect(result.stdout).toContain('Scope: this and the following occurrences'); }); + test('--scope following --json includes scope alongside the API response', async () => { + api.on('PUT /v1/lancamentos/80', { id: 80, descricao: 'NuCel', valor: -45, data: '2027-01-25', status: 'agendado' }); + + const result = await runCli( + ['entries', 'update', '80', '-D', '2027-01-25', '--scope', 'following', '--json'], + { home, api } + ); + + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ id: 80, status: 'agendado', scope: 'following' }); + }); + + test('an entry outside a series --json has no scope field', async () => { + api.on('PUT /v1/lancamentos/70', { ...PENDING_ENTRY, descricao: 'Café X' }); + + const result = await runCli(['entries', 'update', '70', '-d', 'Café X', '--json'], { home, api }); + + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout)).not.toHaveProperty('scope'); + }); + test('--scope this is explicit, so no warning', async () => { api.on('PUT /v1/lancamentos/80', { id: 80, descricao: 'NuCel', valor: -45, data: '2027-01-10', status: 'agendado' });