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 @@
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