diff --git a/README.md b/README.md index 647f196..b4ec84d 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,7 @@ All data commands accept `--json`. Use `--help` on a command group to see all it |------|----------| | Find | `search [--limit]` · `autocomplete` · `trending` · `deals` · `info [--credit-options\|--bundle\|--card\|--reviews]` | | Price history | `price history [--since ] [--series] [--no-cache]` | +| Price watch | `watch add [--target ] [--drop ]` · `watch add --from-wishlist ` · `watch list` · `watch check` · `watch rm ` | | Reviews | `reviews [--page ] [--sort ]` · `myreviews …` | | Recommend | `recommend layout [--location ] [--plid ]` · `recommend --model [--plid ] [--limit 10]` · `buy-again` | | Cart | `cart` · `cart add ` · `cart add --sku ` · `cart add --plid ` · `cart set-qty` · `cart remove` · `cart basket` · `cart clear` | @@ -99,6 +100,14 @@ All data commands accept `--json`. Use `--help` on a command group to see all it Use `--since 90d` to select a time window. Use `--series --json` to include the points in JSON output. +### Price watch + +`takealot watch add ` adds a product to a local watchlist. It makes no request. `takealot watch check` gets price data from Serval. Run it once a day. + +The `target` reason means that the price is at or below your target. The `drop` reason means that the price fell by the selected percentage. The `low` reason means that the price is at or below the earlier low in a history of at least 30 points. + +The watchlist can hold 50 products. A check can make at most 50 HTTP attempts by default. Use `--max-requests` to set a lower limit. A check stops new requests when Serval returns HTTP 429 or 403. + ### Writes and account-security changes A command that changes state is a dry run until you add `--confirm`. Add `--yes` to skip the interactive prompt. An agent must get the owner's clear yes for the specific action before it uses `--confirm`. `--i-know` is a safety flag, not permission. diff --git a/docs/plans/watchlist.md b/docs/plans/watchlist.md new file mode 100644 index 0000000..7a44679 --- /dev/null +++ b/docs/plans/watchlist.md @@ -0,0 +1,220 @@ +# Plan: `takealot watch` watchlist and price-drop checks via Serval (#51) + +## Goal + +Let the owner keep a local list of Takealot products and find price drops once a day. The price data comes from servaltracker.com through the existing `price history` code (`src/lib/serval.ts`, `src/commands/price.ts`). The owner has verbal permission from the Serval operator. A Hermes cron job, outside this repository, runs the check once a day and posts drops to Discord. + +## Commands + +``` +takealot watch add [--target ] [--drop ] +takealot watch add --from-wishlist [--drop ] +takealot watch rm +takealot watch list +takealot watch check [--no-update] [--max-requests ] +``` + +All commands accept `--json` and `--verbose`. + +- `` uses `parsePlidRef()` from `src/lib/product-ref.ts`. Anything else exits 4. +- `--target `: a positive number with at most 2 decimals, at most 10,000,000. Alert when the current price is at or below it. +- `--drop `: an integer from 1 to 90. Default 5. Alert when the price falls by at least this percent against the reference price (see Drop rules). +- `--from-wishlist `: a positive integer. Read `wishlist items ` from Takealot (read-only, needs login) and add every item's `plid`. Items without a valid `plid` are skipped and reported. `` and `--from-wishlist` together exit 4. `--target` with `--from-wishlist` exits 4. +- `watch add` on a PLID that is already in the list updates `target` and `drop` only when the caller gives them, and keeps its state. +- `watch add` makes no Serval request. The first `watch check` gets the data. +- `watch rm` on a PLID that is not in the list exits 4, code `not_watched`. +- `watch list` makes no network request. It prints what the list and the last check hold. +- `--max-requests `: an integer from 1 to 50. Default 50. It counts **HTTP attempts** to Serval (a retry counts as one attempt). Cache hits do not count. +- `--no-update`: run the check but do not write `watchlist.json`. The Serval cache is still read and written, exactly as `price history` does. `--no-update` changes only the watchlist file. + +## Storage + +- File: `~/.config/takealot-cli/watchlist.json` (use `configDir()` from `src/lib/config.ts`, so `XDG_CONFIG_HOME` applies). Mode 0600. Write with `atomicWriteJson()`. +- Do not use the credentials lock. Use a separate lock with the same directory-lock approach that `config.ts` uses for credentials. Name it `watchlist.lock` and hold it around every read-modify-write. A second process that cannot get the lock in 10 s exits 1 with code `watchlist_locked`. +- Shape: + +``` +{ + schema: 1, + items: [ + { + plid, title, addedAt, + target: number | null, + dropPercent: number, + referencePrice: number | null, + referenceDate: string | null, + lastCheckedAt: string | null, + lastPrice: number | null, + lastPriceDate: string | null, + lastStatus: "ok" | "not_tracked" | "stale" | "error" | null, + alertArmed: boolean, + lastAlert: { price: number, date: string, reasons: ("target" | "drop" | "low")[] } | null + } + ] +} +``` + +- At most 50 items. `watch add` that would pass 50 exits 4, code `watchlist_full`. `--from-wishlist` adds items until the limit, then stops and reports the rest as skipped. +- Validate the file on every read. Every rule below must hold, or the file is invalid: + - `schema` is 1. `items` is an array of at most 50 objects. No unknown keys. + - `plid`: positive safe integer, unique in the list. + - `title`: `null` or a string of at most 200 characters. + - `addedAt`, `lastCheckedAt`: ISO 8601 UTC strings (`new Date(v).toISOString() === v`), not in the future. `lastCheckedAt` can be `null`. + - `referenceDate`, `lastPriceDate`: `YYYY-MM-DD` strings that are valid dates, or `null`. + - `target`: `null` or a finite number greater than 0 and at most 10,000,000, with at most 2 decimals. + - `dropPercent`: an integer from 1 to 90. + - `referencePrice`, `lastPrice`: `null` or a finite number greater than 0 and at most 10,000,000. + - `lastStatus`: one of the listed values or `null`. `alertArmed`: boolean. + - `lastAlert`: `null`, or an object with `price` (as `lastPrice`), `date` (`YYYY-MM-DD`), and `reasons` (a non-empty array of unique values from `target`, `drop`, `low`). + - Cross-field: `referencePrice` and `referenceDate` are both null or both set. `lastPrice` and `lastPriceDate` are both null or both set. +- A file that fails validation is not changed. The command exits 1 with code `watchlist_invalid`, the path and the first failed rule. It never repairs or overwrites the file. +- A missing file is an empty list. + +## `watch check` + +### Locks + +- `watch check` takes `watchlist.lock` at the start, reads the file, and holds the lock until it has written the file at the end. The lock covers the network waits. A second `watch check`, `watch add` or `watch rm` waits up to 10 s and then exits 1 with `watchlist_locked`. `--no-update` still takes the lock, so it reads a consistent file, but it writes nothing. +- A held lock is reclaimed only when its same-host owner pid is dead. A lock from another host is never reclaimed. +- Serval single-flight: `loadServalHistory()` takes a per-PLID lock (`serval/PLID.lock`, same directory-lock approach) around "read cache, fetch if missing or expired, write cache". After it gets the lock, it reads the cache again before any fetch. So `price history`, a manual `watch check` and the cron job that run at the same time make at most one successful fetch per PLID per 24 hours. A `not_tracked` result is cached as in `price history`. A failed fetch (network, timeout, 5xx after the retry, other HTTP code) writes a failure marker `{ failedAt, code }` in the same per-PLID cache directory. For 10 minutes after `failedAt`, a caller that gets the lock returns that failure and does not fetch. So a burst of concurrent callers makes at most 2 attempts for one PLID. A returned failure marker counts as 0 attempts and is not a cache hit. It is handled like a live failure with the same code: `serval_rate_limited` or `serval_forbidden` from a marker also sets `serval_blocked` and stops later fetches in this run. If the per-PLID lock cannot be taken in 20 s, that item fails with `serval_busy` and no fetch. `price history` uses the same lock. + +Reclaim is best-effort for a dead same-host owner. Correctness comes from fencing: every write checks ownership first and aborts with `lock_lost`. The worst case after a crash is one extra Serval request, never two writers. + +### Steps + +For each item, in the stored order: + +1. Get the Serval result with the same code path as `price history`: the 24-hour cache first, then one fetch (at most 2 HTTP requests per item, as in the existing rules). Refactor the cache read, fetch and parse in `src/commands/price.ts` into one exported function, for example `loadServalHistory(plid, { cache: true, version, verbose, ctx })`, that both commands use. Do not change its behaviour for `price history`. +2. Count HTTP attempts. Before a fetch, if `attempts + 2 > --max-requests` (the fetch can need a retry), do not start it. Mark this item and the remaining items that need a fetch `skipped: "request_limit"`. Items with a valid cache are still served. +3. Wait 3 s before each fetch after the first fetch of the run. Cache hits have no wait. +4. The existing fetch never retries 429, 401 or 403 (it retries only network errors, timeouts and 5xx). Keep that. If a fetch fails with `serval_rate_limited` or `serval_forbidden`, stop all further fetches in this run. Mark the items that need a fetch `skipped: "serval_blocked"`. Serve items that have a valid cache. +5. Other per-item errors (`not_tracked`, `serval_format_changed`, `network`, `timeout`, other HTTP codes) are recorded for that item. The run continues. + +The check never uses the Takealot API. It needs no login. + +### Drop rules + +For an item with Serval data `ok`. Inputs: `current` and `currentDate` are the last point of the cleaned Serval series, exactly as in `price history`. `points` is the full cleaned series. + +1. **Stale.** If `currentDate` is more than 7 days before today (UTC), the status is `stale`. Update `lastCheckedAt` and `lastStatus` only. No alert. Do not change any price, reference or alert state. +2. **Not newer.** If `lastPriceDate` is set and `currentDate <= lastPriceDate`, the data is not new. Update `lastCheckedAt` and `lastStatus` only. No alert. This stops a repeat check on the same day, or older cached data, from changing state. +3. **First data.** If `referencePrice` is null: set `referencePrice = current`, `referenceDate = currentDate`, `alertArmed = true`, and update `lastPrice`, `lastPriceDate`, `lastCheckedAt`, `lastStatus` and `title`. No alert. +4. **Reasons.** An item can have more than one reason: + - `target`: `target` is not null and `current <= target`. + - `drop`: `current <= referencePrice * (1 - dropPercent / 100)`. + - `low`: `points` has at least 30 entries, and `current` is less than or equal to the minimum price of every point before the last one (by array position). +5. **Alert.** Alert when there is at least one reason and `alertArmed` is true. Then set `alertArmed = false` and `lastAlert = { price: current, date: currentDate, reasons }`. +6. **Re-arm.** Set `alertArmed = true` only when this check has no reason at all (no `target`, no `drop`, no `low`). The re-arm is applied after step 5, so it affects the next check. Then a later fall can alert again. For example, an alert at R80, a rise to R100 (no reason, re-arm) and a fall to R80 again gives two alerts. A price that stays at or below the target (R80, R82, R79) gives one alert, because the `target` reason stays true and the item does not re-arm. +7. **Reference.** The drop baseline is the most recent high point: + - Save `previousReference = referencePrice` before this step. The output uses `previousReference`. + - If the `drop` reason was in an alert that fired in this check, set `referencePrice = current`, `referenceDate = currentDate`. + - Else, if `current > referencePrice`, set `referencePrice = current`, `referenceDate = currentDate`. + - A `target` or `low` alert without `drop` does not change the reference. +8. **Always** (for new data): update `lastPrice`, `lastPriceDate`, `lastCheckedAt`, `lastStatus`, `title`. + +Items without `ok` data: + +- `not_tracked`: set `lastStatus = "not_tracked"` and `lastCheckedAt`. +- A fetch or parse error: set `lastStatus = "error"` and `lastCheckedAt`. +- Skipped (`request_limit`, `serval_blocked`): change nothing. The item keeps its previous `lastStatus` and `lastCheckedAt`, because it was not checked. + +Output `items[]` always has `plid`, `title`, `status`, `current`, `currentDate` and `reference`. `current` and `currentDate` are the Serval values for `ok`, `stale` and `not_newer`, and `null` otherwise. `reference` is the stored `referencePrice` before this check (`previousReference`), or `null` when there was none. + +Output `items[].status` is one of `ok`, `stale`, `not_newer`, `not_tracked`, `error`, `skipped`. `skipped` items also have `skipped: "request_limit" | "serval_blocked"`. `error` items also have `error: { code, message }`. The stored `lastStatus` for `ok` and `not_newer` is `"ok"`, and for `stale` it is `"stale"`. + +All comparisons use the unrounded Serval price. Output values are rounded to 2 decimals as in `price history`. `referencePrice` is always greater than 0 (validation), so `changePercent` is always defined. + +### Output + +JSON: + +``` +{ + checkedAt, attempts, cacheHits, runError, + alerts: [ { plid, title, url, current, currentDate, reference, target, dropPercent, changePercent, reasons, historyMin, historyMinDate } ], + items: [ { plid, title, status, current, currentDate, reference, skipped?, error? } ], + errors: [ { plid, code, message } ] +} +``` + +- `url` is the Takealot product URL `https://www.takealot.com/x/PLID`. (The Serval URL is in `price history`.) +- `reference` in an alert is the value before this check (`previousReference`), not the updated one. `changePercent` is `(current - previousReference) / previousReference * 100`, rounded to 1 decimal. It is negative for a drop. +- `historyMin` is the minimum price of every point before the last one (by array position). `historyMinDate` is the date of the latest point with that price. `historyMin` and `historyMinDate` are `null` when there are fewer than 2 points. +- Titles: the stored `title` is the cleaned Serval title from `cleanServalTitle()`, truncated to 200 characters. A `null` title stays `null`. +- Human output: one line per alert (title, price, reasons, change), then a summary line (`N alerts, M items checked, K skipped, E errors`). No alerts: print `No price drops.` + +`attempts` is the number of HTTP attempts to Serval in this run. `runError` is `null` or `{ code, message }`. It is set in these cases: + +- `serval_blocked`: a 429 or 403 stopped fetching. +- `request_limit`: the cap stopped at least one fetch. +- `no_results`: the list is not empty and no item gave usable data (every item failed, was skipped, or was not tracked). + +If more than one applies, use the first in this order: `serval_blocked`, `no_results`, `request_limit`. + +Exit codes: + +- 0: the check ran and `runError` is `null` or `request_limit`. Alerts, per-item errors and skipped items do not change this. +- 1: `runError` is `serval_blocked` or `no_results`, or the check could not run (invalid watchlist file, lock timeout). The cron job treats exit 1 as a failure and posts it. +- 4: usage error. +- An empty watchlist exits 0 with no alerts and `runError: null`. + +Alerts do not change the exit code. The cron job reads the JSON. + +## Network rules + +- The only network calls are the existing Serval fetch (for `check`) and the Takealot `wishlist items` read (for `add --from-wishlist`). +- No Serval search or submit call. No new Serval endpoints. +- The Serval request stays as it is now: the fixed URL template, the literal headers, no Takealot credentials. +- The maximum HTTP attempts to Serval in one `watch check` is `--max-requests`, at most 50. Retries count toward it. + +## Hermes cron (outside this repository, after release) + +- One job, daily at 08:00 SAST, delivered to the shopping Discord channel. +- It runs `takealot watch check --json`. It posts only when `alerts` is not empty or when the run exits 1. It is silent otherwise. +- The owner confirms the job before it is created. + +## Tests + +- Watchlist file: create, add, update, remove, the 50 limit, duplicate add, every validation rule (one failing test each), invalid file is not changed, missing file is empty, mode 0600, the lock (second writer waits, times out with `watchlist_locked`, `--no-update` takes the lock and writes nothing). +- Single-flight: two concurrent loaders for one PLID make one fetch. The second reads the cache after it gets the lock. The per-PLID lock times out with `serval_busy`. +- `add --from-wishlist`: items with and without `plid`, the limit, no Serval request, combination errors. +- Drop rules, with fixture histories: + - first check sets reference, no alert + - drop at exactly `dropPercent`, just under it + - target equal and below + - new low with fewer than 30 points (no `low`) and 30 or more + - stale item never alerts + - the same price does not alert twice, and a recovery then a new fall alerts again (R80, R100, R80) + - a price that stays at or below the target (R80, R82, R79 with target R85) alerts once + - a target-only or low-only alert does not move the reference + - reference moves up after a rise and stays after a small fall + - stale data and data not newer than `lastPriceDate` change only `lastCheckedAt` and `lastStatus` + - `low` with duplicate timestamps uses array position + - several reasons at once +- Check loop, with a mocked Serval fetch: + - cache hits have no wait and do not count as attempts + - the 3 s wait between fetches (fake timers) + - `--max-requests` counts attempts, and a fetch that could need a retry does not start when only 1 attempt is left + - 429 and 403 make one attempt only and stop all further fetches, `runError: serval_blocked`, exit 1 + - per-item errors continue + - `--no-update` does not write `watchlist.json` + - no usable result gives `runError: no_results`, exit 1 + - an empty list exits 0 +- `price history` output is unchanged after the refactor (the existing tests pass without edits). +- Guard: `send()` still rejects the Serval origin. +- One live test, skipped in CI, read-only: `watch check --no-update` against a temporary `XDG_CONFIG_HOME` with PLID46639928. + +## Docs + +README, `skill/SKILL.md` and `skill/references/commands.md` get a short section. Say: + +- the data comes from Serval, once a day +- `watch add` makes no request +- what each alert reason means +- the limits (50 items, request cap, stop on 429 and 403) + +The skill says that `watch` writes only the local watchlist file and never changes the Takealot account, so it needs no `--confirm`. The docs pass `ste-lint` with 0 hard violations. + +## Out of scope + +Local price snapshots for products that Serval does not track, alerts from the CLI itself (it prints JSON, the cron posts), and a Takealot wishlist sync in the other direction. diff --git a/skill/SKILL.md b/skill/SKILL.md index ab7e09d..33423b4 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -60,6 +60,21 @@ takealot price history --since 90d --json Serval usually records one price each day, but the history can have gaps. The last price can be stale. Code `not_tracked` with exit 4 means that Serval does not track the product. +## Price watch + +Add a product. Then inspect the local watchlist once a day: + +```bash +takealot watch add --target 500 --drop 5 --json +takealot watch check --json +``` + +`watch add` makes no request. `watch check` gets data from Serval. A `target` alert is at or below the target. A `drop` alert meets the selected percentage. A `low` alert is at or below the earlier low in a history of at least 30 points. + +The watchlist has a limit of 50 products. The request cap is 50 HTTP attempts by default. The run stops new requests after HTTP 429 or 403. + +`watch` writes only the local watchlist file. It never changes the Takealot account. It does not need `--confirm`. + ## Cart changes that remove or change lines `cart remove ` and `cart set-qty ` read the cart before and after the write. diff --git a/skill/references/commands.md b/skill/references/commands.md index 112ffc9..1854df2 100644 --- a/skill/references/commands.md +++ b/skill/references/commands.md @@ -32,12 +32,21 @@ takealot deals takealot info [--card] [--reviews] [--credit-options] [--bundle ] takealot reviews [--page ] [--sort ] takealot price history [--since ] [--series] [--no-cache] +takealot watch add [--target ] [--drop ] +takealot watch add --from-wishlist [--drop ] +takealot watch list +takealot watch check [--no-update] [--max-requests ] +takealot watch rm ``` If the price data is missing, `info` tries again one time. If it is still missing, `price` and `skuId` are null and `unavailableReason` gives the cause. `cart add --plid` then stops with exit 1. The `price history` command reads data from Serval. Serval usually records one price each day, but the history can have gaps. The last price can be stale. Code `not_tracked` with exit 4 means that Serval does not track the product. +`watch add` makes no request. `watch check` gets data from Serval once a day. `target` means at or below the target. `drop` means a fall by the selected percentage. `low` means at or below the earlier low in a history of at least 30 points. + +The watchlist has a limit of 50 products. A check has a default cap of 50 HTTP attempts. It stops new requests after HTTP 429 or 403. These commands write only the local watchlist. They do not change the Takealot account and do not need `--confirm`. + ## Recommendations ```bash diff --git a/src/__tests__/watch.test.ts b/src/__tests__/watch.test.ts new file mode 100644 index 0000000..3821009 --- /dev/null +++ b/src/__tests__/watch.test.ts @@ -0,0 +1,617 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Command } from 'commander'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import type { Context } from '../lib/context.js'; +import { withDirectoryLock } from '../lib/directory-lock.js'; +import type { ServalPoint } from '../lib/serval.js'; +import { + loadWatchlist, + newWatchItem, + saveWatchlist, + watchlistLockPath, + watchlistPath, + type WatchItem, +} from '../lib/watchlist.js'; +import { + loadServalHistory, + servalCachePath, + servalLockPath, + type OkCacheRecord, +} from '../commands/price.js'; +import { applyWatchHistory, watchAddCommand, watchCheckCommand } from '../commands/watch.js'; +import { run } from '../cli.js'; + +const NOW = Date.UTC(2026, 8, 27, 8); +const ISO_NOW = new Date(NOW).toISOString(); +let temporaryDirectory: string; +let originalConfigHome: string | undefined; +let originalCacheHome: string | undefined; + +function context() { + let result: any; + const ctx = { + logger: { + result: (_human: () => void, value: unknown) => { + result = value; + }, + warn: vi.fn(), + }, + } as unknown as Context; + return { + ctx, + get result() { + return result; + }, + }; +} + +function point(day: number, price: number): ServalPoint { + return [Date.UTC(2026, 8, day, 8), price]; +} + +function record(points: ServalPoint[], plid = 42, title = 'Tracked product'): OkCacheRecord { + return { + schema: 1, + status: 'ok', + plid, + fetchedAt: ISO_NOW, + title, + current: points, + listing: [], + }; +} + +function item(overrides: Partial = {}): WatchItem { + return { + ...newWatchItem(42, new Date(NOW - 30 * 86_400_000).toISOString(), null, 5), + ...overrides, + }; +} + +function html(points: ServalPoint[], title = 'Tracked product'): string { + const data = { + labels: points.map(([time]) => time), + datasets: [{ label: 'Current Price', data: points.map(([, price]) => price) }], + }; + return `

${title}

\ndata = ${JSON.stringify(data)};\n`; +} + +function response(points: ServalPoint[], status = 200): Response { + return new Response(status === 200 ? html(points) : '', { status }); +} + +function seed(items: WatchItem[]): void { + saveWatchlist({ schema: 1, items }); +} + +function writeOwner( + directory: string, + owner: { pid: number; nonce: string; host: string; acquiredAt: number }, +): void { + fs.mkdirSync(directory, { recursive: true }); + fs.writeFileSync(path.join(directory, 'owner.json'), JSON.stringify(owner)); +} + +function checkOptions(fetchImpl?: typeof fetch) { + return { + update: true, + maxRequests: 50, + verbose: false, + version: 'test', + now: () => NOW, + fetchImpl, + wait: vi.fn().mockResolvedValue(undefined), + }; +} + +beforeEach(() => { + originalConfigHome = process.env.XDG_CONFIG_HOME; + originalCacheHome = process.env.XDG_CACHE_HOME; + temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'takealot-watch-test-')); + process.env.XDG_CONFIG_HOME = path.join(temporaryDirectory, 'config'); + process.env.XDG_CACHE_HOME = path.join(temporaryDirectory, 'cache'); +}); + +afterEach(() => { + process.exitCode = undefined; + vi.useRealTimers(); + vi.restoreAllMocks(); + fs.rmSync(temporaryDirectory, { recursive: true, force: true }); + if (originalConfigHome === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = originalConfigHome; + if (originalCacheHome === undefined) delete process.env.XDG_CACHE_HOME; + else process.env.XDG_CACHE_HOME = originalCacheHome; +}); + +describe('Serval single-flight loader', () => { + it('makes one fetch for two concurrent loaders', async () => { + const captured = context(); + const fetchImpl = vi.fn(async () => response([point(26, 100), point(27, 90)])); + const options = { + cache: true, + version: 'test', + verbose: false, + ctx: captured.ctx, + fetchImpl: fetchImpl as unknown as typeof fetch, + now: () => NOW, + }; + const [first, second] = await Promise.all([ + loadServalHistory(42, options), + loadServalHistory(42, options), + ]); + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect([first.cached, second.cached].sort()).toEqual([false, true]); + }); + + it('never reclaims a lock owned by another host, however old', async () => { + const lock = servalLockPath(42); + writeOwner(lock, { pid: 1, nonce: 'other', host: 'another-host', acquiredAt: 0 }); + await expect( + withDirectoryLock(lock, { timeoutMs: 100, error: () => new Error('held') }, async () => 'entered'), + ).rejects.toThrow('held'); + expect(JSON.parse(fs.readFileSync(path.join(lock, 'owner.json'), 'utf8')).host).toBe('another-host'); + }); + + it('fences a holder after another owner replaces the lock', async () => { + const lock = servalLockPath(42); + await withDirectoryLock( + lock, + { timeoutMs: 1_000, error: () => new Error('held') }, + async (held) => { + expect(() => held.assertHeld()).not.toThrow(); + writeOwner(lock, { + pid: process.pid, + nonce: 'replacement', + host: os.hostname(), + acquiredAt: Date.now(), + }); + expect(() => held.assertHeld()).toThrowError( + expect.objectContaining({ code: 'lock_lost' }), + ); + }, + ); + }); + + it('lets exactly one of many contenders reclaim a dead same-host lock', async () => { + const lock = servalLockPath(42); + writeOwner(lock, { + pid: 2 ** 31 - 1, + nonce: 'dead', + host: os.hostname(), + acquiredAt: 0, + }); + let inside = 0; + let peak = 0; + await Promise.all( + Array.from({ length: 8 }, () => + withDirectoryLock(lock, { timeoutMs: 5_000, error: () => new Error('timeout') }, async () => { + inside += 1; + peak = Math.max(peak, inside); + await new Promise((resolve) => setTimeout(resolve, 15)); + inside -= 1; + }), + ), + ); + expect(peak).toBe(1); + expect(fs.existsSync(lock)).toBe(false); + }); + + it('refuses to fetch outside the lock when the cache is required but unavailable', async () => { + const captured = context(); + const fetchImpl = vi.fn(async () => response([point(27, 90)])); + fs.mkdirSync(temporaryDirectory, { recursive: true }); + fs.writeFileSync(path.join(temporaryDirectory, 'cache'), 'not a directory'); + await expect( + loadServalHistory(42, { + cache: true, + version: 'test', + verbose: false, + ctx: captured.ctx, + fetchImpl: fetchImpl as unknown as typeof fetch, + now: () => NOW, + requireCache: true, + }), + ).rejects.toMatchObject({ code: 'serval_cache_unavailable' }); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('times out with serval_busy', async () => { + let release = (): void => undefined; + const held = withDirectoryLock( + servalLockPath(42), + { timeoutMs: 1_000, error: () => new Error('locked') }, + () => new Promise((resolve) => { + release = resolve; + }), + ); + await vi.waitFor(() => expect(fs.existsSync(servalLockPath(42))).toBe(true)); + await expect( + loadServalHistory(42, { + cache: true, + version: 'test', + verbose: false, + ctx: context().ctx, + fetchImpl: vi.fn() as unknown as typeof fetch, + now: () => NOW, + lockTimeoutMs: 5, + }), + ).rejects.toMatchObject({ code: 'serval_busy' }); + release(); + await held; + }); + + it('uses a failure marker for ten minutes', async () => { + const fetchImpl = vi.fn().mockResolvedValue(new Response('', { status: 429 })); + const options = { + cache: true, + version: 'test', + verbose: false, + ctx: context().ctx, + fetchImpl: fetchImpl as unknown as typeof fetch, + now: () => NOW, + }; + await expect(loadServalHistory(42, options)).rejects.toMatchObject({ + code: 'serval_rate_limited', + }); + await expect(loadServalHistory(42, options)).rejects.toMatchObject({ + code: 'serval_rate_limited', + }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); +}); + +describe('drop rules', () => { + it('sets the first reference without an alert', () => { + const watched = item(); + const applied = applyWatchHistory(watched, record([point(27, 100)]), ISO_NOW); + expect(applied.alert).toBeNull(); + expect(watched).toMatchObject({ referencePrice: 100, lastPrice: 100, alertArmed: true }); + }); + + it.each([ + ['exactly', 95, true], + ['just under', 95.01, false], + ])('alerts for a drop %s the threshold', (_name, current, alerts) => { + const watched = item({ referencePrice: 100, referenceDate: '2026-09-26' }); + const applied = applyWatchHistory(watched, record([point(26, 100), point(27, current)]), ISO_NOW); + expect(applied.alert?.reasons.includes('drop') ?? false).toBe(alerts); + }); + + it.each([ + ['equal', 85], + ['below', 84], + ])('alerts when the target is %s', (_name, current) => { + const watched = item({ target: 85, referencePrice: 100, referenceDate: '2026-09-26' }); + expect( + applyWatchHistory(watched, record([point(26, 100), point(27, current)]), ISO_NOW).alert?.reasons, + ).toContain('target'); + }); + + it.each([ + ['fewer than 30', 29, false], + ['30', 30, true], + ])('applies low with %s points', (_name, count, expected) => { + const points = Array.from({ length: count }, (_, index) => [ + Date.UTC(2026, 7, 29 + index, 8), + index === count - 1 ? 90 : 100, + ] as ServalPoint); + const watched = item({ dropPercent: 90, referencePrice: 100, referenceDate: '2026-09-01' }); + const applied = applyWatchHistory(watched, record(points), ISO_NOW); + expect(applied.alert?.reasons.includes('low') ?? false).toBe(expected); + }); + + it('does not alert or change price state for stale data', () => { + const watched = item({ + target: 200, + referencePrice: 100, + referenceDate: '2026-09-01', + lastPrice: 100, + lastPriceDate: '2026-09-01', + }); + const before = { ...watched }; + const applied = applyWatchHistory(watched, record([point(1, 50)]), ISO_NOW); + expect(applied.item.status).toBe('stale'); + expect(watched).toMatchObject({ + referencePrice: before.referencePrice, + lastPrice: before.lastPrice, + lastPriceDate: before.lastPriceDate, + alertArmed: before.alertArmed, + }); + }); + + it('does not alert twice for the same date', () => { + const watched = item({ target: 85, referencePrice: 100, referenceDate: '2026-09-26' }); + expect(applyWatchHistory(watched, record([point(27, 80)]), ISO_NOW).alert).not.toBeNull(); + expect(applyWatchHistory(watched, record([point(27, 80)]), ISO_NOW).alert).toBeNull(); + }); + + it('re-arms after a recovery and alerts after a new fall', () => { + const watched = item({ target: 85, dropPercent: 90, referencePrice: 100, referenceDate: '2026-09-24' }); + expect(applyWatchHistory(watched, record([point(25, 80)]), new Date(Date.UTC(2026, 8, 25, 8)).toISOString()).alert).not.toBeNull(); + expect(applyWatchHistory(watched, record([point(26, 100)]), new Date(Date.UTC(2026, 8, 26, 8)).toISOString()).alert).toBeNull(); + expect(applyWatchHistory(watched, record([point(27, 80)]), ISO_NOW).alert).not.toBeNull(); + }); + + it('alerts once while a price stays below its target', () => { + const watched = item({ target: 85, dropPercent: 90, referencePrice: 100, referenceDate: '2026-09-24' }); + const alerts = [80, 82, 79].map((price, index) => + applyWatchHistory( + watched, + record([point(25 + index, price)]), + new Date(Date.UTC(2026, 8, 25 + index, 8)).toISOString(), + ).alert, + ); + expect(alerts.filter(Boolean)).toHaveLength(1); + }); + + it.each([ + ['target-only', { target: 99, dropPercent: 90 }, [point(26, 100), point(27, 98)]], + ['low-only', { target: null, dropPercent: 90 }, Array.from({ length: 30 }, (_, index) => [Date.UTC(2026, 7, 29 + index, 8), index === 29 ? 99 : 100] as ServalPoint)], + ])('does not move the reference for a %s alert', (_name, overrides, points) => { + const watched = item({ referencePrice: 100, referenceDate: '2026-09-01', ...overrides }); + const applied = applyWatchHistory(watched, record(points), ISO_NOW); + expect(applied.alert).not.toBeNull(); + expect(watched.referencePrice).toBe(100); + }); + + it('moves the reference up when a target-only alert is above it', () => { + const watched = item({ referencePrice: 100, referenceDate: '2026-09-01', target: 120, dropPercent: 90 }); + const applied = applyWatchHistory(watched, record([point(26, 100), point(27, 110)]), ISO_NOW); + expect(applied.alert?.reasons).toEqual(['target']); + expect(applied.alert?.reference).toBe(100); + expect(watched.referencePrice).toBe(110); + }); + + it('keeps the stored title when Serval has none', () => { + const watched = item({ referencePrice: 100, referenceDate: '2026-09-01', title: 'Stored title' }); + applyWatchHistory(watched, { ...record([point(27, 100)]), title: null }, ISO_NOW); + expect(watched.title).toBe('Stored title'); + }); + + it('moves the reference up and keeps it after a small fall', () => { + const watched = item({ referencePrice: 100, referenceDate: '2026-09-24' }); + applyWatchHistory(watched, record([point(25, 110)]), new Date(Date.UTC(2026, 8, 25, 8)).toISOString()); + expect(watched.referencePrice).toBe(110); + applyWatchHistory(watched, record([point(26, 108)]), new Date(Date.UTC(2026, 8, 26, 8)).toISOString()); + expect(watched.referencePrice).toBe(110); + }); + + it('changes only check metadata for data that is not newer', () => { + const watched = item({ + referencePrice: 100, + referenceDate: '2026-09-26', + lastPrice: 90, + lastPriceDate: '2026-09-27', + alertArmed: false, + }); + const before = structuredClone(watched); + const applied = applyWatchHistory(watched, record([point(27, 80)]), ISO_NOW); + expect(applied.item.status).toBe('not_newer'); + expect({ ...watched, lastCheckedAt: before.lastCheckedAt, lastStatus: before.lastStatus }).toEqual(before); + }); + + it('uses array position for a duplicate-time low', () => { + const points = Array.from({ length: 28 }, (_, index) => point(index + 1, 100)); + points.push(point(27, 90), point(27, 90)); + const watched = item({ dropPercent: 90, referencePrice: 100, referenceDate: '2026-08-01' }); + expect(applyWatchHistory(watched, record(points), ISO_NOW).alert?.reasons).toContain('low'); + }); + + it('reports several reasons at once', () => { + const points = Array.from({ length: 29 }, (_, index) => [Date.UTC(2026, 7, 29 + index, 8), 100] as ServalPoint); + points.push(point(27, 80)); + const watched = item({ target: 85, referencePrice: 100, referenceDate: '2026-08-01' }); + expect(applyWatchHistory(watched, record(points), ISO_NOW).alert?.reasons).toEqual([ + 'target', + 'drop', + 'low', + ]); + }); +}); + +describe('watch check loop', () => { + it('serves cache hits without attempts or waits', async () => { + const captured = context(); + const fetchImpl = vi.fn(async () => response([point(27, 100)])); + await loadServalHistory(42, { + cache: true, + version: 'test', + verbose: false, + ctx: captured.ctx, + fetchImpl: fetchImpl as unknown as typeof fetch, + now: () => NOW, + }); + seed([item()]); + fetchImpl.mockClear(); + const wait = vi.fn().mockResolvedValue(undefined); + await watchCheckCommand(captured.ctx, { ...checkOptions(fetchImpl as unknown as typeof fetch), wait }); + expect(captured.result).toMatchObject({ attempts: 0, cacheHits: 1 }); + expect(fetchImpl).not.toHaveBeenCalled(); + expect(wait).not.toHaveBeenCalled(); + }); + + it('waits three seconds between fetches with fake timers', async () => { + vi.useFakeTimers(); + seed([item({ plid: 42 }), item({ plid: 43 })]); + const captured = context(); + const fetchImpl = vi.fn(async (url: string | URL | Request) => + response([point(27, String(url).includes('PLID42') ? 100 : 90)]), + ); + const promise = watchCheckCommand(captured.ctx, { + ...checkOptions(fetchImpl as unknown as typeof fetch), + wait: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)), + }); + await vi.advanceTimersByTimeAsync(0); + expect(fetchImpl).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(2_999); + expect(fetchImpl).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + await promise; + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); + + it('counts retry attempts and skips when only one request remains', async () => { + seed([item({ plid: 42 }), item({ plid: 43 })]); + const captured = context(); + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(new Response('', { status: 500 })) + .mockResolvedValueOnce(response([point(27, 100)])); + await watchCheckCommand(captured.ctx, { + ...checkOptions(fetchImpl as unknown as typeof fetch), + maxRequests: 3, + }); + expect(captured.result).toMatchObject({ + attempts: 2, + runError: { code: 'request_limit' }, + items: [{ plid: 42, status: 'ok' }, { plid: 43, status: 'skipped', skipped: 'request_limit' }], + }); + }); + + it.each([429, 403])('stops after HTTP %s and exits one', async (status) => { + seed([item({ plid: 42 }), item({ plid: 43 })]); + const captured = context(); + const fetchImpl = vi.fn().mockResolvedValue(new Response('', { status })); + await watchCheckCommand(captured.ctx, checkOptions(fetchImpl as unknown as typeof fetch)); + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(captured.result).toMatchObject({ + attempts: 1, + runError: { code: 'serval_blocked' }, + items: [{ status: 'error' }, { status: 'skipped', skipped: 'serval_blocked' }], + }); + expect(process.exitCode).toBe(1); + }); + + it('continues after a per-item error', async () => { + seed([item({ plid: 42 }), item({ plid: 43 })]); + const captured = context(); + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(new Response('', { status: 418 })) + .mockResolvedValueOnce(response([point(27, 90)])); + await watchCheckCommand(captured.ctx, checkOptions(fetchImpl as unknown as typeof fetch)); + expect(captured.result.items).toMatchObject([{ status: 'error' }, { status: 'ok' }]); + expect(captured.result.runError).toBeNull(); + }); + + it('does not write the watchlist after its lock is replaced', async () => { + seed([item()]); + const before = fs.readFileSync(watchlistPath(), 'utf8'); + const captured = context(); + const fetchImpl = vi.fn(async () => { + writeOwner(watchlistLockPath(), { + pid: process.pid, + nonce: 'replacement', + host: os.hostname(), + acquiredAt: Date.now(), + }); + return response([point(27, 100)]); + }); + await watchCheckCommand(captured.ctx, checkOptions(fetchImpl as unknown as typeof fetch)); + expect(captured.result.runError).toMatchObject({ code: 'lock_lost' }); + expect(process.exitCode).toBe(1); + expect(fs.readFileSync(watchlistPath(), 'utf8')).toBe(before); + }); + + it('records a lost Serval lock and continues without its cache write', async () => { + seed([item({ plid: 42 }), item({ plid: 43 })]); + const captured = context(); + const fetchImpl = vi.fn(async (url: string | URL | Request) => { + if (String(url).includes('PLID42')) { + writeOwner(servalLockPath(42), { + pid: process.pid, + nonce: 'replacement', + host: os.hostname(), + acquiredAt: Date.now(), + }); + } + return response([point(27, 100)]); + }); + await watchCheckCommand(captured.ctx, checkOptions(fetchImpl as unknown as typeof fetch)); + expect(captured.result.items).toMatchObject([ + { plid: 42, status: 'error', error: { code: 'lock_lost' } }, + { plid: 43, status: 'ok' }, + ]); + expect(captured.result.errors).toMatchObject([{ plid: 42, code: 'lock_lost' }]); + expect(fs.existsSync(servalCachePath(42))).toBe(false); + expect(fs.existsSync(servalCachePath(43))).toBe(true); + }); + + it('does not write the watchlist with no-update', async () => { + seed([item()]); + const before = fs.readFileSync(watchlistPath(), 'utf8'); + const captured = context(); + await watchCheckCommand(captured.ctx, { + ...checkOptions(vi.fn(async () => response([point(27, 100)])) as unknown as typeof fetch), + update: false, + }); + expect(fs.readFileSync(watchlistPath(), 'utf8')).toBe(before); + expect(fs.existsSync(servalCachePath(42))).toBe(true); + }); + + it('returns no_results and exits one when no item has usable data', async () => { + seed([item()]); + const captured = context(); + await watchCheckCommand( + captured.ctx, + checkOptions(vi.fn().mockResolvedValue(new Response('', { status: 404 })) as unknown as typeof fetch), + ); + expect(captured.result.runError).toMatchObject({ code: 'no_results' }); + expect(process.exitCode).toBe(1); + expect(loadWatchlist().items[0]?.lastStatus).toBe('not_tracked'); + }); + + it('returns an empty successful result', async () => { + seed([]); + const captured = context(); + await watchCheckCommand(captured.ctx, checkOptions(vi.fn() as unknown as typeof fetch)); + expect(captured.result).toMatchObject({ attempts: 0, cacheHits: 0, runError: null, items: [] }); + expect(process.exitCode).toBeUndefined(); + }); +}); + +it('watch add exits one without writing after its lock is replaced', async () => { + seed([]); + const before = fs.readFileSync(watchlistPath(), 'utf8'); + const captured = context(); + Object.assign(captured.ctx, { + ensureCredentials: vi.fn().mockResolvedValue({}), + client: { + call: vi.fn(async () => { + writeOwner(watchlistLockPath(), { + pid: process.pid, + nonce: 'replacement', + host: os.hostname(), + acquiredAt: Date.now(), + }); + return { items: [{ plid: 42 }] }; + }), + }, + }); + const output: string[] = []; + vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown) => { + output.push(String(chunk)); + return true; + }) as typeof process.stdout.write); + const command = new Command('add').option('--json'); + command.setOptionValue('json', true); + await run(command, () => + watchAddCommand(captured.ctx, undefined, { fromWishlist: 7 }), + ); + expect(process.exitCode).toBe(1); + expect(JSON.parse(output.join(''))).toMatchObject({ code: 'lock_lost' }); + expect(fs.readFileSync(watchlistPath(), 'utf8')).toBe(before); +}); + +it.skipIf(process.env.TAKEALOT_LIVE !== '1')('checks one live product without updating', async () => { + seed([item({ plid: 46639928 })]); + await watchCheckCommand(context().ctx, { + update: false, + maxRequests: 2, + verbose: false, + version: 'test', + }); + expect(fs.readFileSync(watchlistPath(), 'utf8')).toContain('46639928'); +}); diff --git a/src/__tests__/watchlist.test.ts b/src/__tests__/watchlist.test.ts new file mode 100644 index 0000000..b6e2f60 --- /dev/null +++ b/src/__tests__/watchlist.test.ts @@ -0,0 +1,289 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import type { Context } from '../lib/context.js'; +import { withDirectoryLock } from '../lib/directory-lock.js'; +import { WatchError } from '../lib/errors.js'; +import { + loadWatchlist, + newWatchItem, + saveWatchlist, + validateWatchlist, + watchlistLockPath, + watchlistPath, + withWatchlistLock, + type Watchlist, +} from '../lib/watchlist.js'; +import { + watchAddCommand, + watchCheckCommand, + watchListCommand, + watchRemoveCommand, +} from '../commands/watch.js'; + +const NOW = Date.UTC(2026, 8, 27, 8); +const ISO_NOW = new Date(NOW).toISOString(); +let temporaryDirectory: string; +let originalConfigHome: string | undefined; +let originalCacheHome: string | undefined; + +function context(response: unknown = { items: [] }) { + let result: unknown; + const call = vi.fn().mockResolvedValue(response); + const ctx = { + logger: { + result: (_human: () => void, value: unknown) => { + result = value; + }, + warn: vi.fn(), + }, + ensureCredentials: vi.fn().mockResolvedValue({}), + client: { call }, + } as unknown as Context; + return { + ctx, + call, + get result() { + return result; + }, + }; +} + +function validWatchlist(): Watchlist { + return { + schema: 1, + items: [ + { + ...newWatchItem(42, ISO_NOW, 99.99, 5), + title: 'Product', + referencePrice: 120, + referenceDate: '2026-09-26', + lastCheckedAt: ISO_NOW, + lastPrice: 110, + lastPriceDate: '2026-09-27', + lastStatus: 'ok', + lastAlert: { price: 110, date: '2026-09-27', reasons: ['drop'] }, + }, + ], + }; +} + +beforeEach(() => { + originalConfigHome = process.env.XDG_CONFIG_HOME; + originalCacheHome = process.env.XDG_CACHE_HOME; + temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'takealot-watchlist-test-')); + process.env.XDG_CONFIG_HOME = path.join(temporaryDirectory, 'config'); + process.env.XDG_CACHE_HOME = path.join(temporaryDirectory, 'cache'); +}); + +afterEach(() => { + process.exitCode = undefined; + vi.useRealTimers(); + vi.restoreAllMocks(); + fs.rmSync(temporaryDirectory, { recursive: true, force: true }); + if (originalConfigHome === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = originalConfigHome; + if (originalCacheHome === undefined) delete process.env.XDG_CACHE_HOME; + else process.env.XDG_CACHE_HOME = originalCacheHome; +}); + +describe('watchlist file', () => { + it('treats a missing file as an empty list', () => { + expect(loadWatchlist(NOW)).toEqual({ schema: 1, items: [] }); + }); + + it('writes mode 0600 and reads the file', () => { + const watchlist = validWatchlist(); + saveWatchlist(watchlist); + expect(loadWatchlist(NOW)).toEqual(watchlist); + expect(fs.statSync(watchlistPath()).mode & 0o777).toBe(0o600); + }); + + const invalidCases: Array<[string, (value: any) => void]> = [ + ['root object', (value) => Object.assign(value, { extra: true })], + ['schema', (value) => (value.schema = 2)], + ['items array', (value) => (value.items = {})], + ['50 item limit', (value) => (value.items = Array.from({ length: 51 }, (_, index) => newWatchItem(index + 1, ISO_NOW, null, 5)))], + ['item object', (value) => (value.items[0] = null)], + ['item keys', (value) => (value.items[0].extra = true)], + ['positive plid', (value) => (value.items[0].plid = 0)], + ['unique plid', (value) => value.items.push({ ...value.items[0] })], + ['title', (value) => (value.items[0].title = 'x'.repeat(201))], + ['addedAt', (value) => (value.items[0].addedAt = '2026-09-27')], + ['future addedAt', (value) => (value.items[0].addedAt = new Date(NOW + 1).toISOString())], + ['lastCheckedAt', (value) => (value.items[0].lastCheckedAt = 'bad')], + ['referenceDate', (value) => (value.items[0].referenceDate = '2026-02-30')], + ['lastPriceDate', (value) => (value.items[0].lastPriceDate = '2026-13-01')], + ['target positive', (value) => (value.items[0].target = 0)], + ['target decimals', (value) => (value.items[0].target = 1.001)], + ['target maximum', (value) => (value.items[0].target = 10_000_001)], + ['drop percent', (value) => (value.items[0].dropPercent = 91)], + ['reference price', (value) => (value.items[0].referencePrice = -1)], + ['last price', (value) => (value.items[0].lastPrice = Number.NaN)], + ['last status', (value) => (value.items[0].lastStatus = 'skipped')], + ['alert armed', (value) => (value.items[0].alertArmed = 1)], + ['last alert object', (value) => (value.items[0].lastAlert = [])], + ['last alert keys', (value) => (value.items[0].lastAlert.extra = true)], + ['last alert price', (value) => (value.items[0].lastAlert.price = 0)], + ['last alert date', (value) => (value.items[0].lastAlert.date = 'bad')], + ['last alert reasons', (value) => (value.items[0].lastAlert.reasons = [])], + ['last alert reason values', (value) => (value.items[0].lastAlert.reasons = ['sale'])], + ['last alert unique reasons', (value) => (value.items[0].lastAlert.reasons = ['drop', 'drop'])], + ['reference pair', (value) => (value.items[0].referenceDate = null)], + ['last price pair', (value) => (value.items[0].lastPriceDate = null)], + ]; + + it.each(invalidCases)('rejects invalid %s', (_name, change) => { + const value = structuredClone(validWatchlist()); + change(value); + expect(() => validateWatchlist(value, NOW)).toThrowError( + expect.objectContaining({ code: 'watchlist_invalid' }), + ); + }); + + it('does not change an invalid file', () => { + fs.mkdirSync(path.dirname(watchlistPath()), { recursive: true }); + fs.writeFileSync(watchlistPath(), '{"schema":2}\n'); + const before = fs.readFileSync(watchlistPath(), 'utf8'); + expect(() => loadWatchlist(NOW)).toThrow(WatchError); + expect(fs.readFileSync(watchlistPath(), 'utf8')).toBe(before); + }); + + it('creates, updates, lists, and removes products without a Serval request', async () => { + const captured = context(); + await watchAddCommand(captured.ctx, 'PLID42', { target: 100 }); + await watchAddCommand(captured.ctx, '42', { drop: 10 }); + await watchListCommand(captured.ctx); + expect(loadWatchlist().items[0]).toMatchObject({ plid: 42, target: 100, dropPercent: 10 }); + expect(fetch).toBeDefined(); + await watchRemoveCommand(captured.ctx, '42'); + expect(loadWatchlist().items).toEqual([]); + }); + + it('keeps state on duplicate add and updates only supplied fields', async () => { + const watchlist = validWatchlist(); + saveWatchlist(watchlist); + const captured = context(); + await watchAddCommand(captured.ctx, '42', { drop: 12 }); + expect(loadWatchlist().items[0]).toEqual({ + ...watchlist.items[0], + dropPercent: 12, + }); + }); + + it('rejects a missing remove', async () => { + await expect(watchRemoveCommand(context().ctx, '42')).rejects.toMatchObject({ + code: 'not_watched', + }); + }); + + it('rejects the fifty-first direct add', async () => { + saveWatchlist({ + schema: 1, + items: Array.from({ length: 50 }, (_, index) => newWatchItem(index + 1, ISO_NOW, null, 5)), + }); + await expect(watchAddCommand(context().ctx, '100', {})).rejects.toMatchObject({ + code: 'watchlist_full', + }); + }); + + it('waits for the watchlist lock and then writes', async () => { + let release = (): void => undefined; + const held = withWatchlistLock( + () => new Promise((resolve) => { + release = resolve; + }), + 1_000, + ); + await vi.waitFor(() => expect(fs.existsSync(watchlistLockPath())).toBe(true)); + const add = watchAddCommand(context().ctx, '42', {}); + setTimeout(release, 30); + await Promise.all([held, add]); + expect(loadWatchlist().items).toHaveLength(1); + }); + + it('times out with watchlist_locked', async () => { + let release = (): void => undefined; + const held = withWatchlistLock( + () => new Promise((resolve) => { + release = resolve; + }), + 1_000, + ); + await vi.waitFor(() => expect(fs.existsSync(watchlistLockPath())).toBe(true)); + await expect(withWatchlistLock(async () => undefined, 5)).rejects.toMatchObject({ + code: 'watchlist_locked', + }); + release(); + await held; + }); + + it('takes the lock for no-update and writes nothing', async () => { + saveWatchlist({ schema: 1, items: [] }); + const before = fs.readFileSync(watchlistPath(), 'utf8'); + let release = (): void => undefined; + const held = withDirectoryLock( + watchlistLockPath(), + { timeoutMs: 1_000, error: () => new Error('locked') }, + () => new Promise((resolve) => { + release = resolve; + }), + ); + await vi.waitFor(() => expect(fs.existsSync(watchlistLockPath())).toBe(true)); + await expect( + watchCheckCommand(context().ctx, { + update: false, + maxRequests: 50, + verbose: false, + version: 'test', + lockTimeoutMs: 5, + }), + ).rejects.toMatchObject({ code: 'watchlist_locked' }); + release(); + await held; + await watchCheckCommand(context().ctx, { + update: false, + maxRequests: 50, + verbose: false, + version: 'test', + }); + expect(fs.readFileSync(watchlistPath(), 'utf8')).toBe(before); + }); +}); + +describe('wishlist import', () => { + it('adds valid PLIDs, skips missing ones, and makes no Serval request', async () => { + const captured = context({ items: [{ plid: 42 }, { title: 'missing' }, { plid: 'PLID43' }] }); + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + await watchAddCommand(captured.ctx, undefined, { fromWishlist: 7, drop: 8 }); + expect(loadWatchlist().items.map((item) => [item.plid, item.dropPercent])).toEqual([ + [42, 8], + [43, 8], + ]); + expect(captured.result).toMatchObject({ skipped: [{ plid: null, reason: 'invalid_plid' }] }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('stops at the limit and reports the rest', async () => { + saveWatchlist({ + schema: 1, + items: Array.from({ length: 49 }, (_, index) => newWatchItem(index + 1, ISO_NOW, null, 5)), + }); + const captured = context({ items: [{ plid: 100 }, { plid: 101 }] }); + await watchAddCommand(captured.ctx, undefined, { fromWishlist: 7 }); + expect(loadWatchlist().items).toHaveLength(50); + expect(captured.result).toMatchObject({ + added: [100], + skipped: [{ plid: 101, reason: 'watchlist_full' }], + }); + }); + + it.each([ + ['both sources', '42', { fromWishlist: 7 }], + ['neither source', undefined, {}], + ['target import', undefined, { fromWishlist: 7, target: 10 }], + ])('rejects %s', async (_name, product, options) => { + await expect(watchAddCommand(context().ctx, product, options)).rejects.toBeInstanceOf(Error); + }); +}); diff --git a/src/cli.ts b/src/cli.ts index 7d2cda5..fd1d88c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -13,7 +13,7 @@ import { pathToFileURL } from 'node:url'; import { Context, OtpFlowError, type GlobalOptions } from './lib/context.js'; import { ApiError } from './lib/api-client.js'; import { isAuthFailure } from './lib/auth.js'; -import { ServalError, UsageError } from './lib/errors.js'; +import { ServalError, UsageError, WatchError } from './lib/errors.js'; import { redactText } from './lib/redact.js'; import { c } from './lib/ui.js'; import { searchCommand } from './commands/search.js'; @@ -31,6 +31,12 @@ import { registerCatalogue } from './commands/register.js'; import { mutateEndpoint, readBodyFromFlags } from './commands/generic.js'; import { wishlistAdd, wishlistMove, wishlistRemoveItems } from './commands/wishlist.js'; import { priceHistoryCommand } from './commands/price.js'; +import { + watchAddCommand, + watchCheckCommand, + watchListCommand, + watchRemoveCommand, +} from './commands/watch.js'; declare const __TAKEALOT_VERSION__: string | undefined; const packageVersion = (): string => { @@ -67,6 +73,15 @@ const intListOpt = (name: string) => (value: string, previous: number[] = []) => intOpt(name)(value), ]; +const priceOpt = (name: string) => (value: string) => { + if (!/^\d+(?:\.\d{1,2})?$/.test(value)) throw new UsageError(`invalid ${name}: ${value}`); + const amount = Number(value); + if (!Number.isFinite(amount) || amount <= 0 || amount > 10_000_000) { + throw new UsageError(`invalid ${name}: ${value}`); + } + return amount; +}; + /** Add the two global flags to a command so they parse in any position. */ function withGlobals(cmd: Command): Command { return cmd @@ -171,6 +186,7 @@ function errorDetails(err: unknown): { } if (err instanceof UsageError) return { error, code: err.code }; if (err instanceof ServalError) return { error, code: err.code }; + if (err instanceof WatchError) return { error, code: err.code }; if (err instanceof OtpFlowError) return { error, code: err.code }; if (isAuthFailure(err)) return { error, code: err.code }; if (err instanceof CommanderError) return { error, code: err.code }; @@ -334,6 +350,55 @@ withGlobals(price.command('history')) ), ); +const watch = withGlobals(program.command('watch')) + .description('manage a local price watchlist') + .allowExcessArguments(false); + +withGlobals(watch.command('add')) + .description('add or update a watched product') + .allowExcessArguments(false) + .argument('[product]', 'PLID, number, or Takealot product URL') + .option('--target ', 'alert at or below this price', priceOpt('--target')) + .option('--drop ', 'drop percentage from 1 to 90', idOpt('--drop')) + .option('--from-wishlist ', 'add all products in a wishlist', idOpt('--from-wishlist')) + .action( + ( + product: string | undefined, + options: { target?: number; drop?: number; fromWishlist?: number }, + command: Command, + ) => run(command, (ctx) => watchAddCommand(ctx, product, options)), + ); + +withGlobals(watch.command('rm')) + .description('remove a watched product') + .allowExcessArguments(false) + .argument('', 'PLID, number, or Takealot product URL') + .action((product: string, _options: unknown, command: Command) => + run(command, (ctx) => watchRemoveCommand(ctx, product)), + ); + +withGlobals(watch.command('list')) + .description('show the local watchlist') + .allowExcessArguments(false) + .action((_options: unknown, command: Command) => run(command, watchListCommand)); + +withGlobals(watch.command('check')) + .description('check watched products for price drops') + .allowExcessArguments(false) + .option('--no-update', 'do not update the watchlist file') + .option('--max-requests ', 'maximum Serval HTTP attempts', idOpt('--max-requests'), 50) + .action( + (options: { update: boolean; maxRequests: number }, command: Command) => + run(command, (ctx) => + watchCheckCommand(ctx, { + update: options.update, + maxRequests: options.maxRequests, + verbose: globalFlags(command).verbose ?? false, + version: VERSION, + }), + ), + ); + const recommend = withGlobals(program.command('recommend')) .description('product recommendations') .allowExcessArguments(false) diff --git a/src/commands/price.ts b/src/commands/price.ts index 62d0e47..059d5ef 100644 --- a/src/commands/price.ts +++ b/src/commands/price.ts @@ -3,7 +3,12 @@ import * as os from 'node:os'; import * as path from 'node:path'; import type { Context } from '../lib/context.js'; import { atomicWriteJson } from '../lib/config.js'; -import { UsageError } from '../lib/errors.js'; +import { ServalError, UsageError } from '../lib/errors.js'; +import { + LockLostError, + withDirectoryLock, + type DirectoryLockHandle, +} from '../lib/directory-lock.js'; import { parsePlidRef } from '../lib/product-ref.js'; import { cleanServalPoints, @@ -22,7 +27,7 @@ interface PriceHistoryOptions { version: string; } -interface OkCacheRecord { +export interface OkCacheRecord { schema: 1; status: 'ok'; plid: number; @@ -32,14 +37,38 @@ interface OkCacheRecord { listing: ServalPoint[]; } -interface NotTrackedCacheRecord { +export interface NotTrackedCacheRecord { schema: 1; status: 'not_tracked'; plid: number; fetchedAt: string; } -type CacheRecord = OkCacheRecord | NotTrackedCacheRecord; +export type ServalHistoryRecord = OkCacheRecord | NotTrackedCacheRecord; + +interface FailureMarker { + failedAt: string; + code: string; +} + +export interface LoadServalHistoryOptions { + cache: boolean; + version: string; + verbose: boolean; + ctx: Context; + beforeFetch?: () => Promise; + onAttempt?: () => void; + fetchImpl?: typeof fetch; + now?: () => number; + wait?: (milliseconds: number) => Promise; + lockTimeoutMs?: number; + requireCache?: boolean; +} + +export interface LoadedServalHistory { + record: ServalHistoryRecord; + cached: boolean; +} interface PriceHistoryResult { plid: number; @@ -96,6 +125,13 @@ export function servalCachePath(plid: number): string { return path.join(cacheBase(), 'serval', `PLID${plid}.json`); } +export function servalLockPath(plid: number): string { + return path.join(cacheBase(), 'serval', `PLID${plid}.lock`); +} + +const servalFailurePath = (plid: number): string => + path.join(cacheBase(), 'serval', `PLID${plid}.failure.json`); + function validIsoTime(value: unknown, now: number): value is string { if (typeof value !== 'string') return false; const time = Date.parse(value); @@ -107,7 +143,7 @@ function samePoints(left: unknown, right: ServalPoint[]): boolean { return JSON.stringify(left) === JSON.stringify(right); } -function readCache(plid: number, now: number): CacheRecord | null { +function readCache(plid: number, now: number): ServalHistoryRecord | null { let value: unknown; try { value = JSON.parse(fs.readFileSync(servalCachePath(plid), 'utf8')); @@ -160,16 +196,27 @@ function readCache(plid: number, now: number): CacheRecord | null { }; } -function writeCache(record: CacheRecord, verbose: boolean, ctx: Context): void { +function ensureCacheDirectory(): void { + const base = cacheBase(); + const directory = path.join(base, 'serval'); + fs.mkdirSync(base, { recursive: true, mode: 0o700 }); + fs.chmodSync(base, 0o700); + fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); + fs.chmodSync(directory, 0o700); +} + +function writeCache( + record: ServalHistoryRecord, + verbose: boolean, + ctx: Context, + lock: DirectoryLockHandle, +): void { try { - const base = cacheBase(); - const directory = path.join(base, 'serval'); - fs.mkdirSync(base, { recursive: true, mode: 0o700 }); - fs.chmodSync(base, 0o700); - fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); - fs.chmodSync(directory, 0o700); + ensureCacheDirectory(); + lock.assertHeld(); atomicWriteJson(servalCachePath(record.plid), record, 0o600); } catch (error) { + if (error instanceof LockLostError) throw error; if (verbose) { ctx.logger.warn( `could not write the Serval cache: ${error instanceof Error ? error.message : String(error)}`, @@ -178,6 +225,143 @@ function writeCache(record: CacheRecord, verbose: boolean, ctx: Context): void { } } +function readFailure(plid: number, now: number): FailureMarker | null { + try { + const value = JSON.parse(fs.readFileSync(servalFailurePath(plid), 'utf8')) as unknown; + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const marker = value as Record; + if (typeof marker.failedAt !== 'string' || typeof marker.code !== 'string') return null; + const failedAt = Date.parse(marker.failedAt); + if (!Number.isFinite(failedAt) || failedAt > now || now - failedAt >= 10 * 60 * 1000) return null; + return { failedAt: marker.failedAt, code: marker.code }; + } catch { + return null; + } +} + +function writeFailure( + plid: number, + marker: FailureMarker, + verbose: boolean, + ctx: Context, + lock: DirectoryLockHandle, +): void { + try { + ensureCacheDirectory(); + lock.assertHeld(); + atomicWriteJson(servalFailurePath(plid), marker, 0o600); + } catch (error) { + if (error instanceof LockLostError) throw error; + if (verbose) ctx.logger.warn(`could not write the Serval failure marker: ${error instanceof Error ? error.message : String(error)}`); + } +} + +function clearFailure(plid: number, lock: DirectoryLockHandle): void { + lock.assertHeld(); + try { + fs.rmSync(servalFailurePath(plid), { force: true }); + } catch {} +} + +function failureFromMarker(marker: FailureMarker): ServalError { + return new ServalError(`Serval request recently failed with ${marker.code}`, marker.code); +} + +export async function loadServalHistory( + plid: number, + options: LoadServalHistoryOptions, +): Promise { + const fetchFresh = async ( + now: number, + lock?: DirectoryLockHandle, + ): Promise => { + await options.beforeFetch?.(); + let fetched; + try { + fetched = await fetchServalPage(plid, { + version: options.version, + fetchImpl: options.fetchImpl, + now: options.now, + wait: options.wait, + onAttempt: options.onAttempt, + }); + } catch (error) { + if (lock && error instanceof ServalError) { + writeFailure( + plid, + { failedAt: new Date((options.now ?? Date.now)()).toISOString(), code: error.code }, + options.verbose, + options.ctx, + lock, + ); + } + throw error; + } + if (fetched.status === 'not_tracked') { + const missing: NotTrackedCacheRecord = { + schema: 1, + status: 'not_tracked', + plid, + fetchedAt: fetched.fetchedAt, + }; + if (lock) { + writeCache(missing, options.verbose, options.ctx, lock); + clearFailure(plid, lock); + } + return { record: missing, cached: false }; + } + const parsed = parseServalPage(fetched.html, now); + if (parsed.current.length === 0) { + throw new UsageError(`Serval has no usable price data for PLID${plid}`, 'no_data'); + } + const loaded: OkCacheRecord = { + schema: 1, + status: 'ok', + plid, + fetchedAt: fetched.fetchedAt, + title: parsed.title, + current: parsed.current, + listing: parsed.listing, + }; + if (lock) { + writeCache(loaded, options.verbose, options.ctx, lock); + clearFailure(plid, lock); + } + return { record: loaded, cached: false }; + }; + try { + ensureCacheDirectory(); + } catch (error) { + if (options.requireCache) { + throw new ServalError( + `Serval cache directory is not available: ${error instanceof Error ? error.message : String(error)}`, + 'serval_cache_unavailable', + ); + } + if (options.verbose) { + options.ctx.logger.warn( + `could not write the Serval cache: ${error instanceof Error ? error.message : String(error)}`, + ); + } + return fetchFresh((options.now ?? Date.now)()); + } + return withDirectoryLock( + servalLockPath(plid), + { + timeoutMs: options.lockTimeoutMs ?? 20_000, + error: () => new ServalError(`Serval cache is busy for PLID${plid}`, 'serval_busy'), + }, + async (lock) => { + const now = (options.now ?? Date.now)(); + const record = options.cache ? readCache(plid, now) : null; + if (record) return { record, cached: true }; + const failure = options.cache ? readFailure(plid, now) : null; + if (failure) throw failureFromMarker(failure); + return fetchFresh(now, lock); + }, + ); +} + function notTracked(plid: number): UsageError { return new UsageError(`Serval does not track PLID${plid}; see ${servalUrl(plid)}`, 'not_tracked'); } @@ -309,40 +493,9 @@ export async function priceHistoryCommand( const plid = parsePlidRef(product); const days = parsePriceWindow(options.since); const now = Date.now(); - let record = options.cache ? readCache(plid, now) : null; - let cached = record !== null; + const loaded = await loadServalHistory(plid, { ...options, ctx }); + const { record, cached } = loaded; if (record?.status === 'not_tracked') throw notTracked(plid); - - if (!record) { - const fetched = await fetchServalPage(plid, { version: options.version }); - if (fetched.status === 'not_tracked') { - const missing: NotTrackedCacheRecord = { - schema: 1, - status: 'not_tracked', - plid, - fetchedAt: fetched.fetchedAt, - }; - writeCache(missing, options.verbose, ctx); - throw notTracked(plid); - } - const parsed = parseServalPage(fetched.html, now); - if (parsed.current.length === 0) { - throw new UsageError(`Serval has no usable price data for PLID${plid}`, 'no_data'); - } - record = { - schema: 1, - status: 'ok', - plid, - fetchedAt: fetched.fetchedAt, - title: parsed.title, - current: parsed.current, - listing: parsed.listing, - }; - writeCache(record, options.verbose, ctx); - cached = false; - } - - if (record.status !== 'ok') throw notTracked(plid); const result = buildPriceHistory(record, options.since, options.series, cached, now); const selected = windowPoints(record.current, days); ctx.logger.result(() => renderHuman(result, selected), result); diff --git a/src/commands/watch.ts b/src/commands/watch.ts new file mode 100644 index 0000000..0b05121 --- /dev/null +++ b/src/commands/watch.ts @@ -0,0 +1,488 @@ +import type { Context } from '../lib/context.js'; +import { LockLostError } from '../lib/directory-lock.js'; +import { ServalError, UsageError } from '../lib/errors.js'; +import { parsePlidRef } from '../lib/product-ref.js'; +import type { ServalPoint } from '../lib/serval.js'; +import { cleanServalTitle } from '../lib/serval.js'; +import { rand } from '../lib/ui.js'; +import { + loadWatchlist, + newWatchItem, + saveWatchlist, + withWatchlistLock, + type WatchItem, + type WatchReason, + type Watchlist, +} from '../lib/watchlist.js'; +import { loadServalHistory, type OkCacheRecord } from './price.js'; + +interface WatchAddOptions { + target?: number; + drop?: number; + fromWishlist?: number; +} + +interface WatchCheckOptions { + update: boolean; + maxRequests: number; + verbose: boolean; + version: string; + now?: () => number; + wait?: (milliseconds: number) => Promise; + fetchImpl?: typeof fetch; + lockTimeoutMs?: number; + servalLockTimeoutMs?: number; +} + +interface WatchItemResult { + plid: number; + title: string | null; + status: 'ok' | 'stale' | 'not_newer' | 'not_tracked' | 'error' | 'skipped'; + current: number | null; + currentDate: string | null; + reference: number | null; + skipped?: 'request_limit' | 'serval_blocked'; + error?: { code: string; message: string }; +} + +interface WatchAlert { + plid: number; + title: string | null; + url: string; + current: number; + currentDate: string; + reference: number; + target: number | null; + dropPercent: number; + changePercent: number; + reasons: WatchReason[]; + historyMin: number | null; + historyMinDate: string | null; +} + +interface WatchCheckResult { + checkedAt: string; + attempts: number; + cacheHits: number; + runError: { code: string; message: string } | null; + alerts: WatchAlert[]; + items: WatchItemResult[]; + errors: Array<{ plid: number; code: string; message: string }>; +} + +class SkippedFetch extends Error { + constructor(readonly reason: 'request_limit' | 'serval_blocked') { + super(reason); + } +} + +const DAY_MS = 86_400_000; +const roundPrice = (value: number): number => Math.round(value * 100) / 100; +const dateOf = (point: ServalPoint): string => new Date(point[0]).toISOString().slice(0, 10); + +function validateTarget(value: number | undefined): void { + if ( + value !== undefined && + (!Number.isFinite(value) || + value <= 0 || + value > 10_000_000 || + Math.abs(value * 100 - Math.round(value * 100)) >= 1e-7) + ) { + throw new UsageError('--target must be a positive amount with at most 2 decimals'); + } +} + +function validateDrop(value: number | undefined): void { + if (value !== undefined && (!Number.isInteger(value) || value < 1 || value > 90)) { + throw new UsageError('--drop must be an integer from 1 to 90'); + } +} + +function wishlistRows(value: unknown): unknown[] { + if (Array.isArray(value)) return value; + if (!value || typeof value !== 'object') return []; + const record = value as Record; + for (const key of ['items', 'products', 'results']) { + if (Array.isArray(record[key])) return record[key] as unknown[]; + } + return wishlistRows(record.data); +} + +function wishlistPlid(value: unknown): number | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const raw = (value as Record).plid; + try { + if (typeof raw === 'number') return parsePlidRef(String(raw)); + if (typeof raw === 'string') return parsePlidRef(raw); + } catch {} + return null; +} + +function addProducts( + watchlist: Watchlist, + plids: Array, + options: WatchAddOptions, + addedAt: string, +): { added: number[]; updated: number[]; skipped: Array<{ plid: number | null; reason: string }> } { + const added: number[] = []; + const updated: number[] = []; + const skipped: Array<{ plid: number | null; reason: string }> = []; + for (const plid of plids) { + if (plid === null) { + skipped.push({ plid, reason: 'invalid_plid' }); + continue; + } + const existing = watchlist.items.find((item) => item.plid === plid); + if (existing) { + if (options.target !== undefined) existing.target = options.target; + if (options.drop !== undefined) existing.dropPercent = options.drop; + updated.push(plid); + continue; + } + if (watchlist.items.length >= 50) { + if (options.fromWishlist !== undefined) { + skipped.push({ plid, reason: 'watchlist_full' }); + continue; + } + throw new UsageError('the watchlist can contain at most 50 products', 'watchlist_full'); + } + watchlist.items.push( + newWatchItem(plid, addedAt, options.target ?? null, options.drop ?? 5), + ); + added.push(plid); + } + return { added, updated, skipped }; +} + +export async function watchAddCommand( + ctx: Context, + product: string | undefined, + options: WatchAddOptions, +): Promise { + validateTarget(options.target); + validateDrop(options.drop); + if ((product === undefined) === (options.fromWishlist === undefined)) { + throw new UsageError('provide either or --from-wishlist '); + } + if (options.fromWishlist !== undefined && options.target !== undefined) { + throw new UsageError('--target cannot be used with --from-wishlist'); + } + let directPlids: Array | null = null; + if (options.fromWishlist !== undefined) { + if (!Number.isSafeInteger(options.fromWishlist) || options.fromWishlist <= 0) { + throw new UsageError('--from-wishlist must be a positive integer'); + } + } else { + directPlids = [parsePlidRef(product!)]; + } + const result = await withWatchlistLock(async (lock) => { + let plids = directPlids; + if (options.fromWishlist !== undefined) { + await ctx.ensureCredentials(); + const response = await ctx.client.call('wishlist.items', { + params: { groupId: options.fromWishlist }, + }); + plids = wishlistRows(response).map(wishlistPlid); + } + const watchlist = loadWatchlist(); + const changes = addProducts(watchlist, plids!, options, new Date().toISOString()); + lock.assertHeld(); + saveWatchlist(watchlist); + return { ...changes, count: watchlist.items.length, items: watchlist.items }; + }); + ctx.logger.result( + () => process.stdout.write(`Watchlist now has ${result.count} item${result.count === 1 ? '' : 's'}.\n`), + result, + ); +} + +export async function watchRemoveCommand(ctx: Context, product: string): Promise { + const plid = parsePlidRef(product); + const result = await withWatchlistLock(async (lock) => { + const watchlist = loadWatchlist(); + const index = watchlist.items.findIndex((item) => item.plid === plid); + if (index < 0) throw new UsageError(`PLID${plid} is not watched`, 'not_watched'); + watchlist.items.splice(index, 1); + lock.assertHeld(); + saveWatchlist(watchlist); + return { removed: plid, count: watchlist.items.length }; + }); + ctx.logger.result(() => process.stdout.write(`Removed PLID${plid} from the watchlist.\n`), result); +} + +export async function watchListCommand(ctx: Context): Promise { + const watchlist = loadWatchlist(); + ctx.logger.result( + () => { + if (watchlist.items.length === 0) { + process.stdout.write('The watchlist is empty.\n'); + return; + } + for (const item of watchlist.items) { + process.stdout.write(`${item.title ?? `PLID${item.plid}`} · ${item.lastPrice === null ? 'not checked' : rand(item.lastPrice)}\n`); + } + }, + { count: watchlist.items.length, items: watchlist.items }, + ); +} + +function priorMinimum(points: ServalPoint[]): { price: number; date: string } | null { + if (points.length < 2) return null; + let minimum = points[0]!; + for (const point of points.slice(1, -1)) { + if (point[1] <= minimum[1]) minimum = point; + } + return { price: minimum[1], date: dateOf(minimum) }; +} + +export function applyWatchHistory( + item: WatchItem, + record: OkCacheRecord, + checkedAt: string, +): { item: WatchItemResult; alert: WatchAlert | null } { + const points = record.current; + const last = points.at(-1)!; + const current = last[1]; + if (current <= 0) throw new ServalError('Serval returned a non-positive current price', 'no_data'); + const currentDate = dateOf(last); + const previousReference = item.referencePrice; + const outputBase = { + plid: item.plid, + title: item.title, + current: roundPrice(current), + currentDate, + reference: previousReference === null ? null : roundPrice(previousReference), + }; + const checkedTime = Date.parse(checkedAt); + const today = Date.UTC( + new Date(checkedTime).getUTCFullYear(), + new Date(checkedTime).getUTCMonth(), + new Date(checkedTime).getUTCDate(), + ); + if (Date.parse(`${currentDate}T00:00:00.000Z`) < today - 7 * DAY_MS) { + item.lastCheckedAt = checkedAt; + item.lastStatus = 'stale'; + return { item: { ...outputBase, status: 'stale' }, alert: null }; + } + if (item.lastPriceDate !== null && currentDate <= item.lastPriceDate) { + item.lastCheckedAt = checkedAt; + item.lastStatus = 'ok'; + return { item: { ...outputBase, status: 'not_newer' }, alert: null }; + } + const title = cleanServalTitle(record.title ?? '') ?? item.title; + if (item.referencePrice === null) { + item.referencePrice = current; + item.referenceDate = currentDate; + item.alertArmed = true; + item.lastPrice = current; + item.lastPriceDate = currentDate; + item.lastCheckedAt = checkedAt; + item.lastStatus = 'ok'; + item.title = title; + return { item: { ...outputBase, title, status: 'ok' }, alert: null }; + } + const reference = previousReference!; + const historyMinimum = priorMinimum(points); + const reasons: WatchReason[] = []; + if (item.target !== null && current <= item.target) reasons.push('target'); + if (current <= item.referencePrice * (1 - item.dropPercent / 100)) reasons.push('drop'); + if (points.length >= 30 && historyMinimum !== null && current <= historyMinimum.price) reasons.push('low'); + const fired = reasons.length > 0 && item.alertArmed; + let alert: WatchAlert | null = null; + if (fired) { + item.alertArmed = false; + item.lastAlert = { price: current, date: currentDate, reasons }; + alert = { + plid: item.plid, + title, + url: `https://www.takealot.com/x/PLID${item.plid}`, + current: roundPrice(current), + currentDate, + reference: roundPrice(reference), + target: item.target === null ? null : roundPrice(item.target), + dropPercent: item.dropPercent, + changePercent: Math.round(((current - reference) / reference) * 1000) / 10, + reasons, + historyMin: historyMinimum === null ? null : roundPrice(historyMinimum.price), + historyMinDate: historyMinimum?.date ?? null, + }; + } + if (reasons.length === 0) item.alertArmed = true; + if (fired && reasons.includes('drop')) { + item.referencePrice = current; + item.referenceDate = currentDate; + } else if (current > item.referencePrice) { + item.referencePrice = current; + item.referenceDate = currentDate; + } + item.lastPrice = current; + item.lastPriceDate = currentDate; + item.lastCheckedAt = checkedAt; + item.lastStatus = 'ok'; + item.title = title; + return { item: { ...outputBase, title, status: 'ok' }, alert }; +} + +function errorCode(error: unknown): string { + if ( + error instanceof ServalError || + error instanceof UsageError || + error instanceof LockLostError + ) { + return error.code; + } + return 'runtime_error'; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function chooseRunError( + blocked: boolean, + usable: number, + limited: boolean, + count: number, +): WatchCheckResult['runError'] { + if (blocked) return { code: 'serval_blocked', message: 'Serval stopped further requests' }; + if (count > 0 && usable === 0) return { code: 'no_results', message: 'No watched item returned usable price data' }; + if (limited) return { code: 'request_limit', message: 'The Serval request limit stopped some checks' }; + return null; +} + +function renderCheck(result: WatchCheckResult): void { + if (result.alerts.length === 0) { + process.stdout.write('No price drops.\n'); + } else { + for (const alert of result.alerts) { + process.stdout.write(`${alert.title ?? `PLID${alert.plid}`}: ${rand(alert.current)} · ${alert.reasons.join(', ')} · ${alert.changePercent.toFixed(1)}%\n`); + } + } + const skipped = result.items.filter((item) => item.status === 'skipped').length; + process.stdout.write(`${result.alerts.length} alerts, ${result.items.length} items checked, ${skipped} skipped, ${result.errors.length} errors\n`); +} + +export async function watchCheckCommand(ctx: Context, options: WatchCheckOptions): Promise { + if (!Number.isInteger(options.maxRequests) || options.maxRequests < 1 || options.maxRequests > 50) { + throw new UsageError('--max-requests must be an integer from 1 to 50'); + } + const now = options.now ?? Date.now; + const wait = options.wait ?? ((milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds))); + const result = await withWatchlistLock(async (lock) => { + const checkedAt = new Date(now()).toISOString(); + const watchlist = loadWatchlist(now()); + const items: WatchItemResult[] = []; + const alerts: WatchAlert[] = []; + const errors: WatchCheckResult['errors'] = []; + let attempts = 0; + let cacheHits = 0; + let fetches = 0; + let blocked = false; + let limited = false; + let usable = 0; + for (const item of watchlist.items) { + try { + const loaded = await loadServalHistory(item.plid, { + cache: true, + version: options.version, + verbose: options.verbose, + ctx, + fetchImpl: options.fetchImpl, + now, + wait, + lockTimeoutMs: options.servalLockTimeoutMs, + requireCache: true, + onAttempt: () => { + attempts += 1; + }, + beforeFetch: async () => { + if (blocked) throw new SkippedFetch('serval_blocked'); + if (attempts + 2 > options.maxRequests) { + limited = true; + throw new SkippedFetch('request_limit'); + } + if (fetches > 0) await wait(3_000); + fetches += 1; + }, + }); + if (loaded.cached) cacheHits += 1; + if (loaded.record.status === 'not_tracked') { + item.lastCheckedAt = checkedAt; + item.lastStatus = 'not_tracked'; + items.push({ + plid: item.plid, + title: item.title, + status: 'not_tracked', + current: null, + currentDate: null, + reference: item.referencePrice === null ? null : roundPrice(item.referencePrice), + }); + continue; + } + const applied = applyWatchHistory(item, loaded.record, checkedAt); + items.push(applied.item); + if (applied.alert) alerts.push(applied.alert); + usable += 1; + } catch (error) { + if (error instanceof SkippedFetch) { + items.push({ + plid: item.plid, + title: item.title, + status: 'skipped', + current: null, + currentDate: null, + reference: item.referencePrice === null ? null : roundPrice(item.referencePrice), + skipped: error.reason, + }); + continue; + } + const code = errorCode(error); + const message = errorMessage(error); + if (code === 'serval_rate_limited' || code === 'serval_forbidden') blocked = true; + item.lastCheckedAt = checkedAt; + item.lastStatus = 'error'; + const failure = { plid: item.plid, code, message }; + errors.push(failure); + items.push({ + plid: item.plid, + title: item.title, + status: 'error', + current: null, + currentDate: null, + reference: item.referencePrice === null ? null : roundPrice(item.referencePrice), + error: { code, message }, + }); + } + } + const completed = { + checkedAt, + attempts, + cacheHits, + runError: chooseRunError(blocked, usable, limited, watchlist.items.length), + alerts, + items, + errors, + } satisfies WatchCheckResult; + if (options.update) { + try { + lock.assertHeld(); + } catch (error) { + if (!(error instanceof LockLostError)) throw error; + return { + ...completed, + runError: { code: error.code, message: error.message }, + } satisfies WatchCheckResult; + } + saveWatchlist(watchlist); + } + return completed; + }, options.lockTimeoutMs); + ctx.logger.result(() => renderCheck(result), result); + if ( + result.runError?.code === 'serval_blocked' || + result.runError?.code === 'no_results' || + result.runError?.code === 'lock_lost' + ) { + process.exitCode = 1; + } +} diff --git a/src/lib/directory-lock.ts b/src/lib/directory-lock.ts new file mode 100644 index 0000000..b02ab14 --- /dev/null +++ b/src/lib/directory-lock.ts @@ -0,0 +1,136 @@ +import * as crypto from 'node:crypto'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { WatchError } from './errors.js'; + +interface LockOwner { + pid: number; + nonce: string; + host: string; + acquiredAt: number; +} + +interface DirectoryLockOptions { + timeoutMs: number; + error: (owner: LockOwner | null) => Error; + now?: () => number; +} + +export interface DirectoryLockHandle { + assertHeld(): void; +} + +export class LockLostError extends WatchError { + constructor(directory: string) { + super(`lock ownership was lost for ${directory}`, 'lock_lost'); + this.name = 'LockLostError'; + } +} + +const HOSTNAME = os.hostname(); +const ownerPath = (directory: string): string => path.join(directory, 'owner.json'); +const wait = (milliseconds: number): Promise => + new Promise((resolve) => setTimeout(resolve, milliseconds)); + +function ownerOf(directory: string): LockOwner | null { + try { + return JSON.parse(fs.readFileSync(ownerPath(directory), 'utf8')) as LockOwner; + } catch { + return null; + } +} + +function processIsAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error: any) { + return error?.code === 'EPERM'; + } +} + +function publish(directory: string, nonce: string, acquiredAt: number): boolean { + const parent = path.dirname(directory); + const staging = `${directory}.new.${process.pid}.${nonce}`; + try { + fs.mkdirSync(parent, { recursive: true, mode: 0o700 }); + fs.rmSync(staging, { recursive: true, force: true }); + fs.mkdirSync(staging, { mode: 0o700 }); + fs.writeFileSync( + ownerPath(staging), + JSON.stringify({ pid: process.pid, nonce, host: HOSTNAME, acquiredAt }), + { mode: 0o600 }, + ); + fs.renameSync(staging, directory); + return true; + } catch { + try { + fs.rmSync(staging, { recursive: true, force: true }); + } catch {} + return false; + } +} + +function deadSameHost(owner: LockOwner | null): owner is LockOwner { + return owner !== null && owner.host === HOSTNAME && !processIsAlive(owner.pid); +} + +function removeDeadLock(directory: string): boolean { + const owner = ownerOf(directory); + if (!deadSameHost(owner)) return false; + const claimed = `${directory}.dead.${process.pid}.${crypto.randomUUID()}`; + try { + fs.renameSync(directory, claimed); + } catch { + return false; + } + if (ownerOf(claimed)?.nonce !== owner.nonce) { + try { + fs.renameSync(claimed, directory); + } catch {} + return false; + } + try { + fs.rmSync(claimed, { recursive: true, force: true }); + return true; + } catch { + return false; + } +} + +function release(directory: string, nonce: string): void { + if (ownerOf(directory)?.nonce !== nonce) return; + try { + fs.rmSync(directory, { recursive: true, force: true }); + } catch {} +} + +export async function withDirectoryLock( + directory: string, + options: DirectoryLockOptions, + fn: (lock: DirectoryLockHandle) => Promise, +): Promise { + const nonce = crypto.randomUUID(); + const now = options.now ?? Date.now; + const deadline = now() + options.timeoutMs; + let delay = 20; + for (;;) { + const acquiredAt = now(); + if (publish(directory, nonce, acquiredAt)) break; + if (removeDeadLock(directory) && publish(directory, nonce, now())) break; + if (now() >= deadline) throw options.error(ownerOf(directory)); + await wait(delay); + delay = Math.min(Math.round(delay * 1.5), 200); + } + const lock: DirectoryLockHandle = { + assertHeld: () => { + if (ownerOf(directory)?.nonce !== nonce) throw new LockLostError(directory); + }, + }; + try { + return await fn(lock); + } finally { + release(directory, nonce); + } +} diff --git a/src/lib/errors.ts b/src/lib/errors.ts index a7ca83c..906d31a 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -31,3 +31,13 @@ export class ServalError extends Error { this.name = 'ServalError'; } } + +export class WatchError extends Error { + constructor( + message: string, + readonly code: string, + ) { + super(message); + this.name = 'WatchError'; + } +} diff --git a/src/lib/serval.ts b/src/lib/serval.ts index 4841770..22c4678 100644 --- a/src/lib/serval.ts +++ b/src/lib/serval.ts @@ -19,6 +19,7 @@ export interface ServalFetchOptions { retryDelayMs?: number; now?: () => number; wait?: (milliseconds: number) => Promise; + onAttempt?: () => void; } const EARLIEST_TIME = Date.UTC(2000, 0, 1); @@ -227,6 +228,7 @@ export async function fetchServalPage( const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { + options.onAttempt?.(); const response = await fetchImpl(url, { method: 'GET', redirect: 'manual', diff --git a/src/lib/watchlist.ts b/src/lib/watchlist.ts new file mode 100644 index 0000000..ec0b34c --- /dev/null +++ b/src/lib/watchlist.ts @@ -0,0 +1,175 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { atomicWriteJson, configDir } from './config.js'; +import { withDirectoryLock, type DirectoryLockHandle } from './directory-lock.js'; +import { WatchError } from './errors.js'; + +export type WatchStatus = 'ok' | 'not_tracked' | 'stale' | 'error'; +export type WatchReason = 'target' | 'drop' | 'low'; + +export interface WatchItem { + plid: number; + title: string | null; + addedAt: string; + target: number | null; + dropPercent: number; + referencePrice: number | null; + referenceDate: string | null; + lastCheckedAt: string | null; + lastPrice: number | null; + lastPriceDate: string | null; + lastStatus: WatchStatus | null; + alertArmed: boolean; + lastAlert: { price: number; date: string; reasons: WatchReason[] } | null; +} + +export interface Watchlist { + schema: 1; + items: WatchItem[]; +} + +const LIST_KEYS = ['schema', 'items']; +const ITEM_KEYS = [ + 'plid', + 'title', + 'addedAt', + 'target', + 'dropPercent', + 'referencePrice', + 'referenceDate', + 'lastCheckedAt', + 'lastPrice', + 'lastPriceDate', + 'lastStatus', + 'alertArmed', + 'lastAlert', +]; +const ALERT_KEYS = ['price', 'date', 'reasons']; +const REASONS = new Set(['target', 'drop', 'low']); +const STATUSES = new Set(['ok', 'not_tracked', 'stale', 'error']); + +export const watchlistPath = (): string => path.join(configDir(), 'watchlist.json'); +export const watchlistLockPath = (): string => path.join(configDir(), 'watchlist.lock'); + +const sameKeys = (value: Record, allowed: string[]): boolean => + Object.keys(value).length === allowed.length && Object.keys(value).every((key) => allowed.includes(key)); + +function isoTime(value: unknown, now: number): value is string { + if (typeof value !== 'string') return false; + const parsed = Date.parse(value); + return Number.isFinite(parsed) && parsed <= now && new Date(parsed).toISOString() === value; +} + +function dateOnly(value: unknown): value is string { + if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; + const parsed = Date.parse(`${value}T00:00:00.000Z`); + return Number.isFinite(parsed) && new Date(parsed).toISOString().slice(0, 10) === value; +} + +const positivePrice = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value) && value > 0 && value <= 10_000_000; + +const validTarget = (value: unknown): value is number => + positivePrice(value) && Math.abs(value * 100 - Math.round(value * 100)) < 1e-7; + +function invalid(rule: string): never { + throw new WatchError(`${watchlistPath()}: ${rule}`, 'watchlist_invalid'); +} + +function validateItem(value: unknown, index: number, now: number): WatchItem { + if (!value || typeof value !== 'object' || Array.isArray(value)) invalid(`items[${index}] must be an object`); + const item = value as Record; + if (!sameKeys(item, ITEM_KEYS)) invalid(`items[${index}] has missing or unknown keys`); + if (!Number.isSafeInteger(item.plid) || (item.plid as number) <= 0) invalid(`items[${index}].plid must be a positive safe integer`); + if (item.title !== null && (typeof item.title !== 'string' || item.title.length > 200)) invalid(`items[${index}].title must be null or at most 200 characters`); + if (!isoTime(item.addedAt, now)) invalid(`items[${index}].addedAt must be a non-future ISO 8601 UTC time`); + if (item.lastCheckedAt !== null && !isoTime(item.lastCheckedAt, now)) invalid(`items[${index}].lastCheckedAt must be null or a non-future ISO 8601 UTC time`); + if (item.referenceDate !== null && !dateOnly(item.referenceDate)) invalid(`items[${index}].referenceDate must be null or YYYY-MM-DD`); + if (item.lastPriceDate !== null && !dateOnly(item.lastPriceDate)) invalid(`items[${index}].lastPriceDate must be null or YYYY-MM-DD`); + if (item.target !== null && !validTarget(item.target)) invalid(`items[${index}].target must be null or a positive price with at most 2 decimals`); + if (!Number.isInteger(item.dropPercent) || (item.dropPercent as number) < 1 || (item.dropPercent as number) > 90) invalid(`items[${index}].dropPercent must be an integer from 1 to 90`); + if (item.referencePrice !== null && !positivePrice(item.referencePrice)) invalid(`items[${index}].referencePrice must be null or a positive price`); + if (item.lastPrice !== null && !positivePrice(item.lastPrice)) invalid(`items[${index}].lastPrice must be null or a positive price`); + if (item.lastStatus !== null && !STATUSES.has(item.lastStatus as WatchStatus)) invalid(`items[${index}].lastStatus is invalid`); + if (typeof item.alertArmed !== 'boolean') invalid(`items[${index}].alertArmed must be a boolean`); + if (item.lastAlert !== null) { + if (!item.lastAlert || typeof item.lastAlert !== 'object' || Array.isArray(item.lastAlert)) invalid(`items[${index}].lastAlert must be null or an object`); + const alert = item.lastAlert as Record; + if (!sameKeys(alert, ALERT_KEYS)) invalid(`items[${index}].lastAlert has missing or unknown keys`); + if (!positivePrice(alert.price)) invalid(`items[${index}].lastAlert.price must be a positive price`); + if (!dateOnly(alert.date)) invalid(`items[${index}].lastAlert.date must be YYYY-MM-DD`); + if (!Array.isArray(alert.reasons) || alert.reasons.length === 0 || alert.reasons.some((reason) => !REASONS.has(reason as WatchReason)) || new Set(alert.reasons).size !== alert.reasons.length) invalid(`items[${index}].lastAlert.reasons must contain unique alert reasons`); + } + if ((item.referencePrice === null) !== (item.referenceDate === null)) invalid(`items[${index}] must set referencePrice and referenceDate together`); + if ((item.lastPrice === null) !== (item.lastPriceDate === null)) invalid(`items[${index}] must set lastPrice and lastPriceDate together`); + return item as unknown as WatchItem; +} + +export function validateWatchlist(value: unknown, now = Date.now()): Watchlist { + if (!value || typeof value !== 'object' || Array.isArray(value)) invalid('root must be an object'); + const record = value as Record; + if (!sameKeys(record, LIST_KEYS)) invalid('root has missing or unknown keys'); + if (record.schema !== 1) invalid('schema must be 1'); + if (!Array.isArray(record.items)) invalid('items must be an array'); + if (record.items.length > 50) invalid('items must contain at most 50 products'); + const items = record.items.map((item, index) => validateItem(item, index, now)); + if (new Set(items.map((item) => item.plid)).size !== items.length) invalid('items must have unique plids'); + return { schema: 1, items }; +} + +export function loadWatchlist(now = Date.now()): Watchlist { + let text: string; + try { + text = fs.readFileSync(watchlistPath(), 'utf8'); + } catch (error: any) { + if (error?.code === 'ENOENT') return { schema: 1, items: [] }; + throw error; + } + try { + return validateWatchlist(JSON.parse(text), now); + } catch (error) { + if (error instanceof WatchError) throw error; + invalid('file must contain valid JSON'); + } +} + +export function saveWatchlist(watchlist: Watchlist): void { + atomicWriteJson(watchlistPath(), watchlist, 0o600); +} + +export async function withWatchlistLock( + fn: (lock: DirectoryLockHandle) => Promise, + timeoutMs = 10_000, +): Promise { + return withDirectoryLock( + watchlistLockPath(), + { + timeoutMs, + error: () => new WatchError(`another takealot process holds ${watchlistLockPath()}`, 'watchlist_locked'), + }, + fn, + ); +} + +export function newWatchItem( + plid: number, + addedAt: string, + target: number | null, + dropPercent: number, +): WatchItem { + return { + plid, + title: null, + addedAt, + target, + dropPercent, + referencePrice: null, + referenceDate: null, + lastCheckedAt: null, + lastPrice: null, + lastPriceDate: null, + lastStatus: null, + alertArmed: true, + lastAlert: null, + }; +}