diff --git a/package-lock.json b/package-lock.json index 3ba4c7ea..f36fc92e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@switchbot/openapi-cli", - "version": "2.1.0", + "version": "1.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@switchbot/openapi-cli", - "version": "2.1.0", + "version": "1.3.1", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/package.json b/package.json index 731bf876..10180d14 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@switchbot/openapi-cli", - "version": "1.3.0", + "version": "1.3.1", "description": "Command-line interface for SwitchBot API v1.1", "keywords": [ "switchbot", diff --git a/src/commands/batch.ts b/src/commands/batch.ts index 82fc5f5e..8c20ef39 100644 --- a/src/commands/batch.ts +++ b/src/commands/batch.ts @@ -1,4 +1,5 @@ import { Command } from 'commander'; +import type { AxiosInstance } from 'axios'; import { printJson, isJsonMode, handleError } from '../utils/output.js'; import { fetchDeviceList, @@ -6,9 +7,11 @@ import { isDestructiveCommand, buildHubLocationMap, } from '../lib/devices.js'; +import { createClient } from '../api/client.js'; import { parseFilter, applyFilter, FilterSyntaxError } from '../utils/filter.js'; import { isDryRun } from '../utils/flags.js'; import { DryRunSignal } from '../api/client.js'; +import { getCachedTypeMap } from '../devices/cache.js'; interface BatchResult { succeeded: Array<{ deviceId: string; result: unknown }>; @@ -58,7 +61,7 @@ async function resolveTargetIds(options: { filter?: string; ids?: string; readStdin: boolean; -}): Promise<{ ids: string[]; typeMap: Map }> { +}, getClient?: () => AxiosInstance): Promise<{ ids: string[]; typeMap: Map }> { const explicit: string[] = []; if (options.ids) { @@ -84,18 +87,14 @@ async function resolveTargetIds(options: { ); } - // Always fetch the device list so we can (a) apply --filter when present - // and (b) build a deviceId → deviceType map for destructive/validation - // checks regardless of how the ids were provided. - const body = await fetchDeviceList(); - const hubLoc = buildHubLocationMap(body.deviceList); - - const typeMap = new Map(); - for (const d of body.deviceList) if (d.deviceType) typeMap.set(d.deviceId, d.deviceType); - for (const ir of body.infraredRemoteList) typeMap.set(ir.deviceId, ir.remoteType); + const typeMap = getCachedTypeMap(explicit); let ids: string[]; if (hasFilter) { + const body = await fetchDeviceList(getClient?.()); + const hubLoc = buildHubLocationMap(body.deviceList); + for (const d of body.deviceList) if (d.deviceType) typeMap.set(d.deviceId, d.deviceType); + for (const ir of body.infraredRemoteList) typeMap.set(ir.deviceId, ir.remoteType); const clauses = parseFilter(options.filter); const matched = applyFilter(clauses, body.deviceList, body.infraredRemoteList, hubLoc); const filteredIds = new Set(matched.map((m) => m.deviceId)); @@ -103,6 +102,12 @@ async function resolveTargetIds(options: { explicit.length > 0 ? explicit.filter((id) => filteredIds.has(id)) : [...filteredIds]; } else { ids = explicit; + const missingTypeInfo = ids.some((id) => !typeMap.has(id)); + if (missingTypeInfo) { + const body = await fetchDeviceList(getClient?.()); + for (const d of body.deviceList) if (d.deviceType) typeMap.set(d.deviceId, d.deviceType); + for (const ir of body.infraredRemoteList) typeMap.set(ir.deviceId, ir.remoteType); + } } return { ids, typeMap }; @@ -167,6 +172,8 @@ Examples: // Trailing "-" sentinel selects stdin mode. const extra = commandObj.args ?? []; const readStdin = Boolean(options.stdin) || extra.includes('-'); + let client: AxiosInstance | undefined; + const getClient = (): AxiosInstance => (client ??= createClient()); let resolved: Awaited>; try { @@ -174,7 +181,7 @@ Examples: filter: options.filter, ids: options.ids, readStdin, - }); + }, getClient); } catch (error) { if (error instanceof FilterSyntaxError) { if (isJsonMode()) { @@ -259,7 +266,7 @@ Examples: const outcomes = await runPool(resolved.ids, concurrency, async (id) => { try { - const result = await executeCommand(id, cmd, parsedParam, effectiveType); + const result = await executeCommand(id, cmd, parsedParam, effectiveType, getClient()); if (!isJsonMode()) { console.log(`✓ ${id}: ${cmd}`); } diff --git a/src/commands/expand.ts b/src/commands/expand.ts index 738e9001..a7d95764 100644 --- a/src/commands/expand.ts +++ b/src/commands/expand.ts @@ -152,11 +152,16 @@ Examples: if (command === 'setAll') { parameter = buildAcSetAll(options); } else if (command === 'setPosition') { - const isBlind = deviceType === 'Blind Tilt'; + if (!cached) { + throw new UsageError( + `Device ${deviceId} is not in the local cache — run 'switchbot devices list' first so 'expand' knows whether this is a Curtain or a Blind Tilt.` + ); + } + const isBlind = deviceType.startsWith('Blind Tilt'); parameter = isBlind ? buildBlindTiltSetPosition(options) : buildCurtainSetPosition(options); - } else if (command === 'setMode' && (deviceType.startsWith('Relay Switch') || options.channel !== undefined)) { + } else if (command === 'setMode' && deviceType.startsWith('Relay Switch')) { parameter = buildRelaySetMode(options); } else { throw new UsageError( diff --git a/src/commands/plan.ts b/src/commands/plan.ts index d3e99837..cbbcaf22 100644 --- a/src/commands/plan.ts +++ b/src/commands/plan.ts @@ -54,6 +54,10 @@ const PLAN_JSON_SCHEMA = { { type: 'object', required: ['type', 'command'], + oneOf: [ + { required: ['deviceId'], not: { required: ['deviceName'] } }, + { required: ['deviceName'], not: { required: ['deviceId'] } }, + ], properties: { type: { const: 'command' }, deviceId: { type: 'string', minLength: 1 }, diff --git a/src/commands/watch.ts b/src/commands/watch.ts index 2241428c..250ed6b2 100644 --- a/src/commands/watch.ts +++ b/src/commands/watch.ts @@ -3,6 +3,7 @@ import { printJson, isJsonMode, handleError, UsageError } from '../utils/output. import { fetchDeviceStatus } from '../lib/devices.js'; import { getCachedDevice } from '../devices/cache.js'; import { parseDurationToMs, getFields } from '../utils/flags.js'; +import { createClient } from '../api/client.js'; const DEFAULT_INTERVAL_MS = 30_000; const MIN_INTERVAL_MS = 1_000; @@ -129,6 +130,7 @@ Examples: try { const prev = new Map>(); + const client = createClient(); let tick = 0; while (!ac.signal.aborted) { tick++; @@ -139,7 +141,7 @@ Examples: deviceIds.map(async (id) => { const cached = getCachedDevice(id); try { - const body = await fetchDeviceStatus(id); + const body = await fetchDeviceStatus(id, client); const changed = diff(prev.get(id), body, fields); prev.set(id, body); if (Object.keys(changed).length === 0 && !options.includeUnchanged) { diff --git a/src/devices/cache.ts b/src/devices/cache.ts index 79cf4ec8..e428ef09 100644 --- a/src/devices/cache.ts +++ b/src/devices/cache.ts @@ -54,12 +54,18 @@ function cacheFilePath(): string { // In-memory hot-cache: undefined = not yet loaded, null = loaded but empty. let _listCache: DeviceCache | null | undefined = undefined; +let _statusCache: StatusCache | undefined = undefined; /** Force the next loadCache() call to re-read from disk. Used in tests. */ export function resetListCache(): void { _listCache = undefined; } +/** Force the next loadStatusCache() call to re-read from disk. Used in tests. */ +export function resetStatusCache(): void { + _statusCache = undefined; +} + export function loadCache(): DeviceCache | null { if (_listCache !== undefined) return _listCache; const file = cacheFilePath(); @@ -88,6 +94,26 @@ export function getCachedDevice(deviceId: string): CachedDevice | null { return cache.devices[deviceId] ?? null; } +/** Build a deviceId -> type map from the metadata cache. */ +export function getCachedTypeMap(deviceIds?: Iterable): Map { + const cache = loadCache(); + const out = new Map(); + if (!cache) return out; + + if (deviceIds) { + for (const id of deviceIds) { + const entry = cache.devices[id]; + if (entry?.type) out.set(id, entry.type); + } + return out; + } + + for (const [deviceId, entry] of Object.entries(cache.devices)) { + if (entry.type) out.set(deviceId, entry.type); + } + return out; +} + export function updateCacheFromDeviceList(body: DeviceListBodyShape): void { const devices: Record = {}; @@ -184,21 +210,29 @@ function statusCacheFilePath(): string { } export function loadStatusCache(): StatusCache { + if (_statusCache !== undefined) return _statusCache; const file = statusCacheFilePath(); - if (!fs.existsSync(file)) return { entries: {} }; + if (!fs.existsSync(file)) { + _statusCache = { entries: {} }; + return _statusCache; + } try { const raw = fs.readFileSync(file, 'utf-8'); const parsed = JSON.parse(raw) as StatusCache; if (!parsed || typeof parsed.entries !== 'object' || parsed.entries === null) { - return { entries: {} }; + _statusCache = { entries: {} }; + return _statusCache; } + _statusCache = parsed; return parsed; } catch { - return { entries: {} }; + _statusCache = { entries: {} }; + return _statusCache; } } function saveStatusCache(cache: StatusCache): void { + _statusCache = cache; try { const file = statusCacheFilePath(); const dir = path.dirname(file); @@ -238,20 +272,22 @@ function evictExpiredStatusEntries(cache: StatusCache, ttlMs: number, now = Date export function setCachedStatus( deviceId: string, body: Record, - now = new Date() + now = new Date(), + ttlMsForGc = DEFAULT_STATUS_GC_TTL_MS ): void { const cache = loadStatusCache(); cache.entries[deviceId] = { fetchedAt: now.toISOString(), body, }; - evictExpiredStatusEntries(cache, DEFAULT_STATUS_GC_TTL_MS, now.getTime()); + evictExpiredStatusEntries(cache, ttlMsForGc, now.getTime()); saveStatusCache(cache); } export function clearStatusCache(): void { const file = statusCacheFilePath(); if (fs.existsSync(file)) fs.unlinkSync(file); + _statusCache = { entries: {} }; } /** Summary for `switchbot cache show`. */ diff --git a/src/devices/device-meta.ts b/src/devices/device-meta.ts index 497ea0ae..b2ec5250 100644 --- a/src/devices/device-meta.ts +++ b/src/devices/device-meta.ts @@ -32,7 +32,12 @@ export function loadDeviceMeta(): DeviceMetaFile { try { const raw = fs.readFileSync(file, 'utf-8'); const parsed = JSON.parse(raw) as DeviceMetaFile; - if (!parsed || typeof parsed.devices !== 'object' || parsed.devices === null) { + if ( + !parsed || + parsed.version !== '1' || + typeof parsed.devices !== 'object' || + parsed.devices === null + ) { return { version: '1', devices: {} }; } return parsed; @@ -45,7 +50,9 @@ export function saveDeviceMeta(meta: DeviceMetaFile): void { const file = metaFilePath(); const dir = path.dirname(file); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(file, JSON.stringify(meta, null, 2), { mode: 0o600 }); + const tmp = `${file}.tmp-${process.pid}`; + fs.writeFileSync(tmp, JSON.stringify(meta, null, 2), { mode: 0o600 }); + fs.renameSync(tmp, file); } export function getDeviceMeta(deviceId: string): DeviceMeta | null { diff --git a/src/lib/devices.ts b/src/lib/devices.ts index 1ae8e979..4a38fe6a 100644 --- a/src/lib/devices.ts +++ b/src/lib/devices.ts @@ -141,7 +141,7 @@ export async function fetchDeviceStatus( `/v1.1/devices/${deviceId}/status` ); if (mode.statusTtlMs > 0) { - setCachedStatus(deviceId, res.data.body); + setCachedStatus(deviceId, res.data.body, new Date(), mode.statusTtlMs); } return res.data.body; } diff --git a/src/utils/name-resolver.ts b/src/utils/name-resolver.ts index b5c6361f..532ed7c3 100644 --- a/src/utils/name-resolver.ts +++ b/src/utils/name-resolver.ts @@ -38,10 +38,10 @@ function resolveDeviceByName(query: string): NameResolveResult { // exact match if (rawName === q) return { ok: true, deviceId }; - // alias substring/fuzzy + // alias substring/fuzzy (only query ⊂ alias, not the reverse) if (alias) { const normAlias = normalizeDeviceName(alias); - if (normAlias.includes(q) || q.includes(normAlias)) { + if (normAlias.includes(q)) { candidates.push({ deviceId, name: device.name, score: 1 }); continue; } @@ -52,8 +52,9 @@ function resolveDeviceByName(query: string): NameResolveResult { } } - // name substring - if (rawName.includes(q) || q.includes(rawName)) { + // name substring (only query ⊂ name, not the reverse — avoids long queries + // collapsing onto short device names) + if (rawName.includes(q)) { candidates.push({ deviceId, name: device.name, score: 1 }); continue; } diff --git a/src/utils/quota.ts b/src/utils/quota.ts index b3121a6f..2a300a9c 100644 --- a/src/utils/quota.ts +++ b/src/utils/quota.ts @@ -38,6 +38,13 @@ export interface QuotaFile { } const MAX_RETAINED_DAYS = 7; +const FLUSH_DELAY_MS = 250; + +let quotaCache: QuotaFile | null = null; +let loadedPath: string | null = null; +let dirty = false; +let flushTimer: NodeJS.Timeout | null = null; +let flushHooksRegistered = false; function quotaFilePath(): string { return path.join(os.homedir(), '.switchbot', 'quota.json'); @@ -56,8 +63,7 @@ function emptyFile(): QuotaFile { return { days: {} }; } -export function loadQuota(): QuotaFile { - const file = quotaFilePath(); +function loadQuotaFromDisk(file: string): QuotaFile { if (!fs.existsSync(file)) return emptyFile(); try { const raw = fs.readFileSync(file, 'utf-8'); @@ -69,8 +75,7 @@ export function loadQuota(): QuotaFile { } } -function saveQuota(data: QuotaFile): void { - const file = quotaFilePath(); +function saveQuota(data: QuotaFile, file = quotaFilePath()): void { const dir = path.dirname(file); try { if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); @@ -80,6 +85,61 @@ function saveQuota(data: QuotaFile): void { } } +function clearScheduledFlush(): void { + if (flushTimer) { + clearTimeout(flushTimer); + flushTimer = null; + } +} + +function syncLoadedQuota(): QuotaFile { + const file = quotaFilePath(); + if (loadedPath !== file) { + clearScheduledFlush(); + quotaCache = loadQuotaFromDisk(file); + loadedPath = file; + dirty = false; + } + if (!quotaCache) { + quotaCache = loadQuotaFromDisk(file); + loadedPath = file; + } + return quotaCache; +} + +function ensureFlushHooks(): void { + if (flushHooksRegistered) return; + flushHooksRegistered = true; + + process.on('beforeExit', () => flushQuota()); + process.on('exit', () => flushQuota()); + // SIGINT/SIGTERM: attaching a listener suppresses Node's default terminate. + // Flush the counter, then re-raise the conventional exit code (128 + signo). + process.on('SIGINT', () => { + flushQuota(); + process.exit(130); + }); + process.on('SIGTERM', () => { + flushQuota(); + process.exit(143); + }); +} + +function scheduleFlush(): void { + dirty = true; + ensureFlushHooks(); + if (flushTimer) return; + flushTimer = setTimeout(() => { + flushTimer = null; + flushQuota(); + }, FLUSH_DELAY_MS); + flushTimer.unref?.(); +} + +export function loadQuota(): QuotaFile { + return syncLoadedQuota(); +} + function prune(data: QuotaFile): QuotaFile { const keys = Object.keys(data.days).sort(); if (keys.length <= MAX_RETAINED_DAYS) return data; @@ -123,15 +183,31 @@ export function normaliseEndpoint(method: string, url: string): string { export function recordRequest(method: string, url: string, now: Date = new Date()): void { const key = today(now); const endpoint = normaliseEndpoint(method, url); - const data = loadQuota(); + const data = syncLoadedQuota(); const bucket: DayBucket = data.days[key] ?? { total: 0, endpoints: {} }; bucket.total += 1; bucket.endpoints[endpoint] = (bucket.endpoints[endpoint] ?? 0) + 1; data.days[key] = bucket; + quotaCache = prune(data); + scheduleFlush(); +} + +export function flushQuota(): void { + if (!dirty) return; + const data = syncLoadedQuota(); saveQuota(prune(data)); + dirty = false; +} + +export function resetQuotaState(): void { + clearScheduledFlush(); + quotaCache = null; + loadedPath = null; + dirty = false; } export function resetQuota(): void { + resetQuotaState(); const file = quotaFilePath(); try { if (fs.existsSync(file)) fs.unlinkSync(file); diff --git a/tests/commands/batch.test.ts b/tests/commands/batch.test.ts index 33de6302..fd817474 100644 --- a/tests/commands/batch.test.ts +++ b/tests/commands/batch.test.ts @@ -29,10 +29,20 @@ vi.mock('../../src/api/client.js', () => ({ const cacheMock = vi.hoisted(() => ({ map: new Map(), getCachedDevice: vi.fn((id: string) => cacheMock.map.get(id) ?? null), + getCachedTypeMap: vi.fn((ids?: Iterable) => { + const out = new Map(); + if (!ids) return out; + for (const id of ids) { + const entry = cacheMock.map.get(id); + if (entry?.type) out.set(id, entry.type); + } + return out; + }), updateCacheFromDeviceList: vi.fn(), })); vi.mock('../../src/devices/cache.js', () => ({ getCachedDevice: cacheMock.getCachedDevice, + getCachedTypeMap: cacheMock.getCachedTypeMap, updateCacheFromDeviceList: cacheMock.updateCacheFromDeviceList, loadCache: vi.fn(() => null), clearCache: vi.fn(), @@ -41,6 +51,7 @@ vi.mock('../../src/devices/cache.js', () => ({ getCachedStatus: vi.fn(() => null), setCachedStatus: vi.fn(), clearStatusCache: vi.fn(), + resetStatusCache: vi.fn(), loadStatusCache: vi.fn(() => ({ entries: {} })), describeCache: vi.fn(() => ({ list: { path: '', exists: false }, @@ -103,8 +114,10 @@ describe('devices batch', () => { beforeEach(() => { apiMock.__instance.get.mockReset(); apiMock.__instance.post.mockReset(); + apiMock.createClient.mockClear(); cacheMock.map.clear(); cacheMock.getCachedDevice.mockClear(); + cacheMock.getCachedTypeMap.mockClear(); flagsMock.dryRun = false; }); @@ -170,6 +183,26 @@ describe('devices batch', () => { expect(parsed.summary.total).toBe(2); }); + it('uses cached type info for --ids without fetching the device list', async () => { + cacheMock.map.set('BOT1', { type: 'Bot', name: 'Kitchen', category: 'physical' }); + cacheMock.map.set('BOT2', { type: 'Bot', name: 'Office', category: 'physical' }); + apiMock.__instance.post.mockResolvedValue({ data: { statusCode: 100, body: {} } }); + + const result = await runCli(registerDevicesCommand, [ + '--json', + 'devices', + 'batch', + 'turnOn', + '--ids', + 'BOT1,BOT2', + ]); + + expect(result.exitCode).toBeNull(); + expect(apiMock.__instance.get).not.toHaveBeenCalled(); + expect(apiMock.createClient).toHaveBeenCalledTimes(1); + expect(apiMock.__instance.post).toHaveBeenCalledTimes(2); + }); + it('surfaces partial failures in the failed[] array and exits 1', async () => { apiMock.__instance.get.mockResolvedValue({ data: { statusCode: 100, body: DEVICE_LIST_BODY } }); // BOT1 succeeds, BOT2 fails. diff --git a/tests/commands/cache.test.ts b/tests/commands/cache.test.ts index 752a5b37..094acc2d 100644 --- a/tests/commands/cache.test.ts +++ b/tests/commands/cache.test.ts @@ -8,6 +8,7 @@ import { updateCacheFromDeviceList, setCachedStatus, resetListCache, + resetStatusCache, } from '../../src/devices/cache.js'; import { runCli } from '../helpers/cli.js'; @@ -17,11 +18,13 @@ beforeEach(() => { tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sbcli-cachecmd-')); vi.spyOn(os, 'homedir').mockReturnValue(tmpHome); resetListCache(); + resetStatusCache(); }); afterEach(() => { vi.restoreAllMocks(); resetListCache(); + resetStatusCache(); try { fs.rmSync(tmpHome, { recursive: true, force: true }); } catch { diff --git a/tests/commands/quota.test.ts b/tests/commands/quota.test.ts index 3edf2b7a..f17edd5f 100644 --- a/tests/commands/quota.test.ts +++ b/tests/commands/quota.test.ts @@ -23,10 +23,11 @@ afterEach(() => { async function seedQuota(): Promise { // Write a quota file with a couple of entries on today's date. - const { recordRequest } = await import('../../src/utils/quota.js'); + const { recordRequest, flushQuota } = await import('../../src/utils/quota.js'); recordRequest('GET', 'https://api.switch-bot.com/v1.1/devices'); recordRequest('GET', 'https://api.switch-bot.com/v1.1/devices'); recordRequest('POST', 'https://api.switch-bot.com/v1.1/devices/ABC/commands'); + flushQuota(); } describe('quota command', () => { diff --git a/tests/commands/watch.test.ts b/tests/commands/watch.test.ts index f7b12153..30146417 100644 --- a/tests/commands/watch.test.ts +++ b/tests/commands/watch.test.ts @@ -28,10 +28,12 @@ const cacheMock = vi.hoisted(() => ({ map: new Map(), getCachedDevice: vi.fn((id: string) => cacheMock.map.get(id) ?? null), updateCacheFromDeviceList: vi.fn(), + getCachedTypeMap: vi.fn(() => new Map()), })); vi.mock('../../src/devices/cache.js', () => ({ getCachedDevice: cacheMock.getCachedDevice, + getCachedTypeMap: cacheMock.getCachedTypeMap, updateCacheFromDeviceList: cacheMock.updateCacheFromDeviceList, loadCache: vi.fn(() => null), clearCache: vi.fn(), @@ -40,6 +42,7 @@ vi.mock('../../src/devices/cache.js', () => ({ getCachedStatus: vi.fn(() => null), setCachedStatus: vi.fn(), clearStatusCache: vi.fn(), + resetStatusCache: vi.fn(), loadStatusCache: vi.fn(() => ({ entries: {} })), describeCache: vi.fn(() => ({ list: { path: '', exists: false }, @@ -81,6 +84,7 @@ describe('devices watch', () => { beforeEach(() => { apiMock.__instance.get.mockReset(); apiMock.__instance.post.mockReset(); + apiMock.createClient.mockClear(); cacheMock.map.clear(); cacheMock.getCachedDevice.mockClear(); // Make sleep near-instant so --max exits the loop quickly. @@ -122,6 +126,7 @@ describe('devices watch', () => { expect(ev.tick).toBe(1); expect(ev.changed.power).toEqual({ from: null, to: 'on' }); expect(ev.changed.battery).toEqual({ from: null, to: 90 }); + expect(apiMock.createClient).toHaveBeenCalledTimes(1); }); it('only emits changed fields on subsequent ticks', async () => { diff --git a/tests/devices/cache.test.ts b/tests/devices/cache.test.ts index 804535fe..bc7fe8fb 100644 --- a/tests/devices/cache.test.ts +++ b/tests/devices/cache.test.ts @@ -16,6 +16,7 @@ import { clearStatusCache, describeCache, resetListCache, + resetStatusCache, } from '../../src/devices/cache.js'; // Redirect the cache to a test-only temp directory by overriding both @@ -27,11 +28,13 @@ beforeEach(() => { vi.spyOn(os, 'homedir').mockReturnValue(tmpDir); process.argv = ['node', 'switchbot']; resetListCache(); + resetStatusCache(); }); afterEach(() => { vi.restoreAllMocks(); resetListCache(); + resetStatusCache(); fs.rmSync(tmpDir, { recursive: true, force: true }); }); @@ -246,6 +249,28 @@ describe('status cache', () => { fs.writeFileSync(path.join(dir, 'status.json'), '{not json'); expect(loadStatusCache()).toEqual({ entries: {} }); }); + + it('loadStatusCache serves hot-cache after the first read', () => { + setCachedStatus('BOT1', { power: 'on' }); + const file = path.join(tmpDir, '.switchbot', 'status.json'); + expect(loadStatusCache().entries.BOT1?.body).toEqual({ power: 'on' }); + + fs.writeFileSync( + file, + JSON.stringify({ + entries: { + BOT1: { + fetchedAt: new Date().toISOString(), + body: { power: 'off' }, + }, + }, + }), + ); + + expect(loadStatusCache().entries.BOT1?.body).toEqual({ power: 'on' }); + resetStatusCache(); + expect(loadStatusCache().entries.BOT1?.body).toEqual({ power: 'off' }); + }); }); describe('describeCache', () => { diff --git a/tests/utils/quota.test.ts b/tests/utils/quota.test.ts index 6d9558d3..9bb8fa30 100644 --- a/tests/utils/quota.test.ts +++ b/tests/utils/quota.test.ts @@ -13,8 +13,10 @@ beforeEach(() => { vi.spyOn(os, 'homedir').mockReturnValue(tmpRoot); }); -afterEach(() => { +afterEach(async () => { vi.restoreAllMocks(); + const { resetQuotaState } = await importQuota(); + resetQuotaState(); try { fs.rmSync(tmpRoot, { recursive: true, force: true }); } catch { @@ -79,10 +81,11 @@ describe('recordRequest + todayUsage', () => { }); it('increments per call and writes to ~/.switchbot/quota.json', async () => { - const { recordRequest, todayUsage } = await importQuota(); + const { recordRequest, todayUsage, flushQuota } = await importQuota(); recordRequest('GET', 'https://api.switch-bot.com/v1.1/devices'); recordRequest('GET', 'https://api.switch-bot.com/v1.1/devices'); recordRequest('POST', 'https://api.switch-bot.com/v1.1/devices/DEAD1234/commands'); + flushQuota(); const u = todayUsage(); expect(u.total).toBe(3); expect(u.endpoints['GET /v1.1/devices']).toBe(2); @@ -100,13 +103,14 @@ describe('recordRequest + todayUsage', () => { }); it('retains at most 7 days of history', async () => { - const { recordRequest, loadQuota } = await importQuota(); + const { recordRequest, loadQuota, flushQuota } = await importQuota(); const base = new Date('2026-04-10T12:00:00'); for (let i = 0; i < 10; i++) { const d = new Date(base); d.setDate(base.getDate() + i); recordRequest('GET', 'https://api.switch-bot.com/v1.1/devices', d); } + flushQuota(); const data = loadQuota(); expect(Object.keys(data.days).length).toBe(7); }); @@ -120,4 +124,10 @@ describe('recordRequest + todayUsage', () => { recordRequest('GET', 'https://api.switch-bot.com/v1.1/devices'); expect(todayUsage().total).toBe(1); }); + + it('keeps pending usage visible before the debounced flush runs', async () => { + const { recordRequest, todayUsage } = await importQuota(); + recordRequest('GET', 'https://api.switch-bot.com/v1.1/devices'); + expect(todayUsage().total).toBe(1); + }); });