Compare commits

..

9 commits

12 changed files with 434 additions and 167 deletions

3
.gitignore vendored
View file

@ -1,4 +1,5 @@
node_modules/ node_modules/
test/calendar/ test/calendar/
calendar/ calendar/
logs/ logs/
.qodo

Binary file not shown.

View file

@ -7,8 +7,9 @@ The Calendar Merger project is a web application that allows users to merge mult
- Add calendars - Add calendars
- Specify prefixes for each calendar - Specify prefixes for each calendar
- Override event summaries if desired - Override event summaries if desired
- Search for and edit previously merged calendars
The application also generates a unique URL for the merged calendar and updates it every hour using a cron job. The application also generates a unique URL for the merged calendar and updates it ever so often.
## Features ## Features
@ -16,7 +17,8 @@ The application also generates a unique URL for the merged calendar and updates
- Specify prefixes for each calendar - Specify prefixes for each calendar
- Optionally override event summaries - Optionally override event summaries
- Generate a unique URL for the merged calendar - Generate a unique URL for the merged calendar
- Automatically update the merged calendar every hour - Automatically update the merged calendar
- Search for and edit previously merged calendars
## Calender Directory ## Calender Directory
@ -76,7 +78,7 @@ This maps the container's port `3012` to the host system's port `3012`. The appl
If you prefer to use Docker Compose, ensure you have a `docker-compose.yml` file in your project directory. Then, run: If you prefer to use Docker Compose, ensure you have a `docker-compose.yml` file in your project directory. Then, run:
```bash ```bash
docker-compose up -d docker compose up -d
``` ```
This will automatically build and start the container based on the configuration in the `docker-compose.yml` file. This will automatically build and start the container based on the configuration in the `docker-compose.yml` file.

View file

@ -9,6 +9,6 @@ services:
- NODE_ENV=production - NODE_ENV=production
- NODE_PORT=3012 - NODE_PORT=3012
volumes: volumes:
- ./calendar:/app/calendar - ./calendar:/Calmerge/calendar
- ./logs:/app/logs - ./logs:/Calmerge/logs
restart: unless-stopped restart: unless-stopped

View file

@ -2,7 +2,7 @@
FROM node:18-alpine FROM node:18-alpine
# Set working directory inside the container # Set working directory inside the container
WORKDIR /usr/src/app WORKDIR /Calmerge
# Copy package.json and package-lock.json for installing dependencies # Copy package.json and package-lock.json for installing dependencies
COPY package*.json ./ COPY package*.json ./

View file

@ -9,9 +9,23 @@
<body> <body>
<div class="container"> <div class="container">
<h1>📅 Calendar Merger</h1> <h1>📅 Calendar Merger</h1>
<!-- Search Section -->
<div class="form-card search-section">
<h2>Find Existing Calendar</h2>
<div class="input-group">
<div class="search-container">
<input type="text" id="calendar-search" placeholder="Enter calendar name">
<button type="button" id="search-btn" class="button primary-btn">Search</button>
</div>
<div id="search-result"></div>
</div>
</div>
<!-- Merge Form -->
<div class="form-card"> <div class="form-card">
<form id="merge-form"> <form id="merge-form">
<h2>Merge Calendars</h2>
<div class="input-group"> <div class="input-group">
<input type="text" <input type="text"
id="link-group-name" id="link-group-name"
@ -41,8 +55,10 @@
</div> </div>
</form> </form>
</div> </div>
<div id="result"></div> <!-- Result Section -->
<div id="result"></div>
</div> </div>
<script src="script.js"></script> <script src="script.js"></script>

View file

@ -2,7 +2,18 @@ const form = document.getElementById('merge-form');
const calendars = document.getElementById('calendars'); const calendars = document.getElementById('calendars');
const addCalendarButton = document.getElementById('add-calendar'); const addCalendarButton = document.getElementById('add-calendar');
const result = document.getElementById('result'); const result = document.getElementById('result');
const resultInfo = document.querySelector('.result-info');
// Search functionality elements
const searchBtn = document.getElementById('search-btn');
const calendarSearch = document.getElementById('calendar-search');
const searchResult = document.getElementById('search-result');
// Variable to track if we're editing an existing calendar
let isEditing = false;
let currentCalendarName = '';
// Variable to track the index of the calendar being added
let calendarIndex = 1; let calendarIndex = 1;
let mergedUrl = ''; let mergedUrl = '';
@ -12,78 +23,221 @@ function isValidUrl(url) {
return urlPattern.test(url); return urlPattern.test(url);
} }
addCalendarButton.addEventListener('click', () => { // Function to extract calendar name from URL or input
const newCalendar = document.createElement('div'); function extractCalendarName(input) {
newCalendar.className = 'calendar-entry'; // If it's a URL, extract the last part of the path
newCalendar.innerHTML = ` if (input.startsWith('http')) {
<input type="url" id="url-${calendarIndex}" placeholder="https://example.com/calendar.ics"> try {
<input type="text" id="prefix-${calendarIndex}" placeholder="Event prefix"> // Remove trailing slash if present
<div class="checkbox-group"> if (input.endsWith('/')) {
<input type="checkbox" id="override-${calendarIndex}"> input = input.slice(0, -1);
<label for="override-${calendarIndex}">Override</label> }
</div>
<button type="button" class="remove-btn" title="Remove calendar"></button>
`;
calendars.appendChild(newCalendar);
calendarIndex++;
});
form.addEventListener('submit', (event) => {
event.preventDefault();
const linkGroupName = document.getElementById('link-group-name').value;
const calendarsData = [];
let valid = true; // Flag to track URL validity
for (let i = 0; i < calendarIndex; i++) { // Extract the last part of the path
const prefix = document.getElementById(`prefix-${i}`); const url = new URL(input);
const override = document.getElementById(`override-${i}`); const pathParts = url.pathname.split('/').filter(part => part.length > 0);
const url = document.getElementById(`url-${i}`);
// If there's a path part, use the last one
if (prefix && override && url) { if (pathParts.length > 0) {
// Validate the URL let lastPart = pathParts[pathParts.length - 1];
if (!isValidUrl(url.value)) {
valid = false; // Set flag to false if any URL is invalid // Remove .ics extension if present
alert(`Invalid URL format for calendar ${i + 1}: ${url.value}`); if (lastPart.endsWith('.ics')) {
} else { lastPart = lastPart.slice(0, -4);
calendarsData.push({
prefix: prefix.value,
override: override.checked,
url: url.value
});
}
} }
return lastPart;
} }
if (valid) { } catch (e) {
fetch('/merge', { console.error('Error parsing URL:', e);
method: 'POST', }
headers: { }
'Content-Type': 'application/json'
}, // If not a URL or URL parsing failed, just return the input as is
body: JSON.stringify({ linkGroupName, calendars: calendarsData }) return input;
}) }
.then((response) => {
if (!response.ok) { // Event listener for the search button
return response.json().then(err => { throw err; }); if (searchBtn) {
searchBtn.addEventListener('click', searchCalendar);
// Also search when pressing Enter in the search field
if (calendarSearch) {
calendarSearch.addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
searchCalendar();
}
});
}
}
// Function to search for a calendar
function searchCalendar() {
let calendarName = calendarSearch.value.trim();
if (!calendarName) {
searchResult.innerHTML = '<div class="alert alert-warning">Please enter a calendar name</div>';
return;
}
// Extract just the calendar name if a URL was entered
calendarName = extractCalendarName(calendarName);
searchResult.innerHTML = '<div class="spinner-border text-primary" role="status"><span class="visually-hidden">Loading...</span></div>';
// Check if calendar exists
fetch(`/calendar-config/${calendarName}`)
.then(response => response.json())
.then(data => {
if (data.exists) {
searchResult.innerHTML = `<div class="alert alert-success">Calendar found!</div>`;
loadCalendarConfig(data.config, calendarName);
} else {
searchResult.innerHTML = `<div class="alert alert-danger">Calendar not found</div>`;
}
})
.catch(error => {
console.error('Error searching for calendar:', error);
searchResult.innerHTML = `<div class="alert alert-danger">Error searching for calendar</div>`;
});
}
// Function to load calendar configuration for editing
function loadCalendarConfig(config, calendarName) {
// Set editing mode
isEditing = true;
currentCalendarName = calendarName;
// Update link group name
document.getElementById('link-group-name').value = config.linkGroupName;
// Clear existing calendars
while (calendars.children.length > 0) {
calendars.removeChild(calendars.lastChild);
}
// Reset calendar index
calendarIndex = 0;
// Add calendars from config
config.calendars.forEach(calendar => {
const newCalendar = document.createElement('div');
newCalendar.className = 'calendar-entry';
newCalendar.innerHTML = `
<input type="url" id="url-${calendarIndex}" placeholder="https://example.com/calendar.ics" value="${calendar.url}">
<input type="text" id="prefix-${calendarIndex}" placeholder="Event prefix" value="${calendar.prefix || ''}">
<div class="checkbox-group">
<input type="checkbox" id="override-${calendarIndex}" ${calendar.override ? 'checked' : ''}>
<label for="override-${calendarIndex}">Override</label>
</div>
<button type="button" class="remove-btn" title="Remove calendar"></button>
`;
calendars.appendChild(newCalendar);
calendarIndex++;
});
// Scroll to the form
form.scrollIntoView({ behavior: 'smooth' });
}
// Event listener for adding new calendar
addCalendarButton.addEventListener('click', () => {
const newCalendar = document.createElement('div');
newCalendar.className = 'calendar-entry';
newCalendar.innerHTML = `
<input type="url" id="url-${calendarIndex}" placeholder="https://example.com/calendar.ics">
<input type="text" id="prefix-${calendarIndex}" placeholder="Event prefix">
<div class="checkbox-group">
<input type="checkbox" id="override-${calendarIndex}">
<label for="override-${calendarIndex}">Override</label>
</div>
<button type="button" class="remove-btn" title="Remove calendar"></button>
`;
calendars.appendChild(newCalendar);
calendarIndex++;
});
// Event listener for form submission
form.addEventListener('submit', (event) => {
event.preventDefault();
const linkGroupName = document.getElementById('link-group-name').value;
const calendarsData = [];
let valid = true; // Flag to track URL validity
for (let i = 0; i < calendarIndex; i++) {
const prefix = document.getElementById(`prefix-${i}`);
const override = document.getElementById(`override-${i}`);
const url = document.getElementById(`url-${i}`);
if (prefix && override && url && url.value) {
// Validate the URL
if (url.value.startsWith('http') && !isValidUrl(url.value)) {
valid = false; // Set flag to false if any URL is invalid
alert(`Invalid URL format for calendar ${i + 1}: ${url.value}`);
} else {
calendarsData.push({
prefix: prefix.value,
override: override.checked,
url: url.value
});
}
}
}
if (valid) {
// Determine if we're creating or updating
const endpoint = isEditing ? `/calendar/${currentCalendarName}` : '/merge';
const method = isEditing ? 'PUT' : 'POST';
fetch(endpoint, {
method: method,
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ linkGroupName, calendars: calendarsData })
})
.then((response) => {
if (!response.ok) {
return response.json().then(err => { throw err; });
}
return response.json();
})
.then((data) => {
mergedUrl = data.url;
// Hide the info text when showing results
if (resultInfo) {
resultInfo.style.display = 'none';
}
if (isEditing) {
result.innerHTML = `Updated calendar URL: <a href="${data.url}">${data.url}</a>`;
// Update the search result
if (searchResult) {
searchResult.innerHTML = `<div class="alert alert-success">Calendar updated successfully!</div>`;
} }
return response.json();
}) // Reset editing state
.then((data) => { isEditing = false;
mergedUrl = data.url; currentCalendarName = '';
} else {
result.innerHTML = `Merged calendar URL: <a href="${data.url}">${data.url}</a>`; result.innerHTML = `Merged calendar URL: <a href="${data.url}">${data.url}</a>`;
console.log('Links added successfully!');
})
.catch((error) => {
console.error('Error:', error);
result.innerHTML = `Error merging calendars: ${error.message || 'Unknown error'}`
});
}
});
document.addEventListener('click', (event) => {
if (event.target.classList.contains('remove-btn')) {
const calendarEntry = event.target.closest('.calendar-entry');
if (calendarEntry) {
calendarEntry.remove();
} }
console.log('Operation completed successfully!');
})
.catch((error) => {
console.error('Error:', error);
result.innerHTML = `Error: ${error.message || 'Unknown error'}`
});
}
});
// Event listener for removing a calendar entry
document.addEventListener('click', (event) => {
if (event.target.classList.contains('remove-btn')) {
const calendarEntry = event.target.closest('.calendar-entry');
if (calendarEntry) {
calendarEntry.remove();
} }
}); }
});

View file

@ -112,6 +112,7 @@
background: var(--surface); background: var(--surface);
border-radius: 8px; border-radius: 8px;
margin-top: 1rem; margin-top: 1rem;
margin-bottom: 2rem;
border: 2px dashed var(--border); border: 2px dashed var(--border);
} }
@ -156,4 +157,31 @@
content: '×'; content: '×';
font-size: 1.4rem; font-size: 1.4rem;
line-height: 1; line-height: 1;
} }
/* Search Section Styling */
.search-section {
background: var(--surface);
border-radius: 12px;
padding: 2rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
margin-bottom: 2rem;
}
.search-container {
display: flex;
gap: 0.75rem;
margin-top: 0.5rem;
}
.search-container input {
flex: 1;
}
.search-container button {
white-space: nowrap;
}
#search-result {
margin-top: 1rem;
}

View file

@ -15,33 +15,18 @@ export const sanitizeFilename = (filename) => filename.replace(/[<>:"/\\|?* ]/g,
// Fetch calendar data from URL or file // Fetch calendar data from URL or file
export async function fetchCalendarData(calendar) { export async function fetchCalendarData(calendar) {
const isFilePath = !calendar.url.startsWith('http'); const isFilePath = !calendar.url.startsWith('http');
if (isFilePath) {
// logger.debug(`Reading calendar from file: ${calendar.url}`);
return { data: fs.readFileSync(path.resolve(calendar.url), 'utf-8'), ...calendar };
}
try { try {
// First try the original URL if (isFilePath) {
const initialResponse = await axios.get(calendar.url); // logger.debug(`Reading calendar from file: ${calendar.url}`);
return { data: initialResponse.data, ...calendar }; return { data: fs.readFileSync(path.resolve(calendar.url), 'utf-8'), ...calendar };
} catch (initialError) { } else {
logger.debug(`Initial fetch failed, trying extension adjustment for: ${calendar.url}`); // logger.debug(`Fetching calendar from URL: ${calendar.url}`);
const response = await axios.get(calendar.url);
// Determine alternate URL version return { data: response.data, ...calendar };
const altUrl = calendar.url.endsWith('.ics')
? calendar.url.slice(0, -4) // Remove .ics
: calendar.url + '.ics'; // Add .ics
try {
// Try the alternate version
const altResponse = await axios.get(altUrl);
logger.debug(`Success with adjusted URL: ${altUrl}`);
return { data: altResponse.data, ...calendar };
} catch (altError) {
logger.error(`Both URL versions failed:
Original: ${calendar.url}
Adjusted: ${altUrl}`);
throw new Error(`Calendar fetch failed for both URL versions`);
} }
} catch (error) {
logger.error(`Error retrieving calendar from ${calendar.url}: ${error.message}`);
throw new Error(`Error retrieving calendar from ${calendar.url}: ${error.message}`);
} }
} }

View file

@ -39,6 +39,67 @@ router.post('/merge', async (req, res) => {
} }
}); });
// New endpoint to check if a calendar exists and return its configuration
router.get('/calendar-config/:name', (req, res) => {
const calendarName = sanitizeFilename(req.params.name);
const jsonFilePath = path.join(MERGED_CALENDARS_DIR, `${calendarName}.json`);
try {
if (fs.existsSync(jsonFilePath)) {
const configData = JSON.parse(fs.readFileSync(jsonFilePath, 'utf-8'));
res.json({
exists: true,
config: configData,
url: `${req.protocol}://${req.get('host')}/calendar/${calendarName}`
});
} else {
res.json({ exists: false });
}
} catch (error) {
console.error('Error checking calendar:', error.message);
res.status(500).json({ error: 'Failed to check calendar configuration' });
}
});
// Update an existing calendar configuration
router.put('/calendar/:name', async (req, res) => {
const calendarName = sanitizeFilename(req.params.name);
const { linkGroupName, calendars } = req.body;
// Validate the input
if (!linkGroupName || !Array.isArray(calendars) || calendars.length === 0) {
return res.status(400).json({ error: 'Invalid input. Please provide a linkGroupName and at least one calendar.' });
}
try {
// Check if the calendar exists
const jsonFilePath = path.join(MERGED_CALENDARS_DIR, `${calendarName}.json`);
if (!fs.existsSync(jsonFilePath)) {
return res.status(404).json({ error: 'Calendar not found.' });
}
// Fetch calendar data
const results = await Promise.all(calendars.map(fetchCalendarData));
// Generate merged calendar using ical.js
const calendarComponent = createCalendarComponent(linkGroupName);
addEventsToCalendar(calendarComponent, results);
// Save the updated calendar to a file
saveCalendarFile(`${calendarName}.ics`, calendarComponent.toString());
// Save the updated configuration
saveCalendarFile(`${calendarName}.json`, JSON.stringify({ linkGroupName, calendars }, null, 2));
res.json({
url: `${req.protocol}://${req.get('host')}/calendar/${calendarName}`
});
} catch (error) {
console.error('Error updating calendar:', error.message);
res.status(500).json({ error: 'Failed to update calendar' });
}
});
// Refresh calendar if outdated // Refresh calendar if outdated
async function refreshCalendarData(calendarName) { async function refreshCalendarData(calendarName) {
const jsonFilePath = path.join(MERGED_CALENDARS_DIR, `${calendarName}.json`); const jsonFilePath = path.join(MERGED_CALENDARS_DIR, `${calendarName}.json`);
@ -57,9 +118,15 @@ async function refreshCalendarData(calendarName) {
// Serve the merged calendar file and refresh if older than an hour // Serve the merged calendar file and refresh if older than an hour
router.get('/calendar/:name', async (req, res) => { router.get('/calendar/:name', async (req, res) => {
const calendarName = req.params.name; // Extract the calendar name and remove .ics extension if present
let calendarName = req.params.name;
if (calendarName.endsWith('.ics')) {
calendarName = calendarName.slice(0, -4);
}
const icsFilePath = path.join(MERGED_CALENDARS_DIR, `${calendarName}.ics`); const icsFilePath = path.join(MERGED_CALENDARS_DIR, `${calendarName}.ics`);
const fullUrl = `${req.protocol}://${req.get('host')}${req.originalUrl}`;
try { try {
// Check if the .ics file exists // Check if the .ics file exists
console.log(`Serving calendar for: ${calendarName}`); console.log(`Serving calendar for: ${calendarName}`);
@ -73,7 +140,11 @@ router.get('/calendar/:name', async (req, res) => {
} }
res.setHeader('Content-Type', 'text/calendar'); res.setHeader('Content-Type', 'text/calendar');
res.sendFile(icsFilePath); res.sendFile(icsFilePath);
// Log the successful request with URL format and status code
console.log(`Serving calendar ${calendarName} for ${fullUrl}: 200`);
} else { } else {
console.log(`Calendar not found: ${calendarName} for ${fullUrl}: 404`);
res.status(404).json({ error: 'Calendar not found.' }); res.status(404).json({ error: 'Calendar not found.' });
} }
} catch (error) { } catch (error) {
@ -82,4 +153,4 @@ router.get('/calendar/:name', async (req, res) => {
} }
}); });
export default router; export default router;

View file

@ -7,8 +7,22 @@ console.log(`Starting server in ${process.cwd()}`);
const app = express(); const app = express();
app.use(express.json()); app.use(express.json());
// Serve static files from the 'public' directory // Mount API routes BEFORE static files
app.use(express.static(path.join(process.cwd(), 'public'))); // This ensures that API requests are handled by your routes first
app.use('/', routes); app.use('/', routes);
// Serve static files from the 'public' directory
// This will only handle requests that weren't matched by the routes
app.use(express.static(path.join(process.cwd(), 'public')));
// Optional: Add a catch-all route for client-side routing
app.get('*', (req, res) => {
// Only serve index.html for non-API, non-file requests
if (!req.path.startsWith('/api') && !req.path.includes('.')) {
res.sendFile(path.join(process.cwd(), 'public', 'index.html'));
} else {
res.status(404).send('Not found');
}
});
export default app; export default app;

View file

@ -2,15 +2,12 @@ import request from 'supertest';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import { fetchCalendarData } from '../src/calendarUtil.js';
// ESM equivalent of __dirname // ESM equivalent of __dirname
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
const CALENDARS_DIR = path.join(process.cwd(), 'calendar'); const CALENDARS_DIR = path.join(__dirname, 'calendar');
const TEST_CALENDARS_DIR = path.join(__dirname, 'test_calendars'); const TEST_CALENDARS_DIR = path.join(__dirname, 'test_calendars');
const EXPECTED_OUTPUTS_DIR = path.join(__dirname, 'expected_outputs'); const EXPECTED_OUTPUTS_DIR = path.join(__dirname, 'expected_outputs');
@ -29,8 +26,9 @@ describe('Calendar Merging API', () => {
await new Promise(resolve => server.close(resolve)); await new Promise(resolve => server.close(resolve));
// Clean up the merged calendars directory after tests // Clean up the merged calendars directory after tests
fs.rmdirSync(CALENDARS_DIR, { recursive: true });
if (fs.existsSync(CALENDARS_DIR)) { if (fs.existsSync(CALENDARS_DIR)) {
fs.rmSync(CALENDARS_DIR, { recursive: true, force: true }); fs.rmdirSync(CALENDARS_DIR, { recursive: true });
} }
// Optional: Add a delay to ensure all handles are released // Optional: Add a delay to ensure all handles are released
@ -282,60 +280,58 @@ describe('Calendar Merging API', () => {
expect(actualOutput).toBe(expectedOutput); expect(actualOutput).toBe(expectedOutput);
}); });
describe('Smart URL Handling', () => { // Test accessing calendar with and without .ics extension
let mockAxios; test('Access calendar with and without .ics extension', async () => {
// Create a test calendar
beforeAll(() => { const response = await request(server)
mockAxios = new MockAdapter(axios); .post('/merge')
}); .send({
linkGroupName: 'Extension Test Calendar',
calendars: [
{
url: getTestCalendarFilename('sf_public_holidays.ics'),
prefix: 'Test',
override: false,
},
],
});
afterEach(() => { expect(response.status).toBe(200);
mockAxios.reset();
}); // Check if the file was created
const filePath = path.join(CALENDARS_DIR, 'Extension_Test_Calendar.ics');
expect(fs.existsSync(filePath)).toBe(true);
// Test accessing without .ics extension
const responseWithoutExtension = await request(server)
.get('/calendar/Extension_Test_Calendar');
expect(responseWithoutExtension.status).toBe(200);
expect(responseWithoutExtension.headers['content-type']).toMatch(/text\/calendar/);
// Test accessing with .ics extension
const responseWithExtension = await request(server)
.get('/calendar/Extension_Test_Calendar.ics');
expect(responseWithExtension.status).toBe(200);
expect(responseWithExtension.headers['content-type']).toMatch(/text\/calendar/);
// Verify both responses contain the same content
expect(responseWithoutExtension.text).toBe(responseWithExtension.text);
});
afterAll(() => { // Test 404 response for non-existent calendar
mockAxios.restore(); test('Return 404 for non-existent calendar with and without .ics extension', async () => {
}); // Test accessing non-existent calendar without .ics extension
const responseWithoutExtension = await request(server)
test('should use original URL when valid without .ics', async () => { .get('/calendar/NonExistentCalendar');
const validUrl = 'https://cals.ftt.gmbh/calendar/germanholithunder';
mockAxios.onGet(validUrl).reply(200, 'VALID_CALENDAR'); expect(responseWithoutExtension.status).toBe(404);
const result = await fetchCalendarData({ url: validUrl }); // Test accessing non-existent calendar with .ics extension
expect(result.data).toBe('VALID_CALENDAR'); const responseWithExtension = await request(server)
}); .get('/calendar/NonExistentCalendar.ics');
test('should try .ics version when original fails', async () => { expect(responseWithExtension.status).toBe(404);
const invalidUrl = 'https://cals.ftt.gmbh/calendar/germanholithunder.ics';
const validUrl = invalidUrl.slice(0, -4);
mockAxios
.onGet(invalidUrl).reply(404)
.onGet(validUrl).reply(200, 'VALID_CALENDAR');
const result = await fetchCalendarData({ url: invalidUrl });
expect(result.data).toBe('VALID_CALENDAR');
});
test('should preserve valid .ics URLs', async () => {
const googleUrl = 'https://calendar.google.com/.../basic.ics';
mockAxios.onGet(googleUrl).reply(200, 'GOOGLE_CALENDAR');
const result = await fetchCalendarData({ url: googleUrl });
expect(result.data).toBe('GOOGLE_CALENDAR');
});
test('should try both versions for ambiguous URLs', async () => {
const baseUrl = 'https://example.com/calendar';
const icsUrl = baseUrl + '.ics';
mockAxios
.onGet(baseUrl).reply(404)
.onGet(icsUrl).reply(200, 'ICS_CALENDAR');
const result = await fetchCalendarData({ url: baseUrl });
expect(result.data).toBe('ICS_CALENDAR');
});
}); });
}); });