diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..a76ea15 --- /dev/null +++ b/.envrc @@ -0,0 +1,2 @@ +use flake +watch_file flake.lock diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index fb56a1f..a37b02d 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -22,6 +22,9 @@ jobs: - run: pnpm install --frozen-lockfile + - name: Verify lockfile is up to date + run: pnpm install --lockfile-only && git diff --exit-code pnpm-lock.yaml + - name: Type check working-directory: ./subtrack run: pnpm lint:types @@ -29,3 +32,6 @@ jobs: - run: pnpm build - run: pnpm test + + - name: Audit dependencies + run: pnpm audit --audit-level=high diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 0000000..b3a337f --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,35 @@ +name: OpenSSF Scorecard + +on: + push: + branches: [main] + schedule: + - cron: "0 0 * * 1" + workflow_dispatch: + +permissions: + contents: read + +jobs: + analysis: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + security-events: write + + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - uses: ossf/scorecard-action@v2.4.0 + with: + results_file: results.sarif + results_format: sarif + publish_results: true + + - name: Upload Scorecard SARIF result + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: results.sarif diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..9c883ef --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,49 @@ +# Security Policy + +## Reporting a Vulnerability + +If you discover a security vulnerability in subtrack, please report it privately. + +**Do not** report security vulnerabilities via public GitHub issues. + +Instead, send a description of the issue (including steps to reproduce, affected versions, and any relevant code context) to one of the following: + +- Open a **private security advisory** at: https://github.com/nazozokc/subtrack/security/advisories/new +- Email: **nazozokc@icloud.com** + +You should receive a response within **48 hours**. If you don't hear back, follow up via the advisory thread. + +## Scope + +This policy covers the `subtrack` npm package and the `subtrack-monorepo` at https://github.com/nazozokc/subtrack. + +The following are **out of scope**: +- The documentation site under `docs/` +- Third-party dependencies (report those to the respective maintainers) +- Theoretical vulnerabilities without a practical exploit path + +## What to Expect + +- I will acknowledge receipt of your report within 48 hours +- I will investigate and provide a timeline for a fix +- Once a fix is ready, I will release a patch and credit you in the release notes (unless you prefer to remain anonymous) + +## Supported Versions + +| Version | Supported | +|---------|-----------| +| >= 4.x | ✅ Active | +| < 4.x | ❌ No longer supported | + +## Supply Chain Security + +subtrack takes supply chain security seriously: + +- **Provenance attestation**: All npm releases use `pnpm publish --provenance` (SLSA Level 1+) +- **Dependency review**: Every pull request is scanned for new vulnerabilities via GitHub's dependency review action +- **Renovate bot**: Dependencies are updated with a 7-day minimum release age to detect malicious releases before they reach this project +- **Lockfile**: A `pnpm-lock.yaml` is committed and verified with `--frozen-lockfile` in CI +- **Limited build scripts**: Only `esbuild` is permitted to run postinstall scripts (`pnpm.onlyBuiltDependencies`) +- **CodeQL**: Static analysis runs on every push and PR +- **pnpm audit**: Runs in CI to catch known vulnerabilities +- **OpenSSF Scorecard**: Automated supply chain health assessment diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index cfa050b..40d774d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,5 +2,8 @@ packages: - "subtrack" - "docs" +# Only esbuild is permitted to run install scripts. +# All other dependencies (including transitive) are blocked from running +# postinstall/preinstall scripts as a supply chain security measure. allowBuilds: esbuild: true diff --git a/subtrack/README.md b/subtrack/README.md index f186283..3bfba8c 100644 --- a/subtrack/README.md +++ b/subtrack/README.md @@ -6,7 +6,7 @@

subtrack

-[![Socket Badge](https://badge.socket.dev/npm/package/subtrack/4.0.3)](https://badge.socket.dev/npm/package/subtrack/4.0.3) +[![Socket Badge](https://badge.socket.dev/npm/package/subtrack)](https://socket.dev/npm/package/subtrack)

subtrack demo diff --git a/subtrack/src/commands.test.ts b/subtrack/src/__tests__/commands.test.ts similarity index 86% rename from subtrack/src/commands.test.ts rename to subtrack/src/__tests__/commands.test.ts index 8029db3..aeeee4c 100644 --- a/subtrack/src/commands.test.ts +++ b/subtrack/src/__tests__/commands.test.ts @@ -44,7 +44,7 @@ vi.mock("consola", () => { }) // Mock pricing module for LLM usage tests -vi.mock("./pricing.ts", () => ({ +vi.mock("../pricing.ts", () => ({ ensurePricingCache: vi.fn().mockResolvedValue({ "gpt-4o": { input_cost_per_token: 2.5e-6, @@ -120,7 +120,7 @@ beforeAll(async () => { description TEXT )`) - const db = await import("./db.ts") + const db = await import("../db.ts") db.__setDb(testDb) originalFetch = globalThis.fetch @@ -177,25 +177,25 @@ function writeTempFile(filename: string, content: string): string { // ── parseCsvLine ───────────────────────────────────────── test("parseCsvLine parses simple fields", async () => { - const { parseCsvLine } = await import("./import-csv.ts") + const { parseCsvLine } = await import("../import-csv.ts") const result = parseCsvLine("a,b,c") expect(result).toEqual(["a", "b", "c"]) }) test("parseCsvLine handles quoted fields with commas", async () => { - const { parseCsvLine } = await import("./import-csv.ts") + const { parseCsvLine } = await import("../import-csv.ts") const result = parseCsvLine('"a,b",c') expect(result).toEqual(["a,b", "c"]) }) test("parseCsvLine handles escaped quotes", async () => { - const { parseCsvLine } = await import("./import-csv.ts") + const { parseCsvLine } = await import("../import-csv.ts") const result = parseCsvLine('"say ""hello""",end') expect(result).toEqual(['say "hello"', "end"]) }) test("parseCsvLine handles empty fields", async () => { - const { parseCsvLine } = await import("./import-csv.ts") + const { parseCsvLine } = await import("../import-csv.ts") const result = parseCsvLine("a,,c,") expect(result).toEqual(["a", "", "c", ""]) }) @@ -203,18 +203,18 @@ test("parseCsvLine handles empty fields", async () => { // ── handleTagList ──────────────────────────────────────── test("handleTagList shows info when no tags exist", async () => { - const { handleTagList } = await import("./commands.ts") + const { handleTagList } = await import("../commands.ts") handleTagList() expect(infoMessages).toContain("No tags found") }) test("handleTagList displays tags with counts", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.writeSubscription({ name: "S1", price: 100, currency: "USD", cycle: "monthly", tags: ["video"] }) db.writeSubscription({ name: "S2", price: 200, currency: "JPY", cycle: "monthly", tags: ["video"] }) db.writeSubscription({ name: "S3", price: 300, currency: "JPY", cycle: "monthly", tags: ["storage"] }) - const { handleTagList } = await import("./commands.ts") + const { handleTagList } = await import("../commands.ts") handleTagList() const combined = logMessages.join("\n") expect(combined).toContain("video") @@ -226,22 +226,22 @@ test("handleTagList displays tags with counts", async () => { // ── handleTagRename ────────────────────────────────────── test("handleTagRename shows error when names are empty", async () => { - const { handleTagRename } = await import("./commands.ts") + const { handleTagRename } = await import("../commands.ts") handleTagRename("", "") expect(errorMessages.length).toBeGreaterThan(0) }) test("handleTagRename shows error for non-existent tag", async () => { - const { handleTagRename } = await import("./commands.ts") + const { handleTagRename } = await import("../commands.ts") handleTagRename("nonexistent", "new") expect(errorMessages.some((m) => m.includes("not found"))).toBe(true) }) test("handleTagRename renames a tag successfully", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.writeSubscription({ name: "S1", price: 100, currency: "USD", cycle: "monthly", tags: ["old"] }) - const { handleTagRename } = await import("./commands.ts") + const { handleTagRename } = await import("../commands.ts") handleTagRename("old", "new") expect(successMessages.some((m) => m.includes("old") && m.includes("new"))).toBe(true) @@ -253,22 +253,22 @@ test("handleTagRename renames a tag successfully", async () => { // ── handleTagDelete ────────────────────────────────────── test("handleTagDelete shows error when name is empty", async () => { - const { handleTagDelete } = await import("./commands.ts") + const { handleTagDelete } = await import("../commands.ts") handleTagDelete("") expect(errorMessages.length).toBeGreaterThan(0) }) test("handleTagDelete shows error for non-existent tag", async () => { - const { handleTagDelete } = await import("./commands.ts") + const { handleTagDelete } = await import("../commands.ts") handleTagDelete("nonexistent") expect(errorMessages.some((m) => m.includes("not found"))).toBe(true) }) test("handleTagDelete deletes a tag successfully", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.writeSubscription({ name: "S1", price: 100, currency: "USD", cycle: "monthly", tags: ["delete-me"] }) - const { handleTagDelete } = await import("./commands.ts") + const { handleTagDelete } = await import("../commands.ts") handleTagDelete("delete-me") expect(successMessages.some((m) => m.includes("delete-me"))).toBe(true) expect(db.getTagsWithCount()).toHaveLength(0) @@ -277,19 +277,19 @@ test("handleTagDelete deletes a tag successfully", async () => { // ── handleTagPrune ─────────────────────────────────────── test("handleTagPrune shows info when no orphaned tags", async () => { - const { handleTagPrune } = await import("./commands.ts") + const { handleTagPrune } = await import("../commands.ts") handleTagPrune() expect(infoMessages.some((m) => m.includes("No orphaned"))).toBe(true) }) test("handleTagPrune removes orphaned tags", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.writeSubscription({ name: "S1", price: 100, currency: "USD", cycle: "monthly", tags: ["keep"] }) const [sub] = db.getSubscriptions() db.deleteSubscription(sub.id) testDb.run("INSERT INTO tags (name) VALUES ('orphan1'), ('orphan2')") - const { handleTagPrune } = await import("./commands.ts") + const { handleTagPrune } = await import("../commands.ts") handleTagPrune() expect(successMessages.some((m) => m.includes("3 orphaned"))).toBe(true) }) @@ -297,23 +297,23 @@ test("handleTagPrune removes orphaned tags", async () => { // ── handleExport ───────────────────────────────────────── test("handleExport shows error for unsupported format", async () => { - const { handleExport } = await import("./commands.ts") + const { handleExport } = await import("../commands.ts") await handleExport("pdf", {}) expect(errorMessages.length).toBeGreaterThan(0) expect(errorMessages[0].toLowerCase()).toContain("unsupported") }) test("handleExport shows info when no subscriptions", async () => { - const { handleExport } = await import("./commands.ts") + const { handleExport } = await import("../commands.ts") await handleExport("csv", {}) expect(infoMessages).toContain("No subscriptions found") }) test("handleExport outputs JSON for json format", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.writeSubscription({ name: "Netflix", price: 1500, currency: "JPY", cycle: "monthly", tags: ["video"] }) - const { handleExport } = await import("./commands.ts") + const { handleExport } = await import("../commands.ts") await handleExport("json", {}) const combined = logMessages.join("\n") const parsed = JSON.parse(combined) @@ -322,10 +322,10 @@ test("handleExport outputs JSON for json format", async () => { }) test("handleExport outputs Markdown for md format", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.writeSubscription({ name: "Netflix", price: 1500, currency: "JPY", cycle: "monthly", tags: [] }) - const { handleExport } = await import("./commands.ts") + const { handleExport } = await import("../commands.ts") await handleExport("md", {}) const combined = logMessages.join("\n") expect(combined).toContain("Netflix") @@ -334,11 +334,11 @@ test("handleExport outputs Markdown for md format", async () => { }) test("handleExport filters by tags", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.writeSubscription({ name: "A", price: 100, currency: "USD", cycle: "monthly", tags: ["video"] }) db.writeSubscription({ name: "B", price: 200, currency: "USD", cycle: "monthly", tags: ["audio"] }) - const { handleExport } = await import("./commands.ts") + const { handleExport } = await import("../commands.ts") await handleExport("csv", { tags: "video" }) const combined = logMessages.join("\n") expect(combined).toContain("A") @@ -346,10 +346,10 @@ test("handleExport filters by tags", async () => { }) test("handleExport with currency converts prices", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.writeSubscription({ name: "JP", price: 1600, currency: "JPY", cycle: "monthly", tags: [] }) - const { handleExport } = await import("./commands.ts") + const { handleExport } = await import("../commands.ts") await handleExport("csv", { currency: "USD" }) const combined = logMessages.join("\n") expect(combined).toContain("10") @@ -358,10 +358,10 @@ test("handleExport with currency converts prices", async () => { test("handleExport with currency falls back when fetch fails", async () => { globalThis.fetch = async () => { throw new Error("Network error") } - const db = await import("./db.ts") + const db = await import("../db.ts") db.writeSubscription({ name: "JP", price: 1000, currency: "JPY", cycle: "monthly", tags: [] }) - const { handleExport } = await import("./commands.ts") + const { handleExport } = await import("../commands.ts") await handleExport("csv", { currency: "USD" }) expect(failMessages.length).toBeGreaterThan(0) expect(failMessages[0]).toContain("Failed to fetch exchange rates") @@ -373,7 +373,7 @@ test("handleExport with currency falls back when fetch fails", async () => { // ── CSV injection prevention ───────────────────────────── test("handleExport escapes CSV injection vectors in name", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.writeSubscription({ name: "=CMD", price: 100, currency: "USD", cycle: "monthly", tags: [] }) db.writeSubscription({ name: "+SUM(1,1)", price: 200, currency: "USD", cycle: "monthly", tags: [] }) db.writeSubscription({ name: "-DDE", price: 300, currency: "USD", cycle: "monthly", tags: [] }) @@ -381,7 +381,7 @@ test("handleExport escapes CSV injection vectors in name", async () => { db.writeSubscription({ name: "\tTab", price: 450, currency: "USD", cycle: "monthly", tags: [] }) db.writeSubscription({ name: "Normal", price: 500, currency: "USD", cycle: "monthly", tags: [] }) - const { handleExport } = await import("./commands.ts") + const { handleExport } = await import("../commands.ts") await handleExport("csv", {}) const combined = logMessages.join("\n") @@ -399,7 +399,7 @@ test("handleExport escapes CSV injection vectors in name", async () => { }) test("escapeCsv handles empty strings", async () => { - const { exportCsv } = await import("./export.ts") + const { exportCsv } = await import("../export.ts") const result = exportCsv([ { id: 1, name: "", price: 0, currency: "USD", cycle: "monthly", tags: [] }, ]) @@ -409,10 +409,10 @@ test("escapeCsv handles empty strings", async () => { // ── handleList ─────────────────────────────────────────── test("handleList delegates to spreadSubscription", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.writeSubscription({ name: "Netflix", price: 1500, currency: "JPY", cycle: "monthly", tags: [] }) - const { handleList } = await import("./commands.ts") + const { handleList } = await import("../commands.ts") await handleList({}) const combined = logMessages.join("\n") expect(combined).toContain("Netflix") @@ -420,11 +420,11 @@ test("handleList delegates to spreadSubscription", async () => { }) test("handleList passes sort and desc to getSubscriptions", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.writeSubscription({ name: "B", price: 200, currency: "USD", cycle: "monthly", tags: [] }) db.writeSubscription({ name: "A", price: 100, currency: "USD", cycle: "monthly", tags: [] }) - const { handleList } = await import("./commands.ts") + const { handleList } = await import("../commands.ts") await handleList({ sort: "name", desc: false }) const combined = logMessages.join("\n") const aIdx = combined.indexOf("A") @@ -435,26 +435,26 @@ test("handleList passes sort and desc to getSubscriptions", async () => { // ── handleEdit (non-interactive, with flags) ──────────── test("handleEdit shows info when no subscriptions", async () => { - const { handleEdit } = await import("./commands.ts") + const { handleEdit } = await import("../commands.ts") await handleEdit(1, { name: "New Name" }) expect(infoMessages).toContain("No subscriptions found") }) test("handleEdit shows error for non-existent id", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.writeSubscription({ name: "S1", price: 100, currency: "USD", cycle: "monthly", tags: [] }) - const { handleEdit } = await import("./commands.ts") + const { handleEdit } = await import("../commands.ts") await handleEdit(999, { name: "New Name" }) expect(errorMessages.some((m) => m.includes("not found"))).toBe(true) }) test("handleEdit updates name with --name flag", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.writeSubscription({ name: "OldName", price: 1000, currency: "JPY", cycle: "monthly", tags: [] }) const [sub] = db.getSubscriptions() - const { handleEdit } = await import("./commands.ts") + const { handleEdit } = await import("../commands.ts") await handleEdit(sub.id, { name: "NewName" }) expect(successMessages.some((m) => m.includes("NewName"))).toBe(true) @@ -463,11 +463,11 @@ test("handleEdit updates name with --name flag", async () => { }) test("handleEdit updates price with --price flag", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.writeSubscription({ name: "S1", price: 500, currency: "JPY", cycle: "monthly", tags: [] }) const [sub] = db.getSubscriptions() - const { handleEdit } = await import("./commands.ts") + const { handleEdit } = await import("../commands.ts") await handleEdit(sub.id, { price: "999" }) expect(successMessages.length).toBeGreaterThan(0) @@ -476,11 +476,11 @@ test("handleEdit updates price with --price flag", async () => { }) test("handleEdit updates tags with --tags flag", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.writeSubscription({ name: "S1", price: 500, currency: "JPY", cycle: "monthly", tags: ["old"] }) const [sub] = db.getSubscriptions() - const { handleEdit } = await import("./commands.ts") + const { handleEdit } = await import("../commands.ts") await handleEdit(sub.id, { tags: "new1, new2" }) expect(successMessages.length).toBeGreaterThan(0) @@ -491,7 +491,7 @@ test("handleEdit updates tags with --tags flag", async () => { // ── handleEdit (interactive, with mocked prompts) ─────── test("handleEdit interactive: select picks the subscription", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.writeSubscription({ name: "S1", price: 1000, currency: "JPY", cycle: "monthly", tags: ["x"] }) const [sub] = db.getSubscriptions() @@ -500,7 +500,7 @@ test("handleEdit interactive: select picks the subscription", async () => { vi.mocked(input).mockResolvedValue("Renamed") vi.mocked(confirm).mockResolvedValue(true) - const { handleEdit } = await import("./commands.ts") + const { handleEdit } = await import("../commands.ts") await handleEdit() expect(successMessages.some((m) => m.includes("Renamed"))).toBe(true) @@ -509,20 +509,20 @@ test("handleEdit interactive: select picks the subscription", async () => { }) test("handleEdit interactive: cancels when no fields selected", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.writeSubscription({ name: "S1", price: 1000, currency: "JPY", cycle: "monthly", tags: [] }) const [sub] = db.getSubscriptions() vi.mocked(select).mockResolvedValue(sub) vi.mocked(checkbox).mockResolvedValue([]) - const { handleEdit } = await import("./commands.ts") + const { handleEdit } = await import("../commands.ts") await handleEdit() expect(infoMessages.some((m) => m.includes("Cancelled"))).toBe(true) }) test("handleEdit interactive: cancels when confirm is declined", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.writeSubscription({ name: "S1", price: 1000, currency: "JPY", cycle: "monthly", tags: [] }) const [sub] = db.getSubscriptions() @@ -531,7 +531,7 @@ test("handleEdit interactive: cancels when confirm is declined", async () => { vi.mocked(input).mockResolvedValue("Renamed") vi.mocked(confirm).mockResolvedValue(false) - const { handleEdit } = await import("./commands.ts") + const { handleEdit } = await import("../commands.ts") await handleEdit() expect(infoMessages.some((m) => m.includes("Cancelled"))).toBe(true) const updated = db.getSubscription(sub.id) @@ -541,21 +541,21 @@ test("handleEdit interactive: cancels when confirm is declined", async () => { // ── handleImport ───────────────────────────────────────── test("handleImport shows error when no file argument", async () => { - const { handleImport } = await import("./import-csv.ts") + const { handleImport } = await import("../import-csv.ts") await handleImport("", {}) expect(errorMessages.length).toBeGreaterThan(0) expect(errorMessages[0].toLowerCase()).toContain("usage") }) test("handleImport shows error for non-existent file", async () => { - const { handleImport } = await import("./import-csv.ts") + const { handleImport } = await import("../import-csv.ts") await handleImport("/nonexistent/file.csv", {}) expect(errorMessages.some((m) => m.includes("not found"))).toBe(true) }) test("handleImport shows error for invalid CSV header", async () => { const filePath = writeTempFile("bad-header.csv", "name,price\na,100") - const { handleImport } = await import("./import-csv.ts") + const { handleImport } = await import("../import-csv.ts") await handleImport(filePath, {}) expect(errorMessages.length).toBeGreaterThan(0) expect(errorMessages[0].toLowerCase()).toContain("invalid csv header") @@ -565,10 +565,10 @@ test("handleImport imports valid CSV data", async () => { const csv = "\uFEFFname,cycle,tags,price,currency\nNetflix,monthly,video;entertainment,1500,JPY\nDropbox,monthly,storage,10,USD" const filePath = writeTempFile("valid.csv", csv) - const { handleImport } = await import("./import-csv.ts") + const { handleImport } = await import("../import-csv.ts") await handleImport(filePath, {}) - const db = await import("./db.ts") + const db = await import("../db.ts") const subs = db.getSubscriptions() expect(subs).toHaveLength(2) expect(subs[0].name).toBe("Netflix") @@ -583,10 +583,10 @@ test("handleImport supports dry-run mode", async () => { const csv = "name,cycle,tags,price,currency\nNetflix,monthly,,1500,JPY" const filePath = writeTempFile("dryrun.csv", csv) - const { handleImport } = await import("./import-csv.ts") + const { handleImport } = await import("../import-csv.ts") await handleImport(filePath, { dryRun: true }) - const db = await import("./db.ts") + const db = await import("../db.ts") expect(db.getSubscriptions()).toHaveLength(0) expect(successMessages.some((m) => m.includes("Dry-run"))).toBe(true) }) @@ -595,10 +595,10 @@ test("handleImport skips invalid rows", async () => { const csv = "name,cycle,tags,price,currency\nValid,monthly,,100,JPY\n,monthly,,abc,JPY\nBadPrice,monthly,,notanumber,JPY\nBadCurrency,monthly,,100,ZZ" // ZZ fails regex /^[A-Z]{3}$/ const filePath = writeTempFile("partial.csv", csv) - const { handleImport } = await import("./import-csv.ts") + const { handleImport } = await import("../import-csv.ts") await handleImport(filePath, {}) - const db = await import("./db.ts") + const db = await import("../db.ts") expect(db.getSubscriptions()).toHaveLength(1) expect(db.getSubscriptions()[0].name).toBe("Valid") expect(successMessages.some((m) => m.includes("1 imported"))).toBe(true) @@ -608,16 +608,16 @@ test("handleImport skips invalid rows", async () => { // ── handleSummary ──────────────────────────────────────── test("handleSummary shows info when no subscriptions", async () => { - const { handleSummary } = await import("./commands.ts") + const { handleSummary } = await import("../commands.ts") await handleSummary() expect(infoMessages).toContain("No subscriptions found") }) test("handleSummary displays summary data", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.writeSubscription({ name: "Netflix", price: 1500, currency: "JPY", cycle: "monthly", tags: ["video"] }) - const { handleSummary } = await import("./commands.ts") + const { handleSummary } = await import("../commands.ts") await handleSummary() const combined = logMessages.join("\n") expect(combined).toContain("Total subscriptions:") @@ -628,11 +628,11 @@ test("handleSummary displays summary data", async () => { // ── handleTags ──────────────────────────────────────────── test("handleTags delegates to spreadSubscription with tag filter", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.writeSubscription({ name: "A", price: 100, currency: "USD", cycle: "monthly", tags: ["video"] }) db.writeSubscription({ name: "B", price: 200, currency: "USD", cycle: "monthly", tags: ["audio"] }) - const { handleTags } = await import("./commands.ts") + const { handleTags } = await import("../commands.ts") await handleTags(["video"]) const combined = logMessages.join("\n") expect(combined).toContain("A") @@ -642,10 +642,10 @@ test("handleTags delegates to spreadSubscription with tag filter", async () => { // ── handlePayment ───────────────────────────────────────── test("handlePayment shows monthly total", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.writeSubscription({ name: "Netflix", price: 1500, currency: "JPY", cycle: "monthly", tags: [] }) - const { handlePayment } = await import("./commands.ts") + const { handlePayment } = await import("../commands.ts") await handlePayment("monthly", {}) const combined = logMessages.join("\n") expect(combined).toContain("¥1,500") @@ -653,10 +653,10 @@ test("handlePayment shows monthly total", async () => { }) test("handlePayment with --currency converts to target", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.writeSubscription({ name: "Netflix", price: 1000, currency: "JPY", cycle: "monthly", tags: [] }) - const { handlePayment } = await import("./commands.ts") + const { handlePayment } = await import("../commands.ts") await handlePayment("monthly", { currency: "JPY" }) const combined = logMessages.join("\n") expect(combined).toContain("¥1,000") @@ -676,7 +676,7 @@ test("handleAdd shows info when cancelled at confirm", async () => { .mockResolvedValueOnce("monthly") // cycle vi.mocked(confirm).mockResolvedValueOnce(false) // decline - const { handleAdd } = await import("./commands.ts") + const { handleAdd } = await import("../commands.ts") await handleAdd({}) expect(infoMessages.some((m) => m.includes("Cancelled"))).toBe(true) }) @@ -691,11 +691,11 @@ test("handleAdd creates subscription with prompted fields", async () => { .mockResolvedValueOnce("monthly") vi.mocked(confirm).mockResolvedValueOnce(true) - const { handleAdd } = await import("./commands.ts") + const { handleAdd } = await import("../commands.ts") await handleAdd({}) expect(successMessages.some((m) => m.includes("Spotify"))).toBe(true) - const db = await import("./db.ts") + const db = await import("../db.ts") const subs = db.getSubscriptions() expect(subs).toHaveLength(1) expect(subs[0].name).toBe("Spotify") @@ -703,7 +703,7 @@ test("handleAdd creates subscription with prompted fields", async () => { }) test("handleAdd uses flags when provided (non-interactive)", async () => { - const { handleAdd } = await import("./commands.ts") + const { handleAdd } = await import("../commands.ts") await handleAdd({ name: "FlagService", price: "500", @@ -713,7 +713,7 @@ test("handleAdd uses flags when provided (non-interactive)", async () => { }) expect(successMessages.some((m) => m.includes("FlagService"))).toBe(true) - const db = await import("./db.ts") + const db = await import("../db.ts") const subs = db.getSubscriptions() expect(subs).toHaveLength(1) expect(subs[0].name).toBe("FlagService") @@ -726,13 +726,13 @@ test("handleAdd uses flags when provided (non-interactive)", async () => { // ── handleDelete ────────────────────────────────────────── test("handleDelete shows info when no subscriptions", async () => { - const { handleDelete } = await import("./commands.ts") + const { handleDelete } = await import("../commands.ts") await handleDelete() expect(infoMessages).toContain("No subscriptions found") }) test("handleDelete deletes selected subscriptions", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.writeSubscription({ name: "S1", price: 100, currency: "USD", cycle: "monthly", tags: [] }) db.writeSubscription({ name: "S2", price: 200, currency: "USD", cycle: "monthly", tags: [] }) const [s1, s2] = db.getSubscriptions() @@ -741,7 +741,7 @@ test("handleDelete deletes selected subscriptions", async () => { vi.mocked(checkbox).mockResolvedValue([s1]) vi.mocked(confirm).mockResolvedValue(true) - const { handleDelete } = await import("./commands.ts") + const { handleDelete } = await import("../commands.ts") await handleDelete() const remaining = db.getSubscriptions() @@ -751,12 +751,12 @@ test("handleDelete deletes selected subscriptions", async () => { }) test("handleDelete deletes by ID (non-interactive)", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.writeSubscription({ name: "S1", price: 100, currency: "USD", cycle: "monthly", tags: [] }) db.writeSubscription({ name: "S2", price: 200, currency: "USD", cycle: "monthly", tags: [] }) const [s1] = db.getSubscriptions() - const { handleDelete } = await import("./commands.ts") + const { handleDelete } = await import("../commands.ts") await handleDelete([s1.id]) const remaining = db.getSubscriptions() @@ -766,10 +766,10 @@ test("handleDelete deletes by ID (non-interactive)", async () => { }) test("handleDelete with non-existent ID shows error", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.writeSubscription({ name: "S1", price: 100, currency: "USD", cycle: "monthly", tags: [] }) - const { handleDelete } = await import("./commands.ts") + const { handleDelete } = await import("../commands.ts") await handleDelete([999]) expect(errorMessages.some((m) => m.includes("not found"))).toBe(true) @@ -777,12 +777,12 @@ test("handleDelete with non-existent ID shows error", async () => { }) test("handleDelete cancels when no selection", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.writeSubscription({ name: "S1", price: 100, currency: "USD", cycle: "monthly", tags: [] }) vi.mocked(checkbox).mockResolvedValue([]) - const { handleDelete } = await import("./commands.ts") + const { handleDelete } = await import("../commands.ts") await handleDelete() expect(infoMessages.some((m) => m.includes("Cancelled"))).toBe(true) @@ -790,14 +790,14 @@ test("handleDelete cancels when no selection", async () => { }) test("handleDelete cancels when confirm is declined", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.writeSubscription({ name: "S1", price: 100, currency: "USD", cycle: "monthly", tags: [] }) const [s1] = db.getSubscriptions() vi.mocked(checkbox).mockResolvedValue([s1]) vi.mocked(confirm).mockResolvedValue(false) - const { handleDelete } = await import("./commands.ts") + const { handleDelete } = await import("../commands.ts") await handleDelete() expect(infoMessages.some((m) => m.includes("Cancelled"))).toBe(true) @@ -807,8 +807,8 @@ test("handleDelete cancels when confirm is declined", async () => { // ── handleUsageAdd (non-interactive) ───────────────────── test("handleUsageAdd creates entry with all flags", async () => { - const db = await import("./db.ts") - const { handleUsageAdd } = await import("./usage.ts") + const db = await import("../db.ts") + const { handleUsageAdd } = await import("../usage.ts") await handleUsageAdd({ provider: "openai", @@ -831,8 +831,8 @@ test("handleUsageAdd creates entry with all flags", async () => { }) test("handleUsageAdd with --cost flag uses manual cost when pricing not found", async () => { - const db = await import("./db.ts") - const { handleUsageAdd } = await import("./usage.ts") + const db = await import("../db.ts") + const { handleUsageAdd } = await import("../usage.ts") // Use an unknown model so pricing lookup fails, fall back to --cost await handleUsageAdd({ @@ -851,7 +851,7 @@ test("handleUsageAdd with --cost flag uses manual cost when pricing not found", }) test("handleUsageAdd with invalid --cost shows error", async () => { - const { handleUsageAdd } = await import("./usage.ts") + const { handleUsageAdd } = await import("../usage.ts") await handleUsageAdd({ provider: "openai", model: "gpt-4o", @@ -864,7 +864,7 @@ test("handleUsageAdd with invalid --cost shows error", async () => { }) test("handleUsageAdd with invalid provider shows error", async () => { - const { handleUsageAdd } = await import("./usage.ts") + const { handleUsageAdd } = await import("../usage.ts") await handleUsageAdd({ provider: "nonexistent", model: "gpt-4o", @@ -876,7 +876,7 @@ test("handleUsageAdd with invalid provider shows error", async () => { }) test("handleUsageAdd with invalid tokens shows error", async () => { - const { handleUsageAdd } = await import("./usage.ts") + const { handleUsageAdd } = await import("../usage.ts") await handleUsageAdd({ provider: "openai", model: "gpt-4o", @@ -887,7 +887,7 @@ test("handleUsageAdd with invalid tokens shows error", async () => { }) test("handleUsageAdd with invalid date shows error", async () => { - const { handleUsageAdd } = await import("./usage.ts") + const { handleUsageAdd } = await import("../usage.ts") await handleUsageAdd({ provider: "openai", model: "gpt-4o", @@ -901,13 +901,13 @@ test("handleUsageAdd with invalid date shows error", async () => { // ── handleUsageList ────────────────────────────────────── test("handleUsageList shows info when no entries", async () => { - const { handleUsageList } = await import("./usage.ts") + const { handleUsageList } = await import("../usage.ts") await handleUsageList({}) expect(infoMessages.some((m) => m.includes("No paid usage entries"))).toBe(true) }) test("handleUsageList displays entries", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.addLlmUsage({ provider: "openai", model: "gpt-4o", @@ -918,7 +918,7 @@ test("handleUsageList displays entries", async () => { description: null, }) - const { handleUsageList } = await import("./usage.ts") + const { handleUsageList } = await import("../usage.ts") await handleUsageList({}) const combined = logMessages.join("\n") expect(combined).toContain("openai") @@ -929,7 +929,7 @@ test("handleUsageList displays entries", async () => { // ── handleUsageDelete ───────────────────────────────────── test("handleUsageDelete deletes by ID (non-interactive)", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.addLlmUsage({ provider: "openai", model: "gpt-4o", @@ -942,7 +942,7 @@ test("handleUsageDelete deletes by ID (non-interactive)", async () => { const entries = db.getLlmUsage() const id = entries[0].id - const { handleUsageDelete } = await import("./usage.ts") + const { handleUsageDelete } = await import("../usage.ts") await handleUsageDelete([id]) expect(db.getLlmUsage()).toHaveLength(0) @@ -950,19 +950,19 @@ test("handleUsageDelete deletes by ID (non-interactive)", async () => { }) test("handleUsageDelete with non-existent ID shows error", async () => { - const { handleUsageDelete } = await import("./usage.ts") + const { handleUsageDelete } = await import("../usage.ts") await handleUsageDelete([999]) expect(errorMessages.some((m) => m.includes("not found"))).toBe(true) }) test("handleUsageDelete shows info when no entries", async () => { - const { handleUsageDelete } = await import("./usage.ts") + const { handleUsageDelete } = await import("../usage.ts") await handleUsageDelete() expect(infoMessages.some((m) => m.includes("No usage entries"))).toBe(true) }) test("handleUsageDelete deletes selected entries", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.addLlmUsage({ provider: "openai", model: "gpt-4o", @@ -977,7 +977,7 @@ test("handleUsageDelete deletes selected entries", async () => { vi.mocked(checkbox).mockResolvedValue(entries) vi.mocked(confirm).mockResolvedValue(true) - const { handleUsageDelete } = await import("./usage.ts") + const { handleUsageDelete } = await import("../usage.ts") await handleUsageDelete() expect(db.getLlmUsage()).toHaveLength(0) @@ -989,7 +989,7 @@ test("handleUsageDelete deletes selected entries", async () => { test("handleUsageRefresh shows failure when fetch fails", async () => { globalThis.fetch = async () => { throw new Error("Network error") } - const { handleUsageRefresh } = await import("./usage.ts") + const { handleUsageRefresh } = await import("../usage.ts") await handleUsageRefresh() expect(failMessages.some((m) => m.includes("Failed to fetch"))).toBe(true) @@ -1007,10 +1007,10 @@ test("handleBackup creates compressed backup in specified directory", async () = const tmpDir = mkdtempSync(join(tmpdir(), "subtrack-test-")) - const db = await import("./db.ts") + const db = await import("../db.ts") db.writeSubscription({ name: "S1", price: 100, currency: "USD", cycle: "monthly", tags: [] }) - const { handleBackup } = await import("./commands.ts") + const { handleBackup } = await import("../commands.ts") await handleBackup(tmpDir) // Verify .db.gz file was created @@ -1046,12 +1046,12 @@ test("handleRestore restores from valid backup file", async () => { writeFileSync(backupPath, buf) // Current DB has different data - const db = await import("./db.ts") + const db = await import("../db.ts") db.writeSubscription({ name: "Old", price: 500, currency: "JPY", cycle: "monthly", tags: [] }) vi.mocked(confirm).mockResolvedValue(true) - const { handleRestore } = await import("./commands.ts") + const { handleRestore } = await import("../commands.ts") await handleRestore(backupPath, { force: true }) // Verify restored data @@ -1065,7 +1065,7 @@ test("handleRestore restores from valid backup file", async () => { }) test("handleRestore with non-existent file shows error", async () => { - const { handleRestore } = await import("./commands.ts") + const { handleRestore } = await import("../commands.ts") await handleRestore("/nonexistent/file.db.gz") expect(errorMessages.some((m) => m.includes("not found"))).toBe(true) }) @@ -1077,7 +1077,7 @@ test("handleRestore interactive: shows info when no backups found", async () => const tmpDir = mkdtempSync(join(tmpdir(), "subtrack-test-empty")) - const { handleRestore } = await import("./commands.ts") + const { handleRestore } = await import("../commands.ts") await handleRestore(undefined, { dir: tmpDir }) expect(infoMessages.some((m) => m.includes("No backup files"))).toBe(true) diff --git a/subtrack/src/crypto.test.ts b/subtrack/src/__tests__/crypto.test.ts similarity index 85% rename from subtrack/src/crypto.test.ts rename to subtrack/src/__tests__/crypto.test.ts index 4ab32a6..fef2d7b 100644 --- a/subtrack/src/crypto.test.ts +++ b/subtrack/src/__tests__/crypto.test.ts @@ -22,7 +22,7 @@ afterAll(() => { }) test("encryptBuffer and decryptBuffer round-trip", async () => { - const { encryptBuffer, decryptBuffer } = await import("./crypto.ts") + const { encryptBuffer, decryptBuffer } = await import("../crypto.ts") const original = Buffer.from("Hello, subtrack! This is sensitive data.") const encrypted = encryptBuffer(original) expect(encrypted).not.toEqual(original) @@ -33,7 +33,7 @@ test("encryptBuffer and decryptBuffer round-trip", async () => { }) test("encryptBuffer produces different output each time (random IV)", async () => { - const { encryptBuffer, decryptBuffer } = await import("./crypto.ts") + const { encryptBuffer, decryptBuffer } = await import("../crypto.ts") const original = Buffer.from("same data") const a = encryptBuffer(original) const b = encryptBuffer(original) @@ -44,27 +44,27 @@ test("encryptBuffer produces different output each time (random IV)", async () = }) test("isEncrypted returns true for encrypted data", async () => { - const { encryptBuffer, isEncrypted } = await import("./crypto.ts") + const { encryptBuffer, isEncrypted } = await import("../crypto.ts") const data = Buffer.from("test data") const encrypted = encryptBuffer(data) expect(isEncrypted(encrypted)).toBe(true) }) test("isEncrypted returns false for SQLite magic header", async () => { - const { isEncrypted } = await import("./crypto.ts") + const { isEncrypted } = await import("../crypto.ts") const sqliteBuf = Buffer.from("SQLite format 3\x00") const padded = Buffer.concat([sqliteBuf, Buffer.alloc(100)]) expect(isEncrypted(padded)).toBe(false) }) test("isEncrypted returns false for short buffers", async () => { - const { isEncrypted } = await import("./crypto.ts") + const { isEncrypted } = await import("../crypto.ts") expect(isEncrypted(Buffer.alloc(10))).toBe(false) expect(isEncrypted(Buffer.alloc(0))).toBe(false) }) test("hasEncryptionKey returns true after key creation", async () => { - const { encryptBuffer, hasEncryptionKey } = await import("./crypto.ts") + const { encryptBuffer, hasEncryptionKey } = await import("../crypto.ts") encryptBuffer(Buffer.from("trigger key creation")) expect(hasEncryptionKey()).toBe(true) }) @@ -73,7 +73,7 @@ test("key file is created with restricted permissions (0o600)", async () => { // Windows does not support Unix permission bits via chmod if (process.platform === "win32") return - const { encryptBuffer } = await import("./crypto.ts") + const { encryptBuffer } = await import("../crypto.ts") encryptBuffer(Buffer.from("perm test")) const keyPath = join(tmpDir, ".key") @@ -90,7 +90,7 @@ test("hasEncryptionKey returns true with SUBSC_CLI_DB_PASSPHRASE", async () => { try { // Need to use a clean import to pick up the env var // Since modules are cached, re-use existing module - const { hasEncryptionKey } = await import("./crypto.ts") + const { hasEncryptionKey } = await import("../crypto.ts") expect(hasEncryptionKey()).toBe(true) } finally { if (origPass !== undefined) { diff --git a/subtrack/src/db.test.ts b/subtrack/src/__tests__/db.test.ts similarity index 91% rename from subtrack/src/db.test.ts rename to subtrack/src/__tests__/db.test.ts index b0dade0..5c2ebe2 100644 --- a/subtrack/src/db.test.ts +++ b/subtrack/src/__tests__/db.test.ts @@ -37,7 +37,7 @@ beforeAll(async () => { description TEXT )`) - const db = await import("./db.ts") + const db = await import("../db.ts") db.__setDb(testDb) }) @@ -53,12 +53,12 @@ afterAll(() => { }) test("getSubscriptions returns empty when no data exists", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") expect(db.getSubscriptions()).toEqual([]) }) test("writeSubscription creates a subscription with tags", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.writeSubscription({ name: "Netflix", @@ -80,7 +80,7 @@ test("writeSubscription creates a subscription with tags", async () => { }) test("writeSubscription handles empty tags gracefully", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.writeSubscription({ name: "Dropbox", @@ -96,7 +96,7 @@ test("writeSubscription handles empty tags gracefully", async () => { }) test("writeSubscription supports USD currency", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.writeSubscription({ name: "GitHub Copilot", @@ -111,7 +111,7 @@ test("writeSubscription supports USD currency", async () => { }) test("writeSubscription supports yearly cycle", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.writeSubscription({ name: "iCloud+", @@ -126,7 +126,7 @@ test("writeSubscription supports yearly cycle", async () => { }) test("getSubscriptions returns all subscriptions ordered by id", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.writeSubscription({ name: "A", @@ -158,7 +158,7 @@ test("getSubscriptions returns all subscriptions ordered by id", async () => { }) test("deleteSubscription removes a subscription", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.writeSubscription({ name: "ToDelete", @@ -176,7 +176,7 @@ test("deleteSubscription removes a subscription", async () => { }) test("deleteSubscription cascades to subscription_tags", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.writeSubscription({ name: "WithTags", @@ -210,12 +210,12 @@ test("deleteSubscription cascades to subscription_tags", async () => { }) test("deleteSubscription does not throw when id does not exist", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") expect(() => db.deleteSubscription(99999)).not.toThrow() }) test("tagsSubscription filters by single tag", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.writeSubscription({ name: "Netflix", @@ -238,7 +238,7 @@ test("tagsSubscription filters by single tag", async () => { }) test("tagsSubscription filters by multiple tags with AND logic", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.writeSubscription({ name: "Netflix", @@ -270,7 +270,7 @@ test("tagsSubscription filters by multiple tags with AND logic", async () => { }) test("tagsSubscription returns only subscriptions matching ALL specified tags", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.writeSubscription({ name: "Netflix", @@ -293,7 +293,7 @@ test("tagsSubscription returns only subscriptions matching ALL specified tags", }) test("tagsSubscription returns empty for non-matching tag", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.writeSubscription({ name: "Netflix", @@ -307,13 +307,13 @@ test("tagsSubscription returns empty for non-matching tag", async () => { }) test("tagsSubscription returns empty array for empty input", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") expect(db.tagsSubscription([])).toEqual([]) expect(db.tagsSubscription("")).toEqual([]) }) test("works with multiple subscriptions sharing the same tag", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.writeSubscription({ name: "S1", @@ -335,53 +335,53 @@ test("works with multiple subscriptions sharing the same tag", async () => { }) test("periodFactor returns correct factor for monthly to monthly", async () => { - const { periodFactor } = await import("./types.ts") + const { periodFactor } = await import("../types.ts") expect(periodFactor("monthly", "monthly")).toBe(1) }) test("periodFactor returns correct factor for yearly to monthly", async () => { - const { periodFactor } = await import("./types.ts") + const { periodFactor } = await import("../types.ts") expect(periodFactor("yearly", "monthly")).toBe(1 / 12) }) test("periodFactor returns correct factor for monthly to yearly", async () => { - const { periodFactor } = await import("./types.ts") + const { periodFactor } = await import("../types.ts") expect(periodFactor("monthly", "yearly")).toBe(12) }) test("periodFactor returns correct factor for weekly to monthly", async () => { - const { periodFactor } = await import("./types.ts") + const { periodFactor } = await import("../types.ts") expect(periodFactor("weekly", "monthly")).toBe(52 / 12) }) test("periodFactor returns correct factor for bi-weekly to monthly", async () => { - const { periodFactor } = await import("./types.ts") + const { periodFactor } = await import("../types.ts") expect(periodFactor("bi-weekly", "monthly")).toBe(26 / 12) }) test("periodFactor returns correct factor for quarterly to monthly", async () => { - const { periodFactor } = await import("./types.ts") + const { periodFactor } = await import("../types.ts") expect(periodFactor("quarterly", "monthly")).toBe(4 / 12) }) test("periodFactor returns correct factor for semi-annual to monthly", async () => { - const { periodFactor } = await import("./types.ts") + const { periodFactor } = await import("../types.ts") expect(periodFactor("semi-annual", "monthly")).toBe(2 / 12) }) test("periodFactor defaults to monthly when to is omitted", async () => { - const { periodFactor } = await import("./types.ts") + const { periodFactor } = await import("../types.ts") expect(periodFactor("yearly")).toBe(1 / 12) expect(periodFactor("monthly")).toBe(1) }) test("periodFactor returns correct factor for quarterly to yearly", async () => { - const { periodFactor } = await import("./types.ts") + const { periodFactor } = await import("../types.ts") expect(periodFactor("quarterly", "yearly")).toBe(4) }) test("periodFactor handles all cycle-to-cycle combinations without throwing", async () => { - const { periodFactor, OCCURRENCES_PER_YEAR } = await import("./types.ts") + const { periodFactor, OCCURRENCES_PER_YEAR } = await import("../types.ts") const cycles = Object.keys(OCCURRENCES_PER_YEAR) as Array for (const from of cycles) { for (const to of cycles) { @@ -394,7 +394,7 @@ test("periodFactor handles all cycle-to-cycle combinations without throwing", as }) test("getSubscriptions returns correct data types", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.writeSubscription({ name: "Test", @@ -414,7 +414,7 @@ test("getSubscriptions returns correct data types", async () => { }) test("does not share tags between different subscriptions", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.writeSubscription({ name: "Netflix", @@ -441,7 +441,7 @@ test("does not share tags between different subscriptions", async () => { // ── sort ────────────────────────────────────────────────── test("getSubscriptions sorts by name ascending", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.writeSubscription({ name: "C", price: 100, currency: "USD", cycle: "monthly", tags: [] }) db.writeSubscription({ name: "A", price: 200, currency: "USD", cycle: "monthly", tags: [] }) db.writeSubscription({ name: "B", price: 300, currency: "USD", cycle: "monthly", tags: [] }) @@ -453,7 +453,7 @@ test("getSubscriptions sorts by name ascending", async () => { }) test("getSubscriptions sorts by name descending", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.writeSubscription({ name: "A", price: 100, currency: "USD", cycle: "monthly", tags: [] }) db.writeSubscription({ name: "B", price: 200, currency: "USD", cycle: "monthly", tags: [] }) db.writeSubscription({ name: "C", price: 300, currency: "USD", cycle: "monthly", tags: [] }) @@ -465,7 +465,7 @@ test("getSubscriptions sorts by name descending", async () => { }) test("getSubscriptions sorts by price ascending", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.writeSubscription({ name: "A", price: 300, currency: "USD", cycle: "monthly", tags: [] }) db.writeSubscription({ name: "B", price: 100, currency: "USD", cycle: "monthly", tags: [] }) db.writeSubscription({ name: "C", price: 200, currency: "USD", cycle: "monthly", tags: [] }) @@ -477,7 +477,7 @@ test("getSubscriptions sorts by price ascending", async () => { }) test("getSubscriptions falls back to id order for invalid sort field", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.writeSubscription({ name: "B", price: 100, currency: "USD", cycle: "monthly", tags: [] }) db.writeSubscription({ name: "A", price: 200, currency: "USD", cycle: "monthly", tags: [] }) @@ -489,7 +489,7 @@ test("getSubscriptions falls back to id order for invalid sort field", async () // ── getSubscription ─────────────────────────────────────── test("getSubscription returns a single subscription by id", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.writeSubscription({ name: "Target", price: 500, currency: "JPY", cycle: "monthly", tags: ["test"] }) const [all] = db.getSubscriptions() @@ -500,14 +500,14 @@ test("getSubscription returns a single subscription by id", async () => { }) test("getSubscription returns undefined for non-existent id", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") expect(db.getSubscription(99999)).toBeUndefined() }) // ── updateSubscription ──────────────────────────────────── test("updateSubscription updates a single field", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.writeSubscription({ name: "Old Name", price: 1000, currency: "JPY", cycle: "monthly", tags: [] }) const [sub] = db.getSubscriptions() @@ -519,7 +519,7 @@ test("updateSubscription updates a single field", async () => { }) test("updateSubscription updates all fields", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.writeSubscription({ name: "Old", price: 1000, currency: "JPY", cycle: "monthly", tags: ["old"] }) const [sub] = db.getSubscriptions() @@ -540,7 +540,7 @@ test("updateSubscription updates all fields", async () => { }) test("updateSubscription replaces tags when specified", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.writeSubscription({ name: "Test", price: 500, currency: "JPY", cycle: "monthly", tags: ["old1", "old2"] }) const [sub] = db.getSubscriptions() @@ -551,7 +551,7 @@ test("updateSubscription replaces tags when specified", async () => { }) test("updateSubscription does not clear tags when not specified", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.writeSubscription({ name: "Test", price: 500, currency: "JPY", cycle: "monthly", tags: ["keep"] }) const [sub] = db.getSubscriptions() @@ -564,7 +564,7 @@ test("updateSubscription does not clear tags when not specified", async () => { // ── getTagsWithCount ────────────────────────────────────── test("getTagsWithCount returns tag usage counts", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.writeSubscription({ name: "S1", price: 100, currency: "USD", cycle: "monthly", tags: ["shared"] }) db.writeSubscription({ name: "S2", price: 200, currency: "JPY", cycle: "monthly", tags: ["shared", "unique"] }) @@ -576,14 +576,14 @@ test("getTagsWithCount returns tag usage counts", async () => { }) test("getTagsWithCount returns empty array when no tags exist", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") expect(db.getTagsWithCount()).toEqual([]) }) // ── renameTag ───────────────────────────────────────────── test("renameTag renames a tag", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.writeSubscription({ name: "S1", price: 100, currency: "USD", cycle: "monthly", tags: ["old"] }) const result = db.renameTag("old", "new") @@ -595,7 +595,7 @@ test("renameTag renames a tag", async () => { }) test("renameTag merges when target name already exists", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.writeSubscription({ name: "S1", price: 100, currency: "USD", cycle: "monthly", tags: ["a"] }) db.writeSubscription({ name: "S2", price: 200, currency: "JPY", cycle: "monthly", tags: ["b"] }) @@ -607,14 +607,14 @@ test("renameTag merges when target name already exists", async () => { }) test("renameTag returns false for non-existent tag", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") expect(db.renameTag("nonexistent", "new")).toBe(false) }) // ── deleteTag ───────────────────────────────────────────── test("deleteTag removes a tag", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.writeSubscription({ name: "S1", price: 100, currency: "USD", cycle: "monthly", tags: ["remove"] }) const result = db.deleteTag("remove") @@ -623,14 +623,14 @@ test("deleteTag removes a tag", async () => { }) test("deleteTag returns false for non-existent tag", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") expect(db.deleteTag("nonexistent")).toBe(false) }) // ── pruneTags ───────────────────────────────────────────── test("pruneTags removes orphaned tags", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") // Create tags via subscription db.writeSubscription({ name: "S1", price: 100, currency: "USD", cycle: "monthly", tags: ["keep"] }) // Orphan tag by deleting subscription (CASCADE removes subscription_tags) @@ -649,7 +649,7 @@ test("pruneTags removes orphaned tags", async () => { }) test("pruneTags does not remove tags still in use", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.writeSubscription({ name: "S1", price: 100, currency: "USD", cycle: "monthly", tags: ["active"] }) const count = db.pruneTags() @@ -663,7 +663,7 @@ test("pruneTags does not remove tags still in use", async () => { // ── LLM Usage ───────────────────────────────────────────── test("addLlmUsage creates a usage entry", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.addLlmUsage({ provider: "openai", model: "gpt-4o", @@ -688,7 +688,7 @@ test("addLlmUsage creates a usage entry", async () => { }) test("addLlmUsage allows null description", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.addLlmUsage({ provider: "anthropic", model: "claude-3-opus-20240229", @@ -705,7 +705,7 @@ test("addLlmUsage allows null description", async () => { }) test("getLlmUsage filters by provider", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.addLlmUsage({ provider: "openai", model: "gpt-4o", input_tokens: 100, output_tokens: 50, cost: 0.1, date: "2026-06-01", description: null }) db.addLlmUsage({ provider: "anthropic", model: "claude-3", input_tokens: 200, output_tokens: 100, cost: 0.2, date: "2026-06-02", description: null }) @@ -715,7 +715,7 @@ test("getLlmUsage filters by provider", async () => { }) test("getLlmUsage filters by date range", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.addLlmUsage({ provider: "openai", model: "gpt-4o", input_tokens: 100, output_tokens: 50, cost: 0.1, date: "2026-06-01", description: null }) db.addLlmUsage({ provider: "openai", model: "gpt-4o-mini", input_tokens: 200, output_tokens: 100, cost: 0.2, date: "2026-06-15", description: null }) @@ -725,7 +725,7 @@ test("getLlmUsage filters by date range", async () => { }) test("getLlmUsage returns entries ordered by date desc", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.addLlmUsage({ provider: "openai", model: "a", input_tokens: 1, output_tokens: 1, cost: 0.01, date: "2026-06-01", description: null }) db.addLlmUsage({ provider: "openai", model: "b", input_tokens: 1, output_tokens: 1, cost: 0.01, date: "2026-06-15", description: null }) db.addLlmUsage({ provider: "openai", model: "c", input_tokens: 1, output_tokens: 1, cost: 0.01, date: "2026-06-10", description: null }) @@ -737,7 +737,7 @@ test("getLlmUsage returns entries ordered by date desc", async () => { }) test("deleteLlmUsage removes an entry", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.addLlmUsage({ provider: "openai", model: "gpt-4o", input_tokens: 100, output_tokens: 50, cost: 0.5, date: "2026-06-19", description: null }) const before = db.getLlmUsage() @@ -749,12 +749,12 @@ test("deleteLlmUsage removes an entry", async () => { }) test("deleteLlmUsage returns false for non-existent id", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") expect(db.deleteLlmUsage(99999)).toBe(false) }) test("getLlmUsageTotal sums cost in date range", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.addLlmUsage({ provider: "openai", model: "gpt-4o", input_tokens: 100, output_tokens: 50, cost: 1.0, date: "2026-06-01", description: null }) db.addLlmUsage({ provider: "openai", model: "gpt-4o-mini", input_tokens: 200, output_tokens: 100, cost: 2.0, date: "2026-06-15", description: null }) db.addLlmUsage({ provider: "anthropic", model: "claude-3", input_tokens: 300, output_tokens: 150, cost: 3.0, date: "2026-07-01", description: null }) @@ -764,7 +764,7 @@ test("getLlmUsageTotal sums cost in date range", async () => { }) test("getLlmUsageTotalByProvider groups cost by provider", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.addLlmUsage({ provider: "openai", model: "gpt-4o", input_tokens: 100, output_tokens: 50, cost: 1.0, date: "2026-06-01", description: null }) db.addLlmUsage({ provider: "openai", model: "gpt-4o-mini", input_tokens: 200, output_tokens: 100, cost: 2.0, date: "2026-06-15", description: null }) db.addLlmUsage({ provider: "anthropic", model: "claude-3", input_tokens: 300, output_tokens: 150, cost: 3.0, date: "2026-06-10", description: null }) @@ -780,14 +780,14 @@ test("getLlmUsageTotalByProvider groups cost by provider", async () => { // ── Backup / Restore ───────────────────────────────────── test("getDefaultBackupDir returns path under getDbDir", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") const backupDir = db.getDefaultBackupDir() expect(backupDir).toContain(db.getDbDir()) expect(backupDir).toContain("backups") }) test("getBackupFiles returns empty for non-existent directory", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") const files = db.getBackupFiles("/nonexistent/path/subtrack-test-backups") expect(files).toEqual([]) }) @@ -803,7 +803,7 @@ test("getBackupFiles finds .db.gz files", async () => { writeFileSync(join(tmpDir, "subtrack_20260619_100000.db"), "fake-db-content") writeFileSync(join(tmpDir, "subtrack.db"), "should-be-excluded") - const db = await import("./db.ts") + const db = await import("../db.ts") const files = db.getBackupFiles(tmpDir) expect(files).toHaveLength(2) @@ -839,7 +839,7 @@ test("restoreDb replaces in-memory database", async () => { // Current DB has different data testDb.run("INSERT INTO subscriptions (name, price, currency, cycle) VALUES ('OldService', 500, 'JPY', 'monthly')") - const db = await import("./db.ts") + const db = await import("../db.ts") // Verify current state const before = db.getSubscriptions() @@ -875,7 +875,7 @@ test("restoreDb throws for invalid schema", async () => { writeFileSync(badPath, Buffer.from(badDb.export())) badDb.close() - const db = await import("./db.ts") + const db = await import("../db.ts") expect(() => db.restoreDb(badPath)).toThrow("missing 'subscriptions' table") if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true }) @@ -884,7 +884,7 @@ test("restoreDb throws for invalid schema", async () => { // ── Backup hash ────────────────────────────────────────── test("getBackupHashPath returns path with .sha256 suffix", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") expect(db.getBackupHashPath("/backups/test.db")).toBe("/backups/test.db.sha256") expect(db.getBackupHashPath("/backups/test.db.gz")).toBe("/backups/test.db.gz.sha256") }) @@ -899,7 +899,7 @@ test("writeBackupHash and verifyBackupHash round-trip", async () => { const backupPath = join(tmpDir, "test_backup.db") writeFileSync(backupPath, "fake database content") - const db = await import("./db.ts") + const db = await import("../db.ts") db.writeBackupHash(backupPath) // Verify sidecar file exists @@ -931,7 +931,7 @@ test("verifyBackupHash returns true when no sidecar file (backward compat)", asy const backupPath = join(tmpDir, "legacy_backup.db") writeFileSync(backupPath, "some content") - const db = await import("./db.ts") + const db = await import("../db.ts") // No .sha256 file — should return true (skip verification) expect(db.verifyBackupHash(backupPath)).toBe(true) } finally { diff --git a/subtrack/src/display.test.ts b/subtrack/src/__tests__/display.test.ts similarity index 90% rename from subtrack/src/display.test.ts rename to subtrack/src/__tests__/display.test.ts index 810c29f..73ce56a 100644 --- a/subtrack/src/display.test.ts +++ b/subtrack/src/__tests__/display.test.ts @@ -2,8 +2,8 @@ import { test, expect, beforeAll, afterAll, beforeEach, afterEach, describe } fr import { consola } from "consola" import initSqlJs from "sql.js" import type { Database } from "sql.js" -import { spreadSubscription } from "./display" -import type { SharedArgs } from "./types.ts" +import { spreadSubscription } from "../display" +import type { SharedArgs } from "../types.ts" const logMessages: string[] = [] const infoMessages: string[] = [] @@ -262,13 +262,13 @@ test("currency with empty list shows info", async () => { // ── exportCsv tests ────────────────────────────────────── test("exportCsv returns header-only when no subscriptions", async () => { - const { exportCsv } = await import("./export.ts") + const { exportCsv } = await import("../export.ts") const csv = exportCsv([]) expect(csv).toBe("\uFEFFname,cycle,tags,price,currency") }) test("exportCsv produces correct csv for single subscription", async () => { - const { exportCsv } = await import("./export.ts") + const { exportCsv } = await import("../export.ts") const csv = exportCsv([makeSub({ name: "Netflix", price: 1500, currency: "JPY" })]) const lines = csv.split("\n") expect(lines).toHaveLength(2) @@ -277,7 +277,7 @@ test("exportCsv produces correct csv for single subscription", async () => { }) test("exportCsv handles tags correctly", async () => { - const { exportCsv } = await import("./export.ts") + const { exportCsv } = await import("../export.ts") const csv = exportCsv([ makeSub({ name: "Netflix", tags: ["video", "entertainment"] }), ]) @@ -286,7 +286,7 @@ test("exportCsv handles tags correctly", async () => { }) test("exportCsv escapes commas in name", async () => { - const { exportCsv } = await import("./export.ts") + const { exportCsv } = await import("../export.ts") const csv = exportCsv([ makeSub({ name: "Service, Inc.", price: 100 }), ]) @@ -295,7 +295,7 @@ test("exportCsv escapes commas in name", async () => { }) test("exportCsv handles multiple subscriptions", async () => { - const { exportCsv } = await import("./export.ts") + const { exportCsv } = await import("../export.ts") const csv = exportCsv([ makeSub({ id: 1, name: "A", price: 10, currency: "USD", tags: ["x"] }), makeSub({ id: 2, name: "B", price: 20, currency: "USD", tags: ["y"] }), @@ -307,7 +307,7 @@ test("exportCsv handles multiple subscriptions", async () => { }) test("exportCsv begins with BOM", async () => { - const { exportCsv } = await import("./export.ts") + const { exportCsv } = await import("../export.ts") const csv = exportCsv([makeSub()]) expect(csv.charCodeAt(0)).toBe(0xfeff) }) @@ -315,13 +315,13 @@ test("exportCsv begins with BOM", async () => { // ── exportMd tests ─────────────────────────────────────── test("exportMd returns header and separator when no subscriptions", async () => { - const { exportMd } = await import("./export.ts") + const { exportMd } = await import("../export.ts") const md = exportMd([]) expect(md).toBe("| name | cycle | tags | price | currency |\n| --- | --- | --- | --- | --- |") }) test("exportMd produces correct markdown for single subscription", async () => { - const { exportMd } = await import("./export.ts") + const { exportMd } = await import("../export.ts") const md = exportMd([makeSub({ name: "Netflix", price: 1500, currency: "JPY" })]) const lines = md.split("\n") expect(lines).toHaveLength(3) @@ -329,7 +329,7 @@ test("exportMd produces correct markdown for single subscription", async () => { }) test("exportMd handles tags correctly", async () => { - const { exportMd } = await import("./export.ts") + const { exportMd } = await import("../export.ts") const md = exportMd([ makeSub({ name: "Netflix", tags: ["video", "entertainment"] }), ]) @@ -338,7 +338,7 @@ test("exportMd handles tags correctly", async () => { }) test("exportMd handles multiple subscriptions", async () => { - const { exportMd } = await import("./export.ts") + const { exportMd } = await import("../export.ts") const md = exportMd([ makeSub({ id: 1, name: "A", price: 10, currency: "USD", tags: ["x"] }), makeSub({ id: 2, name: "B", price: 20, currency: "USD", tags: [] }), @@ -352,13 +352,13 @@ test("exportMd handles multiple subscriptions", async () => { // ── showPayment tests ──────────────────────────────────── test("showPayment shows info when no subscriptions", async () => { - const { showPayment } = await import("./payment.ts") + const { showPayment } = await import("../payment.ts") await showPayment("monthly", undefined, []) expect(infoMessages).toContain("No subscriptions found") }) test("showPayment shows monthly total for single currency", async () => { - const { showPayment } = await import("./payment.ts") + const { showPayment } = await import("../payment.ts") await showPayment("monthly", undefined, [ makeSub({ name: "Netflix", price: 1500, currency: "JPY" }), ]) @@ -368,7 +368,7 @@ test("showPayment shows monthly total for single currency", async () => { }) test("showPayment shows yearly total for single currency", async () => { - const { showPayment } = await import("./payment.ts") + const { showPayment } = await import("../payment.ts") await showPayment("yearly", undefined, [ makeSub({ name: "Netflix", price: 1500, currency: "JPY" }), ]) @@ -378,7 +378,7 @@ test("showPayment shows yearly total for single currency", async () => { }) test("showPayment shows per-currency totals for mixed currencies", async () => { - const { showPayment } = await import("./payment.ts") + const { showPayment } = await import("../payment.ts") await showPayment("monthly", undefined, [ makeSub({ name: "Netflix", price: 1500, currency: "JPY" }), makeSub({ name: "GitHub", price: 10, currency: "USD" }), @@ -392,7 +392,7 @@ test("showPayment shows per-currency totals for mixed currencies", async () => { }) test("showPayment yearly converts all cycles correctly", async () => { - const { showPayment } = await import("./payment.ts") + const { showPayment } = await import("../payment.ts") await showPayment("yearly", undefined, [ makeSub({ name: "Monthly", price: 1000, currency: "JPY", cycle: "monthly" }), makeSub({ name: "Yearly", price: 12000, currency: "JPY", cycle: "yearly" }), @@ -403,7 +403,7 @@ test("showPayment yearly converts all cycles correctly", async () => { }) test("showPayment weekly converts correctly", async () => { - const { showPayment } = await import("./payment.ts") + const { showPayment } = await import("../payment.ts") await showPayment("weekly", undefined, [ makeSub({ name: "WeeklySub", price: 100, currency: "USD", cycle: "weekly" }), ]) @@ -412,7 +412,7 @@ test("showPayment weekly converts correctly", async () => { }) test("showPayment with --currency converts all to target currency", async () => { - const { showPayment } = await import("./payment.ts") + const { showPayment } = await import("../payment.ts") await showPayment("monthly", "JPY", [ makeSub({ name: "Local", price: 1000, currency: "JPY" }), makeSub({ name: "Foreign", price: 10, currency: "USD" }), @@ -427,7 +427,7 @@ test("showPayment --currency falls back when fetch fails", async () => { throw new Error("Network error") } - const { showPayment } = await import("./payment.ts") + const { showPayment } = await import("../payment.ts") await showPayment("monthly", "JPY", [ makeSub({ name: "JP", price: 1000, currency: "JPY" }), makeSub({ name: "US", price: 10, currency: "USD" }), @@ -448,13 +448,13 @@ test("showPayment --currency falls back when fetch fails", async () => { // ── exportJson tests ────────────────────────────────────── test("exportJson returns empty array for no subscriptions", async () => { - const { exportJson } = await import("./export.ts") + const { exportJson } = await import("../export.ts") const json = exportJson([]) expect(json).toBe("[]\n") }) test("exportJson produces valid JSON for single subscription", async () => { - const { exportJson } = await import("./export.ts") + const { exportJson } = await import("../export.ts") const json = exportJson([makeSub({ name: "Netflix", price: 1500 })]) const parsed = JSON.parse(json) expect(parsed).toHaveLength(1) @@ -463,14 +463,14 @@ test("exportJson produces valid JSON for single subscription", async () => { }) test("exportJson includes tags array", async () => { - const { exportJson } = await import("./export.ts") + const { exportJson } = await import("../export.ts") const json = exportJson([makeSub({ name: "Netflix", tags: ["video", "entertainment"] })]) const parsed = JSON.parse(json) expect(parsed[0].tags).toEqual(["video", "entertainment"]) }) test("exportJson handles multiple subscriptions", async () => { - const { exportJson } = await import("./export.ts") + const { exportJson } = await import("../export.ts") const json = exportJson([ makeSub({ id: 1, name: "A", price: 10, currency: "USD", tags: [] }), makeSub({ id: 2, name: "B", price: 20, currency: "JPY", tags: ["x"] }), @@ -484,7 +484,7 @@ test("exportJson handles multiple subscriptions", async () => { // ── calcSummary tests ───────────────────────────────────── test("calcSummary returns zero values for empty list", async () => { - const { calcSummary } = await import("./payment.ts") + const { calcSummary } = await import("../payment.ts") const result = calcSummary([]) expect(result.totalCount).toBe(0) expect(result.monthlyByCurrency).toEqual({}) @@ -493,7 +493,7 @@ test("calcSummary returns zero values for empty list", async () => { }) test("calcSummary totals by currency", async () => { - const { calcSummary } = await import("./payment.ts") + const { calcSummary } = await import("../payment.ts") const result = calcSummary([ makeSub({ name: "A", price: 1000, currency: "JPY" }), makeSub({ name: "B", price: 2000, currency: "JPY" }), @@ -507,7 +507,7 @@ test("calcSummary totals by currency", async () => { }) test("calcSummary converts cycles to monthly", async () => { - const { calcSummary } = await import("./payment.ts") + const { calcSummary } = await import("../payment.ts") const result = calcSummary([ makeSub({ name: "A", price: 12000, currency: "JPY", cycle: "yearly" }), ]) @@ -516,7 +516,7 @@ test("calcSummary converts cycles to monthly", async () => { }) test("calcSummary totals by tag", async () => { - const { calcSummary } = await import("./payment.ts") + const { calcSummary } = await import("../payment.ts") const result = calcSummary([ makeSub({ name: "A", price: 1500, currency: "JPY", cycle: "monthly", tags: ["video"] }), makeSub({ name: "B", price: 500, currency: "JPY", cycle: "monthly", tags: ["video"] }), @@ -527,7 +527,7 @@ test("calcSummary totals by tag", async () => { }) test("calcSummary identifies most expensive subscription", async () => { - const { calcSummary } = await import("./payment.ts") + const { calcSummary } = await import("../payment.ts") const result = calcSummary([ makeSub({ name: "A", price: 500, currency: "JPY" }), makeSub({ name: "B", price: 3000, currency: "JPY" }), @@ -540,13 +540,13 @@ test("calcSummary identifies most expensive subscription", async () => { // ── showSummary tests ───────────────────────────────────── test("showSummary shows info when no subscriptions", async () => { - const { showSummary } = await import("./payment.ts") + const { showSummary } = await import("../payment.ts") showSummary([]) expect(infoMessages).toContain("No subscriptions found") }) test("showSummary displays count and most expensive", async () => { - const { showSummary } = await import("./payment.ts") + const { showSummary } = await import("../payment.ts") showSummary([ makeSub({ name: "Premium", price: 3000, currency: "JPY", cycle: "monthly" }), makeSub({ name: "Basic", price: 500, currency: "JPY", cycle: "monthly" }), @@ -561,7 +561,7 @@ test("showSummary displays count and most expensive", async () => { }) test("showSummary displays breakdown by tag", async () => { - const { showSummary } = await import("./payment.ts") + const { showSummary } = await import("../payment.ts") showSummary([ makeSub({ name: "A", price: 1500, currency: "JPY", cycle: "monthly", tags: ["video"] }), makeSub({ name: "B", price: 1000, currency: "JPY", cycle: "monthly", tags: ["video"] }), @@ -576,7 +576,7 @@ test("showSummary displays breakdown by tag", async () => { }) test("showSummary does not show tag section when no tags exist", async () => { - const { showSummary } = await import("./payment.ts") + const { showSummary } = await import("../payment.ts") showSummary([ makeSub({ name: "Alone", price: 1000, currency: "JPY", cycle: "monthly", tags: [] }), ]) @@ -603,7 +603,7 @@ describe("showPayment with --api", () => { date TEXT NOT NULL, description TEXT )`) - const db = await import("./db.ts") + const db = await import("../db.ts") db.__setDb(testDb) }) @@ -616,14 +616,14 @@ describe("showPayment with --api", () => { }) test("shows API usage note when no data", async () => { - const { showPayment } = await import("./payment.ts") + const { showPayment } = await import("../payment.ts") // Pass one subscription so we get past the empty-list check await showPayment("monthly", undefined, [makeSub({ name: "Sub", price: 1000, currency: "JPY" })], true) expect(infoMessages.some((m) => m.toLowerCase().includes("no api usage"))).toBe(true) }) test("shows API usage in USD without --currency", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.addLlmUsage({ provider: "openai", model: "gpt-4o", @@ -634,7 +634,7 @@ describe("showPayment with --api", () => { description: null, }) - const { showPayment } = await import("./payment.ts") + const { showPayment } = await import("../payment.ts") await showPayment("monthly", undefined, [makeSub({ name: "Sub", price: 1000, currency: "JPY" })], true) const combined = logMessages.join("\n") expect(combined).toContain("API usage:") @@ -643,7 +643,7 @@ describe("showPayment with --api", () => { }) test("shows API usage in target currency with --currency", async () => { - const db = await import("./db.ts") + const db = await import("../db.ts") db.addLlmUsage({ provider: "anthropic", model: "claude-3", @@ -654,7 +654,7 @@ describe("showPayment with --api", () => { description: null, }) - const { showPayment } = await import("./payment.ts") + const { showPayment } = await import("../payment.ts") await showPayment("monthly", "JPY", [makeSub({ name: "Sub", price: 1000, currency: "JPY" })], true) const combined = logMessages.join("\n") // --currency path shows "+ API ¥..." inline instead of separate "API usage:" line diff --git a/subtrack/src/pricing.test.ts b/subtrack/src/__tests__/pricing.test.ts similarity index 86% rename from subtrack/src/pricing.test.ts rename to subtrack/src/__tests__/pricing.test.ts index ab48bef..dbd7643 100644 --- a/subtrack/src/pricing.test.ts +++ b/subtrack/src/__tests__/pricing.test.ts @@ -1,5 +1,5 @@ import { test, expect, vi, describe } from "vitest" -import type { PricingCache, ModelPricingEntry } from "./pricing.ts" +import type { PricingCache, ModelPricingEntry } from "../pricing.ts" const SAMPLE_CACHE: PricingCache = { "gpt-4o": { @@ -31,14 +31,14 @@ const SAMPLE_CACHE: PricingCache = { // ── matchModel ──────────────────────────────────────────── test("matchModel finds exact match", async () => { - const { matchModel } = await import("./pricing.ts") + const { matchModel } = await import("../pricing.ts") const result = matchModel(SAMPLE_CACHE, "openai", "gpt-4o") expect(result).not.toBeNull() expect(result?.litellm_provider).toBe("openai") }) test("matchModel returns null for non-existent model", async () => { - const { matchModel } = await import("./pricing.ts") + const { matchModel } = await import("../pricing.ts") const result = matchModel(SAMPLE_CACHE, "openai", "nonexistent-model") expect(result).toBeNull() }) @@ -51,26 +51,26 @@ test("matchModel finds model by provider prefix", async () => { litellm_provider: "openai", }, } - const { matchModel } = await import("./pricing.ts") + const { matchModel } = await import("../pricing.ts") const result = matchModel(cache, "openai", "gpt-4o") expect(result).not.toBeNull() }) test("matchModel finds model by substring", async () => { - const { matchModel } = await import("./pricing.ts") + const { matchModel } = await import("../pricing.ts") const result = matchModel(SAMPLE_CACHE, "openai", "gpt-4o-mini") expect(result).not.toBeNull() expect(result?.input_cost_per_token).toBe(1.5e-7) }) test("matchModel strips version suffixes", async () => { - const { matchModel } = await import("./pricing.ts") + const { matchModel } = await import("../pricing.ts") const result = matchModel(SAMPLE_CACHE, "openai", "gpt-4o-v1") expect(result).not.toBeNull() }) test("matchModel is case-insensitive", async () => { - const { matchModel } = await import("./pricing.ts") + const { matchModel } = await import("../pricing.ts") const result = matchModel(SAMPLE_CACHE, "openai", "GPT-4O") expect(result).not.toBeNull() expect(result?.litellm_provider).toBe("openai") @@ -79,7 +79,7 @@ test("matchModel is case-insensitive", async () => { // ── calculateCostCents ──────────────────────────────────── test("calculateCostCents returns correct cost", async () => { - const { calculateCostCents } = await import("./pricing.ts") + const { calculateCostCents } = await import("../pricing.ts") const pricing: ModelPricingEntry = { input_cost_per_token: 2.5e-6, output_cost_per_token: 1e-5, @@ -92,7 +92,7 @@ test("calculateCostCents returns correct cost", async () => { }) test("calculateCostCents handles zero tokens", async () => { - const { calculateCostCents } = await import("./pricing.ts") + const { calculateCostCents } = await import("../pricing.ts") const pricing: ModelPricingEntry = { input_cost_per_token: 2.5e-6, output_cost_per_token: 1e-5, @@ -101,13 +101,13 @@ test("calculateCostCents handles zero tokens", async () => { }) test("calculateCostCents handles missing pricing fields", async () => { - const { calculateCostCents } = await import("./pricing.ts") + const { calculateCostCents } = await import("../pricing.ts") const pricing: ModelPricingEntry = {} expect(calculateCostCents(pricing, 100, 50)).toBe(0) }) test("calculateCostCents includes reasoning tokens cost", async () => { - const { calculateCostCents } = await import("./pricing.ts") + const { calculateCostCents } = await import("../pricing.ts") const pricing: ModelPricingEntry = { input_cost_per_token: 3e-6, output_cost_per_token: 1.5e-5, @@ -128,7 +128,7 @@ test("ensurePricingCache returns null when fetch fails and no cache", async () = globalThis.fetch = async () => { throw new Error("Network error") } // Reset module state by importing fresh - const pricing = await import("./pricing.ts") + const pricing = await import("../pricing.ts") // This should fail since there's no cache and fetch fails // But the implementation has caching logic, so this may need env setup @@ -153,7 +153,7 @@ test("ensurePricingCache parses GitHub JSON correctly", async () => { headers: { "Content-Type": "application/json" }, }) - const pricing = await import("./pricing.ts") + const pricing = await import("../pricing.ts") // This call will try to fetch, and we can't easily reset the cache state // Just verifying the function doesn't throw diff --git a/subtrack/src/pricing.ts b/subtrack/src/pricing.ts index a04ac8d..ad614c6 100644 --- a/subtrack/src/pricing.ts +++ b/subtrack/src/pricing.ts @@ -73,7 +73,7 @@ export async function ensurePricingCache(): Promise { clearTimeout(timer) } mkdirSync(getConfigDir(), { recursive: true }) - writeFileSync(cachePath, JSON.stringify(data)) + writeFileSync(cachePath, JSON.stringify(data), { mode: 0o600 }) _cache = data return data } catch { diff --git a/subtrack/tsconfig.json b/subtrack/tsconfig.json index 4c797fe..cc2f741 100644 --- a/subtrack/tsconfig.json +++ b/subtrack/tsconfig.json @@ -1,5 +1,5 @@ { - "exclude": ["src/**/*.test.ts"], + "exclude": ["src/__tests__"], "compilerOptions": { // Environment setup & latest features "lib": ["ESNext"],