Skip to content
Open
Show file tree
Hide file tree
Changes from 40 commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
cec0235
integrating if-unmodified-since
Aug 12, 2025
c35c623
integrating if-unmodified-since into dataset, sample
Aug 18, 2025
5e18837
if-unmodified-since into attachments,datablocks,proposals,datasets
Aug 19, 2025
fd0c9f8
make headerDate from if-unmodified-since more robust
Aug 22, 2025
e0b8f53
Merge branch 'SciCatProject:master' into master
joe-baudisch Aug 29, 2025
d08db24
remove unused import
Aug 29, 2025
f91cf6e
Merge branch 'SciCatProject:master' into master
joe-baudisch Aug 29, 2025
3202914
Merge branch 'SciCatProject:master' into master
joe-baudisch Sep 1, 2025
3cd917a
Merge branch 'SciCatProject:master' into master
joe-baudisch Sep 2, 2025
6cb52bf
resolve some conversations
Sep 4, 2025
65fbbc5
Merge branch 'master' into master
joe-baudisch Sep 4, 2025
772f2a9
adding test
Sep 5, 2025
fdfd2de
fixing bug by defining controller method without decorator
Sep 5, 2025
e7aea85
Merge branch 'SciCatProject:master' into master
joe-baudisch Sep 5, 2025
c7effdb
adding attachments.v4.controller test
Sep 5, 2025
a6f0ccf
Merge branch 'SciCatProject:master' into master
joe-baudisch Sep 5, 2025
656905c
adding more tests
Sep 8, 2025
03a1e8a
adding final test with datasets.v4.controller_if-unmodified-since_.sp…
Sep 9, 2025
de6c79a
merge two test-files into one
Sep 9, 2025
5f9fb70
Merge branch 'SciCatProject:master' into master
joe-baudisch Sep 9, 2025
8ed8fdb
Merge branch 'SciCatProject:master' into master
joe-baudisch Sep 10, 2025
11935c1
Merge branch 'SciCatProject:master' into master
joe-baudisch Sep 11, 2025
10a639e
calling extracted logic findByIdAndUpdateInternal from decorated find…
Sep 12, 2025
674d6f7
Merge branch 'SciCatProject:master' into master
joe-baudisch Sep 12, 2025
6b98f57
Merge branch 'SciCatProject:master' into master
joe-baudisch Sep 14, 2025
ae13ef5
lint fix
Sep 15, 2025
3e89b6b
lint fix
Sep 15, 2025
0962048
Merge branch 'SciCatProject:master' into master
joe-baudisch Sep 15, 2025
01718b3
Merge branch 'SciCatProject:master' into master
joe-baudisch Sep 16, 2025
4be531f
Merge branch 'SciCatProject:master' into master
joe-baudisch Sep 16, 2025
9f8b548
Merge branch 'SciCatProject:master' into master
joe-baudisch Sep 18, 2025
38e620a
Merge branch 'SciCatProject:master' into master
joe-baudisch Sep 19, 2025
58155f5
Merge branch 'SciCatProject:master' into master
joe-baudisch Sep 22, 2025
a8d5d7d
Merge branch 'SciCatProject:master' into master
joe-baudisch Sep 23, 2025
cfdbcd3
Merge branch 'SciCatProject:master' into master
joe-baudisch Sep 25, 2025
8c7ad78
Merge branch 'SciCatProject:master' into master
joe-baudisch Sep 25, 2025
5d78614
Merge branch 'SciCatProject:master' into master
joe-baudisch Sep 25, 2025
abf01ae
Merge branch 'SciCatProject:master' into master
joe-baudisch Sep 29, 2025
8384ba2
Merge branch 'SciCatProject:master' into master
joe-baudisch Sep 30, 2025
1990822
Merge branch 'SciCatProject:master' into master
joe-baudisch Oct 2, 2025
fc4c9ca
Merge branch 'master' into master
nitrosx Oct 6, 2025
d8279c1
Merge branch 'SciCatProject:master' into master
joe-baudisch Oct 10, 2025
508ddef
Merge branch 'master' into master
joe-baudisch Oct 20, 2025
d234a01
Merge branch 'SciCatProject:master' into master
joe-baudisch Oct 21, 2025
3a9a64d
Merge branch 'SciCatProject:master' into master
joe-baudisch Oct 24, 2025
4cb7df8
Merge branch 'SciCatProject:master' into master
joe-baudisch Oct 30, 2025
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
112 changes: 112 additions & 0 deletions src/attachments/attachments.v4.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { Test, TestingModule } from "@nestjs/testing";
import { AttachmentsV4Controller } from "./attachments.v4.controller";
import { AttachmentsV4Service } from "./attachments.v4.service";
import { HttpException, HttpStatus } from "@nestjs/common";
import { PartialUpdateAttachmentV4Dto } from "./dto/update-attachment.v4.dto";
import { Attachment } from "./schemas/attachment.schema";
import * as jmp from "json-merge-patch";
import { CaslAbilityFactory } from "src/casl/casl-ability.factory";
import { PoliciesGuard } from "src/casl/guards/policies.guard";

describe("AttachmentsController - findOneAndUpdate", () => {
let controller: AttachmentsV4Controller;
let service: AttachmentsV4Service;

const mockAttachment: Attachment = {
_id: "123",
name: "Test Attachment",
description: "Initial",
updatedAt: new Date("2025-09-01T10:00:00Z"),
// other fields...
};

const mockUpdatedAttachment = {
...mockAttachment,
description: "Updated",
};

const mockCaslAbilityFactory = {
createForUser: jest.fn().mockReturnValue({
can: jest.fn().mockReturnValue(true), // or false depending on test
}),
};

const mockAttachmentsV4Service = {
findOneAndUpdate: jest.fn(),
};

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [AttachmentsV4Controller],
providers: [
{
provide: AttachmentsV4Service,
useValue: mockAttachmentsV4Service,
useValue: {
findOneAndUpdate: jest
.fn()
.mockResolvedValue(mockUpdatedAttachment),
},
},
{
provide: CaslAbilityFactory,
useValue: mockCaslAbilityFactory,
},
PoliciesGuard,
],
}).compile();

controller = module.get<AttachmentsV4Controller>(AttachmentsV4Controller);
service = module.get<AttachmentsV4Service>(AttachmentsV4Service);

// Mock permission check
jest
.spyOn(controller, "checkPermissionsForAttachment")
.mockResolvedValue(mockAttachment);
});

it("should update attachment with application/json", async () => {
const dto: PartialUpdateAttachmentV4Dto = { description: "Updated" };
const headers = { "content-type": "application/json" };

const result = await controller.findOneAndUpdate(
{ headers },
"123",
headers,
dto,
);

expect(result).toEqual(mockUpdatedAttachment);
expect(service.findOneAndUpdate).toHaveBeenCalledWith({ _id: "123" }, dto);
});

it("should update attachment with application/merge-patch+json", async () => {
const dto = { description: null };
const headers = { "content-type": "application/merge-patch+json" };

await controller.findOneAndUpdate({ headers }, "123", headers, dto);

const expectedPatched = jmp.apply(mockAttachment, dto);
expect(service.findOneAndUpdate).toHaveBeenCalledWith(
{ _id: "123" },
expectedPatched,
);
});

it("should throw PRECONDITION_FAILED if If-Unmodified-Since is older than updatedAt", async () => {
const dto = { name: "Should Fail" };
const headers = {
"content-type": "application/json",
"if-unmodified-since": "2000-01-01T00:00:00Z",
};

await expect(
controller.findOneAndUpdate({ headers }, "123", headers, dto),
).rejects.toThrow(
new HttpException(
"Update error due to failed if-modified-since condition",
HttpStatus.PRECONDITION_FAILED,
),
);
});
});
29 changes: 23 additions & 6 deletions src/attachments/attachments.v4.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import {
Patch,
Put,
HttpCode,
Headers,
HttpException,
} from "@nestjs/common";
import {
ApiBearerAuth,
Expand Down Expand Up @@ -365,21 +367,36 @@ Set \`content-type\` header to \`application/merge-patch+json\` if you would lik
async findOneAndUpdate(
@Req() request: Request,
@Param("aid") aid: string,
@Headers() headers: Record<string, string>,
@Body() updateAttachmentDto: PartialUpdateAttachmentV4Dto,
): Promise<OutputAttachmentV4Dto | null> {
const foundAattachment = await this.checkPermissionsForAttachment(
const headerDateString = headers["if-unmodified-since"];
const headerDate =
headerDateString && !isNaN(new Date(headerDateString).getTime())
? new Date(headerDateString)
: null;

const foundAttachment = await this.checkPermissionsForAttachment(
request,
aid,
Action.AttachmentUpdateEndpoint,
);
const updateAttachmentDtoForservice =
request.headers["content-type"] === "application/merge-patch+json"
? jmp.apply(foundAattachment, updateAttachmentDto)
? jmp.apply(foundAttachment, updateAttachmentDto)
: updateAttachmentDto;
return this.attachmentsService.findOneAndUpdate(
{ _id: aid },
updateAttachmentDtoForservice,
);

if (headerDate && headerDate <= foundAttachment.updatedAt) {
throw new HttpException(
"Update error due to failed if-modified-since condition",
HttpStatus.PRECONDITION_FAILED,
);
} else {
return this.attachmentsService.findOneAndUpdate(
{ _id: aid },
updateAttachmentDtoForservice,
);
}
}

// PUT /attachments/:aid
Expand Down
148 changes: 145 additions & 3 deletions src/datasets/datasets.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,35 @@ import { DatasetsService } from "./datasets.service";
import { LogbooksService } from "src/logbooks/logbooks.service";
import { CaslAbilityFactory } from "src/casl/casl-ability.factory";
import { ConfigModule } from "@nestjs/config";
import {
ForbiddenException,
HttpException,
NotFoundException,
} from "@nestjs/common";
import { DatasetType } from "./types/dataset-type.enum";
import { Request } from "express";

class AttachmentsServiceMock {}

class DatablocksServiceMock {}

class DatasetsServiceMock {}

class OrigDatablocksServiceMock {}

class LogbooksServiceMock {}

class CaslAbilityFactoryMock {}
class DatasetsServiceMock {
findOne = jest.fn();
findByIdAndUpdate = jest.fn();
}

class CaslAbilityFactoryMock {
datasetInstanceAccess = jest.fn();
}

describe("DatasetsController", () => {
let controller: DatasetsController;
let datasetsService: DatasetsServiceMock;
let caslAbilityFactory: CaslAbilityFactoryMock;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
Expand All @@ -38,9 +52,137 @@ describe("DatasetsController", () => {
}).compile();

controller = module.get<DatasetsController>(DatasetsController);
datasetsService = module.get(DatasetsService);
caslAbilityFactory = module.get(CaslAbilityFactory);
});

it("should be defined", () => {
expect(controller).toBeDefined();
});

describe("findByIdAndUpdate", () => {
it("should throw NotFoundException if dataset not found", async () => {
datasetsService.findOne.mockResolvedValue(null);

await expect(
controller.findByIdAndUpdate(
{ user: {} } as Request,
"some-pid",
{ "if-unmodified-since": "2023-01-01T00:00:00Z" },
{},
),
).rejects.toThrow(NotFoundException);
});

it("should throw PreconditionFailed if header date <= updatedAt", async () => {
const mockDataset = {
pid: "some-pid",
updatedAt: new Date("2023-01-02T00:00:00Z"),
type: DatasetType.Raw,
};
datasetsService.findOne.mockResolvedValue(mockDataset);

await expect(
controller.findByIdAndUpdate(
{ user: {} } as Request,
"some-pid",
{ "if-unmodified-since": "2023-01-01T00:00:00Z" },
{},
),
).rejects.toThrow(HttpException);
});

it("should throw ForbiddenException if user cannot update", async () => {
const mockDataset = {
pid: "some-pid",
updatedAt: new Date("2023-01-01T00:00:00Z"),
type: DatasetType.Raw,
};
datasetsService.findOne.mockResolvedValue(mockDataset);
jest.spyOn(controller, "validateDatasetObsolete").mockResolvedValue({});
jest
.spyOn(controller, "generateDatasetInstanceForPermissions")
.mockResolvedValue({});
caslAbilityFactory.datasetInstanceAccess.mockReturnValue({
can: () => false,
});

await expect(
controller.findByIdAndUpdate(
{ user: {} } as Request,
"some-pid",
{ "if-unmodified-since": "2023-01-02T00:00:00Z" },
{},
),
).rejects.toThrow(ForbiddenException);
});

it("should return updated dataset if all conditions pass", async () => {
const mockDataset = {
pid: "some-pid",
updatedAt: new Date("2023-01-01T00:00:00Z"),
type: DatasetType.Raw,
};
const updatedDataset = { pid: "some-pid", name: "Updated" };

datasetsService.findOne.mockResolvedValue(mockDataset);
jest.spyOn(controller, "validateDatasetObsolete").mockResolvedValue({});
jest
.spyOn(controller, "generateDatasetInstanceForPermissions")
.mockResolvedValue({});
caslAbilityFactory.datasetInstanceAccess.mockReturnValue({
can: () => true,
});
jest
.spyOn(controller, "convertObsoleteToCurrentSchema")
.mockReturnValue({});
datasetsService.findByIdAndUpdate.mockResolvedValue(updatedDataset);
jest
.spyOn(controller, "convertCurrentToObsoleteSchema")
.mockReturnValue(updatedDataset);

const result = await controller.findByIdAndUpdate(
{ user: {} } as Request,
"some-pid",
{ "if-unmodified-since": "2023-01-02T00:00:00Z" },
{},
);

expect(result).toEqual(updatedDataset);
});

it("should proceed with update if if-unmodified-since header is missing or invalid", async () => {
const mockDataset = {
pid: "some-pid",
updatedAt: new Date("2023-01-01T00:00:00Z"),
type: DatasetType.Raw,
};
const updatedDataset = { pid: "some-pid", name: "Updated" };

datasetsService.findOne.mockResolvedValue(mockDataset);
jest.spyOn(controller, "validateDatasetObsolete").mockResolvedValue({});
jest
.spyOn(controller, "generateDatasetInstanceForPermissions")
.mockResolvedValue({});
caslAbilityFactory.datasetInstanceAccess.mockReturnValue({
can: () => true,
});
jest
.spyOn(controller, "convertObsoleteToCurrentSchema")
.mockReturnValue({});
datasetsService.findByIdAndUpdate.mockResolvedValue(updatedDataset);
jest
.spyOn(controller, "convertCurrentToObsoleteSchema")
.mockReturnValue(updatedDataset);

const result = await controller.findByIdAndUpdate(
{ user: {} } as Request,
"some-pid",
{ "if-unmodified-since": "not-a-valid-date" }, // invalid header
{},
);

expect(result).toEqual(updatedDataset);
});
});
});
Loading
Loading