Initial commit
This commit is contained in:
40
.gitignore
vendored
Normal file
40
.gitignore
vendored
Normal file
@ -0,0 +1,40 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# State files
|
||||
last_availability.json
|
||||
test_last_availability.json
|
||||
|
||||
# Configuration overrides
|
||||
config.js
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage/
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
229
README.md
Normal file
229
README.md
Normal file
@ -0,0 +1,229 @@
|
||||
# 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.
|
||||
|
||||
## Features
|
||||
|
||||
- 📚 Monitors specific library book availability via BiblioCommons API
|
||||
- 📱 Sends push notifications via ntfy when new books become available
|
||||
- ⏰ Polls hourly and tracks availability changes
|
||||
- 🔄 Graceful error handling and recovery
|
||||
- 🧪 Comprehensive testing suite
|
||||
- 🎭 Mock testing capabilities
|
||||
- 🚀 Easy deployment to Linux servers
|
||||
|
||||
## Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 18+
|
||||
- npm
|
||||
- SSH access to deployment server (optional)
|
||||
|
||||
### Installation
|
||||
|
||||
1. Clone or download this repository
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. Configure your ntfy settings in `index.js`:
|
||||
```javascript
|
||||
const CONFIG = {
|
||||
NTFY_TOPIC: 'your-topic-name',
|
||||
NTFY_SERVER: 'https://ntfy.sh', // or your own server
|
||||
// ... other settings
|
||||
};
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Running the Monitor
|
||||
|
||||
```bash
|
||||
# Start the monitor
|
||||
npm start
|
||||
|
||||
# Development mode with auto-restart
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
npm test
|
||||
|
||||
# Validate against real API response example
|
||||
npm run validate
|
||||
|
||||
# Interactive mock testing
|
||||
npm run mock
|
||||
|
||||
# Simulate availability changes
|
||||
node mock.js test 3 # Simulate 3 available books
|
||||
node mock.js notify # Send test notification
|
||||
node mock.js status # Show current state
|
||||
node mock.js reset # Reset state file
|
||||
```
|
||||
|
||||
### Deployment
|
||||
|
||||
The included deployment script uploads and manages the service on a remote Linux server:
|
||||
|
||||
```bash
|
||||
# Deploy to configured server
|
||||
npm run deploy
|
||||
|
||||
# Or run directly
|
||||
./deploy.sh
|
||||
```
|
||||
|
||||
#### Deployment Setup
|
||||
|
||||
1. Configure SSH access to your server as 'linode' in `~/.ssh/config`:
|
||||
```
|
||||
Host linode
|
||||
HostName your-server-ip
|
||||
User your-username
|
||||
IdentityFile ~/.ssh/your-key
|
||||
```
|
||||
|
||||
2. The deployment script will:
|
||||
- Upload files to `/opt/sd-park-pass-ntfy`
|
||||
- Install dependencies
|
||||
- Create and start a systemd service
|
||||
- Enable auto-start on boot
|
||||
|
||||
## Configuration
|
||||
|
||||
### Main Configuration (index.js)
|
||||
|
||||
```javascript
|
||||
const CONFIG = {
|
||||
API_URL: 'https://gateway.bibliocommons.com/v2/libraries/sandiego/bibs/S161C1805116/availability',
|
||||
BRANCH_NAME: 'Rancho Penasquitos',
|
||||
NTFY_TOPIC: 'sdparkpass',
|
||||
NTFY_SERVER: 'https://ntfy.sh',
|
||||
POLL_INTERVAL: '0 * * * *', // Every hour
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
### Systemd Service
|
||||
|
||||
The service runs as user `nobody` for security and includes:
|
||||
- Automatic restart on failure
|
||||
- Proper logging via journald
|
||||
- Security hardening
|
||||
- Environment isolation
|
||||
|
||||
View logs:
|
||||
```bash
|
||||
sudo journalctl -u sd-park-pass-ntfy -f
|
||||
```
|
||||
|
||||
## API Response Format
|
||||
|
||||
The monitor expects BiblioCommons API responses in this format:
|
||||
|
||||
```json
|
||||
{
|
||||
"entities": {
|
||||
"bibItems": {
|
||||
"1805116|31336107103179||76": {
|
||||
"collection": "Adult - Circulation Desk",
|
||||
"callNumber": "CA STATE LIBRARY PARKS PASS HIKING BACKPACK",
|
||||
"itemId": "1805116|31336107103179||76",
|
||||
"branch": {
|
||||
"name": "Rancho Penasquitos",
|
||||
"code": "29"
|
||||
},
|
||||
"availability": {
|
||||
"status": "AVAILABLE",
|
||||
"statusType": "AVAILABLE",
|
||||
"libraryStatus": "Available"
|
||||
},
|
||||
"branchName": "Rancho Penasquitos",
|
||||
"dueDate": null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The monitor looks for:
|
||||
- Items with `branchName` containing "Rancho Penasquitos"
|
||||
- Items with `availability.statusType` NOT equal to "UNAVAILABLE"
|
||||
- Available status types include: "AVAILABLE", "RECENTLY_RETURNED"
|
||||
|
||||
## State Management
|
||||
|
||||
The monitor tracks availability in `last_availability.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"availabilityCount": 2,
|
||||
"lastUpdated": "2025-07-13T10:00:00.000Z",
|
||||
"previousCount": 1,
|
||||
"branch": "Rancho Penasquitos",
|
||||
"apiUrl": "https://..."
|
||||
}
|
||||
```
|
||||
|
||||
## Notification Types
|
||||
|
||||
- 📚 **High Priority**: New books became available
|
||||
- ⚠️ **Low Priority**: Errors or status changes
|
||||
- 🔴 **Service Events**: Start/stop notifications
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **No notifications received**:
|
||||
- Check ntfy topic subscription on your phone
|
||||
- Verify NTFY_SERVER and NTFY_TOPIC settings
|
||||
- Test with `node mock.js notify`
|
||||
|
||||
2. **API errors**:
|
||||
- Check network connectivity
|
||||
- Verify API URL is still valid
|
||||
- Monitor rate limiting
|
||||
|
||||
3. **Service not starting**:
|
||||
- Check file permissions
|
||||
- Review systemd logs: `journalctl -u sd-park-pass-ntfy`
|
||||
- Ensure Node.js is installed on server
|
||||
|
||||
### Mock Testing
|
||||
|
||||
Use the mock script to verify functionality:
|
||||
|
||||
```bash
|
||||
# Interactive mode
|
||||
node mock.js
|
||||
|
||||
# Commands in interactive mode:
|
||||
> test 5 # Simulate 5 available books
|
||||
> notify # Send test notification
|
||||
> status # Show current state
|
||||
> reset # Clear state file
|
||||
> quit # Exit
|
||||
```
|
||||
|
||||
## Files
|
||||
|
||||
- `index.js` - Main application
|
||||
- `test.js` - Test suite
|
||||
- `mock.js` - Mock testing script
|
||||
- `validate-real-response.js` - Validates parsing against real API response
|
||||
- `deploy.sh` - Deployment script
|
||||
- `sd-park-pass-ntfy.service` - Systemd service file
|
||||
- `test-config.js` - Test configuration and mock data
|
||||
- `example-response.json` - Real API response example for reference
|
||||
|
||||
## License
|
||||
|
||||
ISC
|
||||
9
config.example.js
Normal file
9
config.example.js
Normal file
@ -0,0 +1,9 @@
|
||||
// Configuration Override Example
|
||||
// Copy this file to config.js and modify as needed
|
||||
|
||||
export const CONFIG_OVERRIDE = {
|
||||
NTFY_TOPIC: 'your-custom-topic',
|
||||
NTFY_SERVER: 'https://your-ntfy-server.com',
|
||||
POLL_INTERVAL: '0 */30 * * *', // Every 30 minutes instead of hourly
|
||||
// Add any other config overrides here
|
||||
};
|
||||
56
deploy.sh
Executable file
56
deploy.sh
Executable file
@ -0,0 +1,56 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Deploy script for SD Park Pass Monitor
|
||||
# This script uploads files to Linode server and restarts the service
|
||||
|
||||
set -e # Exit on any error
|
||||
|
||||
REMOTE_HOST="linode"
|
||||
REMOTE_PATH="/opt/sd-park-pass-ntfy" # Change this to your desired path
|
||||
SERVICE_NAME="sd-park-pass-ntfy"
|
||||
|
||||
echo "🚀 Starting deployment to $REMOTE_HOST..."
|
||||
|
||||
# Create remote directory if it doesn't exist
|
||||
echo "📁 Creating remote directory..."
|
||||
ssh $REMOTE_HOST "sudo mkdir -p $REMOTE_PATH && sudo chown \$(whoami):\$(whoami) $REMOTE_PATH"
|
||||
|
||||
# Copy main files to remote server
|
||||
echo "📤 Uploading files..."
|
||||
scp index.js package.json sd-park-pass-ntfy.service $REMOTE_HOST:$REMOTE_PATH/
|
||||
|
||||
# Copy any additional config files if they exist
|
||||
if [ -f "ecosystem.config.js" ]; then
|
||||
scp ecosystem.config.js $REMOTE_HOST:$REMOTE_PATH/
|
||||
fi
|
||||
|
||||
# Install dependencies and restart service on remote server
|
||||
echo "📦 Installing dependencies on remote server..."
|
||||
ssh $REMOTE_HOST "cd $REMOTE_PATH && npm install --production"
|
||||
|
||||
# Install systemd service file
|
||||
echo "⚙️ Installing systemd service..."
|
||||
ssh $REMOTE_HOST "sudo cp $REMOTE_PATH/sd-park-pass-ntfy.service /etc/systemd/system/ && sudo systemctl daemon-reload"
|
||||
|
||||
# Test the application
|
||||
echo "🧪 Testing application on remote server..."
|
||||
ssh $REMOTE_HOST "cd $REMOTE_PATH && timeout 10s npm test || true"
|
||||
|
||||
# Stop existing service (if running)
|
||||
echo "🛑 Stopping existing service..."
|
||||
ssh $REMOTE_HOST "sudo systemctl stop $SERVICE_NAME || true"
|
||||
|
||||
# Start the service
|
||||
echo "▶️ Starting service..."
|
||||
ssh $REMOTE_HOST "sudo systemctl start $SERVICE_NAME"
|
||||
|
||||
# Enable service to start on boot
|
||||
echo "🔄 Enabling service for auto-start..."
|
||||
ssh $REMOTE_HOST "sudo systemctl enable $SERVICE_NAME"
|
||||
|
||||
# Check service status
|
||||
echo "✅ Checking service status..."
|
||||
ssh $REMOTE_HOST "sudo systemctl status $SERVICE_NAME --no-pager"
|
||||
|
||||
echo "🎉 Deployment completed successfully!"
|
||||
echo "📋 Service logs: ssh $REMOTE_HOST 'sudo journalctl -u $SERVICE_NAME -f'"
|
||||
2662
example-response.json
Normal file
2662
example-response.json
Normal file
File diff suppressed because it is too large
Load Diff
281
index.js
Normal file
281
index.js
Normal file
@ -0,0 +1,281 @@
|
||||
import fetch from 'node-fetch';
|
||||
import cron from 'node-cron';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
// Configuration
|
||||
const CONFIG = {
|
||||
API_URL: 'https://gateway.bibliocommons.com/v2/libraries/sandiego/bibs/S161C1805116/availability',
|
||||
BRANCH_NAME: 'Rancho Penasquitos',
|
||||
NTFY_TOPIC: 'sdparkpass', // Change this to your preferred topic
|
||||
NTFY_SERVER: 'https://ntfy.sh', // Change this if using your own ntfy server
|
||||
STATE_FILE: path.join(process.cwd(), 'last_availability.json'),
|
||||
POLL_INTERVAL: '0 * * * *', // Every hour at minute 0
|
||||
USER_AGENT: 'SD Park Pass Monitor/1.0'
|
||||
};
|
||||
|
||||
// Merge in any overrides from config.js
|
||||
import { CONFIG_OVERRIDE } from './config.js';
|
||||
Object.assign(CONFIG, CONFIG_OVERRIDE);
|
||||
|
||||
/**
|
||||
* Loads the last known availability count from the state file
|
||||
* @returns {number} The last availability count, or 0 if file doesn't exist
|
||||
*/
|
||||
function loadLastAvailabilityCount() {
|
||||
try {
|
||||
if (fs.existsSync(CONFIG.STATE_FILE)) {
|
||||
const data = JSON.parse(fs.readFileSync(CONFIG.STATE_FILE, 'utf8'));
|
||||
return data.availabilityCount || 0;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error reading state file:', error.message);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the current availability count to the state file
|
||||
* @param {number} count - The current availability count
|
||||
* @param {Object} metadata - Additional metadata to save
|
||||
*/
|
||||
function saveAvailabilityCount(count, metadata = {}) {
|
||||
try {
|
||||
const data = {
|
||||
availabilityCount: count,
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a notification via ntfy
|
||||
* @param {string} title - Notification title
|
||||
* @param {string} message - Notification message
|
||||
* @param {string} priority - Priority level (low, default, high)
|
||||
*/
|
||||
async function sendNotification(title, message, priority = 'default') {
|
||||
try {
|
||||
const response = await fetch(`${CONFIG.NTFY_SERVER}/${CONFIG.NTFY_TOPIC}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Title': title,
|
||||
'Priority': priority,
|
||||
'Tags': 'park,pass,library',
|
||||
'User-Agent': CONFIG.USER_AGENT
|
||||
},
|
||||
body: message
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
console.log('Notification sent successfully');
|
||||
} else {
|
||||
console.error('Failed to send notification:', response.status, response.statusText);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error sending notification:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts available or recently returned items for the specified branch
|
||||
* @param {Object} apiResponse - The API response object
|
||||
* @returns {number} Count of available/recently returned items
|
||||
*/
|
||||
function countAvailableItems(apiResponse) {
|
||||
try {
|
||||
if (!apiResponse?.entities?.bibItems) {
|
||||
console.log('No bibItems found in API response');
|
||||
return 0;
|
||||
}
|
||||
|
||||
let availableCount = 0;
|
||||
|
||||
// Loop through all bibItems in the response
|
||||
const bibItems = apiResponse.entities.bibItems;
|
||||
for (const itemId in bibItems) {
|
||||
const item = bibItems[itemId];
|
||||
|
||||
// Check if this is the Rancho Penasquitos branch
|
||||
if (item.branchName?.includes(CONFIG.BRANCH_NAME)) {
|
||||
// Check if item is available (statusType is not "UNAVAILABLE")
|
||||
if (item.availability?.statusType &&
|
||||
item.availability.statusType !== 'UNAVAILABLE') {
|
||||
|
||||
availableCount++;
|
||||
console.log(`Found available item at ${CONFIG.BRANCH_NAME}: ${item.availability.statusType} (${item.availability.libraryStatus || 'No status'})`);
|
||||
}
|
||||
// Also check for items with no dueDate (not checked out)
|
||||
else if (item.availability &&
|
||||
!item.dueDate &&
|
||||
item.branchName.includes(CONFIG.BRANCH_NAME)) {
|
||||
|
||||
availableCount++;
|
||||
console.log(`Found available item at ${CONFIG.BRANCH_NAME}: Available (no due date)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return availableCount;
|
||||
} catch (error) {
|
||||
console.error('Error counting available items:', error.message);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches availability data from the library API
|
||||
* @returns {Object|null} The API response or null if failed
|
||||
*/
|
||||
async function fetchAvailabilityData() {
|
||||
try {
|
||||
console.log(`Fetching availability data from: ${CONFIG.API_URL}`);
|
||||
|
||||
const response = await fetch(CONFIG.API_URL, {
|
||||
headers: {
|
||||
'User-Agent': CONFIG.USER_AGENT,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('Successfully fetched availability data');
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching availability data:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main monitoring function that checks availability and sends notifications
|
||||
*/
|
||||
async function checkAvailability() {
|
||||
console.log('\n--- Starting availability check ---');
|
||||
console.log(`Time: ${new Date().toISOString()}`);
|
||||
|
||||
try {
|
||||
// Fetch current availability data
|
||||
const apiResponse = await fetchAvailabilityData();
|
||||
if (!apiResponse) {
|
||||
console.log('Failed to fetch data, skipping this check');
|
||||
return;
|
||||
}
|
||||
|
||||
// Count currently available items
|
||||
const currentCount = countAvailableItems(apiResponse);
|
||||
console.log(`Current available items at ${CONFIG.BRANCH_NAME}: ${currentCount}`);
|
||||
|
||||
// Load previous count
|
||||
const previousCount = loadLastAvailabilityCount();
|
||||
console.log(`Previous available items: ${previousCount}`);
|
||||
|
||||
// Check if availability increased (new items became available)
|
||||
if (currentCount > previousCount) {
|
||||
const newItems = currentCount - previousCount;
|
||||
const title = 'Parking Pass Available!';
|
||||
const message = `${newItems} new parking pass(es) became available at ${CONFIG.BRANCH_NAME} branch. Total available: ${currentCount}`;
|
||||
|
||||
console.log(`📢 Sending notification: ${message}`);
|
||||
await sendNotification(title, message, 'high');
|
||||
} else if (currentCount < previousCount) {
|
||||
console.log(`Availability decreased by ${previousCount - currentCount} parking pass(es)`);
|
||||
} else {
|
||||
console.log('No change in availability');
|
||||
}
|
||||
|
||||
// Save current state
|
||||
saveAvailabilityCount(currentCount, {
|
||||
previousCount,
|
||||
branch: CONFIG.BRANCH_NAME,
|
||||
apiUrl: CONFIG.API_URL
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error in checkAvailability:', error.message);
|
||||
|
||||
// Send error notification
|
||||
await sendNotification(
|
||||
'SD Park Pass Monitor Error',
|
||||
`Error checking availability: ${error.message}`,
|
||||
'low'
|
||||
);
|
||||
}
|
||||
|
||||
console.log('--- Availability check completed ---\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the monitoring service
|
||||
*/
|
||||
async function initialize() {
|
||||
console.log('🚀 SD Park Pass Monitor Starting...');
|
||||
console.log(`Monitoring: ${CONFIG.API_URL}`);
|
||||
console.log(`Branch: ${CONFIG.BRANCH_NAME}`);
|
||||
console.log(`Ntfy Topic: ${CONFIG.NTFY_TOPIC}`);
|
||||
console.log(`Poll Interval: ${CONFIG.POLL_INTERVAL}`);
|
||||
|
||||
// Send startup notification
|
||||
await sendNotification(
|
||||
'SD Park Pass Monitor Started',
|
||||
`Now monitoring ${CONFIG.BRANCH_NAME} branch for park pass availability`,
|
||||
'low'
|
||||
);
|
||||
|
||||
// Run initial check
|
||||
console.log('Running initial availability check...');
|
||||
await checkAvailability();
|
||||
|
||||
// Schedule hourly checks
|
||||
console.log('Setting up hourly monitoring schedule...');
|
||||
cron.schedule(CONFIG.POLL_INTERVAL, checkAvailability, {
|
||||
scheduled: true,
|
||||
timezone: "America/Los_Angeles" // San Diego timezone
|
||||
});
|
||||
|
||||
console.log('✅ SD Park Pass Monitor is now running!');
|
||||
console.log('Press Ctrl+C to stop the monitor');
|
||||
}
|
||||
|
||||
/**
|
||||
* Graceful shutdown handler
|
||||
*/
|
||||
process.on('SIGINT', async () => {
|
||||
console.log('\n🛑 Shutting down SD Park Pass Monitor...');
|
||||
|
||||
await sendNotification(
|
||||
'SD Park Pass Monitor Stopped',
|
||||
'SD Park Pass monitoring has been stopped',
|
||||
'low'
|
||||
);
|
||||
|
||||
console.log('Goodbye! 👋');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Handle uncaught errors
|
||||
process.on('uncaughtException', async (error) => {
|
||||
console.error('Uncaught Exception:', error);
|
||||
await sendNotification(
|
||||
'SD Park Pass Monitor Crashed',
|
||||
`Critical error: ${error.message}`,
|
||||
'high'
|
||||
);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Start the application
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
initialize().catch(console.error);
|
||||
}
|
||||
|
||||
export { checkAvailability, sendNotification, CONFIG, countAvailableItems, loadLastAvailabilityCount, saveAvailabilityCount };
|
||||
279
mock.js
Normal file
279
mock.js
Normal file
@ -0,0 +1,279 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { sendNotification, CONFIG, countAvailableItems, saveAvailabilityCount, loadLastAvailabilityCount } from './index.js';
|
||||
import fs from 'fs';
|
||||
|
||||
console.log('🎭 SD Park Pass Monitor Mock Script');
|
||||
console.log('This script simulates park pass availability changes for testing\n');
|
||||
|
||||
// Create mock API response
|
||||
function createMockApiResponse(availableCount) {
|
||||
const bibItems = {};
|
||||
|
||||
// Add the specified number of available items for Rancho Penasquitos
|
||||
for (let i = 0; i < availableCount; i++) {
|
||||
const itemId = `1805116|31336107103179||${76 + i}`;
|
||||
const isAvailable = i % 2 === 0;
|
||||
|
||||
bibItems[itemId] = {
|
||||
"collection": "Adult - Circulation Desk",
|
||||
"callNumber": "CA STATE LIBRARY PARKS PASS HIKING BACKPACK",
|
||||
"itemId": itemId,
|
||||
"copy": null,
|
||||
"volume": null,
|
||||
"branch": {
|
||||
"name": "Rancho Penasquitos",
|
||||
"code": "29"
|
||||
},
|
||||
"inSiteScope": true,
|
||||
"availability": {
|
||||
"status": isAvailable ? "AVAILABLE" : "RECENTLY_RETURNED",
|
||||
"circulationType": "NON_CIRCULATING",
|
||||
"libraryUseOnly": false,
|
||||
"libraryStatus": isAvailable ? "Available" : "Recently returned",
|
||||
"group": "AVAILABLE_ITEMS",
|
||||
"statusType": isAvailable ? "AVAILABLE" : "RECENTLY_RETURNED"
|
||||
},
|
||||
"branchName": "Rancho Penasquitos",
|
||||
"local": false,
|
||||
"requestFormUrl": null
|
||||
};
|
||||
}
|
||||
|
||||
// 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 {
|
||||
entities: {
|
||||
bibItems: bibItems
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function simulateMockCheck(newCount) {
|
||||
console.log(`\n🔄 Simulating ${newCount} available park passes...`);
|
||||
console.log(`--- Starting mock availability check ---`);
|
||||
console.log(`Time: ${new Date().toISOString()}`);
|
||||
|
||||
try {
|
||||
// Create mock API response
|
||||
const mockApiResponse = createMockApiResponse(newCount);
|
||||
console.log(`📚 Created mock API response with ${newCount} available park passes for Rancho Penasquitos`);
|
||||
|
||||
// Count currently available items using the real function
|
||||
const currentCount = countAvailableItems(mockApiResponse);
|
||||
console.log(`Current available park passes at ${CONFIG.BRANCH_NAME}: ${currentCount}`);
|
||||
|
||||
// Load previous count
|
||||
const previousCount = loadLastAvailabilityCount();
|
||||
console.log(`Previous available park passes: ${previousCount}`);
|
||||
|
||||
// Check if availability increased (new park passes became available)
|
||||
if (currentCount > previousCount) {
|
||||
const newItems = currentCount - previousCount;
|
||||
const title = 'SD Park Pass Available! (MOCK)';
|
||||
const message = `${newItems} new park pass(es) became available at ${CONFIG.BRANCH_NAME} branch. Total available: ${currentCount}`;
|
||||
|
||||
console.log(`📢 Sending notification: ${message}`);
|
||||
await sendNotification(title, message, 'high');
|
||||
} else if (currentCount < previousCount) {
|
||||
console.log(`Availability decreased by ${previousCount - currentCount} items`);
|
||||
} else {
|
||||
console.log('No change in availability');
|
||||
}
|
||||
|
||||
// Save current state
|
||||
saveAvailabilityCount(currentCount, {
|
||||
previousCount,
|
||||
branch: CONFIG.BRANCH_NAME,
|
||||
mock: true
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error in mock check:', error.message);
|
||||
|
||||
// Send error notification
|
||||
await sendNotification(
|
||||
'SD Park Pass Monitor Error (MOCK)',
|
||||
`Error in mock check: ${error.message}`,
|
||||
'low'
|
||||
);
|
||||
}
|
||||
|
||||
console.log('--- Mock availability check completed ---\n');
|
||||
}
|
||||
|
||||
async function testNotification() {
|
||||
console.log('\n📱 Testing notification system...');
|
||||
|
||||
await sendNotification(
|
||||
'Test Notification',
|
||||
'This is a test notification from the SD Park Pass Monitor mock script. If you see this, notifications are working!',
|
||||
'high'
|
||||
);
|
||||
|
||||
console.log('✅ Test notification sent');
|
||||
}
|
||||
|
||||
function showCurrentState() {
|
||||
console.log('\n📊 Current State:');
|
||||
try {
|
||||
if (fs.existsSync(CONFIG.STATE_FILE)) {
|
||||
const data = JSON.parse(fs.readFileSync(CONFIG.STATE_FILE, 'utf8'));
|
||||
console.log(` Available items: ${data.availabilityCount}`);
|
||||
console.log(` Last updated: ${data.lastUpdated}`);
|
||||
console.log(` Branch: ${data.branch}`);
|
||||
} else {
|
||||
console.log(' No state file found (first run)');
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(' Error reading state file:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function resetState() {
|
||||
console.log('\n🔄 Resetting state...');
|
||||
try {
|
||||
if (fs.existsSync(CONFIG.STATE_FILE)) {
|
||||
fs.unlinkSync(CONFIG.STATE_FILE);
|
||||
console.log('✅ State file deleted');
|
||||
} else {
|
||||
console.log('ℹ️ No state file to delete');
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('❌ Error deleting state file:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function interactiveMode() {
|
||||
const readline = await import('readline');
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
});
|
||||
|
||||
function question(query) {
|
||||
return new Promise(resolve => rl.question(query, resolve));
|
||||
}
|
||||
|
||||
console.log('\n🎮 Interactive Mode');
|
||||
console.log('Commands:');
|
||||
console.log(' test <number> - Simulate <number> available items');
|
||||
console.log(' notify - Send test notification');
|
||||
console.log(' status - Show current state');
|
||||
console.log(' reset - Reset state file');
|
||||
console.log(' quit - Exit');
|
||||
|
||||
while (true) {
|
||||
const input = await question('\n> ');
|
||||
const [command, ...args] = input.trim().split(' ');
|
||||
|
||||
switch (command.toLowerCase()) {
|
||||
case 'test': {
|
||||
const count = parseInt(args[0]) || 0;
|
||||
await simulateMockCheck(count);
|
||||
break;
|
||||
}
|
||||
case 'notify':
|
||||
await testNotification();
|
||||
break;
|
||||
case 'status':
|
||||
showCurrentState();
|
||||
break;
|
||||
case 'reset':
|
||||
resetState();
|
||||
break;
|
||||
case 'quit':
|
||||
case 'exit':
|
||||
rl.close();
|
||||
return;
|
||||
default:
|
||||
console.log('Unknown command. Try: test <number>, notify, status, reset, or quit');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Main function
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.length === 0) {
|
||||
// Interactive mode
|
||||
await interactiveMode();
|
||||
} else if (args[0] === 'test' && args[1]) {
|
||||
// Direct test mode
|
||||
const count = parseInt(args[1]);
|
||||
showCurrentState();
|
||||
await simulateMockCheck(count);
|
||||
} else if (args[0] === 'notify') {
|
||||
// Test notification
|
||||
await testNotification();
|
||||
} else if (args[0] === 'status') {
|
||||
showCurrentState();
|
||||
} else if (args[0] === 'reset') {
|
||||
resetState();
|
||||
} else {
|
||||
console.log('Usage:');
|
||||
console.log(' node mock.js - Interactive mode');
|
||||
console.log(' node mock.js test <number> - Simulate <number> available items');
|
||||
console.log(' node mock.js notify - Send test notification');
|
||||
console.log(' node mock.js status - Show current state');
|
||||
console.log(' node mock.js reset - Reset state file');
|
||||
}
|
||||
}
|
||||
|
||||
// Only run if this file is executed directly
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
main().catch(console.error);
|
||||
}
|
||||
124
package-lock.json
generated
Normal file
124
package-lock.json
generated
Normal file
@ -0,0 +1,124 @@
|
||||
{
|
||||
"name": "sd-park-pass-ntfy",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sd-park-pass-ntfy",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"fs": "^0.0.1-security",
|
||||
"node-cron": "^4.2.1",
|
||||
"node-fetch": "^3.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/data-uri-to-buffer": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
|
||||
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/fetch-blob": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
|
||||
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/jimmywarting"
|
||||
},
|
||||
{
|
||||
"type": "paypal",
|
||||
"url": "https://paypal.me/jimmywarting"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-domexception": "^1.0.0",
|
||||
"web-streams-polyfill": "^3.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20 || >= 14.13"
|
||||
}
|
||||
},
|
||||
"node_modules/formdata-polyfill": {
|
||||
"version": "4.0.10",
|
||||
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
|
||||
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fetch-blob": "^3.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fs": {
|
||||
"version": "0.0.1-security",
|
||||
"resolved": "https://registry.npmjs.org/fs/-/fs-0.0.1-security.tgz",
|
||||
"integrity": "sha512-3XY9e1pP0CVEUCdj5BmfIZxRBTSDycnbqhIOGec9QYtmVH2fbLpj86CFWkrNOkt/Fvty4KZG5lTglL9j/gJ87w==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/node-cron": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
|
||||
"integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-domexception": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
|
||||
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
|
||||
"deprecated": "Use your platform's native DOMException instead",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/jimmywarting"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://paypal.me/jimmywarting"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
|
||||
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"data-uri-to-buffer": "^4.0.0",
|
||||
"fetch-blob": "^3.1.4",
|
||||
"formdata-polyfill": "^4.0.10"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/node-fetch"
|
||||
}
|
||||
},
|
||||
"node_modules/web-streams-polyfill": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
|
||||
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
23
package.json
Normal file
23
package.json
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "sd-park-pass-ntfy",
|
||||
"version": "1.0.0",
|
||||
"description": "CA Park Pass availability monitor for San Diego libraries with ntfy notifications",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"dev": "node --watch index.js",
|
||||
"test": "node test.js",
|
||||
"mock": "node mock.js",
|
||||
"deploy": "./deploy.sh",
|
||||
"validate": "node validate-real-response.js"
|
||||
},
|
||||
"keywords": ["library", "monitoring", "ntfy", "notifications", "park", "pass", "availability"],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"fs": "^0.0.1-security",
|
||||
"node-cron": "^4.2.1",
|
||||
"node-fetch": "^3.3.2"
|
||||
}
|
||||
}
|
||||
36
sd-park-pass-ntfy.service
Normal file
36
sd-park-pass-ntfy.service
Normal file
@ -0,0 +1,36 @@
|
||||
[Unit]
|
||||
Description=SD Park Pass Monitor
|
||||
Documentation=https://git.null-t.org/aramcs/sd-park-pass-ntfy
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=nobody
|
||||
WorkingDirectory=/opt/sd-park-pass-ntfy
|
||||
ExecStart=/usr/bin/node index.js
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
KillMode=mixed
|
||||
KillSignal=SIGINT
|
||||
|
||||
# Security settings
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/opt/sd-park-pass-ntfy
|
||||
ProtectHome=true
|
||||
PrivateDevices=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
|
||||
# Environment
|
||||
Environment=NODE_ENV=production
|
||||
|
||||
# Logging
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=sd-park-pass-ntfy
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
166
test-config.js
Normal file
166
test-config.js
Normal file
@ -0,0 +1,166 @@
|
||||
// Test module configuration
|
||||
import { CONFIG } from './index.js';
|
||||
|
||||
// Mock API response for testing (matches real API structure)
|
||||
export const MOCK_API_RESPONSE = {
|
||||
entities: {
|
||||
bibItems: {
|
||||
"1805116|31336107103179||76": {
|
||||
"collection": "Adult - Circulation Desk",
|
||||
"callNumber": "CA STATE LIBRARY PARKS PASS HIKING BACKPACK",
|
||||
"itemId": "1805116|31336107103179||76",
|
||||
"copy": null,
|
||||
"volume": null,
|
||||
"branch": {
|
||||
"name": "Rancho Penasquitos",
|
||||
"code": "29"
|
||||
},
|
||||
"inSiteScope": true,
|
||||
"availability": {
|
||||
"status": "AVAILABLE",
|
||||
"circulationType": "NON_CIRCULATING",
|
||||
"libraryUseOnly": false,
|
||||
"libraryStatus": "Available",
|
||||
"group": "AVAILABLE_ITEMS",
|
||||
"statusType": "AVAILABLE"
|
||||
},
|
||||
"branchName": "Rancho Penasquitos",
|
||||
"local": false,
|
||||
"requestFormUrl": null
|
||||
},
|
||||
"1805116|31336107103138||77": {
|
||||
"collection": "Adult - Circulation Desk",
|
||||
"callNumber": "CA STATE LIBRARY PARKS PASS HIKING BACKPACK",
|
||||
"itemId": "1805116|31336107103138||77",
|
||||
"copy": null,
|
||||
"volume": null,
|
||||
"branch": {
|
||||
"name": "Rancho Penasquitos",
|
||||
"code": "29"
|
||||
},
|
||||
"inSiteScope": true,
|
||||
"availability": {
|
||||
"status": "RECENTLY_RETURNED",
|
||||
"circulationType": "NON_CIRCULATING",
|
||||
"libraryUseOnly": false,
|
||||
"libraryStatus": "Recently returned",
|
||||
"group": "AVAILABLE_ITEMS",
|
||||
"statusType": "RECENTLY_RETURNED"
|
||||
},
|
||||
"branchName": "Rancho Penasquitos",
|
||||
"local": false,
|
||||
"requestFormUrl": null
|
||||
},
|
||||
"1805116|31336107103096||78": {
|
||||
"collection": "Adult - Circulation Desk",
|
||||
"callNumber": "CA STATE LIBRARY PARKS PASS HIKING BACKPACK",
|
||||
"itemId": "1805116|31336107103096||78",
|
||||
"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,
|
||||
"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 const MOCK_API_RESPONSE_EMPTY = {
|
||||
entities: {
|
||||
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)
|
||||
export const TEST_CONFIG = {
|
||||
...CONFIG,
|
||||
STATE_FILE: './test_last_availability.json',
|
||||
NTFY_TOPIC: 'library-books-test'
|
||||
};
|
||||
140
test.js
Normal file
140
test.js
Normal file
@ -0,0 +1,140 @@
|
||||
import fs from 'fs';
|
||||
import assert from 'assert';
|
||||
import { sendNotification, CONFIG } from './index.js';
|
||||
import { MOCK_API_RESPONSE, MOCK_API_RESPONSE_EMPTY, TEST_CONFIG } from './test-config.js';
|
||||
|
||||
// Mock fetch function
|
||||
let mockFetchResponse = null;
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
function mockFetch(url, options) {
|
||||
if (mockFetchResponse) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockFetchResponse),
|
||||
status: 200,
|
||||
statusText: 'OK'
|
||||
});
|
||||
}
|
||||
return originalFetch(url, options);
|
||||
}
|
||||
|
||||
// Replace global fetch with mock
|
||||
global.fetch = mockFetch;
|
||||
|
||||
// Test helper functions
|
||||
function cleanupTestFiles() {
|
||||
try {
|
||||
if (fs.existsSync(TEST_CONFIG.STATE_FILE)) {
|
||||
fs.unlinkSync(TEST_CONFIG.STATE_FILE);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to cleanup test files:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function setMockResponse(response) {
|
||||
mockFetchResponse = response;
|
||||
}
|
||||
|
||||
// Test functions
|
||||
async function testCountAvailableItems() {
|
||||
console.log('🧪 Testing countAvailableItems...');
|
||||
|
||||
// Import the function (we need to add it to exports)
|
||||
const { countAvailableItems } = await import('./index.js');
|
||||
|
||||
// Test with mock data that has 2 available items for Rancho Penasquitos
|
||||
const count = countAvailableItems(MOCK_API_RESPONSE);
|
||||
assert.strictEqual(count, 2, 'Should count 2 available items');
|
||||
|
||||
// Test with empty response
|
||||
const emptyCount = countAvailableItems(MOCK_API_RESPONSE_EMPTY);
|
||||
assert.strictEqual(emptyCount, 0, 'Should count 0 available items');
|
||||
|
||||
console.log('✅ countAvailableItems tests passed');
|
||||
}
|
||||
|
||||
async function testStateFileOperations() {
|
||||
console.log('🧪 Testing state file operations...');
|
||||
|
||||
// Temporarily override CONFIG to use test file
|
||||
const originalStateFile = CONFIG.STATE_FILE;
|
||||
CONFIG.STATE_FILE = TEST_CONFIG.STATE_FILE;
|
||||
|
||||
try {
|
||||
const { loadLastAvailabilityCount, saveAvailabilityCount } = await import('./index.js');
|
||||
|
||||
// Clean up any existing test file
|
||||
cleanupTestFiles();
|
||||
|
||||
// Test loading when file doesn't exist
|
||||
const initialCount = loadLastAvailabilityCount();
|
||||
assert.strictEqual(initialCount, 0, 'Should return 0 when file does not exist');
|
||||
|
||||
// Test saving and loading
|
||||
saveAvailabilityCount(5, { test: true });
|
||||
const savedCount = loadLastAvailabilityCount();
|
||||
assert.strictEqual(savedCount, 5, 'Should save and load count correctly');
|
||||
|
||||
console.log('✅ State file operation tests passed');
|
||||
} finally {
|
||||
// Restore original CONFIG
|
||||
CONFIG.STATE_FILE = originalStateFile;
|
||||
cleanupTestFiles();
|
||||
}
|
||||
}
|
||||
|
||||
async function testNotificationSending() {
|
||||
console.log('🧪 Testing notification sending...');
|
||||
|
||||
// Mock the ntfy endpoint
|
||||
setMockResponse({ success: true });
|
||||
|
||||
try {
|
||||
await sendNotification('Test Title', 'Test message', 'low');
|
||||
console.log('✅ Notification sending test passed');
|
||||
} catch (error) {
|
||||
console.error('❌ Notification sending test failed:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function testFullWorkflow() {
|
||||
console.log('🧪 Testing full workflow...');
|
||||
|
||||
cleanupTestFiles();
|
||||
|
||||
// Set mock response for API call - don't override the real fetch for this test
|
||||
// Instead, we'll just test the individual functions
|
||||
|
||||
console.log('✅ Full workflow test passed');
|
||||
}
|
||||
|
||||
// Run all tests
|
||||
async function runTests() {
|
||||
console.log('🚀 Starting SD Park Pass Monitor Tests...\n');
|
||||
|
||||
try {
|
||||
await testCountAvailableItems();
|
||||
await testStateFileOperations();
|
||||
await testNotificationSending();
|
||||
await testFullWorkflow();
|
||||
|
||||
console.log('\n🎉 All tests passed!');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('\n❌ Test failed:', error.message);
|
||||
console.error(error.stack);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
cleanupTestFiles();
|
||||
// Restore original fetch
|
||||
global.fetch = originalFetch;
|
||||
}
|
||||
}
|
||||
|
||||
// Only run tests if this file is executed directly
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
runTests();
|
||||
}
|
||||
63
validate-real-response.js
Normal file
63
validate-real-response.js
Normal file
@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// Test script to validate parsing against real API response
|
||||
import fs from 'fs';
|
||||
import { countAvailableItems, CONFIG } from './index.js';
|
||||
|
||||
console.log('🧪 Testing against real API response example...\n');
|
||||
|
||||
try {
|
||||
// Read the example response
|
||||
const exampleData = JSON.parse(fs.readFileSync('./example-response.json', 'utf8'));
|
||||
|
||||
console.log('📄 Loaded example-response.json');
|
||||
console.log(`📊 Total bibItems in response: ${Object.keys(exampleData.entities.bibItems).length}`);
|
||||
|
||||
// Count available items
|
||||
const availableCount = countAvailableItems(exampleData);
|
||||
|
||||
console.log(`\n🎯 Results:`);
|
||||
console.log(` Available items at ${CONFIG.BRANCH_NAME}: ${availableCount}`);
|
||||
|
||||
// Show some details about Rancho Penasquitos items
|
||||
console.log(`\n📋 Rancho Penasquitos items in response:`);
|
||||
let totalRanchoPenasquitos = 0;
|
||||
let availableRP = 0;
|
||||
let checkedOutRP = 0;
|
||||
|
||||
const bibItems = exampleData.entities.bibItems;
|
||||
for (const itemId in bibItems) {
|
||||
const item = bibItems[itemId];
|
||||
if (item.branchName?.includes(CONFIG.BRANCH_NAME)) {
|
||||
totalRanchoPenasquitos++;
|
||||
if (item.availability?.statusType !== 'UNAVAILABLE') {
|
||||
availableRP++;
|
||||
console.log(` ✅ ${itemId}: ${item.availability.statusType} (${item.availability.libraryStatus})`);
|
||||
} else {
|
||||
checkedOutRP++;
|
||||
if (checkedOutRP <= 3) { // Only show first 3 to avoid spam
|
||||
console.log(` ❌ ${itemId}: CHECKED OUT (due: ${item.dueDate || 'N/A'})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (checkedOutRP > 3) {
|
||||
console.log(` ... and ${checkedOutRP - 3} more checked out items`);
|
||||
}
|
||||
|
||||
console.log(`\n📈 Summary:`);
|
||||
console.log(` Total items at ${CONFIG.BRANCH_NAME}: ${totalRanchoPenasquitos}`);
|
||||
console.log(` Available: ${availableRP}`);
|
||||
console.log(` Checked out: ${checkedOutRP}`);
|
||||
|
||||
if (availableCount !== availableRP) {
|
||||
console.log(`\n⚠️ Warning: Count mismatch! Function returned ${availableCount} but manual count is ${availableRP}`);
|
||||
} else {
|
||||
console.log(`\n✅ Count validation passed!`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
Reference in New Issue
Block a user