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:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user