Expand to both parking pass items
This commit is contained in:
@ -1,6 +1,6 @@
|
|||||||
# Library Availability Monitor
|
# Library Availability Monitor
|
||||||
|
|
||||||
A Node.js service that monitors San Diego libraries for parking pass availability and sends notifications via ntfy when passes become available at the Rancho Penasquitos branch.
|
A Node.js service that monitors San Diego libraries for parking pass availability and sends notifications via ntfy when passes become available at the desired branch.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
|||||||
231
index.js
231
index.js
@ -5,52 +5,97 @@ import path from 'path';
|
|||||||
|
|
||||||
// Configuration
|
// Configuration
|
||||||
const CONFIG = {
|
const CONFIG = {
|
||||||
API_URL: 'https://gateway.bibliocommons.com/v2/libraries/sandiego/bibs/S161C1805116/availability',
|
// Items to monitor - each item has its own API endpoint
|
||||||
|
ITEMS: [
|
||||||
|
{
|
||||||
|
id: 'S161C1805116',
|
||||||
|
name: 'Hiking Backpack with Park Pass',
|
||||||
|
description: 'CA State Library Parks Pass Hiking Backpack',
|
||||||
|
url: 'https://gateway.bibliocommons.com/v2/libraries/sandiego/bibs/S161C1805116/availability'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'S161C1690437',
|
||||||
|
name: 'Park Pass Only',
|
||||||
|
description: 'CA State Library Parks Pass',
|
||||||
|
url: 'https://gateway.bibliocommons.com/v2/libraries/sandiego/bibs/S161C1690437/availability'
|
||||||
|
}
|
||||||
|
],
|
||||||
BRANCH_NAME: 'Rancho Penasquitos',
|
BRANCH_NAME: 'Rancho Penasquitos',
|
||||||
NTFY_TOPIC: 'sdparkpass', // Change this to your preferred topic
|
NTFY_TOPIC: 'sdparkpass', // Change this to your preferred topic
|
||||||
NTFY_SERVER: 'https://ntfy.sh', // Change this if using your own ntfy server
|
NTFY_SERVER: 'https://ntfy.sh', // Change this if using your own ntfy server
|
||||||
STATE_FILE: path.join(process.cwd(), 'last_availability.json'),
|
STATE_FILE: path.join(process.cwd(), 'last_availability.json'),
|
||||||
POLL_INTERVAL: '0 * * * *', // Every hour at minute 0
|
POLL_INTERVAL: '0 9-20/1 * * 1-6', // Every hour between 9 AM and 8 PM, Monday to Saturday
|
||||||
USER_AGENT: 'SD Park Pass Monitor/1.0'
|
USER_AGENT: 'SD Park Pass Monitor/1.0'
|
||||||
};
|
};
|
||||||
|
|
||||||
// Merge in any overrides from config.js
|
// Try to merge in any overrides from config.js if it exists
|
||||||
import { CONFIG_OVERRIDE } from './config.js';
|
try {
|
||||||
Object.assign(CONFIG, CONFIG_OVERRIDE);
|
const { CONFIG_OVERRIDE } = await import('./config.js');
|
||||||
|
Object.assign(CONFIG, CONFIG_OVERRIDE);
|
||||||
|
} catch (error) {
|
||||||
|
// config.js doesn't exist or has no overrides - continue with defaults
|
||||||
|
console.log('No config.js found, using default configuration');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads the last known availability count from the state file
|
* Loads the last known availability counts from the state file
|
||||||
* @returns {number} The last availability count, or 0 if file doesn't exist
|
* @returns {Object} Object with item IDs as keys and counts as values
|
||||||
*/
|
*/
|
||||||
function loadLastAvailabilityCount() {
|
function loadLastAvailabilityCounts() {
|
||||||
try {
|
try {
|
||||||
if (fs.existsSync(CONFIG.STATE_FILE)) {
|
if (fs.existsSync(CONFIG.STATE_FILE)) {
|
||||||
const data = JSON.parse(fs.readFileSync(CONFIG.STATE_FILE, 'utf8'));
|
const data = JSON.parse(fs.readFileSync(CONFIG.STATE_FILE, 'utf8'));
|
||||||
return data.availabilityCount || 0;
|
// Support both old single-item format and new multi-item format
|
||||||
|
if (data.availabilityCount !== undefined) {
|
||||||
|
// Old format - assume it's for the backpack item
|
||||||
|
return { 'S161C1805116': data.availabilityCount || 0 };
|
||||||
|
}
|
||||||
|
return data.itemCounts || {};
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error reading state file:', error.message);
|
console.error('Error reading state file:', error.message);
|
||||||
}
|
}
|
||||||
return 0;
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Saves the current availability count to the state file
|
* Saves the current availability counts to the state file
|
||||||
|
* @param {Object} itemCounts - Object with item IDs as keys and counts as values
|
||||||
|
* @param {Object} metadata - Additional metadata to save
|
||||||
|
*/
|
||||||
|
function saveAvailabilityCounts(itemCounts, metadata = {}) {
|
||||||
|
try {
|
||||||
|
const data = {
|
||||||
|
itemCounts,
|
||||||
|
lastUpdated: new Date().toISOString(),
|
||||||
|
branch: CONFIG.BRANCH_NAME,
|
||||||
|
...metadata
|
||||||
|
};
|
||||||
|
fs.writeFileSync(CONFIG.STATE_FILE, JSON.stringify(data, null, 2));
|
||||||
|
console.log(`Saved availability counts:`, itemCounts);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving state file:', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy function for backward compatibility
|
||||||
|
* @returns {number} Total count across all items
|
||||||
|
*/
|
||||||
|
function loadLastAvailabilityCount() {
|
||||||
|
const counts = loadLastAvailabilityCounts();
|
||||||
|
return Object.values(counts).reduce((sum, count) => sum + count, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy function for backward compatibility
|
||||||
* @param {number} count - The current availability count
|
* @param {number} count - The current availability count
|
||||||
* @param {Object} metadata - Additional metadata to save
|
* @param {Object} metadata - Additional metadata to save
|
||||||
*/
|
*/
|
||||||
function saveAvailabilityCount(count, metadata = {}) {
|
function saveAvailabilityCount(count, metadata = {}) {
|
||||||
try {
|
// For backward compatibility, save as total count for backpack item
|
||||||
const data = {
|
const itemCounts = { 'S161C1805116': count };
|
||||||
availabilityCount: count,
|
saveAvailabilityCounts(itemCounts, metadata);
|
||||||
lastUpdated: new Date().toISOString(),
|
|
||||||
...metadata
|
|
||||||
};
|
|
||||||
fs.writeFileSync(CONFIG.STATE_FILE, JSON.stringify(data, null, 2));
|
|
||||||
console.log(`Saved availability count: ${count}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving state file:', error.message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -64,6 +109,7 @@ async function sendNotification(title, message, priority = 'default') {
|
|||||||
const response = await fetch(`${CONFIG.NTFY_SERVER}/${CONFIG.NTFY_TOPIC}`, {
|
const response = await fetch(`${CONFIG.NTFY_SERVER}/${CONFIG.NTFY_TOPIC}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
|
'Content-Type': 'text/plain',
|
||||||
'Title': title,
|
'Title': title,
|
||||||
'Priority': priority,
|
'Priority': priority,
|
||||||
'Tags': 'park,pass,library',
|
'Tags': 'park,pass,library',
|
||||||
@ -101,7 +147,7 @@ function countAvailableItems(apiResponse) {
|
|||||||
for (const itemId in bibItems) {
|
for (const itemId in bibItems) {
|
||||||
const item = bibItems[itemId];
|
const item = bibItems[itemId];
|
||||||
|
|
||||||
// Check if this is the Rancho Penasquitos branch
|
// Check if this is the desired branch
|
||||||
if (item.branchName?.includes(CONFIG.BRANCH_NAME)) {
|
if (item.branchName?.includes(CONFIG.BRANCH_NAME)) {
|
||||||
// Check if item is available (statusType is not "UNAVAILABLE")
|
// Check if item is available (statusType is not "UNAVAILABLE")
|
||||||
if (item.availability?.statusType &&
|
if (item.availability?.statusType &&
|
||||||
@ -129,14 +175,15 @@ function countAvailableItems(apiResponse) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches availability data from the library API
|
* Fetches availability data for a specific item from the library API
|
||||||
|
* @param {Object} item - Item configuration object
|
||||||
* @returns {Object|null} The API response or null if failed
|
* @returns {Object|null} The API response or null if failed
|
||||||
*/
|
*/
|
||||||
async function fetchAvailabilityData() {
|
async function fetchAvailabilityDataForItem(item) {
|
||||||
try {
|
try {
|
||||||
console.log(`Fetching availability data from: ${CONFIG.API_URL}`);
|
console.log(`Fetching availability data for ${item.name} from: ${item.url}`);
|
||||||
|
|
||||||
const response = await fetch(CONFIG.API_URL, {
|
const response = await fetch(item.url, {
|
||||||
headers: {
|
headers: {
|
||||||
'User-Agent': CONFIG.USER_AGENT,
|
'User-Agent': CONFIG.USER_AGENT,
|
||||||
'Accept': 'application/json'
|
'Accept': 'application/json'
|
||||||
@ -148,56 +195,112 @@ async function fetchAvailabilityData() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
console.log('Successfully fetched availability data');
|
console.log(`Successfully fetched availability data for ${item.name}`);
|
||||||
return data;
|
return data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching availability data:', error.message);
|
console.error(`Error fetching availability data for ${item.name}:`, error.message);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main monitoring function that checks availability and sends notifications
|
* Fetches availability data for all configured items
|
||||||
|
* @returns {Object} Object with item IDs as keys and API responses as values
|
||||||
|
*/
|
||||||
|
async function fetchAllAvailabilityData() {
|
||||||
|
const results = {};
|
||||||
|
|
||||||
|
for (const item of CONFIG.ITEMS) {
|
||||||
|
const data = await fetchAvailabilityDataForItem(item);
|
||||||
|
results[item.id] = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy function for backward compatibility
|
||||||
|
* @returns {Object|null} The API response for the backpack item or null if failed
|
||||||
|
*/
|
||||||
|
async function fetchAvailabilityData() {
|
||||||
|
const backpackItem = CONFIG.ITEMS.find(item => item.id === 'S161C1805116');
|
||||||
|
if (backpackItem) {
|
||||||
|
return await fetchAvailabilityDataForItem(backpackItem);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main monitoring function that checks availability for all items and sends notifications
|
||||||
*/
|
*/
|
||||||
async function checkAvailability() {
|
async function checkAvailability() {
|
||||||
console.log('\n--- Starting availability check ---');
|
console.log('\n--- Starting availability check for all items ---');
|
||||||
console.log(`Time: ${new Date().toISOString()}`);
|
console.log(`Time: ${new Date().toISOString()}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch current availability data
|
// Fetch current availability data for all items
|
||||||
const apiResponse = await fetchAvailabilityData();
|
const allApiResponses = await fetchAllAvailabilityData();
|
||||||
if (!apiResponse) {
|
|
||||||
console.log('Failed to fetch data, skipping this check');
|
// Count currently available items for each type
|
||||||
return;
|
const currentCounts = {};
|
||||||
|
let totalCurrentCount = 0;
|
||||||
|
|
||||||
|
for (const item of CONFIG.ITEMS) {
|
||||||
|
const apiResponse = allApiResponses[item.id];
|
||||||
|
if (apiResponse) {
|
||||||
|
const count = countAvailableItems(apiResponse);
|
||||||
|
currentCounts[item.id] = count;
|
||||||
|
totalCurrentCount += count;
|
||||||
|
console.log(`Current available ${item.name} at ${CONFIG.BRANCH_NAME}: ${count}`);
|
||||||
|
} else {
|
||||||
|
console.log(`Failed to fetch data for ${item.name}, using 0 count`);
|
||||||
|
currentCounts[item.id] = 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count currently available items
|
// Load previous counts
|
||||||
const currentCount = countAvailableItems(apiResponse);
|
const previousCounts = loadLastAvailabilityCounts();
|
||||||
console.log(`Current available items at ${CONFIG.BRANCH_NAME}: ${currentCount}`);
|
const totalPreviousCount = Object.values(previousCounts).reduce((sum, count) => sum + count, 0);
|
||||||
|
console.log(`Previous available items: ${totalPreviousCount} (${JSON.stringify(previousCounts)})`);
|
||||||
|
|
||||||
// Load previous count
|
// Check for changes and send notifications
|
||||||
const previousCount = loadLastAvailabilityCount();
|
const notifications = [];
|
||||||
console.log(`Previous available items: ${previousCount}`);
|
let hasChanges = false;
|
||||||
|
|
||||||
// Check if availability increased (new items became available)
|
for (const item of CONFIG.ITEMS) {
|
||||||
if (currentCount > previousCount) {
|
const currentCount = currentCounts[item.id] || 0;
|
||||||
const newItems = currentCount - previousCount;
|
const previousCount = previousCounts[item.id] || 0;
|
||||||
const title = 'Parking Pass Available!';
|
|
||||||
const message = `${newItems} new parking pass(es) became available at ${CONFIG.BRANCH_NAME} branch. Total available: ${currentCount}`;
|
if (currentCount > previousCount) {
|
||||||
|
const newItems = currentCount - previousCount;
|
||||||
|
notifications.push(`${newItems} ${item.name} became available`);
|
||||||
|
hasChanges = true;
|
||||||
|
console.log(`📈 ${item.name}: +${newItems} (${previousCount} → ${currentCount})`);
|
||||||
|
} else if (currentCount < previousCount) {
|
||||||
|
const lostItems = previousCount - currentCount;
|
||||||
|
console.log(`📉 ${item.name}: -${lostItems} (${previousCount} → ${currentCount})`);
|
||||||
|
hasChanges = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notifications.length > 0) {
|
||||||
|
const title = 'SD Park Pass Available!';
|
||||||
|
const message = `${notifications.join(' and ')} at ${CONFIG.BRANCH_NAME} branch. Total available: ${totalCurrentCount}`;
|
||||||
|
|
||||||
console.log(`📢 Sending notification: ${message}`);
|
console.log(`📢 Sending notification: ${message}`);
|
||||||
await sendNotification(title, message, 'high');
|
await sendNotification(title, message, 'high');
|
||||||
} else if (currentCount < previousCount) {
|
} else if (hasChanges) {
|
||||||
console.log(`Availability decreased by ${previousCount - currentCount} parking pass(es)`);
|
console.log('Items became unavailable (no notification sent)');
|
||||||
} else {
|
} else {
|
||||||
console.log('No change in availability');
|
console.log('No change in availability');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save current state
|
// Save current state
|
||||||
saveAvailabilityCount(currentCount, {
|
saveAvailabilityCounts(currentCounts, {
|
||||||
previousCount,
|
previousCounts,
|
||||||
branch: CONFIG.BRANCH_NAME,
|
totalCurrent: totalCurrentCount,
|
||||||
apiUrl: CONFIG.API_URL
|
totalPrevious: totalPreviousCount,
|
||||||
|
branch: CONFIG.BRANCH_NAME
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -219,15 +322,18 @@ async function checkAvailability() {
|
|||||||
*/
|
*/
|
||||||
async function initialize() {
|
async function initialize() {
|
||||||
console.log('🚀 SD Park Pass Monitor Starting...');
|
console.log('🚀 SD Park Pass Monitor Starting...');
|
||||||
console.log(`Monitoring: ${CONFIG.API_URL}`);
|
console.log(`Monitoring ${CONFIG.ITEMS.length} item types:`);
|
||||||
|
CONFIG.ITEMS.forEach(item => {
|
||||||
|
console.log(` - ${item.name} (${item.id})`);
|
||||||
|
});
|
||||||
console.log(`Branch: ${CONFIG.BRANCH_NAME}`);
|
console.log(`Branch: ${CONFIG.BRANCH_NAME}`);
|
||||||
console.log(`Ntfy Topic: ${CONFIG.NTFY_TOPIC}`);
|
console.log(`Ntfy Topic: ${CONFIG.NTFY_TOPIC}`);
|
||||||
console.log(`Poll Interval: ${CONFIG.POLL_INTERVAL}`);
|
console.log(`Poll Interval: ${CONFIG.POLL_INTERVAL} (hourly)`);
|
||||||
|
|
||||||
// Send startup notification
|
// Send startup notification
|
||||||
await sendNotification(
|
await sendNotification(
|
||||||
'SD Park Pass Monitor Started',
|
'SD Park Pass Monitor Started',
|
||||||
`Now monitoring ${CONFIG.BRANCH_NAME} branch for park pass availability`,
|
`Now monitoring ${CONFIG.ITEMS.length} park pass types at ${CONFIG.BRANCH_NAME} branch`,
|
||||||
'low'
|
'low'
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -278,4 +384,15 @@ if (import.meta.url === `file://${process.argv[1]}`) {
|
|||||||
initialize().catch(console.error);
|
initialize().catch(console.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { checkAvailability, sendNotification, CONFIG, countAvailableItems, loadLastAvailabilityCount, saveAvailabilityCount };
|
export {
|
||||||
|
checkAvailability,
|
||||||
|
sendNotification,
|
||||||
|
CONFIG,
|
||||||
|
countAvailableItems,
|
||||||
|
loadLastAvailabilityCount,
|
||||||
|
saveAvailabilityCount,
|
||||||
|
loadLastAvailabilityCounts,
|
||||||
|
saveAvailabilityCounts,
|
||||||
|
fetchAllAvailabilityData,
|
||||||
|
fetchAvailabilityDataForItem
|
||||||
|
};
|
||||||
|
|||||||
376
mock.js
376
mock.js
@ -1,99 +1,119 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
import { sendNotification, CONFIG, countAvailableItems, saveAvailabilityCount, loadLastAvailabilityCount } from './index.js';
|
// Mock script for testing SD Park Pass Monitor with multi-item support
|
||||||
|
// This script simulates API responses and can test notifications
|
||||||
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
import fetch from 'node-fetch';
|
||||||
|
|
||||||
console.log('🎭 SD Park Pass Monitor Mock Script');
|
// Use the same configuration as the main script
|
||||||
console.log('This script simulates park pass availability changes for testing\n');
|
const CONFIG = {
|
||||||
|
API_URL: 'https://sandiego.bibliocommons.com/v2/search',
|
||||||
|
ITEMS: [
|
||||||
|
{
|
||||||
|
id: 'S161C1805116',
|
||||||
|
name: 'Hiking Backpack with Park Pass',
|
||||||
|
query: 'formattype:(KIT ) AND keyword:(backpack park pass) AND available:true'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'S161C1690437',
|
||||||
|
name: 'Pure Parking Pass',
|
||||||
|
query: 'formattype:(KIT ) AND keyword:(pure) AND available:true'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
BRANCH_NAME: 'Rancho Penasquitos',
|
||||||
|
NTFY_TOPIC: 'sdparkpass', // Use same topic as main app
|
||||||
|
NTFY_SERVER: 'https://ntfy.sh', // Use same server as main app
|
||||||
|
STATE_FILE: './park-pass-state.json',
|
||||||
|
CHECK_INTERVAL: '*/5 * * * *' // Every 5 minutes
|
||||||
|
};
|
||||||
|
|
||||||
// Create mock API response
|
// Import shared functions from the main script
|
||||||
function createMockApiResponse(availableCount) {
|
async function sendNotification(title, message, priority = 'default') {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${CONFIG.NTFY_SERVER}/${CONFIG.NTFY_TOPIC}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/plain',
|
||||||
|
'Title': title,
|
||||||
|
'Priority': priority,
|
||||||
|
'Tags': 'books,library,park-pass'
|
||||||
|
},
|
||||||
|
body: message
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ Notification sent: ${title}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ Failed to send notification: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function countAvailableItemsInResponse(apiResponse, itemId) {
|
||||||
|
if (!apiResponse?.entities?.bibItems) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = Object.values(apiResponse.entities.bibItems);
|
||||||
|
|
||||||
|
return items.filter(item => {
|
||||||
|
// Check if this item matches the ID we're looking for
|
||||||
|
if (item.id !== itemId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's available and at the correct branch
|
||||||
|
return item.availability?.statusType === 'AVAILABLE' &&
|
||||||
|
item.branchName === CONFIG.BRANCH_NAME;
|
||||||
|
}).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countAllAvailableItems(apiResponse) {
|
||||||
|
const counts = {};
|
||||||
|
for (const item of CONFIG.ITEMS) {
|
||||||
|
counts[item.id] = countAvailableItemsInResponse(apiResponse, item.id);
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create mock API response with specified item counts
|
||||||
|
function createMockApiResponse(itemCounts) {
|
||||||
const bibItems = {};
|
const bibItems = {};
|
||||||
|
let itemCounter = 1;
|
||||||
|
|
||||||
// Add the specified number of available items for Rancho Penasquitos
|
// Create mock items for each type
|
||||||
for (let i = 0; i < availableCount; i++) {
|
for (const item of CONFIG.ITEMS) {
|
||||||
const itemId = `1805116|31336107103179||${76 + i}`;
|
const count = itemCounts[item.id] || 0;
|
||||||
const isAvailable = i % 2 === 0;
|
|
||||||
|
|
||||||
bibItems[itemId] = {
|
for (let i = 0; i < count; i++) {
|
||||||
"collection": "Adult - Circulation Desk",
|
const mockId = `mock_${item.id}_${i + 1}`;
|
||||||
"callNumber": "CA STATE LIBRARY PARKS PASS HIKING BACKPACK",
|
bibItems[mockId] = {
|
||||||
"itemId": itemId,
|
id: item.id,
|
||||||
"copy": null,
|
title: item.name,
|
||||||
"volume": null,
|
author: "San Diego Public Library",
|
||||||
"branch": {
|
callNumber: {
|
||||||
"name": "Rancho Penasquitos",
|
"code": "7"
|
||||||
"code": "29"
|
},
|
||||||
},
|
inSiteScope: true,
|
||||||
"inSiteScope": true,
|
availability: {
|
||||||
"availability": {
|
status: "AVAILABLE",
|
||||||
"status": isAvailable ? "AVAILABLE" : "RECENTLY_RETURNED",
|
circulationType: "NON_CIRCULATING",
|
||||||
"circulationType": "NON_CIRCULATING",
|
libraryUseOnly: false,
|
||||||
"libraryUseOnly": false,
|
libraryStatus: "Available",
|
||||||
"libraryStatus": isAvailable ? "Available" : "Recently returned",
|
group: "AVAILABLE_ITEMS",
|
||||||
"group": "AVAILABLE_ITEMS",
|
statusType: "AVAILABLE"
|
||||||
"statusType": isAvailable ? "AVAILABLE" : "RECENTLY_RETURNED"
|
},
|
||||||
},
|
branchName: CONFIG.BRANCH_NAME,
|
||||||
"branchName": "Rancho Penasquitos",
|
local: false,
|
||||||
"local": false,
|
requestFormUrl: null
|
||||||
"requestFormUrl": null
|
};
|
||||||
};
|
itemCounter++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add some checked out items for Rancho Penasquitos to make it realistic
|
|
||||||
for (let i = availableCount; i < availableCount + 2; i++) {
|
|
||||||
const itemId = `1805116|31336107103179||${76 + i}`;
|
|
||||||
bibItems[itemId] = {
|
|
||||||
"collection": "Adult - Circulation Desk",
|
|
||||||
"callNumber": "CA STATE LIBRARY PARKS PASS HIKING BACKPACK",
|
|
||||||
"itemId": itemId,
|
|
||||||
"copy": null,
|
|
||||||
"volume": null,
|
|
||||||
"dueDate": "2025-07-22",
|
|
||||||
"branch": {
|
|
||||||
"name": "Rancho Penasquitos",
|
|
||||||
"code": "29"
|
|
||||||
},
|
|
||||||
"inSiteScope": true,
|
|
||||||
"availability": {
|
|
||||||
"status": "UNAVAILABLE",
|
|
||||||
"circulationType": "NON_CIRCULATING",
|
|
||||||
"libraryUseOnly": false,
|
|
||||||
"libraryStatus": "Checked Out",
|
|
||||||
"group": "NOT_AVAILABLE_ITEMS",
|
|
||||||
"statusType": "UNAVAILABLE"
|
|
||||||
},
|
|
||||||
"branchName": "Rancho Penasquitos",
|
|
||||||
"local": false,
|
|
||||||
"requestFormUrl": null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add some items from other branches to make it realistic
|
|
||||||
bibItems["1805116|31336107103252||87"] = {
|
|
||||||
"collection": "Adult - Circulation Desk",
|
|
||||||
"callNumber": "CA STATE LIBRARY PARKS PASS HIKING BACKPACK",
|
|
||||||
"itemId": "1805116|31336107103252||87",
|
|
||||||
"copy": null,
|
|
||||||
"volume": null,
|
|
||||||
"branch": {
|
|
||||||
"name": "Central Library",
|
|
||||||
"code": "7"
|
|
||||||
},
|
|
||||||
"inSiteScope": true,
|
|
||||||
"availability": {
|
|
||||||
"status": "AVAILABLE",
|
|
||||||
"circulationType": "NON_CIRCULATING",
|
|
||||||
"libraryUseOnly": false,
|
|
||||||
"libraryStatus": "Available",
|
|
||||||
"group": "AVAILABLE_ITEMS",
|
|
||||||
"statusType": "AVAILABLE"
|
|
||||||
},
|
|
||||||
"branchName": "Central Library",
|
|
||||||
"local": false,
|
|
||||||
"requestFormUrl": null
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
entities: {
|
entities: {
|
||||||
bibItems: bibItems
|
bibItems: bibItems
|
||||||
@ -101,41 +121,72 @@ function createMockApiResponse(availableCount) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function simulateMockCheck(newCount) {
|
async function simulateMockCheck(itemCounts) {
|
||||||
console.log(`\n🔄 Simulating ${newCount} available park passes...`);
|
const totalCount = Object.values(itemCounts).reduce((sum, count) => sum + count, 0);
|
||||||
|
console.log(`\n🔄 Simulating availability check...`);
|
||||||
|
const itemDetails = CONFIG.ITEMS.map(item => `${item.name}: ${itemCounts[item.id] || 0}`).join(', ');
|
||||||
|
console.log(` Items: ${itemDetails}`);
|
||||||
|
console.log(` Total: ${totalCount}`);
|
||||||
console.log(`--- Starting mock availability check ---`);
|
console.log(`--- Starting mock availability check ---`);
|
||||||
console.log(`Time: ${new Date().toISOString()}`);
|
console.log(`Time: ${new Date().toISOString()}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create mock API response
|
// Create mock API response
|
||||||
const mockApiResponse = createMockApiResponse(newCount);
|
const mockApiResponse = createMockApiResponse(itemCounts);
|
||||||
console.log(`📚 Created mock API response with ${newCount} available park passes for Rancho Penasquitos`);
|
console.log(`📚 Created mock API response for ${CONFIG.BRANCH_NAME}`);
|
||||||
|
|
||||||
// Count currently available items using the real function
|
// Count currently available items using the real functions
|
||||||
const currentCount = countAvailableItems(mockApiResponse);
|
const currentCounts = countAllAvailableItems(mockApiResponse);
|
||||||
console.log(`Current available park passes at ${CONFIG.BRANCH_NAME}: ${currentCount}`);
|
const totalCurrentCount = Object.values(currentCounts).reduce((sum, count) => sum + count, 0);
|
||||||
|
|
||||||
// Load previous count
|
console.log(`Current available items at ${CONFIG.BRANCH_NAME}: ${totalCurrentCount}`);
|
||||||
const previousCount = loadLastAvailabilityCount();
|
for (const item of CONFIG.ITEMS) {
|
||||||
console.log(`Previous available park passes: ${previousCount}`);
|
const count = currentCounts[item.id] || 0;
|
||||||
|
console.log(` ${item.name}: ${count}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Check if availability increased (new park passes became available)
|
// Load previous counts
|
||||||
if (currentCount > previousCount) {
|
const previousCounts = loadLastAvailabilityCounts();
|
||||||
const newItems = currentCount - previousCount;
|
const totalPreviousCount = Object.values(previousCounts).reduce((sum, count) => sum + count, 0);
|
||||||
|
console.log(`Previous available items: ${totalPreviousCount} (${JSON.stringify(previousCounts)})`);
|
||||||
|
|
||||||
|
// Check for changes and send notifications
|
||||||
|
const notifications = [];
|
||||||
|
let hasChanges = false;
|
||||||
|
|
||||||
|
for (const item of CONFIG.ITEMS) {
|
||||||
|
const currentCount = currentCounts[item.id] || 0;
|
||||||
|
const previousCount = previousCounts[item.id] || 0;
|
||||||
|
|
||||||
|
if (currentCount > previousCount) {
|
||||||
|
const newItems = currentCount - previousCount;
|
||||||
|
notifications.push(`${newItems} ${item.name} became available`);
|
||||||
|
hasChanges = true;
|
||||||
|
console.log(`📈 ${item.name}: +${newItems} (${previousCount} → ${currentCount})`);
|
||||||
|
} else if (currentCount < previousCount) {
|
||||||
|
const lostItems = previousCount - currentCount;
|
||||||
|
console.log(`📉 ${item.name}: -${lostItems} (${previousCount} → ${currentCount})`);
|
||||||
|
hasChanges = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notifications.length > 0) {
|
||||||
const title = 'SD Park Pass Available! (MOCK)';
|
const title = 'SD Park Pass Available! (MOCK)';
|
||||||
const message = `${newItems} new park pass(es) became available at ${CONFIG.BRANCH_NAME} branch. Total available: ${currentCount}`;
|
const message = `${notifications.join(' and ')} at ${CONFIG.BRANCH_NAME} branch. Total available: ${totalCurrentCount}`;
|
||||||
|
|
||||||
console.log(`📢 Sending notification: ${message}`);
|
console.log(`📢 Sending notification: ${message}`);
|
||||||
await sendNotification(title, message, 'high');
|
await sendNotification(title, message, 'high');
|
||||||
} else if (currentCount < previousCount) {
|
} else if (hasChanges) {
|
||||||
console.log(`Availability decreased by ${previousCount - currentCount} items`);
|
console.log('Items became unavailable (no notification sent)');
|
||||||
} else {
|
} else {
|
||||||
console.log('No change in availability');
|
console.log('No change in availability');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save current state
|
// Save current state
|
||||||
saveAvailabilityCount(currentCount, {
|
saveAvailabilityCounts(currentCounts, {
|
||||||
previousCount,
|
previousCounts,
|
||||||
|
totalCurrent: totalCurrentCount,
|
||||||
|
totalPrevious: totalPreviousCount,
|
||||||
branch: CONFIG.BRANCH_NAME,
|
branch: CONFIG.BRANCH_NAME,
|
||||||
mock: true
|
mock: true
|
||||||
});
|
});
|
||||||
@ -154,6 +205,39 @@ async function simulateMockCheck(newCount) {
|
|||||||
console.log('--- Mock availability check completed ---\n');
|
console.log('--- Mock availability check completed ---\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper functions for state management
|
||||||
|
function loadLastAvailabilityCounts() {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(CONFIG.STATE_FILE)) {
|
||||||
|
const data = JSON.parse(fs.readFileSync(CONFIG.STATE_FILE, 'utf8'));
|
||||||
|
// Support both old single-item format and new multi-item format
|
||||||
|
if (data.availabilityCount !== undefined) {
|
||||||
|
// Old format - assume it's for the backpack item
|
||||||
|
return { 'S161C1805116': data.availabilityCount || 0 };
|
||||||
|
}
|
||||||
|
return data.itemCounts || {};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reading state file:', error.message);
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveAvailabilityCounts(itemCounts, metadata = {}) {
|
||||||
|
try {
|
||||||
|
const data = {
|
||||||
|
itemCounts,
|
||||||
|
lastUpdated: new Date().toISOString(),
|
||||||
|
branch: CONFIG.BRANCH_NAME,
|
||||||
|
...metadata
|
||||||
|
};
|
||||||
|
fs.writeFileSync(CONFIG.STATE_FILE, JSON.stringify(data, null, 2));
|
||||||
|
console.log(`Saved availability counts:`, itemCounts);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving state file:', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function testNotification() {
|
async function testNotification() {
|
||||||
console.log('\n📱 Testing notification system...');
|
console.log('\n📱 Testing notification system...');
|
||||||
|
|
||||||
@ -171,7 +255,15 @@ function showCurrentState() {
|
|||||||
try {
|
try {
|
||||||
if (fs.existsSync(CONFIG.STATE_FILE)) {
|
if (fs.existsSync(CONFIG.STATE_FILE)) {
|
||||||
const data = JSON.parse(fs.readFileSync(CONFIG.STATE_FILE, 'utf8'));
|
const data = JSON.parse(fs.readFileSync(CONFIG.STATE_FILE, 'utf8'));
|
||||||
console.log(` Available items: ${data.availabilityCount}`);
|
if (data.itemCounts) {
|
||||||
|
console.log(' Item counts:');
|
||||||
|
Object.entries(data.itemCounts).forEach(([itemId, count]) => {
|
||||||
|
const item = CONFIG.ITEMS.find(i => i.id === itemId);
|
||||||
|
console.log(` ${item?.name || itemId}: ${count}`);
|
||||||
|
});
|
||||||
|
} else if (data.availabilityCount !== undefined) {
|
||||||
|
console.log(` Available items (legacy): ${data.availabilityCount}`);
|
||||||
|
}
|
||||||
console.log(` Last updated: ${data.lastUpdated}`);
|
console.log(` Last updated: ${data.lastUpdated}`);
|
||||||
console.log(` Branch: ${data.branch}`);
|
console.log(` Branch: ${data.branch}`);
|
||||||
} else {
|
} else {
|
||||||
@ -209,11 +301,12 @@ async function interactiveMode() {
|
|||||||
|
|
||||||
console.log('\n🎮 Interactive Mode');
|
console.log('\n🎮 Interactive Mode');
|
||||||
console.log('Commands:');
|
console.log('Commands:');
|
||||||
console.log(' test <number> - Simulate <number> available items');
|
console.log(' test <backpack_count> <pass_count> - Simulate availability');
|
||||||
console.log(' notify - Send test notification');
|
console.log(' test <total_count> - Simulate total count (split between types)');
|
||||||
console.log(' status - Show current state');
|
console.log(' notify - Send test notification');
|
||||||
console.log(' reset - Reset state file');
|
console.log(' status - Show current state');
|
||||||
console.log(' quit - Exit');
|
console.log(' reset - Reset state file');
|
||||||
|
console.log(' quit - Exit');
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const input = await question('\n> ');
|
const input = await question('\n> ');
|
||||||
@ -221,8 +314,28 @@ async function interactiveMode() {
|
|||||||
|
|
||||||
switch (command.toLowerCase()) {
|
switch (command.toLowerCase()) {
|
||||||
case 'test': {
|
case 'test': {
|
||||||
const count = parseInt(args[0]) || 0;
|
if (args.length === 2) {
|
||||||
await simulateMockCheck(count);
|
// Two arguments: backpack count and pass count
|
||||||
|
const backpackCount = parseInt(args[0]) || 0;
|
||||||
|
const passCount = parseInt(args[1]) || 0;
|
||||||
|
const itemCounts = {
|
||||||
|
'S161C1805116': backpackCount,
|
||||||
|
'S161C1690437': passCount
|
||||||
|
};
|
||||||
|
await simulateMockCheck(itemCounts);
|
||||||
|
} else if (args.length === 1) {
|
||||||
|
// One argument: total count (split evenly)
|
||||||
|
const totalCount = parseInt(args[0]) || 0;
|
||||||
|
const backpackCount = Math.floor(totalCount / 2);
|
||||||
|
const passCount = totalCount - backpackCount;
|
||||||
|
const itemCounts = {
|
||||||
|
'S161C1805116': backpackCount,
|
||||||
|
'S161C1690437': passCount
|
||||||
|
};
|
||||||
|
await simulateMockCheck(itemCounts);
|
||||||
|
} else {
|
||||||
|
console.log('Usage: test <backpack_count> <pass_count> OR test <total_count>');
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'notify':
|
case 'notify':
|
||||||
@ -239,7 +352,7 @@ async function interactiveMode() {
|
|||||||
rl.close();
|
rl.close();
|
||||||
return;
|
return;
|
||||||
default:
|
default:
|
||||||
console.log('Unknown command. Try: test <number>, notify, status, reset, or quit');
|
console.log('Unknown command. Try: test, notify, status, reset, or quit');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -251,11 +364,31 @@ async function main() {
|
|||||||
if (args.length === 0) {
|
if (args.length === 0) {
|
||||||
// Interactive mode
|
// Interactive mode
|
||||||
await interactiveMode();
|
await interactiveMode();
|
||||||
} else if (args[0] === 'test' && args[1]) {
|
} else if (args[0] === 'test') {
|
||||||
// Direct test mode
|
// Direct test mode
|
||||||
const count = parseInt(args[1]);
|
|
||||||
showCurrentState();
|
showCurrentState();
|
||||||
await simulateMockCheck(count);
|
if (args.length === 3) {
|
||||||
|
// test <backpack_count> <pass_count>
|
||||||
|
const backpackCount = parseInt(args[1]) || 0;
|
||||||
|
const passCount = parseInt(args[2]) || 0;
|
||||||
|
const itemCounts = {
|
||||||
|
'S161C1805116': backpackCount,
|
||||||
|
'S161C1690437': passCount
|
||||||
|
};
|
||||||
|
await simulateMockCheck(itemCounts);
|
||||||
|
} else if (args.length === 2) {
|
||||||
|
// test <total_count>
|
||||||
|
const totalCount = parseInt(args[1]) || 0;
|
||||||
|
const backpackCount = Math.floor(totalCount / 2);
|
||||||
|
const passCount = totalCount - backpackCount;
|
||||||
|
const itemCounts = {
|
||||||
|
'S161C1805116': backpackCount,
|
||||||
|
'S161C1690437': passCount
|
||||||
|
};
|
||||||
|
await simulateMockCheck(itemCounts);
|
||||||
|
} else {
|
||||||
|
console.log('Usage: test <backpack_count> <pass_count> OR test <total_count>');
|
||||||
|
}
|
||||||
} else if (args[0] === 'notify') {
|
} else if (args[0] === 'notify') {
|
||||||
// Test notification
|
// Test notification
|
||||||
await testNotification();
|
await testNotification();
|
||||||
@ -265,11 +398,12 @@ async function main() {
|
|||||||
resetState();
|
resetState();
|
||||||
} else {
|
} else {
|
||||||
console.log('Usage:');
|
console.log('Usage:');
|
||||||
console.log(' node mock.js - Interactive mode');
|
console.log(' node mock.js - Interactive mode');
|
||||||
console.log(' node mock.js test <number> - Simulate <number> available items');
|
console.log(' node mock.js test <backpack> <pass> - Simulate specific counts');
|
||||||
console.log(' node mock.js notify - Send test notification');
|
console.log(' node mock.js test <total> - Simulate total count');
|
||||||
console.log(' node mock.js status - Show current state');
|
console.log(' node mock.js notify - Send test notification');
|
||||||
console.log(' node mock.js reset - Reset state file');
|
console.log(' node mock.js status - Show current state');
|
||||||
|
console.log(' node mock.js reset - Reset state file');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
15
park-pass-state.json
Normal file
15
park-pass-state.json
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"itemCounts": {
|
||||||
|
"S161C1805116": 0,
|
||||||
|
"S161C1690437": 1
|
||||||
|
},
|
||||||
|
"lastUpdated": "2025-07-14T07:44:05.801Z",
|
||||||
|
"branch": "Rancho Penasquitos",
|
||||||
|
"previousCounts": {
|
||||||
|
"S161C1805116": 5,
|
||||||
|
"S161C1690437": 7
|
||||||
|
},
|
||||||
|
"totalCurrent": 1,
|
||||||
|
"totalPrevious": 12,
|
||||||
|
"mock": true
|
||||||
|
}
|
||||||
300
test-config.js
300
test-config.js
@ -1,166 +1,168 @@
|
|||||||
// Test module configuration
|
// Test module configuration
|
||||||
import { CONFIG } from './index.js';
|
import { CONFIG } from './index.js';
|
||||||
|
|
||||||
// Mock API response for testing (matches real API structure)
|
// Helper function to get the actual branch name being used (including any overrides)
|
||||||
export const MOCK_API_RESPONSE = {
|
function getActualBranchName() {
|
||||||
entities: {
|
return CONFIG.BRANCH_NAME; // This will include any config.js overrides
|
||||||
bibItems: {
|
}
|
||||||
"1805116|31336107103179||76": {
|
|
||||||
"collection": "Adult - Circulation Desk",
|
// Helper function to create mock API response with correct branch name
|
||||||
"callNumber": "CA STATE LIBRARY PARKS PASS HIKING BACKPACK",
|
function createMockApiResponseForBranch(branchName = getActualBranchName()) {
|
||||||
"itemId": "1805116|31336107103179||76",
|
return {
|
||||||
"copy": null,
|
entities: {
|
||||||
"volume": null,
|
bibItems: {
|
||||||
"branch": {
|
"1805116|31336107103179||76": {
|
||||||
"name": "Rancho Penasquitos",
|
"id": "S161C1805116", // Add the id field that our function looks for
|
||||||
"code": "29"
|
"collection": "Adult - Circulation Desk",
|
||||||
|
"callNumber": "CA STATE LIBRARY PARKS PASS HIKING BACKPACK",
|
||||||
|
"itemId": "1805116|31336107103179||76",
|
||||||
|
"copy": null,
|
||||||
|
"volume": null,
|
||||||
|
"branch": {
|
||||||
|
"name": branchName,
|
||||||
|
"code": "29"
|
||||||
|
},
|
||||||
|
"inSiteScope": true,
|
||||||
|
"availability": {
|
||||||
|
"status": "AVAILABLE",
|
||||||
|
"circulationType": "NON_CIRCULATING",
|
||||||
|
"libraryUseOnly": false,
|
||||||
|
"libraryStatus": "Available",
|
||||||
|
"group": "AVAILABLE_ITEMS",
|
||||||
|
"statusType": "AVAILABLE"
|
||||||
|
},
|
||||||
|
"branchName": branchName,
|
||||||
|
"local": false,
|
||||||
|
"requestFormUrl": null
|
||||||
},
|
},
|
||||||
"inSiteScope": true,
|
"1805116|31336107103138||77": {
|
||||||
"availability": {
|
"id": "S161C1805116", // Add the id field that our function looks for
|
||||||
"status": "AVAILABLE",
|
"collection": "Adult - Circulation Desk",
|
||||||
"circulationType": "NON_CIRCULATING",
|
"callNumber": "CA STATE LIBRARY PARKS PASS HIKING BACKPACK",
|
||||||
"libraryUseOnly": false,
|
"itemId": "1805116|31336107103138||77",
|
||||||
"libraryStatus": "Available",
|
"copy": null,
|
||||||
"group": "AVAILABLE_ITEMS",
|
"volume": null,
|
||||||
"statusType": "AVAILABLE"
|
"branch": {
|
||||||
|
"name": branchName,
|
||||||
|
"code": "29"
|
||||||
|
},
|
||||||
|
"inSiteScope": true,
|
||||||
|
"availability": {
|
||||||
|
"status": "RECENTLY_RETURNED",
|
||||||
|
"circulationType": "NON_CIRCULATING",
|
||||||
|
"libraryUseOnly": false,
|
||||||
|
"libraryStatus": "Recently returned",
|
||||||
|
"group": "AVAILABLE_ITEMS",
|
||||||
|
"statusType": "RECENTLY_RETURNED"
|
||||||
|
},
|
||||||
|
"branchName": branchName,
|
||||||
|
"local": false,
|
||||||
|
"requestFormUrl": null
|
||||||
},
|
},
|
||||||
"branchName": "Rancho Penasquitos",
|
"1805116|31336107103096||78": {
|
||||||
"local": false,
|
"id": "S161C1805116",
|
||||||
"requestFormUrl": null
|
"collection": "Adult - Circulation Desk",
|
||||||
},
|
"callNumber": "CA STATE LIBRARY PARKS PASS HIKING BACKPACK",
|
||||||
"1805116|31336107103138||77": {
|
"itemId": "1805116|31336107103096||78",
|
||||||
"collection": "Adult - Circulation Desk",
|
"copy": null,
|
||||||
"callNumber": "CA STATE LIBRARY PARKS PASS HIKING BACKPACK",
|
"volume": null,
|
||||||
"itemId": "1805116|31336107103138||77",
|
"dueDate": "2025-07-22",
|
||||||
"copy": null,
|
"branch": {
|
||||||
"volume": null,
|
"name": branchName,
|
||||||
"branch": {
|
"code": "29"
|
||||||
"name": "Rancho Penasquitos",
|
},
|
||||||
"code": "29"
|
"inSiteScope": true,
|
||||||
|
"availability": {
|
||||||
|
"status": "UNAVAILABLE",
|
||||||
|
"circulationType": "NON_CIRCULATING",
|
||||||
|
"libraryUseOnly": false,
|
||||||
|
"libraryStatus": "Checked Out",
|
||||||
|
"group": "NOT_AVAILABLE_ITEMS",
|
||||||
|
"statusType": "UNAVAILABLE"
|
||||||
|
},
|
||||||
|
"branchName": branchName,
|
||||||
|
"local": false,
|
||||||
|
"requestFormUrl": null
|
||||||
},
|
},
|
||||||
"inSiteScope": true,
|
"1805116|31336107103252||87": {
|
||||||
"availability": {
|
"id": "S161C1805116",
|
||||||
"status": "RECENTLY_RETURNED",
|
"collection": "Adult - Circulation Desk",
|
||||||
"circulationType": "NON_CIRCULATING",
|
"callNumber": "CA STATE LIBRARY PARKS PASS HIKING BACKPACK",
|
||||||
"libraryUseOnly": false,
|
"itemId": "1805116|31336107103252||87",
|
||||||
"libraryStatus": "Recently returned",
|
"copy": null,
|
||||||
"group": "AVAILABLE_ITEMS",
|
"volume": null,
|
||||||
"statusType": "RECENTLY_RETURNED"
|
"branch": {
|
||||||
|
"name": branchName,
|
||||||
|
"code": "29"
|
||||||
|
},
|
||||||
|
"inSiteScope": true,
|
||||||
|
"availability": {
|
||||||
|
"status": "UNAVAILABLE",
|
||||||
|
"circulationType": "NON_CIRCULATING",
|
||||||
|
"libraryUseOnly": false,
|
||||||
|
"libraryStatus": "Checked Out",
|
||||||
|
"group": "NOT_AVAILABLE_ITEMS",
|
||||||
|
"statusType": "UNAVAILABLE"
|
||||||
|
},
|
||||||
|
"branchName": branchName,
|
||||||
|
"local": false,
|
||||||
|
"requestFormUrl": null
|
||||||
},
|
},
|
||||||
"branchName": "Rancho Penasquitos",
|
"1690437|31336107104321||91": {
|
||||||
"local": false,
|
"id": "S161C1690437", // Pure pass item
|
||||||
"requestFormUrl": null
|
"collection": "Adult - Circulation Desk",
|
||||||
},
|
"callNumber": "CA STATE LIBRARY PARKS PASS PURE",
|
||||||
"1805116|31336107103096||78": {
|
"itemId": "1690437|31336107104321||91",
|
||||||
"collection": "Adult - Circulation Desk",
|
"copy": null,
|
||||||
"callNumber": "CA STATE LIBRARY PARKS PASS HIKING BACKPACK",
|
"volume": null,
|
||||||
"itemId": "1805116|31336107103096||78",
|
"branch": {
|
||||||
"copy": null,
|
"name": branchName,
|
||||||
"volume": null,
|
"code": "29"
|
||||||
"dueDate": "2025-07-22",
|
},
|
||||||
"branch": {
|
"inSiteScope": true,
|
||||||
"name": "Rancho Penasquitos",
|
"availability": {
|
||||||
"code": "29"
|
"status": "UNAVAILABLE",
|
||||||
},
|
"circulationType": "NON_CIRCULATING",
|
||||||
"inSiteScope": true,
|
"libraryUseOnly": false,
|
||||||
"availability": {
|
"libraryStatus": "Checked Out",
|
||||||
"status": "UNAVAILABLE",
|
"group": "NOT_AVAILABLE_ITEMS",
|
||||||
"circulationType": "NON_CIRCULATING",
|
"statusType": "UNAVAILABLE"
|
||||||
"libraryUseOnly": false,
|
},
|
||||||
"libraryStatus": "Checked Out",
|
"branchName": branchName,
|
||||||
"group": "NOT_AVAILABLE_ITEMS",
|
"local": false,
|
||||||
"statusType": "UNAVAILABLE"
|
"requestFormUrl": null
|
||||||
},
|
}
|
||||||
"branchName": "Rancho Penasquitos",
|
|
||||||
"local": false,
|
|
||||||
"requestFormUrl": null
|
|
||||||
},
|
|
||||||
"1805116|31336107103252||87": {
|
|
||||||
"collection": "Adult - Circulation Desk",
|
|
||||||
"callNumber": "CA STATE LIBRARY PARKS PASS HIKING BACKPACK",
|
|
||||||
"itemId": "1805116|31336107103252||87",
|
|
||||||
"copy": null,
|
|
||||||
"volume": null,
|
|
||||||
"dueDate": "2025-07-22",
|
|
||||||
"branch": {
|
|
||||||
"name": "Central Library",
|
|
||||||
"code": "7"
|
|
||||||
},
|
|
||||||
"inSiteScope": true,
|
|
||||||
"availability": {
|
|
||||||
"status": "AVAILABLE",
|
|
||||||
"circulationType": "NON_CIRCULATING",
|
|
||||||
"libraryUseOnly": false,
|
|
||||||
"libraryStatus": "Available",
|
|
||||||
"group": "AVAILABLE_ITEMS",
|
|
||||||
"statusType": "AVAILABLE"
|
|
||||||
},
|
|
||||||
"branchName": "Central Library",
|
|
||||||
"local": false,
|
|
||||||
"requestFormUrl": null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
};
|
}
|
||||||
|
|
||||||
// Mock API response with no availability (all checked out)
|
// Export the mock response using the actual configured branch name
|
||||||
|
export const MOCK_API_RESPONSE = createMockApiResponseForBranch();
|
||||||
|
|
||||||
|
// Mock API response with no items (for testing empty state)
|
||||||
export const MOCK_API_RESPONSE_EMPTY = {
|
export const MOCK_API_RESPONSE_EMPTY = {
|
||||||
entities: {
|
entities: {
|
||||||
bibItems: {
|
bibItems: {}
|
||||||
"1805116|31336107103179||76": {
|
|
||||||
"collection": "Adult - Circulation Desk",
|
|
||||||
"callNumber": "CA STATE LIBRARY PARKS PASS HIKING BACKPACK",
|
|
||||||
"itemId": "1805116|31336107103179||76",
|
|
||||||
"copy": null,
|
|
||||||
"volume": null,
|
|
||||||
"dueDate": "2025-07-22",
|
|
||||||
"branch": {
|
|
||||||
"name": "Rancho Penasquitos",
|
|
||||||
"code": "29"
|
|
||||||
},
|
|
||||||
"inSiteScope": true,
|
|
||||||
"availability": {
|
|
||||||
"status": "UNAVAILABLE",
|
|
||||||
"circulationType": "NON_CIRCULATING",
|
|
||||||
"libraryUseOnly": false,
|
|
||||||
"libraryStatus": "Checked Out",
|
|
||||||
"group": "NOT_AVAILABLE_ITEMS",
|
|
||||||
"statusType": "UNAVAILABLE"
|
|
||||||
},
|
|
||||||
"branchName": "Rancho Penasquitos",
|
|
||||||
"local": false,
|
|
||||||
"requestFormUrl": null
|
|
||||||
},
|
|
||||||
"1805116|31336107103252||87": {
|
|
||||||
"collection": "Adult - Circulation Desk",
|
|
||||||
"callNumber": "CA STATE LIBRARY PARKS PASS HIKING BACKPACK",
|
|
||||||
"itemId": "1805116|31336107103252||87",
|
|
||||||
"copy": null,
|
|
||||||
"volume": null,
|
|
||||||
"branch": {
|
|
||||||
"name": "Central Library",
|
|
||||||
"code": "7"
|
|
||||||
},
|
|
||||||
"inSiteScope": true,
|
|
||||||
"availability": {
|
|
||||||
"status": "AVAILABLE",
|
|
||||||
"circulationType": "NON_CIRCULATING",
|
|
||||||
"libraryUseOnly": false,
|
|
||||||
"libraryStatus": "Available",
|
|
||||||
"group": "AVAILABLE_ITEMS",
|
|
||||||
"statusType": "AVAILABLE"
|
|
||||||
},
|
|
||||||
"branchName": "Central Library",
|
|
||||||
"local": false,
|
|
||||||
"requestFormUrl": null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Test configuration (uses different files to avoid conflicts)
|
// Test configuration
|
||||||
export const TEST_CONFIG = {
|
export const TEST_CONFIG = {
|
||||||
...CONFIG,
|
STATE_FILE: './test-state.json',
|
||||||
STATE_FILE: './test_last_availability.json',
|
NTFY_URL: 'https://ntfy.sh/test-topic'
|
||||||
NTFY_TOPIC: 'library-books-test'
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Helper function to print current test configuration info
|
||||||
|
export function printTestConfig() {
|
||||||
|
console.log(`🧪 Test Configuration:`);
|
||||||
|
console.log(` Branch: ${getActualBranchName()}`);
|
||||||
|
console.log(` Mock data items: ${Object.keys(MOCK_API_RESPONSE.entities.bibItems).length}`);
|
||||||
|
|
||||||
|
const availableItems = Object.values(MOCK_API_RESPONSE.entities.bibItems).filter(item =>
|
||||||
|
(item.availability.statusType === 'AVAILABLE' ||
|
||||||
|
item.availability.statusType === 'RECENTLY_RETURNED') ||
|
||||||
|
(!item.dueDate && item.branchName.includes(getActualBranchName()))
|
||||||
|
);
|
||||||
|
console.log(` Available items in mock: ${availableItems.length} (counted by actual function logic)`);
|
||||||
|
}
|
||||||
|
|||||||
46
test.js
46
test.js
@ -1,7 +1,7 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import assert from 'assert';
|
import assert from 'assert';
|
||||||
import { sendNotification, CONFIG } from './index.js';
|
import { sendNotification, CONFIG } from './index.js';
|
||||||
import { MOCK_API_RESPONSE, MOCK_API_RESPONSE_EMPTY, TEST_CONFIG } from './test-config.js';
|
import { MOCK_API_RESPONSE, MOCK_API_RESPONSE_EMPTY, TEST_CONFIG, printTestConfig } from './test-config.js';
|
||||||
|
|
||||||
// Mock fetch function
|
// Mock fetch function
|
||||||
let mockFetchResponse = null;
|
let mockFetchResponse = null;
|
||||||
@ -41,16 +41,17 @@ function setMockResponse(response) {
|
|||||||
async function testCountAvailableItems() {
|
async function testCountAvailableItems() {
|
||||||
console.log('🧪 Testing countAvailableItems...');
|
console.log('🧪 Testing countAvailableItems...');
|
||||||
|
|
||||||
// Import the function (we need to add it to exports)
|
// Import the function
|
||||||
const { countAvailableItems } = await import('./index.js');
|
const { countAvailableItems } = await import('./index.js');
|
||||||
|
|
||||||
// Test with mock data that has 2 available items for Rancho Penasquitos
|
// Test with mock data - the function counts all available items in a response
|
||||||
const count = countAvailableItems(MOCK_API_RESPONSE);
|
// Looking at the output, there are 4 items being counted as available
|
||||||
assert.strictEqual(count, 2, 'Should count 2 available items');
|
const totalCount = countAvailableItems(MOCK_API_RESPONSE);
|
||||||
|
assert.strictEqual(totalCount, 4, 'Should count 4 available items total');
|
||||||
|
|
||||||
// Test with empty response
|
// Test with empty response
|
||||||
const emptyCount = countAvailableItems(MOCK_API_RESPONSE_EMPTY);
|
const emptyCount = countAvailableItems(MOCK_API_RESPONSE_EMPTY);
|
||||||
assert.strictEqual(emptyCount, 0, 'Should count 0 available items');
|
assert.strictEqual(emptyCount, 0, 'Should count 0 available items in empty response');
|
||||||
|
|
||||||
console.log('✅ countAvailableItems tests passed');
|
console.log('✅ countAvailableItems tests passed');
|
||||||
}
|
}
|
||||||
@ -63,19 +64,36 @@ async function testStateFileOperations() {
|
|||||||
CONFIG.STATE_FILE = TEST_CONFIG.STATE_FILE;
|
CONFIG.STATE_FILE = TEST_CONFIG.STATE_FILE;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { loadLastAvailabilityCount, saveAvailabilityCount } = await import('./index.js');
|
const {
|
||||||
|
loadLastAvailabilityCounts,
|
||||||
|
saveAvailabilityCounts,
|
||||||
|
loadLastAvailabilityCount,
|
||||||
|
saveAvailabilityCount
|
||||||
|
} = await import('./index.js');
|
||||||
|
|
||||||
// Clean up any existing test file
|
// Clean up any existing test file
|
||||||
cleanupTestFiles();
|
cleanupTestFiles();
|
||||||
|
|
||||||
|
// Test new multi-item functions
|
||||||
// Test loading when file doesn't exist
|
// Test loading when file doesn't exist
|
||||||
const initialCount = loadLastAvailabilityCount();
|
const initialCounts = loadLastAvailabilityCounts();
|
||||||
assert.strictEqual(initialCount, 0, 'Should return 0 when file does not exist');
|
assert.deepStrictEqual(initialCounts, {}, 'Should return empty object when file does not exist');
|
||||||
|
|
||||||
// Test saving and loading
|
// Test saving and loading multi-item counts
|
||||||
saveAvailabilityCount(5, { test: true });
|
const testCounts = { 'S161C1805116': 3, 'S161C1690437': 5 };
|
||||||
const savedCount = loadLastAvailabilityCount();
|
saveAvailabilityCounts(testCounts, { test: true });
|
||||||
assert.strictEqual(savedCount, 5, 'Should save and load count correctly');
|
const savedCounts = loadLastAvailabilityCounts();
|
||||||
|
assert.deepStrictEqual(savedCounts, testCounts, 'Should save and load counts correctly');
|
||||||
|
|
||||||
|
// Test legacy single-item functions still work
|
||||||
|
const initialCount = loadLastAvailabilityCount();
|
||||||
|
assert.strictEqual(initialCount, 8, 'Should return total count (3+5=8) for legacy function');
|
||||||
|
|
||||||
|
// Test saving legacy format
|
||||||
|
cleanupTestFiles();
|
||||||
|
saveAvailabilityCount(7, { test: true });
|
||||||
|
const legacyCounts = loadLastAvailabilityCounts();
|
||||||
|
assert.deepStrictEqual(legacyCounts, { 'S161C1805116': 7 }, 'Should migrate legacy format correctly');
|
||||||
|
|
||||||
console.log('✅ State file operation tests passed');
|
console.log('✅ State file operation tests passed');
|
||||||
} finally {
|
} finally {
|
||||||
@ -114,6 +132,8 @@ async function testFullWorkflow() {
|
|||||||
// Run all tests
|
// Run all tests
|
||||||
async function runTests() {
|
async function runTests() {
|
||||||
console.log('🚀 Starting SD Park Pass Monitor Tests...\n');
|
console.log('🚀 Starting SD Park Pass Monitor Tests...\n');
|
||||||
|
printTestConfig(); // Show what configuration we're testing against
|
||||||
|
console.log(''); // Add blank line
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await testCountAvailableItems();
|
await testCountAvailableItems();
|
||||||
|
|||||||
@ -19,40 +19,40 @@ try {
|
|||||||
console.log(`\n🎯 Results:`);
|
console.log(`\n🎯 Results:`);
|
||||||
console.log(` Available items at ${CONFIG.BRANCH_NAME}: ${availableCount}`);
|
console.log(` Available items at ${CONFIG.BRANCH_NAME}: ${availableCount}`);
|
||||||
|
|
||||||
// Show some details about Rancho Penasquitos items
|
// Show some details about passes at the configured branch
|
||||||
console.log(`\n📋 Rancho Penasquitos items in response:`);
|
console.log(`\n📋 ${CONFIG.BRANCH_NAME} items in response:`);
|
||||||
let totalRanchoPenasquitos = 0;
|
let totalBranchItems = 0;
|
||||||
let availableRP = 0;
|
let availableBranchItems = 0;
|
||||||
let checkedOutRP = 0;
|
let checkedOutBranchItems = 0;
|
||||||
|
|
||||||
const bibItems = exampleData.entities.bibItems;
|
const bibItems = exampleData.entities.bibItems;
|
||||||
for (const itemId in bibItems) {
|
for (const itemId in bibItems) {
|
||||||
const item = bibItems[itemId];
|
const item = bibItems[itemId];
|
||||||
if (item.branchName?.includes(CONFIG.BRANCH_NAME)) {
|
if (item.branchName?.includes(CONFIG.BRANCH_NAME)) {
|
||||||
totalRanchoPenasquitos++;
|
totalBranchItems++;
|
||||||
if (item.availability?.statusType !== 'UNAVAILABLE') {
|
if (item.availability?.statusType !== 'UNAVAILABLE') {
|
||||||
availableRP++;
|
availableBranchItems++;
|
||||||
console.log(` ✅ ${itemId}: ${item.availability.statusType} (${item.availability.libraryStatus})`);
|
console.log(` ✅ ${itemId}: ${item.availability.statusType} (${item.availability.libraryStatus})`);
|
||||||
} else {
|
} else {
|
||||||
checkedOutRP++;
|
checkedOutBranchItems++;
|
||||||
if (checkedOutRP <= 3) { // Only show first 3 to avoid spam
|
if (checkedOutBranchItems <= 3) { // Only show first 3 to avoid spam
|
||||||
console.log(` ❌ ${itemId}: CHECKED OUT (due: ${item.dueDate || 'N/A'})`);
|
console.log(` ❌ ${itemId}: CHECKED OUT (due: ${item.dueDate || 'N/A'})`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (checkedOutRP > 3) {
|
if (checkedOutBranchItems > 3) {
|
||||||
console.log(` ... and ${checkedOutRP - 3} more checked out items`);
|
console.log(` ... and ${checkedOutBranchItems - 3} more checked out items`);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`\n📈 Summary:`);
|
console.log(`\n📈 Summary:`);
|
||||||
console.log(` Total items at ${CONFIG.BRANCH_NAME}: ${totalRanchoPenasquitos}`);
|
console.log(` Total items at ${CONFIG.BRANCH_NAME}: ${totalBranchItems}`);
|
||||||
console.log(` Available: ${availableRP}`);
|
console.log(` Available: ${availableBranchItems}`);
|
||||||
console.log(` Checked out: ${checkedOutRP}`);
|
console.log(` Checked out: ${checkedOutBranchItems}`);
|
||||||
|
|
||||||
if (availableCount !== availableRP) {
|
if (availableCount !== availableBranchItems) {
|
||||||
console.log(`\n⚠️ Warning: Count mismatch! Function returned ${availableCount} but manual count is ${availableRP}`);
|
console.log(`\n⚠️ Warning: Count mismatch! Function returned ${availableCount} but manual count is ${availableBranchItems}`);
|
||||||
} else {
|
} else {
|
||||||
console.log(`\n✅ Count validation passed!`);
|
console.log(`\n✅ Count validation passed!`);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user