Skip to content
Open
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
1 change: 1 addition & 0 deletions projects/lnsp-mediator/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.0",
"jest": "^29.5.0",
"mongodb-memory-server": "^11.0.1",
"prettier": "^3.0.0",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
Expand Down
35 changes: 35 additions & 0 deletions projects/lnsp-mediator/scripts/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# lnsp-mediator scripts

Small operator scripts that talk to a running `lnsp-mediator` over its HTTP API.

## Requirements

- `curl` and `jq` on `$PATH`
- A reachable `lnsp-mediator` — by default `http://localhost:3000`, overridable via
a third positional arg or the `LNSP_MEDIATOR_URL` env var.

## `set-subscription.sh`

Repoint a subscription at a new target URL. Deletes the subscription whose
`targetAddress` matches `OLD_URL` (if any) and then creates one with `NEW_URL`.

```sh
./set-subscription.sh <OLD_URL> <NEW_URL> [BASE_URL]
```

Examples:

```sh
# Against a local dev mediator
./set-subscription.sh http://old.example/cb http://new.example/cb

# Against a staging host
./set-subscription.sh http://old.example/cb http://new.example/cb https://staging.example

# Using LNSP_MEDIATOR_URL
export LNSP_MEDIATOR_URL=https://staging.example
./set-subscription.sh http://old.example/cb http://new.example/cb
```

The script uses only the modern JSON CRUD endpoints (`GET /subscription`,
`DELETE /subscription/:id`, `POST /subscription`), not the legacy SOAP path.
47 changes: 47 additions & 0 deletions projects/lnsp-mediator/scripts/set-subscription.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# set-subscription.sh
#
# Repoint a subscription at a new target URL by deleting the existing
# subscription whose targetAddress == OLD_URL and creating a new one with NEW_URL.
#
# Usage:
# set-subscription.sh <OLD_URL> <NEW_URL> [BASE_URL]
#
# BASE_URL defaults to $LNSP_MEDIATOR_URL or http://localhost:3000
#
# Requires: curl, jq

set -euo pipefail

OLD_URL="${1:-}"
NEW_URL="${2:-}"
BASE_URL="${3:-${LNSP_MEDIATOR_URL:-http://localhost:3000}}"

if [[ -z "$OLD_URL" || -z "$NEW_URL" ]]; then
echo "Usage: $0 <OLD_URL> <NEW_URL> [BASE_URL]" >&2
echo " BASE_URL defaults to \$LNSP_MEDIATOR_URL or http://localhost:3000" >&2
exit 2
fi

command -v curl >/dev/null || { echo "curl is required" >&2; exit 127; }
command -v jq >/dev/null || { echo "jq is required" >&2; exit 127; }

echo "Looking up subscription where targetAddress=${OLD_URL} at ${BASE_URL} ..."
OLD_ID=$(curl -fsS "${BASE_URL}/subscription" \
| jq -r --arg url "$OLD_URL" '.[] | select(.targetAddress == $url) | ._id' \
| head -n 1)

if [[ -n "$OLD_ID" ]]; then
echo "Deleting subscription ${OLD_ID} (${OLD_URL}) ..."
curl -fsS -X DELETE "${BASE_URL}/subscription/${OLD_ID}" >/dev/null
else
echo "No existing subscription for ${OLD_URL} -- skipping delete."
fi

echo "Creating subscription for ${NEW_URL} ..."
curl -fsS -X POST "${BASE_URL}/subscription" \
-H 'Content-Type: application/json' \
-d "{\"targetAddress\":\"${NEW_URL}\"}" \
| jq .

echo "Done."
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { IsOptional, IsString } from 'class-validator';

export class UpdateSubscriptionDto {
@IsOptional()
@IsString()
targetAddress?: string;
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,31 @@ import { SubscriptionService } from './subscription.service';

describe('SubscriptionController', () => {
let controller: SubscriptionController;
let service: jest.Mocked<
Pick<
SubscriptionService,
| 'handleSubscription'
| 'createFromDto'
| 'getAll'
| 'getById'
| 'update'
| 'delete'
>
>;

beforeEach(async () => {
service = {
handleSubscription: jest.fn(),
createFromDto: jest.fn(),
getAll: jest.fn(),
getById: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
};

const module: TestingModule = await Test.createTestingModule({
controllers: [SubscriptionController],
providers: [
{ provide: SubscriptionService, useValue: { handleSubscription: jest.fn(), getAll: jest.fn() } },
],
providers: [{ provide: SubscriptionService, useValue: service }],
}).compile();

controller = module.get<SubscriptionController>(SubscriptionController);
Expand All @@ -19,4 +37,91 @@ describe('SubscriptionController', () => {
it('should be defined', () => {
expect(controller).toBeDefined();
});

describe('POST /subscription', () => {
it('dispatches to handleSubscription when body is parsed SOAP XML', async () => {
const xmlBody = { 'soap:envelope': { 'soap:body': [{}] } };
const xmlResp = {
contentType: 'application/json',
responseBody: 'Subscription created successfully',
status: 200,
};
service.handleSubscription.mockResolvedValue(xmlResp);

const result = await controller.create(xmlBody);

expect(service.handleSubscription).toHaveBeenCalledWith(xmlBody);
expect(service.createFromDto).not.toHaveBeenCalled();
expect(result).toEqual(xmlResp);
});

it('dispatches to createFromDto when body is JSON', async () => {
const jsonBody = { targetAddress: 'http://x' };
const created = {
_id: '507f1f77bcf86cd799439011',
targetAddress: 'http://x',
};
service.createFromDto.mockResolvedValue(created as any);

const result = await controller.create(jsonBody);

expect(service.createFromDto).toHaveBeenCalledWith(jsonBody);
expect(service.handleSubscription).not.toHaveBeenCalled();
expect(result).toEqual(created);
});
});

describe('GET /subscription', () => {
it('delegates to getAll', async () => {
const rows = [{ _id: 'a', targetAddress: 'http://a' }];
service.getAll.mockResolvedValue(rows as any);
await expect(controller.getAll()).resolves.toEqual(rows);
});
});

describe('GET /subscription/:id', () => {
it('delegates to getById with the id param', async () => {
const doc = {
_id: '507f1f77bcf86cd799439011',
targetAddress: 'http://x',
};
service.getById.mockResolvedValue(doc as any);
await expect(
controller.getById('507f1f77bcf86cd799439011'),
).resolves.toEqual(doc);
expect(service.getById).toHaveBeenCalledWith('507f1f77bcf86cd799439011');
});
});

describe('PATCH /subscription/:id', () => {
it('delegates to update with id and dto', async () => {
const updated = {
_id: '507f1f77bcf86cd799439011',
targetAddress: 'http://y',
};
service.update.mockResolvedValue(updated as any);
const dto = { targetAddress: 'http://y' };
await expect(
controller.update('507f1f77bcf86cd799439011', dto),
).resolves.toEqual(updated);
expect(service.update).toHaveBeenCalledWith(
'507f1f77bcf86cd799439011',
dto,
);
});
});

describe('DELETE /subscription/:id', () => {
it('delegates to delete with id', async () => {
const deleted = {
_id: '507f1f77bcf86cd799439011',
targetAddress: 'http://x',
};
service.delete.mockResolvedValue(deleted as any);
await expect(
controller.delete('507f1f77bcf86cd799439011'),
).resolves.toEqual(deleted);
expect(service.delete).toHaveBeenCalledWith('507f1f77bcf86cd799439011');
});
});
});
Original file line number Diff line number Diff line change
@@ -1,24 +1,54 @@
import { Controller, Post, Body, Get } from '@nestjs/common';
import { SubscriptionService } from './subscription.service';
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
} from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { SubscriptionService } from './subscription.service';
import { UpdateSubscriptionDto } from './dto/update-subscription.dto';

@Controller('subscription')
@ApiTags('subscription')
export class SubscriptionController {
constructor(private readonly subscriptionService: SubscriptionService) {}

@Post()
@ApiOperation({ summary: 'Create a new subscription' })
async create(@Body() xmlPayload: any) {
const r = this.subscriptionService.handleSubscription(xmlPayload);

// TODO: Return valid subscription response
return r;
@ApiOperation({
summary:
'Create a subscription (JSON body) or accept a legacy SOAP XML subscribe envelope',
})
async create(@Body() body: any) {
if (body && typeof body === 'object' && 'soap:envelope' in body) {
return this.subscriptionService.handleSubscription(body);
}
return this.subscriptionService.createFromDto(body);
}

@Get()
@ApiOperation({ summary: 'Get all subscriptions' })
@ApiOperation({ summary: 'List all subscriptions' })
async getAll() {
return this.subscriptionService.getAll();
}

@Get(':id')
@ApiOperation({ summary: 'Get a subscription by id' })
async getById(@Param('id') id: string) {
return this.subscriptionService.getById(id);
}

@Patch(':id')
@ApiOperation({ summary: 'Update a subscription by id' })
async update(@Param('id') id: string, @Body() dto: UpdateSubscriptionDto) {
return this.subscriptionService.update(id, dto);
}

@Delete(':id')
@ApiOperation({ summary: 'Delete a subscription by id' })
async delete(@Param('id') id: string) {
return this.subscriptionService.delete(id);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { INestApplication } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { Test, TestingModule } from '@nestjs/testing';
import { MongoMemoryServer } from 'mongodb-memory-server';
import request from 'supertest';
import { SubscriptionModule } from './subscription.module';

// Allow extra time for the first-run mongodb-memory-server binary download.
jest.setTimeout(60_000);

describe('Subscription CRUD (integration)', () => {
let app: INestApplication;
let mongod: MongoMemoryServer;

beforeAll(async () => {
mongod = await MongoMemoryServer.create();

const moduleRef: TestingModule = await Test.createTestingModule({
imports: [MongooseModule.forRoot(mongod.getUri()), SubscriptionModule],
}).compile();

app = moduleRef.createNestApplication();
await app.init();
});

afterAll(async () => {
if (app) await app.close();
if (mongod) await mongod.stop();
});

it('round-trips a subscription through POST / GET list / GET:id / PATCH / DELETE', async () => {
const http = request(app.getHttpServer());

// Create
const createRes = await http
.post('/subscription')
.send({ targetAddress: 'http://a.example/cb' })
.expect(201);
expect(createRes.body.targetAddress).toBe('http://a.example/cb');
const id = createRes.body._id;
expect(typeof id).toBe('string');

// List contains it
const listRes = await http.get('/subscription').expect(200);
expect(Array.isArray(listRes.body)).toBe(true);
expect(listRes.body.map((s: any) => s._id)).toContain(id);

// Read by id
const getRes = await http.get(`/subscription/${id}`).expect(200);
expect(getRes.body.targetAddress).toBe('http://a.example/cb');

// Patch
const patchRes = await http
.patch(`/subscription/${id}`)
.send({ targetAddress: 'http://b.example/cb' })
.expect(200);
expect(patchRes.body.targetAddress).toBe('http://b.example/cb');

// Delete
await http.delete(`/subscription/${id}`).expect(200);

// Now missing
await http.get(`/subscription/${id}`).expect(404);
});

it('returns 400 on invalid ObjectId', async () => {
await request(app.getHttpServer())
.get('/subscription/not-an-id')
.expect(400);
await request(app.getHttpServer())
.delete('/subscription/not-an-id')
.expect(400);
await request(app.getHttpServer())
.patch('/subscription/not-an-id')
.send({ targetAddress: 'http://c' })
.expect(400);
});

it('returns 404 on valid-but-missing id', async () => {
const unknown = '507f1f77bcf86cd799439011';
await request(app.getHttpServer())
.get(`/subscription/${unknown}`)
.expect(404);
await request(app.getHttpServer())
.delete(`/subscription/${unknown}`)
.expect(404);
await request(app.getHttpServer())
.patch(`/subscription/${unknown}`)
.send({ targetAddress: 'http://c' })
.expect(404);
});

it('rejects POST without targetAddress with 400', async () => {
await request(app.getHttpServer())
.post('/subscription')
.send({})
.expect(400);
});
});
Loading
Loading