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
6 changes: 6 additions & 0 deletions apps/api/src/auth/ability.factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ export class AbilityFactory {
ability.can('manage', 'Assignment', { groupId: { in: groupIds } });
ability.can('manage', 'Group', { id: { in: groupIds } });
ability.can('read', 'Instrument');
// Group managers may assemble series instruments on the fly and delete ones they created. A
// series is a bundle of other instruments, not a shared platform asset. The delete ability is
// scoped to manually-created (non-repo) instruments so repo instruments shared across groups can
// never be removed; the service further restricts deletion to series instruments specifically.
ability.can('create', 'Instrument');
ability.can('delete', 'Instrument', { sourceRepoId: null });
ability.can('read', 'InstrumentRepo', { groupIds: { hasSome: groupIds } });
ability.can('create', 'InstrumentRecord');
ability.can('create', 'InstrumentRecordFile', { groupId: { in: groupIds } });
Expand Down
14 changes: 14 additions & 0 deletions apps/api/src/groups/__tests__/groups.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,20 @@ describe('GroupsService', () => {
});
});

it('should drop accessibleInstrumentIds that no longer exist before setting the relation', async () => {
groupModel.findFirst.mockResolvedValueOnce({ name: 'Test Group', settings: {} });
// 'deleted' was removed since the client loaded the group and must not reach the relation set.
instrumentModel.findMany.mockResolvedValueOnce([{ id: 'live-1' }, { id: 'live-2' }]);
await groupsService.updateById('123', { accessibleInstrumentIds: ['live-1', 'deleted', 'live-2'] });
expect(instrumentModel.findMany).toHaveBeenCalledWith({
select: { id: true },
where: { id: { in: ['live-1', 'deleted', 'live-2'] } }
});
expect(groupModel.update.mock.lastCall?.[0]).toMatchObject({
data: { accessibleInstruments: { set: [{ id: 'live-1' }, { id: 'live-2' }] } }
});
});

it('should not throw when the name is unchanged', async () => {
groupModel.findFirst.mockResolvedValueOnce({ name: 'Test Group', settings: {} });
await groupsService.updateById('123', { name: 'Test Group' });
Expand Down
16 changes: 14 additions & 2 deletions apps/api/src/groups/groups.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,23 @@ export class GroupsService {
throw new ConflictException(`Group with name '${data.name}' already exists!`);
}

// Guard against stale client state: an instrument may have been deleted since the client loaded the
// group (the deleted id can linger in the client's accessible list). Connecting a non-existent
// instrument makes Prisma fail the relation update, so restrict the set to ids that still exist.
let validInstrumentIds: string[] | undefined;
if (accessibleInstrumentIds) {
const existingInstruments = await this.instrumentModel.findMany({
select: { id: true },
where: { id: { in: accessibleInstrumentIds } }
});
validInstrumentIds = existingInstruments.map(({ id }) => id);
}

return this.groupModel.update({
data: {
accessibleInstruments: accessibleInstrumentIds
accessibleInstruments: validInstrumentIds
? {
set: accessibleInstrumentIds.map((id) => ({ id }))
set: validInstrumentIds.map((id) => ({ id }))
}
: undefined,
instrumentRepos: instrumentRepoIds
Expand Down
261 changes: 260 additions & 1 deletion apps/api/src/instruments/__tests__/instruments.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,32 +2,291 @@ import { CryptoService, getModelToken, LoggingService, VirtualizationService } f
import type { Model } from '@douglasneuroinformatics/libnest';
import { MockFactory } from '@douglasneuroinformatics/libnest/testing';
import type { MockedInstance } from '@douglasneuroinformatics/libnest/testing';
import { ConflictException, ForbiddenException, NotFoundException } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { beforeEach, describe, expect, it } from 'vitest';
import { bundle } from '@opendatacapture/instrument-bundler';
import type { SeriesInstrument } from '@opendatacapture/runtime-core';
import type { WithID } from '@opendatacapture/schemas/core';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { InstrumentsService } from '../instruments.service';

// Avoid bundling a real instrument (esbuild) during unit tests; `createSeries` only needs `create` to be
// called with whatever the bundler produces.
vi.mock('@opendatacapture/instrument-bundler', () => ({
bundle: vi.fn(() => Promise.resolve('__BUNDLE__'))
}));

// A multilingual series instance as returned by `find`, containing two forms.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add proper type annotation

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@joshunrau a series instrument isn't really an instrument, it's a bundled list of instruments. I see no harm in letting the people who create them delete them, They could even belong to speccific groups instead of every user as well

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — existingSeries is now typed as WithID<SeriesInstrument> (no as any), which also forced the fixture to carry the full details/license/tags shape.

const existingSeries: WithID<SeriesInstrument> = {
__runtimeVersion: 1,
content: {
items: [
{ edition: 1, name: 'FORM_A' },
{ edition: 1, name: 'FORM_B' }
]
},
details: {
description: { en: 'An existing series', fr: 'Une série existante' },
license: 'UNLICENSED',
title: { en: 'Existing Series', fr: 'Série existante' }
},
id: 'existing-series-id',
kind: 'SERIES',
language: ['en', 'fr'],
tags: { en: ['Series'], fr: ['Série'] }
};

describe('InstrumentsService', () => {
let instrumentsService: InstrumentsService;
let cryptoService: MockedInstance<CryptoService>;
let instrumentModel: MockedInstance<Model<'Instrument'>>;
let instrumentRecordModel: MockedInstance<Model<'InstrumentRecord'>>;
let groupModel: MockedInstance<Model<'Group'>>;
let virtualizationService: MockedInstance<VirtualizationService<any>>;

beforeEach(async () => {
vi.mocked(bundle).mockClear();

const moduleRef = await Test.createTestingModule({
providers: [
InstrumentsService,
MockFactory.createForModelToken(getModelToken('Group')),
MockFactory.createForModelToken(getModelToken('Instrument')),
MockFactory.createForModelToken(getModelToken('InstrumentRecord')),
MockFactory.createForService(CryptoService),
MockFactory.createForService(LoggingService),
MockFactory.createForService(VirtualizationService)
]
}).compile();
instrumentsService = moduleRef.get(InstrumentsService);
cryptoService = moduleRef.get(CryptoService);
instrumentModel = moduleRef.get(getModelToken('Instrument'));
instrumentRecordModel = moduleRef.get(getModelToken('InstrumentRecord'));
groupModel = moduleRef.get(getModelToken('Group'));
virtualizationService = moduleRef.get(VirtualizationService);
// `getInstrumentInstance` reads/writes an instance cache on the virtualization context.
(virtualizationService as { context: unknown }).context = { __resolveImport: vi.fn(), instruments: new Map() };
});

it('should be defined', () => {
expect(instrumentsService).toBeDefined();
expect(instrumentModel).toBeDefined();
});

describe('createSeries', () => {
it('asks for confirmation (without creating) when another series already has the same forms', async () => {
vi.spyOn(instrumentsService, 'find').mockResolvedValue([existingSeries]);
const createSpy = vi.spyOn(instrumentsService, 'create');

// Same two forms as `existingSeries` but in a different order and under a different name.
const result = await instrumentsService.createSeries({
details: { title: 'My New Series' },
items: [
{ edition: 1, name: 'FORM_B' },
{ edition: 1, name: 'FORM_A' }
],
language: 'en'
});

expect(result).toEqual({
existingTitle: { en: 'Existing Series', fr: 'Série existante' },
outcome: 'duplicate'
});
expect(createSpy).not.toHaveBeenCalled();
});

it('creates the series when the duplicate is explicitly confirmed', async () => {
vi.spyOn(instrumentsService, 'find').mockResolvedValue([existingSeries]);
const createSpy = vi.spyOn(instrumentsService, 'create').mockResolvedValue({ id: 'new-id' } as any);

const result = await instrumentsService.createSeries({
confirmDuplicate: true,
details: { title: 'My New Series' },
items: [
{ edition: 1, name: 'FORM_A' },
{ edition: 1, name: 'FORM_B' }
],
language: 'en'
});

expect(createSpy).toHaveBeenCalledWith({ bundle: '__BUNDLE__' });
expect(result).toEqual({ instrumentId: 'new-id', outcome: 'created' });
});

it('creates the series directly when no existing series shares its forms', async () => {
vi.spyOn(instrumentsService, 'find').mockResolvedValue([existingSeries]);
const createSpy = vi.spyOn(instrumentsService, 'create').mockResolvedValue({ id: 'fresh-id' } as any);

const result = await instrumentsService.createSeries({
details: { title: 'Totally New' },
items: [
{ edition: 1, name: 'FORM_C' },
{ edition: 1, name: 'FORM_D' }
],
language: 'en'
});

expect(createSpy).toHaveBeenCalledWith({ bundle: '__BUNDLE__' });
expect(result).toEqual({ instrumentId: 'fresh-id', outcome: 'created' });
});

it('generates a valid, unilingual series source payload with selected items in order', async () => {
vi.spyOn(instrumentsService, 'find').mockResolvedValue([]);
vi.spyOn(instrumentsService, 'create').mockResolvedValue({ id: 'generated-id' } as any);

await instrumentsService.createSeries({
clientDetails: { instructions: ['Complete the instruments in order.'] },
details: { description: 'Optional description', title: 'Generated Series' },
items: [
{ edition: 2, name: 'FORM_B' },
{ edition: 1, name: 'FORM_A' }
],
language: 'en'
});

expect(bundle).toHaveBeenCalledTimes(1);

const [bundleOptions] = vi.mocked(bundle).mock.calls[0]!;
expect(bundleOptions.minify).toBe(true);
expect(bundleOptions.inputs).toHaveLength(1);

expect(bundleOptions.inputs[0]!.content).toEqual(expect.any(String));
const source = bundleOptions.inputs[0]!.content as string;
const match = /^export default (.*);$/.exec(source);
expect(match).not.toBeNull();

const definition = JSON.parse(match![1]!);
expect(definition.kind).toBe('SERIES');
// The details/instructions/language are stored verbatim in the caller's language — nothing is
// duplicated across languages or hardcoded.
expect(definition.language).toBe('en');
expect(definition.clientDetails).toEqual({ instructions: ['Complete the instruments in order.'] });
expect(definition.details).toMatchObject({
description: 'Optional description',
title: 'Generated Series'
});
expect(definition.tags).toEqual(['Series']);
expect(definition.content.items).toEqual([
{ edition: 2, name: 'FORM_B' },
{ edition: 1, name: 'FORM_A' }
]);
});

it('runs the generated bundle through create and stores the validated series instrument', async () => {
const items = [
{ edition: 1, name: 'FORM_A' },
{ edition: 1, name: 'FORM_B' }
];
const instance = {
__runtimeVersion: 1,
content: { items },
details: {
description: 'Stored Series',
license: 'UNLICENSED',
title: 'Stored Series'
},
kind: 'SERIES',
language: 'en',
tags: ['Series']
} as const;
const id = `hash:${JSON.stringify({ content: instance.content, title: instance.details.title })}`;

vi.spyOn(instrumentsService, 'find').mockResolvedValue([]);
vi.spyOn(cryptoService, 'hash').mockImplementation((value) => `hash:${value}`);
virtualizationService.eval.mockResolvedValue({ isErr: () => false, value: instance } as any);
instrumentModel.exists.mockResolvedValueOnce(false).mockResolvedValue(true);

const result = await instrumentsService.createSeries({
confirmDuplicate: true,
details: { title: 'Stored Series' },
items,
language: 'en'
});

expect(virtualizationService.eval).toHaveBeenCalledWith('__BUNDLE__');
expect(instrumentModel.exists).toHaveBeenNthCalledWith(1, { id });
expect(instrumentModel.exists).toHaveBeenNthCalledWith(2, { id: 'hash:FORM_A-1' });
expect(instrumentModel.exists).toHaveBeenNthCalledWith(3, { id: 'hash:FORM_B-1' });
expect(instrumentModel.create).toHaveBeenCalledWith({ data: { bundle: '__BUNDLE__', id } });
expect(result).toEqual({ instrumentId: id, outcome: 'created' });
});

it('rejects a title that is already used (case-insensitively) by another instrument', async () => {
vi.spyOn(instrumentsService, 'find').mockResolvedValue([existingSeries]);
const createSpy = vi.spyOn(instrumentsService, 'create');

await expect(
instrumentsService.createSeries({
details: { title: 'existing series' },
items: [
{ edition: 1, name: 'FORM_X' },
{ edition: 1, name: 'FORM_Y' }
],
language: 'en'
})
).rejects.toThrow(ConflictException);
expect(createSpy).not.toHaveBeenCalled();
});
});

describe('generateSeriesInstrumentId', () => {
it('includes the series title so confirmed duplicate form sets can be distinct', () => {
vi.spyOn(cryptoService, 'hash').mockImplementation((value) => value);

const first = {
...existingSeries,
details: { ...existingSeries.details, title: 'First Series' }
};
const second = {
...existingSeries,
details: { ...existingSeries.details, title: 'Second Series' }
};

expect(instrumentsService.generateSeriesInstrumentId(first)).not.toBe(
instrumentsService.generateSeriesInstrumentId(second)
);
expect(cryptoService.hash).toHaveBeenCalledWith(
JSON.stringify({ content: first.content, title: first.details.title })
);
});
});

describe('deleteById', () => {
it('throws when the instrument does not exist', async () => {
instrumentModel.findFirst.mockResolvedValue(null);
await expect(instrumentsService.deleteById('missing')).rejects.toThrow(NotFoundException);
});

it('refuses to delete a non-series (scalar) instrument', async () => {
instrumentModel.findFirst.mockResolvedValue({ bundle: '__BUNDLE__', id: 'scalar' });
virtualizationService.eval.mockResolvedValue({
isErr: () => false,
value: { internal: { edition: 1, name: 'FORM_A' }, kind: 'FORM' }
} as any);

await expect(instrumentsService.deleteById('scalar')).rejects.toThrow(ForbiddenException);
expect(instrumentModel.delete).not.toHaveBeenCalled();
});

it('deletes a series instrument and detaches it from every group', async () => {
instrumentModel.findFirst.mockResolvedValue({ bundle: '__BUNDLE__', id: 'target' });
virtualizationService.eval.mockResolvedValue({
isErr: () => false,
value: { content: { items: [] }, kind: 'SERIES' }
} as any);
groupModel.findMany.mockResolvedValue([{ accessibleInstrumentIds: ['other', 'target'], id: 'g1' }]);

const result = await instrumentsService.deleteById('target');

expect(groupModel.update).toHaveBeenCalledWith({
data: { accessibleInstrumentIds: { set: ['other'] } },
where: { id: 'g1' }
});
expect(instrumentModel.delete).toHaveBeenCalledWith({ where: { id: 'target' } });
expect(result).toEqual({ id: 'target' });
// Series instruments never have records of their own, so record counts are irrelevant here.
expect(instrumentRecordModel.count).not.toHaveBeenCalled();
});
});
});
28 changes: 26 additions & 2 deletions apps/api/src/instruments/instruments.controller.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import { CurrentUser } from '@douglasneuroinformatics/libnest';
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import type { InstrumentKind } from '@opendatacapture/runtime-core';
import type { InstrumentBundleContainer, InstrumentInfo } from '@opendatacapture/schemas/instrument';
// Imported as a value (not a type-only import) so it doubles as the validation schema for the request
// body while also annotating its type — no dedicated DTO class is needed.
import { $CreateSeriesInstrumentData } from '@opendatacapture/schemas/instrument';
import type {
CreateSeriesInstrumentResult,
InstrumentBundleContainer,
InstrumentInfo
} from '@opendatacapture/schemas/instrument';

import type { AppAbility } from '@/auth/auth.types';
import { RouteAccess } from '@/core/decorators/route-access.decorator';
Expand All @@ -22,6 +29,23 @@ export class InstrumentsController {
return this.instrumentsService.create(data);
}

@ApiOperation({ summary: 'Create Series Instrument' })
@Post('series')
@RouteAccess({ action: 'create', subject: 'Instrument' })
createSeries(
Comment on lines +33 to +35
@Body() data: $CreateSeriesInstrumentData,
@CurrentUser('ability') ability: AppAbility
): Promise<CreateSeriesInstrumentResult> {
return this.instrumentsService.createSeries(data, { ability });
}

@ApiOperation({ summary: 'Delete Instrument' })
@Delete(':id')
@RouteAccess({ action: 'delete', subject: 'Instrument' })
delete(@Param('id') id: string, @CurrentUser('ability') ability: AppAbility): Promise<unknown> {
return this.instrumentsService.deleteById(id, { ability });
}

@ApiOperation({ summary: 'Get Instrument Bundle' })
@Get('bundle/:id')
@RouteAccess({ action: 'read', subject: 'Instrument' })
Expand Down
Loading
Loading