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
181 changes: 181 additions & 0 deletions src/bluesky/atproto-resolve.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { Test, TestingModule } from '@nestjs/testing';
import { NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AtprotoResolveController } from './atproto-resolve.controller';
import { EventQueryService } from '../event/services/event-query.service';
import { EventEntity } from '../event/infrastructure/persistence/relational/entities/event.entity';

describe('AtprotoResolveController', () => {
let controller: AtprotoResolveController;
let eventQueryService: {
findByAtprotoUri: jest.Mock;
findBySourceAttributes: jest.Mock;
};
let configService: { get: jest.Mock };

const mockRequest = { tenantId: 'test-tenant' };

beforeEach(async () => {
eventQueryService = {
findByAtprotoUri: jest.fn().mockResolvedValue([]),
findBySourceAttributes: jest.fn().mockResolvedValue([]),
};

configService = {
get: jest.fn().mockReturnValue('https://platform.openmeet.net'),
};

const module: TestingModule = await Test.createTestingModule({
controllers: [AtprotoResolveController],
providers: [
{
provide: EventQueryService,
useValue: eventQueryService,
},
{
provide: ConfigService,
useValue: configService,
},
],
}).compile();

controller = module.get<AtprotoResolveController>(AtprotoResolveController);
});

describe('resolve', () => {
const did = 'did:plc:abc123';
const collection = 'community.lexicon.calendar.event';
const rkey = 'abc456';
const expectedAtUri = `at://${did}/${collection}/${rkey}`;

it('should resolve an OpenMeet-published event by atprotoUri', async () => {
const mockEvent = {
slug: 'my-test-event',
atprotoUri: expectedAtUri,
} as unknown as EventEntity;

eventQueryService.findByAtprotoUri.mockResolvedValue([mockEvent]);

const result = await controller.resolve(
did,
collection,
rkey,
mockRequest,
);

expect(eventQueryService.findByAtprotoUri).toHaveBeenCalledWith(
expectedAtUri,
'test-tenant',
);
expect(result).toEqual({
url: 'https://platform.openmeet.net/events/my-test-event',
slug: 'my-test-event',
type: 'event',
});
});

it('should resolve a firehose-ingested event by sourceId when atprotoUri lookup fails', async () => {
const mockEvent = {
slug: 'ingested-event',
sourceType: 'bluesky',
sourceId: expectedAtUri,
} as unknown as EventEntity;

eventQueryService.findByAtprotoUri.mockResolvedValue([]);
eventQueryService.findBySourceAttributes.mockResolvedValue([mockEvent]);

const result = await controller.resolve(
did,
collection,
rkey,
mockRequest,
);

expect(eventQueryService.findByAtprotoUri).toHaveBeenCalledWith(
expectedAtUri,
'test-tenant',
);
expect(eventQueryService.findBySourceAttributes).toHaveBeenCalledWith(
expectedAtUri,
'bluesky',
'test-tenant',
);
expect(result).toEqual({
url: 'https://platform.openmeet.net/events/ingested-event',
slug: 'ingested-event',
type: 'event',
});
});

it('should throw NotFoundException when no event matches', async () => {
eventQueryService.findByAtprotoUri.mockResolvedValue([]);
eventQueryService.findBySourceAttributes.mockResolvedValue([]);

await expect(
controller.resolve(did, collection, rkey, mockRequest),
).rejects.toThrow(NotFoundException);
});

it('should use FRONTEND_DOMAIN from config', async () => {
configService.get.mockReturnValue('https://custom.example.com');

const mockEvent = {
slug: 'some-event',
atprotoUri: expectedAtUri,
} as unknown as EventEntity;

eventQueryService.findByAtprotoUri.mockResolvedValue([mockEvent]);

const result = await controller.resolve(
did,
collection,
rkey,
mockRequest,
);

expect(configService.get).toHaveBeenCalledWith('app.frontendDomain', {
infer: true,
});
expect(result.url).toBe('https://custom.example.com/events/some-event');
});

it('should throw NotFoundException when FRONTEND_DOMAIN is not configured', async () => {
configService.get.mockReturnValue(undefined);

const mockEvent = {
slug: 'some-event',
atprotoUri: expectedAtUri,
} as unknown as EventEntity;

eventQueryService.findByAtprotoUri.mockResolvedValue([mockEvent]);

await expect(
controller.resolve(did, collection, rkey, mockRequest),
).rejects.toThrow(
'FRONTEND_DOMAIN environment variable is not configured',
);
});

it('should throw NotFoundException for unsupported collection', async () => {
await expect(
controller.resolve(did, 'app.bsky.feed.post', rkey, mockRequest),
).rejects.toThrow(NotFoundException);

expect(eventQueryService.findByAtprotoUri).not.toHaveBeenCalled();
});

it('should prefer atprotoUri match over sourceId match', async () => {
const nativeEvent = {
slug: 'native-event',
atprotoUri: expectedAtUri,
} as unknown as EventEntity;

eventQueryService.findByAtprotoUri.mockResolvedValue([nativeEvent]);

await controller.resolve(did, collection, rkey, mockRequest);

// Should NOT call findBySourceAttributes since atprotoUri found a match
expect(eventQueryService.findBySourceAttributes).not.toHaveBeenCalled();
});
});
});
103 changes: 103 additions & 0 deletions src/bluesky/atproto-resolve.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import {
Controller,
Get,
Inject,
Param,
Req,
Logger,
NotFoundException,
forwardRef,
} from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { ConfigService } from '@nestjs/config';
import { Throttle } from '@nestjs/throttler';
import { Public } from '../auth/decorators/public.decorator';
import { EventQueryService } from '../event/services/event-query.service';

const SUPPORTED_COLLECTIONS = ['community.lexicon.calendar.event'];

@ApiTags('ATProto')
@Controller('atproto')
export class AtprotoResolveController {
private readonly logger = new Logger(AtprotoResolveController.name);

constructor(
@Inject(forwardRef(() => EventQueryService))
private readonly eventQueryService: EventQueryService,
private readonly configService: ConfigService,
) {}

private getFrontendDomain(): string {
const frontendDomain = this.configService.get<string>(
'app.frontendDomain',
{ infer: true },
);
if (!frontendDomain) {
throw new Error('FRONTEND_DOMAIN environment variable is not configured');
}
return frontendDomain;
}

@Public()
@Throttle({ default: { limit: 30, ttl: 60000 } })
@Get('resolve/:did/:collection/:rkey')
@ApiOperation({
summary: 'Resolve an AT URI to an OpenMeet resource URL',
description:
'Given path params that form an AT URI (at://{did}/{collection}/{rkey}), returns the OpenMeet URL for the matching event.',
})
async resolve(
@Param('did') did: string,
@Param('collection') collection: string,
@Param('rkey') rkey: string,
@Req() req: any,
) {
const atUri = `at://${did}/${collection}/${rkey}`;
const tenantId = req.tenantId;

this.logger.debug(`Resolving AT URI: ${atUri} for tenant: ${tenantId}`);

if (!SUPPORTED_COLLECTIONS.includes(collection)) {
throw new NotFoundException(
`Unsupported collection: ${collection}. Supported: ${SUPPORTED_COLLECTIONS.join(', ')}`,
);
}

// First, check for OpenMeet-published events (atprotoUri field)
const nativeEvents = await this.eventQueryService.findByAtprotoUri(
atUri,
tenantId,
);

if (nativeEvents.length > 0) {
const event = nativeEvents[0];
const frontendDomain = this.getFrontendDomain();
return {
url: `${frontendDomain}/events/${event.slug}`,
slug: event.slug,
type: 'event',
};
}

// Second, check for firehose-ingested events (sourceType='bluesky', sourceId=AT URI)
const ingestedEvents = await this.eventQueryService.findBySourceAttributes(
atUri,
'bluesky',
tenantId,
);

if (ingestedEvents.length > 0) {
const event = ingestedEvents[0];
const frontendDomain = this.getFrontendDomain();
return {
url: `${frontendDomain}/events/${event.slug}`,
slug: event.slug,
type: 'event',
};
}

throw new NotFoundException(
`No OpenMeet resource found for AT URI: ${atUri}`,
);
}
}
3 changes: 2 additions & 1 deletion src/bluesky/bluesky.module.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Module, forwardRef } from '@nestjs/common';
import { BlueskyController } from './bluesky.controller';
import { AtprotoResolveController } from './atproto-resolve.controller';
import { BlueskyService } from './bluesky.service';
import { BlueskyIdService } from './bluesky-id.service';
import { BlueskyIdentityService } from './bluesky-identity.service';
Expand All @@ -26,7 +27,7 @@ import { UserAtprotoIdentityModule } from '../user-atproto-identity/user-atproto
UserAtprotoIdentityModule,
forwardRef(() => EventModule),
],
controllers: [BlueskyController],
controllers: [BlueskyController, AtprotoResolveController],
providers: [
BlueskyService,
BlueskyIdService,
Expand Down
Loading