feat: add station matching from receipt merchant name (refs #132)

Add Google Places Text Search to match receipt merchant names (e.g.
"Shell", "COSTCO #123") to real gas stations. Backend exposes
POST /api/stations/match endpoint. Frontend calls it after OCR
extraction and pre-fills locationData with matched station's placeId,
name, and address. Users can clear the match in the review modal.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Eric Gullickson
2026-02-11 09:45:13 -06:00
parent bc91fbad79
commit d8dec64538
10 changed files with 530 additions and 10 deletions

View File

@@ -0,0 +1,258 @@
/**
* @ai-summary Unit tests for station matching from receipt merchant names
*/
// Mock config-loader before any imports that use it
jest.mock('../../../../core/config/config-loader', () => ({
appConfig: {
secrets: { google_maps_api_key: 'mock-api-key' },
getDatabaseUrl: () => 'postgresql://mock:mock@localhost/mock',
getRedisUrl: () => 'redis://localhost',
get: () => ({}),
},
}));
jest.mock('axios');
jest.mock('../../../../core/config/redis');
jest.mock('../../../../core/logging/logger');
jest.mock('../../data/stations.repository');
jest.mock('../../external/google-maps/google-maps.client', () => {
const { GoogleMapsClient } = jest.requireActual('../../external/google-maps/google-maps.client');
return {
GoogleMapsClient,
googleMapsClient: {
searchNearbyStations: jest.fn(),
searchStationByName: jest.fn(),
fetchPhoto: jest.fn(),
},
};
});
import axios from 'axios';
import { GoogleMapsClient } from '../../external/google-maps/google-maps.client';
import { StationsService } from '../../domain/stations.service';
import { StationsRepository } from '../../data/stations.repository';
import { googleMapsClient } from '../../external/google-maps/google-maps.client';
import { mockStations } from '../fixtures/mock-stations';
describe('Station Matching from Receipt', () => {
describe('GoogleMapsClient.searchStationByName', () => {
let client: GoogleMapsClient;
let mockAxios: jest.Mocked<typeof axios>;
beforeEach(() => {
jest.clearAllMocks();
mockAxios = axios as jest.Mocked<typeof axios>;
client = new GoogleMapsClient();
});
it('should match a known station name like "Shell"', async () => {
mockAxios.get.mockResolvedValue({
data: {
results: [
{
place_id: 'ChIJ_shell_match',
name: 'Shell Gas Station',
formatted_address: '123 Main St, San Francisco, CA 94105',
geometry: { location: { lat: 37.7749, lng: -122.4194 } },
rating: 4.2,
photos: [{ photo_reference: 'shell-photo-ref' }],
opening_hours: { open_now: true },
types: ['gas_station'],
},
],
status: 'OK',
},
});
const result = await client.searchStationByName('Shell');
expect(result).not.toBeNull();
expect(result?.placeId).toBe('ChIJ_shell_match');
expect(result?.name).toBe('Shell Gas Station');
expect(result?.address).toBe('123 Main St, San Francisco, CA 94105');
expect(mockAxios.get).toHaveBeenCalledWith(
expect.stringContaining('textsearch/json'),
expect.objectContaining({
params: expect.objectContaining({
query: 'Shell gas station',
type: 'gas_station',
}),
})
);
});
it('should match abbreviated names like "COSTCO #123"', async () => {
mockAxios.get.mockResolvedValue({
data: {
results: [
{
place_id: 'ChIJ_costco_match',
name: 'Costco Gasoline',
formatted_address: '2000 El Camino Real, Redwood City, CA',
geometry: { location: { lat: 37.4849, lng: -122.2278 } },
rating: 4.5,
types: ['gas_station'],
},
],
status: 'OK',
},
});
const result = await client.searchStationByName('COSTCO #123');
expect(result).not.toBeNull();
expect(result?.name).toBe('Costco Gasoline');
expect(result?.placeId).toBe('ChIJ_costco_match');
});
it('should match "BP" station name', async () => {
mockAxios.get.mockResolvedValue({
data: {
results: [
{
place_id: 'ChIJ_bp_match',
name: 'BP',
formatted_address: '500 Market St, San Francisco, CA',
geometry: { location: { lat: 37.79, lng: -122.40 } },
types: ['gas_station'],
},
],
status: 'OK',
},
});
const result = await client.searchStationByName('BP');
expect(result).not.toBeNull();
expect(result?.name).toBe('BP');
});
it('should return null when no match is found', async () => {
mockAxios.get.mockResolvedValue({
data: {
results: [],
status: 'ZERO_RESULTS',
},
});
const result = await client.searchStationByName('Unknown Station XYZ123');
expect(result).toBeNull();
});
it('should return null gracefully on API error', async () => {
mockAxios.get.mockRejectedValue(new Error('Network error'));
const result = await client.searchStationByName('Shell');
expect(result).toBeNull();
});
it('should return null on API denial', async () => {
mockAxios.get.mockResolvedValue({
data: {
results: [],
status: 'REQUEST_DENIED',
error_message: 'Invalid key',
},
});
const result = await client.searchStationByName('Shell');
expect(result).toBeNull();
});
it('should include rating and photo reference when available', async () => {
mockAxios.get.mockResolvedValue({
data: {
results: [
{
place_id: 'ChIJ_rated',
name: 'Chevron',
formatted_address: '789 Oak Ave, Portland, OR',
geometry: { location: { lat: 45.52, lng: -122.68 } },
rating: 4.7,
photos: [{ photo_reference: 'chevron-photo' }],
opening_hours: { open_now: false },
types: ['gas_station'],
},
],
status: 'OK',
},
});
const result = await client.searchStationByName('Chevron');
expect(result?.rating).toBe(4.7);
expect(result?.photoReference).toBe('chevron-photo');
expect(result?.isOpen).toBe(false);
});
});
describe('StationsService.matchStationFromReceipt', () => {
let service: StationsService;
let mockRepository: jest.Mocked<StationsRepository>;
const mockSearchByName = googleMapsClient.searchStationByName as jest.Mock;
beforeEach(() => {
jest.clearAllMocks();
mockRepository = {
cacheStation: jest.fn().mockResolvedValue(undefined),
getCachedStation: jest.fn(),
saveStation: jest.fn(),
getUserSavedStations: jest.fn().mockResolvedValue([]),
updateSavedStation: jest.fn(),
deleteSavedStation: jest.fn(),
} as unknown as jest.Mocked<StationsRepository>;
service = new StationsService(mockRepository);
});
it('should return matched station for known merchant name', async () => {
const matchedStation = mockStations[0]!;
mockSearchByName.mockResolvedValue(matchedStation);
const result = await service.matchStationFromReceipt('Shell');
expect(result.matched).toBe(true);
expect(result.station).not.toBeNull();
expect(result.station?.name).toBe('Shell Gas Station - Downtown');
expect(mockRepository.cacheStation).toHaveBeenCalledWith(matchedStation);
});
it('should return no match for unknown merchant', async () => {
mockSearchByName.mockResolvedValue(null);
const result = await service.matchStationFromReceipt('Unknown Store');
expect(result.matched).toBe(false);
expect(result.station).toBeNull();
expect(mockRepository.cacheStation).not.toHaveBeenCalled();
});
it('should handle empty merchant name', async () => {
const result = await service.matchStationFromReceipt('');
expect(result.matched).toBe(false);
expect(result.station).toBeNull();
});
it('should handle whitespace-only merchant name', async () => {
const result = await service.matchStationFromReceipt(' ');
expect(result.matched).toBe(false);
expect(result.station).toBeNull();
});
it('should cache matched station for future saveStation calls', async () => {
const matchedStation = mockStations[1]!;
mockSearchByName.mockResolvedValue(matchedStation);
await service.matchStationFromReceipt('Chevron');
expect(mockRepository.cacheStation).toHaveBeenCalledWith(matchedStation);
});
});
});