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
63 changes: 63 additions & 0 deletions src/event/services/event-integration.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { Counter, Histogram } from 'prom-client';
import { BlueskyIdService } from '../../bluesky/bluesky-id.service';
import { FileService } from '../../file/file.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
import axios from 'axios';

// Add constants for metrics tokens
const PROM_METRIC_EVENT_INTEGRATION_PROCESSED_TOTAL =
Expand Down Expand Up @@ -584,6 +585,68 @@ describe('EventIntegrationService', () => {
);
expect(residualMs).toBeGreaterThanOrEqual(simulatedCreatorIoMs - 1);
});

it('should not re-geocode an update whose address is unchanged and already geocoded', async () => {
eventQueryService.findBySourceAttributes.mockResolvedValue([
{
...mockExistingEvent,
location: '123 Main St, Louisville, KY',
lat: 38.25,
lon: -85.76,
} as unknown as EventEntity,
]);
const geocodeSpy = jest
.spyOn(service as any, 'geocodeAddress')
.mockResolvedValue(null);

const result = await service.processExternalEvent(
eventWithAddressOnly,
'tenant1',
);

expect(geocodeSpy).not.toHaveBeenCalled();
expect(result.lat).toBe(38.25);
expect(result.lon).toBe(-85.76);
});

it('should geocode an update whose address changed', async () => {
eventQueryService.findBySourceAttributes.mockResolvedValue([
{
...mockExistingEvent,
location: '1 Old Rd, Lexington, KY',
lat: 38.04,
lon: -84.5,
} as unknown as EventEntity,
]);
const geocodeSpy = jest
.spyOn(service as any, 'geocodeAddress')
.mockResolvedValue({ lat: 38.25, lon: -85.76 });

const result = await service.processExternalEvent(
eventWithAddressOnly,
'tenant1',
);

expect(geocodeSpy).toHaveBeenCalledWith('123 Main St, Louisville, KY');
expect(result.lat).toBe(38.25);
});

it('should stop at the first 429 instead of retrying shorter addresses', async () => {
const getSpy = jest.spyOn(axios, 'get').mockRejectedValue(
Object.assign(new Error('Request failed with status code 429'), {
isAxiosError: true,
response: { status: 429 },
}),
);

const coords = await (service as any).geocodeAddress(
'Boulder, Boulder, CO, US',
);

expect(coords).toBeNull();
expect(getSpy).toHaveBeenCalledTimes(1);
getSpy.mockRestore();
});
});

describe('Unique violation on insert (23505)', () => {
Expand Down
16 changes: 15 additions & 1 deletion src/event/services/event-integration.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export class EventIntegrationService {
private readonly tracer = trace.getTracer('event-integration-service');
private lastGeocodingRequest = 0;
private readonly geocodingDelayMs = 1100; // Nominatim rate limit: 1 req/sec, so wait 1.1s to be safe
private readonly geocodingTimeoutMs = 5000;

constructor(
private readonly tenantService: TenantConnectionService,
Expand Down Expand Up @@ -458,6 +459,7 @@ export class EventIntegrationService {
headers: {
'User-Agent': 'OpenMeet/1.0 (https://openmeet.net)',
},
timeout: this.geocodingTimeoutMs,
},
);

Expand Down Expand Up @@ -492,6 +494,12 @@ export class EventIntegrationService {
`Error geocoding "${currentAddress}": ${error.message}`,
);

// Rate limited: every shorter variant would be refused too, and each
// retry costs another geocodingDelayMs inside the request
if (axios.isAxiosError(error) && error.response?.status === 429) {
return null;
}

// On error, also try with shorter address
const commaIndex = currentAddress.indexOf(',');
if (commaIndex === -1) {
Expand Down Expand Up @@ -762,13 +770,19 @@ export class EventIntegrationService {

// Handle location
if (eventData.location) {
// A re-sent record with the same address keeps its stored coordinates;
// geocoding it again on every update is what got us 429'd by Nominatim
const alreadyGeocoded =
eventData.location.description === existingEvent.location &&
!!existingEvent.lat &&
!!existingEvent.lon;
if (eventData.location.description) {
existingEvent.location = eventData.location.description;
}
if (eventData.location.lat && eventData.location.lon) {
existingEvent.lat = eventData.location.lat;
existingEvent.lon = eventData.location.lon;
} else if (eventData.location.description) {
} else if (eventData.location.description && !alreadyGeocoded) {
// Try to geocode the address if we have a description but no coordinates
const coords = await this.geocodeAddress(
eventData.location.description,
Expand Down
26 changes: 26 additions & 0 deletions src/pds/pds-account.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,32 @@ describe('PdsAccountService', () => {
);
});

it('should return true when a newer PDS reports HandleNotFound (400)', async () => {
const errorResponse: AxiosError = {
isAxiosError: true,
response: {
data: {
error: 'HandleNotFound',
message: 'Unable to resolve handle',
},
status: 400,
statusText: 'Bad Request',
headers: {},
config: { headers: new AxiosHeaders() },
},
message: 'Request failed with status code 400',
name: 'AxiosError',
config: { headers: new AxiosHeaders() },
toJSON: () => ({}),
};

httpService.get.mockReturnValue(throwError(() => errorResponse));

const result = await service.isHandleAvailable('available.dev.opnmt.me');

expect(result).toBe(true);
});

it('should return false when handle exists', async () => {
const successResponse: AxiosResponse = {
data: {
Expand Down
8 changes: 6 additions & 2 deletions src/pds/pds-account.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,9 +227,13 @@ export class PdsAccountService {
| { error?: string; message?: string }
| undefined;

// AT Protocol returns "InvalidRequest" with message about resolution failure
// when a handle doesn't exist. Other 400 errors (like InvalidHandle for
// PDS 0.4.5036+ returns "HandleNotFound" when a handle doesn't exist;
// older versions return "InvalidRequest" with a message about the
// resolution failure. Other 400 errors (like InvalidHandle for
// malformed input) should be thrown.
if (data?.error === 'HandleNotFound') {
return true;
}
if (
data?.error === 'InvalidRequest' &&
data?.message &&
Expand Down
Loading