Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 54 additions & 2 deletions src/app/__tests__/api/osrm-routing-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const buildRequest = (query: string): NextRequest =>

describe('OSRM routing proxy validation', () => {
const originalAdminDomain = process.env.ADMIN_DOMAIN;
const originalAdminProxySecret = process.env.ADMIN_PROXY_SECRET;

afterEach(() => {
jest.restoreAllMocks();
Expand All @@ -21,6 +22,11 @@ describe('OSRM routing proxy validation', () => {
} else {
process.env.ADMIN_DOMAIN = originalAdminDomain;
}
if (originalAdminProxySecret === undefined) {
delete process.env.ADMIN_PROXY_SECRET;
} else {
process.env.ADMIN_PROXY_SECRET = originalAdminProxySecret;
}
});

it('rejects requests outside the admin domain before proxying upstream', async () => {
Expand Down Expand Up @@ -59,8 +65,53 @@ describe('OSRM routing proxy validation', () => {
expect(fetchSpy).not.toHaveBeenCalled();
});

it('accepts forwarded admin hosts only from local proxy hops', async () => {
it('rejects forwarded admin hosts from local proxy hops without the proxy secret', async () => {
process.env.ADMIN_DOMAIN = 'admin.example.test';
const fetchSpy = jest.spyOn(global, 'fetch');

const response = await GET(
new NextRequest(
'http://127.0.0.1:3000/api/routing/osrm?profile=car&fromLat=51.5&fromLng=-0.1&toLat=48.8&toLng=2.3',
{
headers: {
host: '127.0.0.1:3000',
'x-forwarded-host': 'admin.example.test'
}
}
)
);

await expect(response.json()).resolves.toEqual({ error: 'Admin domain required' });
expect(response.status).toBe(403);
expect(fetchSpy).not.toHaveBeenCalled();
});

it('rejects forwarded admin hosts from local proxy hops with a mismatched proxy secret', async () => {
process.env.ADMIN_DOMAIN = 'admin.example.test';
process.env.ADMIN_PROXY_SECRET = 'proxy-secret';
const fetchSpy = jest.spyOn(global, 'fetch');

const response = await GET(
new NextRequest(
'http://127.0.0.1:3000/api/routing/osrm?profile=car&fromLat=51.5&fromLng=-0.1&toLat=48.8&toLng=2.3',
{
headers: {
host: '127.0.0.1:3000',
'x-forwarded-host': 'admin.example.test',
'x-travel-tracker-admin-proxy-secret': 'wrong-secret'
}
}
)
);

await expect(response.json()).resolves.toEqual({ error: 'Admin domain required' });
expect(response.status).toBe(403);
expect(fetchSpy).not.toHaveBeenCalled();
});

it('accepts forwarded admin hosts only from trusted local proxy hops', async () => {
process.env.ADMIN_DOMAIN = 'admin.example.test';
process.env.ADMIN_PROXY_SECRET = 'proxy-secret';
const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ routes: [] }), {
status: 200,
Expand All @@ -74,7 +125,8 @@ describe('OSRM routing proxy validation', () => {
{
headers: {
host: '127.0.0.1:3000',
'x-forwarded-host': 'admin.example.test'
'x-forwarded-host': 'admin.example.test',
'x-travel-tracker-admin-proxy-secret': 'proxy-secret'
}
}
)
Expand Down
48 changes: 48 additions & 0 deletions src/app/__tests__/api/travel-data-auth-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,54 @@ describe('travel-data API auth and cache boundary', () => {
expect(mockUpdateTravelData).not.toHaveBeenCalled();
});

it('normalizes null route point geometry on batch route-point PATCH updates', async () => {
mockIsAdminDomain.mockResolvedValue(true);
mockLoadUnifiedTripData.mockResolvedValue({
...buildTrip(),
travelData: {
...buildTrip().travelData,
routes: [
{
id: 'route-1',
from: 'A',
to: 'B',
routePoints: [[52.52, 13.405]]
}
]
}
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any

const response = await PATCH(
new NextRequest('https://admin.example.test/api/travel-data?id=trip-1', {
method: 'PATCH',
headers: { host: 'admin.example.test' },
body: JSON.stringify({
batchRouteUpdate: [
{
routeId: 'route-1',
routePoints: null
}
]
})
})
);
const result = await response.json();

expect(response.status).toBe(200);
expect(result).toEqual({
success: true,
message: '1 route(s) updated successfully'
});
expect(mockUpdateTravelData).toHaveBeenCalledWith('trip-1', expect.objectContaining({
routes: [
expect.objectContaining({
id: 'route-1',
routePoints: []
})
]
}));
});

it('coerces stale stored route transport types before validating unrelated delta PATCH updates', async () => {
mockIsAdminDomain.mockResolvedValue(true);
mockLoadUnifiedTripData.mockResolvedValue({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { Expense, BudgetItem, YnabCategoryMapping, YnabTransaction } from '@/app
import { writeFile, unlink } from 'fs/promises';
import { join } from 'path';
import { getDataDir } from '@/app/lib/dataDirectory';
import { createTransactionHash } from '@/app/lib/ynabUtils';

// Mock the admin domain check
jest.mock('@/app/lib/server-domains', () => ({
Expand Down Expand Up @@ -100,7 +101,8 @@ describe('Cost Tracking API Validation Integration Tests', () => {
});

beforeEach(() => {
jest.clearAllMocks();
jest.resetAllMocks();
(mockIsAdminDomain as jest.Mock).mockResolvedValue(true);
});

describe('Cost Tracking Main Endpoint', () => {
Expand Down Expand Up @@ -180,7 +182,7 @@ describe('Cost Tracking API Validation Integration Tests', () => {
.mockResolvedValueOnce(mockTrip2);

const request = new NextRequest('http://localhost/api/cost-tracking/list');
const response = await costTrackingListGET();
const response = await costTrackingListGET(request);
const result = await response.json();

expect(response.status).toBe(200);
Expand Down Expand Up @@ -298,6 +300,116 @@ describe('Cost Tracking API Validation Integration Tests', () => {
})
);
});

it('skips invalid YNAB dates instead of persisting null expense dates', async () => {
const mockData = createMockUnifiedTripData(false);
const updatedData = { ...mockData, updatedAt: new Date().toISOString() };
mockLoadUnifiedTripData.mockResolvedValue(mockData);
mockUpdateCostData.mockResolvedValue(updatedData);

const transaction: YnabTransaction = {
Date: '31/02/2025',
Payee: 'Impossible Date Merchant',
Category: 'Food',
Outflow: '€25.00',
Inflow: '',
Memo: 'Invalid date'
};
const tempFileId = await createTempYnabFile([transaction]);

const mappings: YnabCategoryMapping[] = [{
ynabCategory: 'Food',
mappingType: 'country',
countryName: 'Germany'
}];

const request = new NextRequest(`http://localhost/api/cost-tracking/${mockTripId}/ynab-process`, {
method: 'POST',
body: JSON.stringify({
action: 'import',
tempFileId,
mappings,
selectedTransactions: [{
transactionHash: createTransactionHash(transaction),
transactionSourceIndex: 0,
expenseCategory: 'Food'
}]
})
});

const response = await ynabProcessPOST(request, { params: Promise.resolve({ id: mockTripId }) });
const result = await response.json();

expect(response.status).toBe(200);
expect(result.importedCount).toBe(0);
expect(result.skippedCount).toBe(1);
expect(mockUpdateCostData).toHaveBeenCalledWith(
mockTripId,
expect.objectContaining({
expenses: []
})
);
});

it('imports an instance-keyed duplicate even when a legacy base hash exists', async () => {
const transaction: YnabTransaction = {
Date: '01/05/2024',
Payee: 'Duplicate Merchant',
Category: 'Food',
Outflow: '€30.00',
Inflow: '',
Memo: 'Duplicate'
};
const transactionHash = createTransactionHash(transaction);
const mockData = createMockUnifiedTripData(false);
mockData.costData!.ynabImportData = {
mappings: [],
importedTransactionHashes: [transactionHash]
};
const updatedData = { ...mockData, updatedAt: new Date().toISOString() };
mockLoadUnifiedTripData.mockResolvedValue(mockData);
mockUpdateCostData.mockResolvedValue(updatedData);
const tempFileId = await createTempYnabFile([transaction, transaction]);

const mappings: YnabCategoryMapping[] = [{
ynabCategory: 'Food',
mappingType: 'country',
countryName: 'Germany'
}];

const request = new NextRequest(`http://localhost/api/cost-tracking/${mockTripId}/ynab-process`, {
method: 'POST',
body: JSON.stringify({
action: 'import',
tempFileId,
mappings,
selectedTransactions: [{
transactionHash,
transactionId: `${transactionHash}-1`,
transactionSourceIndex: 1,
expenseCategory: 'Food'
}]
})
});

const response = await ynabProcessPOST(request, { params: Promise.resolve({ id: mockTripId }) });
const result = await response.json();

expect(response.status).toBe(200);
expect(result.importedCount).toBe(1);
expect(mockUpdateCostData).toHaveBeenCalledWith(
mockTripId,
expect.objectContaining({
expenses: expect.arrayContaining([
expect.objectContaining({
id: `ynab-${transactionHash}-1`,
hash: transactionHash,
description: 'Duplicate Merchant'
})
])
})
);
});
});

describe('Validation Endpoint', () => {
Expand Down
34 changes: 34 additions & 0 deletions src/app/__tests__/unit/MultiRouteLinkManager.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';

import MultiRouteLinkManager from '@/app/admin/components/MultiRouteLinkManager';

describe('MultiRouteLinkManager', () => {
it('notifies parent when the last existing link is removed', async () => {
const onLinksChange = jest.fn();

render(
<MultiRouteLinkManager
expenseId="expense-1"
tripId="trip-1"
expenseAmount={100}
initialLinks={[
{
id: 'route-1',
type: 'route',
name: 'A to B'
}
]}
onLinksChange={onLinksChange}
/>
);

await screen.findByText('A to B');
onLinksChange.mockClear();

fireEvent.click(screen.getByTitle('Remove link'));

await waitFor(() => {
expect(onLinksChange).toHaveBeenCalledWith([]);
});
});
});
42 changes: 42 additions & 0 deletions src/app/__tests__/unit/TravelItemSelector.loadFailure.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { render, waitFor } from '@testing-library/react';

import TravelItemSelector from '@/app/admin/components/TravelItemSelector';

jest.mock('@/app/hooks/useExpenseLinks', () => ({
useExpenseLinks: () => ({
expenseLinks: [],
isLoading: false,
error: null
})
}));

describe('TravelItemSelector load failures', () => {
const originalFetch = global.fetch;

afterEach(() => {
global.fetch = originalFetch;
jest.restoreAllMocks();
});

it('clears the current reference when travel item loading fails', async () => {
const onReferenceChange = jest.fn();
global.fetch = jest.fn().mockRejectedValue(new Error('network down')) as jest.Mock;

render(
<TravelItemSelector
expenseId="expense-1"
tripId="trip-1"
initialValue={{
id: 'route-1',
type: 'route',
name: 'Stale route'
}}
onReferenceChange={onReferenceChange}
/>
);

await waitFor(() => {
expect(onReferenceChange).toHaveBeenCalledWith(undefined);
});
});
});
6 changes: 3 additions & 3 deletions src/app/__tests__/unit/weatherService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const emptySummary: WeatherSummary = {
};

describe('weatherService negative cache helpers', () => {
it('treats recent empty cache entries as fresh negative results', () => {
it('does not treat recent empty cache entries as fresh negative results', () => {
const now = new Date('2026-06-01T12:00:00.000Z');

expect(weatherServiceTestUtils.isFreshEmptyCacheEntry({
Expand All @@ -26,10 +26,10 @@ describe('weatherService negative cache helpers', () => {
hasForecast: false,
hasRecorded: false
}
}, now)).toBe(true);
}, now)).toBe(false);
});

it('expires empty cache entries after the negative cache TTL', () => {
it('returns false for older empty cache entries too', () => {
const now = new Date('2026-06-01T12:20:00.000Z');

expect(weatherServiceTestUtils.isFreshEmptyCacheEntry({
Expand Down
4 changes: 0 additions & 4 deletions src/app/admin/components/MultiRouteLinkManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -163,10 +163,6 @@ export default function MultiRouteLinkManager({
if (validation.valid && links.length > 0) {
nextLinks = linksWithSplitValues;
} else if (links.length === 0) {
if (initialLinks.length > 0) {
return;
}

nextLinks = [];
}

Expand Down
Loading
Loading